From 34a8e45e7164b19aa04c96717a6acab13ca042f3 Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Tue, 13 Oct 2026 18:50:11 +0300 Subject: [PATCH 01/17] =?UTF-8?q?fix(tokens):=20=D1=81=D0=B8=D0=BD=D1=85?= =?UTF-8?q?=D1=80=D0=BE=D0=BD=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=BD=D1=8B=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D1=8B=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=B5=D0=B9=20=D0=B2=D0=B2=D0=BE=D0=B4=D0=B0=20?= =?UTF-8?q?=D1=81=20Figma?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design-tokens/input/tokens.json | 25 +++-- scripts/token-generator/README.md | 18 +-- .../__tests__/compiler.test.ts | 17 --- src/theme/tokens/components/dark.json | 106 +++++++++--------- src/theme/tokens/components/light.json | 106 +++++++++--------- src/theme/tokens/semantic/dimensions.json | 3 +- src/theme/tokens/semantic/effects.json | 2 +- 7 files changed, 131 insertions(+), 146 deletions(-) diff --git a/design-tokens/input/tokens.json b/design-tokens/input/tokens.json index 52924de5..b77eeb8e 100644 --- a/design-tokens/input/tokens.json +++ b/design-tokens/input/tokens.json @@ -1005,6 +1005,7 @@ "dimension": { "space": { "none": "{sizing.none}", + "50": "{sizing.1x}", "100": "{sizing.2x}", "150": "{sizing.3x}", "200": "{sizing.4x}", @@ -1067,7 +1068,7 @@ "200": "{sizing.1x}", "300": "{sizing.2x}" }, - "focusRing": { "width": "{sizing.2x}", "offset": "{sizing.none}" }, + "focusRing": { "width": "{sizing.1x}", "offset": "{sizing.none}" }, "depth": { "100": "1000", "200": "1100", @@ -3016,7 +3017,7 @@ }, "addon": { "borderRadius": "{dimension.borderRadius.400}", - "padding": "{dimension.space.400}", + "padding": "{dimension.space.300}", "minWidth": "{dimension.size.1700}" } }, @@ -3086,9 +3087,9 @@ "iconSize": "{dimension.size.450}", "borderWidth": "{dimension.borderWidth.100}", "extXlg": { - "fontSize": "{fonts.fontSize.300}", - "paddingX": "{dimension.space.300}", - "paddingY": "{dimension.space.600}" + "fontSize": "{fonts.fontSize.500}", + "paddingX": "{dimension.space.600}", + "paddingY": "{dimension.space.400}" }, "invalidFocusRing": { "color": "{color.border.status.danger.focus}" } }, @@ -3107,18 +3108,18 @@ "placeholderColor": "{color.fg.muted}", "invalidPlaceholderColor": "{color.fg.status.danger.hover}", "shadow": "{effects.elevation.none}", - "paddingX": "{dimension.space.300}", - "paddingY": "{dimension.space.300}", + "paddingX": "{dimension.space.400}", + "paddingY": "{dimension.space.350}", "borderRadius": "{dimension.borderRadius.300}", "transitionDuration": "{effects.transition.duration.200}", "sm": { - "fontSize": "{fonts.fontSize.300}", - "paddingX": "{dimension.space.300}", - "paddingY": "{dimension.space.200}" + "fontSize": "{fonts.fontSize.200}", + "paddingX": "{dimension.space.350}", + "paddingY": "{dimension.space.300}" }, "lg": { - "fontSize": "{fonts.fontSize.300}", - "paddingX": "{dimension.space.300}", + "fontSize": "{fonts.fontSize.400}", + "paddingX": "{dimension.space.500}", "paddingY": "{dimension.space.400}" }, "focusRing": { diff --git a/scripts/token-generator/README.md b/scripts/token-generator/README.md index e179f802..dc1a06f9 100644 --- a/scripts/token-generator/README.md +++ b/scripts/token-generator/README.md @@ -15,12 +15,12 @@ ## Использование токенов -Component-токены предназначены только для реализации компонентов UI Kit. Они не -входят в темы и публичные экспорты библиотеки. +Component-токены предназначены только для реализации компонентов UI Kit. Они +доступны через `components` темы, но не экспортируются отдельно из пакета. -В `semantic` темы находится только `colorScheme`: Unistyles подставляет -соответствующие light- или dark-токены при переключении темы. Не зависящие от -темы `dimension` и `effects` доступны в статическом `semanticTokens`. +В `semantic` темы находятся `colorScheme`, `dimension` и `effects`. Unistyles +переключает `colorScheme` и component-токены вместе с темой. Общие `dimension` и +`effects` также доступны в статическом `semanticTokens`. ## Результат генерации @@ -54,12 +54,12 @@ components.button.colorScheme.light.outlined ``` `semantic.dimension` записывается в `semantic/dimensions.json`, а -`semantic.effects` — в `semantic/effects.json`. Эти секции генерируются один раз -и не входят в тему. Если их итоговые значения зависят от `colorScheme`, -генератор завершится с ошибкой. +`semantic.effects` — в `semantic/effects.json`. Эти секции генерируются один +раз. Если их итоговые значения зависят от `colorScheme`, генератор завершится с +ошибкой. `fonts.json` собирается из `primitive.fonts`, нормализуется по общим правилам и -не разделяется по цветовым схемам. Пока файл не экспортируется из библиотеки. +не разделяется по цветовым схемам. В теме эти токены находятся в `fonts`. Остальная часть `primitive` нужна только для подстановки ссылок и не записывается в результат. Light- и dark-файлы должны иметь одинаковую структуру diff --git a/scripts/token-generator/__tests__/compiler.test.ts b/scripts/token-generator/__tests__/compiler.test.ts index dce26924..b86dc340 100644 --- a/scripts/token-generator/__tests__/compiler.test.ts +++ b/scripts/token-generator/__tests__/compiler.test.ts @@ -572,23 +572,6 @@ describe('production input tokens', () => { expect(JSON.stringify(compiled)).not.toMatch(/cubic-bezier\s*\(/) }) - test('экспортирует focusRing без изменения его структуры', () => { - expect(compiled.semantic.effects).toHaveProperty( - 'focusRing.shadow', - 'inset 0 0 0 4px #d4fedc' - ) - expect(compiled.components.light).toHaveProperty( - 'accordion.header.focusRing', - { - width: 4, - style: 'none', - color: 'rgba(68, 232, 88, 0.2)', - offset: 0, - shadow: 'inset 0 0 0 4px #d4fedc', - } - ) - }) - test('совпадает с закоммиченными сгенерированными токенами', async () => { await expect( findGeneratedTokenIssues(compiled, TOKENS_DIRECTORY) diff --git a/src/theme/tokens/components/dark.json b/src/theme/tokens/components/dark.json index 74af7db0..79f3d01f 100644 --- a/src/theme/tokens/components/dark.json +++ b/src/theme/tokens/components/dark.json @@ -23,11 +23,11 @@ "borderRadius": 0, "borderWidth": 0, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, - "shadow": "inset 0 0 0 4px #d4fedc" + "shadow": "inset 0 0 0 2px #d4fedc" }, "toggleIcon": { "color": "#ffffff", @@ -101,7 +101,7 @@ "borderRadius": 14, "transitionDuration": 180, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -146,7 +146,7 @@ "activeBorderColor": "#77f48a", "borderRadius": 14, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -237,7 +237,7 @@ "transitionDuration": 180 }, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -254,7 +254,7 @@ "offset": 0, "shadow": "none", "style": "none", - "width": 4 + "width": 2 } }, "separator": { "color": "#ffffff" } @@ -530,7 +530,7 @@ "raisedShadow": "none", "badgeSize": 16, "transitionDuration": 180, - "focusRing": { "width": 4, "style": "none", "offset": 0 }, + "focusRing": { "width": 2, "style": "none", "offset": 0 }, "sm": { "fontSize": 12, "iconOnlyWidth": 36, @@ -576,7 +576,7 @@ "height": 8, "borderRadius": 1600, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -605,12 +605,12 @@ "invalidBorderColor": "#f47f77", "shadow": "none", "focusRing": { - "focusRing": 4, + "focusRing": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, "shadow": "0 0 4px rgba(0, 0, 0, 0.2000)", - "width": 4 + "width": 2 }, "sm": { "width": 8, "height": 8 }, "lg": { "width": 20, "height": 20 }, @@ -645,7 +645,7 @@ "removeIcon": { "size": 16, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -770,11 +770,11 @@ "gap": 8, "padding": 14, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, - "shadow": "inset 0 0 0 4px #d4fedc" + "shadow": "inset 0 0 0 2px #d4fedc" }, "sm": { "padding": 8 }, "lg": { "padding": 20 } @@ -788,11 +788,11 @@ "hoverColor": "#cecfd2", "selectedColor": "#ffffff", "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, - "shadow": "inset 0 0 0 4px #d4fedc" + "shadow": "inset 0 0 0 2px #d4fedc" } }, "bodyCell": { @@ -832,7 +832,7 @@ "size": 32, "borderRadius": 14, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -996,7 +996,7 @@ "activeBorderColor": "#56595f", "borderRadius": 14, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -1028,7 +1028,7 @@ "borderRadius": 8, "padding": 4, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -1253,7 +1253,7 @@ "prev": { "borderRadius": 8 }, "next": { "borderRadius": 8 }, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -1267,7 +1267,7 @@ "borderRadius": 14, "gutter": 8, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -1283,7 +1283,7 @@ "activeBackground": "#f0f0f1", "borderRadius": 14, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -1305,7 +1305,7 @@ "hoverColor": "#56595f", "borderRadius": 14, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -1323,7 +1323,7 @@ "color": "#85888e" } }, - "addon": { "borderRadius": 14, "padding": 14, "minWidth": 272 } + "addon": { "borderRadius": 14, "padding": 10, "minWidth": 272 } }, "inputnumber": { "extend": { @@ -1359,7 +1359,7 @@ "readonlyBackground": "#2b2e33", "iconSize": 16, "borderWidth": 1, - "extXlg": { "fontSize": 16, "paddingX": 10, "paddingY": 20 }, + "extXlg": { "fontSize": 20, "paddingX": 20, "paddingY": 14 }, "invalidFocusRing": { "color": "rgba(232, 82, 68, 0.2)" } }, "root": { @@ -1377,14 +1377,14 @@ "placeholderColor": "#a2a5a9", "invalidPlaceholderColor": "#fbacaa", "shadow": "none", - "paddingX": 10, - "paddingY": 10, + "paddingX": 14, + "paddingY": 12, "borderRadius": 10, "transitionDuration": 180, - "sm": { "fontSize": 16, "paddingX": 10, "paddingY": 8 }, - "lg": { "fontSize": 16, "paddingX": 10, "paddingY": 14 }, + "sm": { "fontSize": 14, "paddingX": 12, "paddingY": 10 }, + "lg": { "fontSize": 18, "paddingX": 18, "paddingY": 14 }, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -1526,7 +1526,7 @@ "hoverColor": "#cecfd2", "hoverBackground": "#2b2e33", "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -1649,7 +1649,7 @@ "hoverColor": "#cecfd2", "hoverBackground": "#2b2e33", "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -1807,7 +1807,7 @@ "width": 32, "height": 32, "borderRadius": 14, - "focusRing": { "width": 4, "style": "none", "offset": 0 } + "focusRing": { "width": 2, "style": "none", "offset": 0 } }, "closeIcon": { "size": 16, "sm": { "size": 16 }, "lg": { "size": 16 } }, "outlined": { "root": { "borderWidth": 1 } }, @@ -1840,7 +1840,7 @@ "sm": { "fontSize": 16, "paddingX": 8, "paddingY": 8 }, "lg": { "fontSize": 16, "paddingX": 14, "paddingY": 14 }, "focusRing": { - "width": 4, + "width": 2, "style": "none", "offset": 0, "shadow": "none", @@ -1935,7 +1935,7 @@ "height": 40, "borderRadius": 14, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2071,13 +2071,13 @@ "offset": 0, "shadow": "none", "style": "none", - "width": 4 + "width": 2 }, "lg": { "height": 20, "width": 20 }, "sm": { "height": 8, "width": 8 } }, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2103,11 +2103,11 @@ "offset": 0, "shadow": "none", "style": "none", - "width": 4 + "width": 2 } }, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2166,7 +2166,7 @@ "sm": { "fontSize": 16, "paddingX": 14, "paddingY": 14 }, "lg": { "fontSize": 16, "paddingX": 14, "paddingY": 14 }, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2259,7 +2259,7 @@ "background": "#f0f0f1", "hoverBackground": "#f0f0f1", "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2272,7 +2272,7 @@ "height": 12, "shadow": "none" }, - "extend": { "hoverRing": "0 0 0 4px rgba(206, 207, 210, 0.5000)" } + "extend": { "hoverRing": "0 0 0 2px rgba(206, 207, 210, 0.5000)" } } }, "splitter": { @@ -2288,7 +2288,7 @@ "size": 4, "borderRadius": 14, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2323,7 +2323,7 @@ "borderRadius": 0, "gap": 8, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2365,7 +2365,7 @@ "offset": 0, "shadow": "none", "style": "none", - "width": 4 + "width": 2 } }, "itemLabel": { @@ -2420,7 +2420,7 @@ "margin": 0, "gap": 8, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2432,7 +2432,7 @@ "color": "#ffffff", "padding": 14, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2445,7 +2445,7 @@ "hoverColor": "#cecfd2", "width": 24, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2572,7 +2572,7 @@ "width": 32, "height": 32, "borderRadius": 14, - "focusRing": { "width": 4, "style": "none", "offset": 0 } + "focusRing": { "width": 2, "style": "none", "offset": 0 } }, "closeIcon": { "size": 16 } }, @@ -2627,7 +2627,7 @@ "borderRadius": 14, "transitionDuration": 180, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2764,7 +2764,7 @@ "gap": 8, "fontWeight": 600, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2841,7 +2841,7 @@ "borderWidth": 1, "shadow": "none", "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2893,7 +2893,7 @@ "offset": 0, "shadow": "none", "style": "none", - "width": 4 + "width": 2 }, "hoverBackground": "#2b2e33", "hoverColor": "#ffffff", @@ -2914,7 +2914,7 @@ "offset": 0, "shadow": "none", "style": "none", - "width": 4 + "width": 2 }, "hoverBackground": "#2b2e33", "hoverColor": "#cecfd2", diff --git a/src/theme/tokens/components/light.json b/src/theme/tokens/components/light.json index 4faa2ed6..38f9cff7 100644 --- a/src/theme/tokens/components/light.json +++ b/src/theme/tokens/components/light.json @@ -23,11 +23,11 @@ "borderRadius": 0, "borderWidth": 0, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, - "shadow": "inset 0 0 0 4px #d4fedc" + "shadow": "inset 0 0 0 2px #d4fedc" }, "toggleIcon": { "color": "#2b2e33", @@ -101,7 +101,7 @@ "borderRadius": 14, "transitionDuration": 180, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -146,7 +146,7 @@ "activeBorderColor": "#1dc831", "borderRadius": 14, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -237,7 +237,7 @@ "transitionDuration": 180 }, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -254,7 +254,7 @@ "offset": 0, "shadow": "none", "style": "none", - "width": 4 + "width": 2 } }, "separator": { "color": "#2b2e33" } @@ -530,7 +530,7 @@ "raisedShadow": "none", "badgeSize": 16, "transitionDuration": 180, - "focusRing": { "width": 4, "style": "none", "offset": 0 }, + "focusRing": { "width": 2, "style": "none", "offset": 0 }, "sm": { "fontSize": 12, "iconOnlyWidth": 36, @@ -576,7 +576,7 @@ "height": 8, "borderRadius": 1600, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -605,12 +605,12 @@ "invalidBorderColor": "#db3424", "shadow": "none", "focusRing": { - "focusRing": 4, + "focusRing": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, "shadow": "0 0 4px rgba(0, 0, 0, 0.2000)", - "width": 4 + "width": 2 }, "sm": { "width": 8, "height": 8 }, "lg": { "width": 20, "height": 20 }, @@ -645,7 +645,7 @@ "removeIcon": { "size": 16, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -770,11 +770,11 @@ "gap": 8, "padding": 14, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, - "shadow": "inset 0 0 0 4px #d4fedc" + "shadow": "inset 0 0 0 2px #d4fedc" }, "sm": { "padding": 8 }, "lg": { "padding": 20 } @@ -788,11 +788,11 @@ "hoverColor": "#56595f", "selectedColor": "#ffffff", "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, - "shadow": "inset 0 0 0 4px #d4fedc" + "shadow": "inset 0 0 0 2px #d4fedc" } }, "bodyCell": { @@ -832,7 +832,7 @@ "size": 32, "borderRadius": 14, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -996,7 +996,7 @@ "activeBorderColor": "#cecfd2", "borderRadius": 14, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -1028,7 +1028,7 @@ "borderRadius": 8, "padding": 4, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -1253,7 +1253,7 @@ "prev": { "borderRadius": 8 }, "next": { "borderRadius": 8 }, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -1267,7 +1267,7 @@ "borderRadius": 14, "gutter": 8, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -1283,7 +1283,7 @@ "activeBackground": "#2b2e33", "borderRadius": 14, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -1305,7 +1305,7 @@ "hoverColor": "#ffffff", "borderRadius": 14, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -1323,7 +1323,7 @@ "color": "#85888e" } }, - "addon": { "borderRadius": 14, "padding": 14, "minWidth": 272 } + "addon": { "borderRadius": 14, "padding": 10, "minWidth": 272 } }, "inputnumber": { "extend": { @@ -1359,7 +1359,7 @@ "readonlyBackground": "#f0f0f1", "iconSize": 16, "borderWidth": 1, - "extXlg": { "fontSize": 16, "paddingX": 10, "paddingY": 20 }, + "extXlg": { "fontSize": 20, "paddingX": 20, "paddingY": 14 }, "invalidFocusRing": { "color": "rgba(232, 82, 68, 0.2)" } }, "root": { @@ -1377,14 +1377,14 @@ "placeholderColor": "#85888e", "invalidPlaceholderColor": "#db3424", "shadow": "none", - "paddingX": 10, - "paddingY": 10, + "paddingX": 14, + "paddingY": 12, "borderRadius": 10, "transitionDuration": 180, - "sm": { "fontSize": 16, "paddingX": 10, "paddingY": 8 }, - "lg": { "fontSize": 16, "paddingX": 10, "paddingY": 14 }, + "sm": { "fontSize": 14, "paddingX": 12, "paddingY": 10 }, + "lg": { "fontSize": 18, "paddingX": 18, "paddingY": 14 }, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -1526,7 +1526,7 @@ "hoverColor": "#56595f", "hoverBackground": "#f0f0f1", "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -1649,7 +1649,7 @@ "hoverColor": "#56595f", "hoverBackground": "#f0f0f1", "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -1807,7 +1807,7 @@ "width": 32, "height": 32, "borderRadius": 14, - "focusRing": { "width": 4, "style": "none", "offset": 0 } + "focusRing": { "width": 2, "style": "none", "offset": 0 } }, "closeIcon": { "size": 16, "sm": { "size": 16 }, "lg": { "size": 16 } }, "outlined": { "root": { "borderWidth": 1 } }, @@ -1840,7 +1840,7 @@ "sm": { "fontSize": 16, "paddingX": 8, "paddingY": 8 }, "lg": { "fontSize": 16, "paddingX": 14, "paddingY": 14 }, "focusRing": { - "width": 4, + "width": 2, "style": "none", "offset": 0, "shadow": "none", @@ -1935,7 +1935,7 @@ "height": 40, "borderRadius": 14, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2071,13 +2071,13 @@ "offset": 0, "shadow": "none", "style": "none", - "width": 4 + "width": 2 }, "lg": { "height": 20, "width": 20 }, "sm": { "height": 8, "width": 8 } }, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2103,11 +2103,11 @@ "offset": 0, "shadow": "none", "style": "none", - "width": 4 + "width": 2 } }, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2166,7 +2166,7 @@ "sm": { "fontSize": 16, "paddingX": 14, "paddingY": 14 }, "lg": { "fontSize": 16, "paddingX": 14, "paddingY": 14 }, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2259,7 +2259,7 @@ "background": "#2b2e33", "hoverBackground": "#2b2e33", "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2272,7 +2272,7 @@ "height": 12, "shadow": "none" }, - "extend": { "hoverRing": "0 0 0 4px rgba(206, 207, 210, 0.5000)" } + "extend": { "hoverRing": "0 0 0 2px rgba(206, 207, 210, 0.5000)" } } }, "splitter": { @@ -2288,7 +2288,7 @@ "size": 4, "borderRadius": 14, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2323,7 +2323,7 @@ "borderRadius": 0, "gap": 8, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2365,7 +2365,7 @@ "offset": 0, "shadow": "none", "style": "none", - "width": 4 + "width": 2 } }, "itemLabel": { @@ -2420,7 +2420,7 @@ "margin": 0, "gap": 8, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2432,7 +2432,7 @@ "color": "#2b2e33", "padding": 14, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2445,7 +2445,7 @@ "hoverColor": "#56595f", "width": 24, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2572,7 +2572,7 @@ "width": 32, "height": 32, "borderRadius": 14, - "focusRing": { "width": 4, "style": "none", "offset": 0 } + "focusRing": { "width": 2, "style": "none", "offset": 0 } }, "closeIcon": { "size": 16 } }, @@ -2627,7 +2627,7 @@ "borderRadius": 14, "transitionDuration": 180, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2764,7 +2764,7 @@ "gap": 8, "fontWeight": 600, "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2841,7 +2841,7 @@ "borderWidth": 1, "shadow": "none", "focusRing": { - "width": 4, + "width": 2, "style": "none", "color": "rgba(68, 232, 88, 0.2)", "offset": 0, @@ -2893,7 +2893,7 @@ "offset": 0, "shadow": "none", "style": "none", - "width": 4 + "width": 2 }, "hoverBackground": "#f0f0f1", "hoverColor": "#2b2e33", @@ -2914,7 +2914,7 @@ "offset": 0, "shadow": "none", "style": "none", - "width": 4 + "width": 2 }, "hoverBackground": "#f0f0f1", "hoverColor": "#56595f", diff --git a/src/theme/tokens/semantic/dimensions.json b/src/theme/tokens/semantic/dimensions.json index 5397a89f..09cf441a 100644 --- a/src/theme/tokens/semantic/dimensions.json +++ b/src/theme/tokens/semantic/dimensions.json @@ -1,5 +1,6 @@ { "space": { + "50": 2, "100": 4, "150": 6, "200": 8, @@ -53,7 +54,7 @@ "max": 1600 }, "borderWidth": { "100": 1, "200": 2, "300": 4, "none": 0 }, - "focusRing": { "width": 4, "offset": 0 }, + "focusRing": { "width": 2, "offset": 0 }, "depth": { "100": 1000, "200": 1100, diff --git a/src/theme/tokens/semantic/effects.json b/src/theme/tokens/semantic/effects.json index 1a1a6294..3d4822c8 100644 --- a/src/theme/tokens/semantic/effects.json +++ b/src/theme/tokens/semantic/effects.json @@ -47,5 +47,5 @@ "contrast": "0px 4px 8px 0px rgba(0, 0, 0, 0.0400)" } }, - "focusRing": { "shadow": "inset 0 0 0 4px #d4fedc" } + "focusRing": { "shadow": "inset 0 0 0 2px #d4fedc" } } From 7fd69aeb0b6cee1568d187774aa1f55fbd085bda Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Thu, 8 Oct 2026 17:33:59 +0300 Subject: [PATCH 02/17] =?UTF-8?q?refactor(input-switch):=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BC=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=BD=D0=B5=D1=81=D1=91=D0=BD=20=D0=B2=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D1=83=D1=8E=20=D0=BF=D0=B0=D0=BF?= =?UTF-8?q?=D0=BA=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Input/index.ts | 2 +- src/components/{Input => }/InputSwitch/InputSwitch.stories.tsx | 0 src/components/{Input => }/InputSwitch/InputSwitch.tsx | 0 .../{Input => }/InputSwitch/__tests__/InputSwitch.test.tsx | 0 .../__tests__/__snapshots__/InputSwitch.test.tsx.snap | 0 src/components/{Input => }/InputSwitch/index.ts | 0 src/components/{Input => }/InputSwitch/styles/index.ts | 0 .../{Input => }/InputSwitch/styles/useHandleStyles.ts | 0 .../{Input => }/InputSwitch/styles/useSliderStyles.ts | 0 9 files changed, 1 insertion(+), 1 deletion(-) rename src/components/{Input => }/InputSwitch/InputSwitch.stories.tsx (100%) rename src/components/{Input => }/InputSwitch/InputSwitch.tsx (100%) rename src/components/{Input => }/InputSwitch/__tests__/InputSwitch.test.tsx (100%) rename src/components/{Input => }/InputSwitch/__tests__/__snapshots__/InputSwitch.test.tsx.snap (100%) rename src/components/{Input => }/InputSwitch/index.ts (100%) rename src/components/{Input => }/InputSwitch/styles/index.ts (100%) rename src/components/{Input => }/InputSwitch/styles/useHandleStyles.ts (100%) rename src/components/{Input => }/InputSwitch/styles/useSliderStyles.ts (100%) diff --git a/src/components/Input/index.ts b/src/components/Input/index.ts index fc5a27d3..0ad31c39 100644 --- a/src/components/Input/index.ts +++ b/src/components/Input/index.ts @@ -1,6 +1,6 @@ export { InputGroup } from './InputGroup' export { InputText } from './InputText' -export { InputSwitch } from './InputSwitch' +export { InputSwitch } from '../InputSwitch' export type { InputTextBaseProps } from './InputTextBase/types' export { InputOtp, diff --git a/src/components/Input/InputSwitch/InputSwitch.stories.tsx b/src/components/InputSwitch/InputSwitch.stories.tsx similarity index 100% rename from src/components/Input/InputSwitch/InputSwitch.stories.tsx rename to src/components/InputSwitch/InputSwitch.stories.tsx diff --git a/src/components/Input/InputSwitch/InputSwitch.tsx b/src/components/InputSwitch/InputSwitch.tsx similarity index 100% rename from src/components/Input/InputSwitch/InputSwitch.tsx rename to src/components/InputSwitch/InputSwitch.tsx diff --git a/src/components/Input/InputSwitch/__tests__/InputSwitch.test.tsx b/src/components/InputSwitch/__tests__/InputSwitch.test.tsx similarity index 100% rename from src/components/Input/InputSwitch/__tests__/InputSwitch.test.tsx rename to src/components/InputSwitch/__tests__/InputSwitch.test.tsx diff --git a/src/components/Input/InputSwitch/__tests__/__snapshots__/InputSwitch.test.tsx.snap b/src/components/InputSwitch/__tests__/__snapshots__/InputSwitch.test.tsx.snap similarity index 100% rename from src/components/Input/InputSwitch/__tests__/__snapshots__/InputSwitch.test.tsx.snap rename to src/components/InputSwitch/__tests__/__snapshots__/InputSwitch.test.tsx.snap diff --git a/src/components/Input/InputSwitch/index.ts b/src/components/InputSwitch/index.ts similarity index 100% rename from src/components/Input/InputSwitch/index.ts rename to src/components/InputSwitch/index.ts diff --git a/src/components/Input/InputSwitch/styles/index.ts b/src/components/InputSwitch/styles/index.ts similarity index 100% rename from src/components/Input/InputSwitch/styles/index.ts rename to src/components/InputSwitch/styles/index.ts diff --git a/src/components/Input/InputSwitch/styles/useHandleStyles.ts b/src/components/InputSwitch/styles/useHandleStyles.ts similarity index 100% rename from src/components/Input/InputSwitch/styles/useHandleStyles.ts rename to src/components/InputSwitch/styles/useHandleStyles.ts diff --git a/src/components/Input/InputSwitch/styles/useSliderStyles.ts b/src/components/InputSwitch/styles/useSliderStyles.ts similarity index 100% rename from src/components/Input/InputSwitch/styles/useSliderStyles.ts rename to src/components/InputSwitch/styles/useSliderStyles.ts From 9640b8f077694256dca013e065a6dd81b4c97065 Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Thu, 8 Oct 2026 17:42:50 +0300 Subject: [PATCH 03/17] =?UTF-8?q?refactor(input-otp):=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D1=81=D1=91=D0=BD=20=D0=B2=20=D0=BE=D1=82=D0=B4?= =?UTF-8?q?=D0=B5=D0=BB=D1=8C=D0=BD=D1=83=D1=8E=20=D0=BF=D0=B0=D0=BF=D0=BA?= =?UTF-8?q?=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Input/index.ts | 6 +----- src/components/{Input => }/InputOtp/InputOtp.stories.tsx | 2 +- src/components/{Input => }/InputOtp/InputOtp.tsx | 0 .../{Input => }/InputOtp/InputOtpErrorRecoveryScenario.tsx | 0 src/components/{Input => }/InputOtp/InputOtpItem.tsx | 2 +- .../InputOtp/__tests__/InputOtp.scenario.test.tsx | 0 .../{Input => }/InputOtp/__tests__/InputOtp.test.tsx | 0 src/components/InputOtp/index.ts | 1 + src/components/{Input => }/InputOtp/testIds.ts | 0 9 files changed, 4 insertions(+), 7 deletions(-) rename src/components/{Input => }/InputOtp/InputOtp.stories.tsx (97%) rename src/components/{Input => }/InputOtp/InputOtp.tsx (100%) rename src/components/{Input => }/InputOtp/InputOtpErrorRecoveryScenario.tsx (100%) rename src/components/{Input => }/InputOtp/InputOtpItem.tsx (98%) rename src/components/{Input => }/InputOtp/__tests__/InputOtp.scenario.test.tsx (100%) rename src/components/{Input => }/InputOtp/__tests__/InputOtp.test.tsx (100%) create mode 100644 src/components/InputOtp/index.ts rename src/components/{Input => }/InputOtp/testIds.ts (100%) diff --git a/src/components/Input/index.ts b/src/components/Input/index.ts index 0ad31c39..76de6a00 100644 --- a/src/components/Input/index.ts +++ b/src/components/Input/index.ts @@ -2,8 +2,4 @@ export { InputGroup } from './InputGroup' export { InputText } from './InputText' export { InputSwitch } from '../InputSwitch' export type { InputTextBaseProps } from './InputTextBase/types' -export { - InputOtp, - InputOtpTestId, - type InputOtpProps, -} from './InputOtp/InputOtp' +export { InputOtp, InputOtpTestId, type InputOtpProps } from '../InputOtp' diff --git a/src/components/Input/InputOtp/InputOtp.stories.tsx b/src/components/InputOtp/InputOtp.stories.tsx similarity index 97% rename from src/components/Input/InputOtp/InputOtp.stories.tsx rename to src/components/InputOtp/InputOtp.stories.tsx index b0600197..b6f50856 100644 --- a/src/components/Input/InputOtp/InputOtp.stories.tsx +++ b/src/components/InputOtp/InputOtp.stories.tsx @@ -14,7 +14,7 @@ const validateOtp = () => }) const meta: Meta = { - title: 'Form/InputOtp', + title: 'Other Form/InputOtp', component: InputOtp, args: { disabled: false, error: false, length: 4, value: '' }, render: (args) => { diff --git a/src/components/Input/InputOtp/InputOtp.tsx b/src/components/InputOtp/InputOtp.tsx similarity index 100% rename from src/components/Input/InputOtp/InputOtp.tsx rename to src/components/InputOtp/InputOtp.tsx diff --git a/src/components/Input/InputOtp/InputOtpErrorRecoveryScenario.tsx b/src/components/InputOtp/InputOtpErrorRecoveryScenario.tsx similarity index 100% rename from src/components/Input/InputOtp/InputOtpErrorRecoveryScenario.tsx rename to src/components/InputOtp/InputOtpErrorRecoveryScenario.tsx diff --git a/src/components/Input/InputOtp/InputOtpItem.tsx b/src/components/InputOtp/InputOtpItem.tsx similarity index 98% rename from src/components/Input/InputOtp/InputOtpItem.tsx rename to src/components/InputOtp/InputOtpItem.tsx index b3ee87d8..e549221b 100644 --- a/src/components/Input/InputOtp/InputOtpItem.tsx +++ b/src/components/InputOtp/InputOtpItem.tsx @@ -12,7 +12,7 @@ import Animated, { type AnimatedStyle } from 'react-native-reanimated' import { StyleSheet } from 'react-native-unistyles' -import effects from '../../../theme/tokens/semantic/effects.json' +import effects from '../../theme/tokens/semantic/effects.json' import { createInputOtpTestIds } from './testIds' diff --git a/src/components/Input/InputOtp/__tests__/InputOtp.scenario.test.tsx b/src/components/InputOtp/__tests__/InputOtp.scenario.test.tsx similarity index 100% rename from src/components/Input/InputOtp/__tests__/InputOtp.scenario.test.tsx rename to src/components/InputOtp/__tests__/InputOtp.scenario.test.tsx diff --git a/src/components/Input/InputOtp/__tests__/InputOtp.test.tsx b/src/components/InputOtp/__tests__/InputOtp.test.tsx similarity index 100% rename from src/components/Input/InputOtp/__tests__/InputOtp.test.tsx rename to src/components/InputOtp/__tests__/InputOtp.test.tsx diff --git a/src/components/InputOtp/index.ts b/src/components/InputOtp/index.ts new file mode 100644 index 00000000..ffc96791 --- /dev/null +++ b/src/components/InputOtp/index.ts @@ -0,0 +1 @@ +export { InputOtp, InputOtpTestId, type InputOtpProps } from './InputOtp' diff --git a/src/components/Input/InputOtp/testIds.ts b/src/components/InputOtp/testIds.ts similarity index 100% rename from src/components/Input/InputOtp/testIds.ts rename to src/components/InputOtp/testIds.ts From bbb211b73e3d4a447251c2299b3540b10c29ebc9 Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Thu, 8 Oct 2026 18:26:02 +0300 Subject: [PATCH 04/17] =?UTF-8?q?chore(storybook):=20=D1=83=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=8F=D0=B4=D0=BE=D1=87=D0=B5=D0=BD=D1=8B=20=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D0=B8=20=D0=B8=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=B9=D0=BA=D0=B0=20=D1=84=D0=BE=D1=80=D0=BC?= =?UTF-8?q?=D0=B0=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .prettierignore | 1 + .storybook/preview.tsx | 1 + src/components/Checkbox/Checkbox.stories.ts | 2 +- src/components/Dialog/Dialog.stories.tsx | 4 +--- .../Dialog/DialogComponent.stories.tsx | 4 +--- .../InputSwitch/InputSwitch.stories.tsx | 20 ++++++++++++++----- src/components/List/Base/ListBase.stories.tsx | 2 +- .../RadioButton/RadioButton.stories.ts | 2 +- .../SelectButton/SelectButton.stories.tsx | 2 +- src/components/Slider/Slider.stories.tsx | 2 +- .../ToggleButton/ToggleButton.stories.ts | 2 +- 11 files changed, 25 insertions(+), 17 deletions(-) diff --git a/.prettierignore b/.prettierignore index b109670a..4c3bdb9c 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,4 @@ android/app/.cxx android/app/build android/build CHANGELOG.md +.storybook/storybook.requires.ts diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index a4f29b17..aad256fd 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -31,6 +31,7 @@ const preview: Preview = { }, ], parameters: { + options: { storySort: { method: 'alphabetical', includeNames: true } }, actions: { argTypesRegex: '^on[A-Z].*' }, controls: { matchers: { color: /(background|color)$/i, date: /Date$/ } }, }, diff --git a/src/components/Checkbox/Checkbox.stories.ts b/src/components/Checkbox/Checkbox.stories.ts index 56d13b3d..81f77364 100644 --- a/src/components/Checkbox/Checkbox.stories.ts +++ b/src/components/Checkbox/Checkbox.stories.ts @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react' import { Checkbox } from './Checkbox' const meta: Meta = { - title: 'Form/Checkbox', + title: 'Other Form/Checkbox', component: Checkbox, args: { checked: false, diff --git a/src/components/Dialog/Dialog.stories.tsx b/src/components/Dialog/Dialog.stories.tsx index 8ec57cf0..539536d0 100644 --- a/src/components/Dialog/Dialog.stories.tsx +++ b/src/components/Dialog/Dialog.stories.tsx @@ -90,6 +90,4 @@ export default meta type Story = StoryObj -const DialogStory: Story = { args: {} } - -export { DialogStory as 'Dialog modal=true' } +export const Modal: Story = { name: 'Dialog modal=true', args: {} } diff --git a/src/components/Dialog/DialogComponent.stories.tsx b/src/components/Dialog/DialogComponent.stories.tsx index aebb6cf8..f44ae225 100644 --- a/src/components/Dialog/DialogComponent.stories.tsx +++ b/src/components/Dialog/DialogComponent.stories.tsx @@ -63,6 +63,4 @@ export default meta type Story = StoryObj -const DialogStory: Story = { args: {} } - -export { DialogStory as 'Dialog modal=false' } +export const NonModal: Story = { name: 'Dialog modal=false', args: {} } diff --git a/src/components/InputSwitch/InputSwitch.stories.tsx b/src/components/InputSwitch/InputSwitch.stories.tsx index d131da1a..2b655e58 100644 --- a/src/components/InputSwitch/InputSwitch.stories.tsx +++ b/src/components/InputSwitch/InputSwitch.stories.tsx @@ -5,15 +5,25 @@ import { useCallback } from 'react' import { InputSwitch } from './InputSwitch' const meta: Meta = { - title: 'Form/InputSwitch', + title: 'Other Form/InputSwitch', + component: InputSwitch, args: { disabled: false, danger: false, checked: false }, - argTypes: { onCheckedChange: { action: 'onCheckedChange' } }, + argTypes: { + disabled: { control: 'boolean' }, + danger: { control: 'boolean' }, + checked: { control: 'boolean' }, + onCheckedChange: { action: 'onCheckedChange', control: false }, + }, render: (args) => { + const { onCheckedChange: onCheckedChangeAction } = args const [, updateArgs] = useArgs() const onCheckedChange = useCallback( - (nextValue: boolean) => updateArgs({ checked: nextValue }), - [updateArgs] + (nextValue: boolean) => { + updateArgs({ checked: nextValue }) + onCheckedChangeAction?.(nextValue) + }, + [onCheckedChangeAction, updateArgs] ) return @@ -24,6 +34,6 @@ export default meta type Story = StoryObj -const InputSwitchStory: Story = {} +const InputSwitchStory: Story = { name: 'InputSwitch' } export { InputSwitchStory as InputSwitch } diff --git a/src/components/List/Base/ListBase.stories.tsx b/src/components/List/Base/ListBase.stories.tsx index 83681162..920a812b 100644 --- a/src/components/List/Base/ListBase.stories.tsx +++ b/src/components/List/Base/ListBase.stories.tsx @@ -83,4 +83,4 @@ const ListBaseStory: Story = { }, } -export { ListBaseStory as Base } +export { ListBaseStory as List } diff --git a/src/components/RadioButton/RadioButton.stories.ts b/src/components/RadioButton/RadioButton.stories.ts index d47ce90c..56efc0a2 100644 --- a/src/components/RadioButton/RadioButton.stories.ts +++ b/src/components/RadioButton/RadioButton.stories.ts @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react' import { RadioButton } from './RadioButton' const meta: Meta = { - title: 'Form/RadioButton', + title: 'Other Form/RadioButton', component: RadioButton, args: { checked: false, disabled: false, state: 'default' }, argTypes: { state: { control: 'radio', options: ['default', 'danger'] } }, diff --git a/src/components/SelectButton/SelectButton.stories.tsx b/src/components/SelectButton/SelectButton.stories.tsx index 5d736c08..cb7f230c 100644 --- a/src/components/SelectButton/SelectButton.stories.tsx +++ b/src/components/SelectButton/SelectButton.stories.tsx @@ -39,7 +39,7 @@ const AnimatedExample = ({ buttons, ...props }: CommonProps) => { } const meta: Meta = { - title: 'Form/SelectButton', + title: 'Other Form/SelectButton', component: SelectButton, args: { buttons, size: 'base', disabled: false }, argTypes: { diff --git a/src/components/Slider/Slider.stories.tsx b/src/components/Slider/Slider.stories.tsx index acabcb08..11733182 100644 --- a/src/components/Slider/Slider.stories.tsx +++ b/src/components/Slider/Slider.stories.tsx @@ -7,7 +7,7 @@ import { StyleSheet } from 'react-native-unistyles' import { Slider, type SliderProps } from './Slider' const meta: Meta = { - title: 'Form/Slider', + title: 'Other Form/Slider', component: Slider, args: { disabled: false }, render: (args) => Template(args), diff --git a/src/components/ToggleButton/ToggleButton.stories.ts b/src/components/ToggleButton/ToggleButton.stories.ts index d7598359..119572b1 100644 --- a/src/components/ToggleButton/ToggleButton.stories.ts +++ b/src/components/ToggleButton/ToggleButton.stories.ts @@ -8,7 +8,7 @@ const svgIcons = { IconArrowDownRight, IconUser, none: undefined } const imageIcons = { hotels: require('./hotels.png'), none: undefined } const meta: Meta = { - title: 'Form/ToggleButton', + title: 'Other Form/ToggleButton', component: ToggleButton, args: { checked: false, From c5609d059e2358830a0976884d74efbfdaaf72db Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Thu, 8 Oct 2026 19:21:45 +0300 Subject: [PATCH 05/17] =?UTF-8?q?refactor(input):=20=D0=B2=D1=8B=D0=BD?= =?UTF-8?q?=D0=B5=D1=81=D0=B5=D0=BD=D1=8B=20legacy-=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D1=8B=20=D0=B8=20=D1=81?= =?UTF-8?q?=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D1=8B=20=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=80=D1=8B=D0=B5=20=D1=8D=D0=BA=D1=81=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 4 + .../Input/InputTextBase/InputTextBase.tsx | 412 --------------- .../Input/__tests__/InputGroup.test.tsx | 24 - .../Input/__tests__/InputGroupAddon.test.tsx | 38 -- .../Input/__tests__/InputText.test.tsx | 15 - .../Input/__tests__/InputTextBase.test.tsx | 236 --------- .../__snapshots__/InputGroup.test.tsx.snap | 171 ------- .../InputGroupAddon.test.tsx.snap | 251 --------- .../__snapshots__/InputText.test.tsx.snap | 15 - .../__snapshots__/InputTextBase.test.tsx.snap | 39 -- src/components/Input/core/useFloatingLabel.ts | 113 +++++ .../Input/core/useInputTextController.ts | 95 ++++ .../Input/core/usePlaceholderLayout.ts | 11 + .../InputTextBase/InputTextBase.tsx | 172 +++++++ .../InputTextBase/InputTextRightContent.tsx | 94 ++++ .../{ => deprecated}/InputTextBase/testIds.ts | 1 - .../{ => deprecated}/InputTextBase/types.ts | 43 +- .../InputTextBase/useInputStyles.ts | 46 +- .../LegacyInputGroup.tsx} | 16 +- .../LegacyInputGroupAddon.tsx} | 8 +- .../LegacyInputText.tsx} | 30 +- .../LegacySecureTextEntryButton.tsx | 39 ++ .../__tests__/InputTextBase.test.tsx | 476 ++++++++++++++++++ .../__tests__/InputTextPlaceholder.test.tsx | 55 ++ .../__tests__/LegacyInputGroup.test.tsx | 114 +++++ .../__tests__/LegacyInputGroupAddon.test.tsx | 27 + .../__tests__/LegacyInputText.test.tsx | 193 +++++++ .../deprecated/resolveInputTextMinHeight.ts | 20 + src/components/Input/deprecated/types.ts | 33 ++ .../deprecated/useLegacyInputTextWrapper.tsx | 65 +++ src/components/Input/index.ts | 6 +- .../Input/stories/InputGroup.stories.tsx | 2 +- .../Input/stories/InputMask.stories.tsx | 123 ----- .../Input/stories/InputText.stories.tsx | 4 +- src/legacy.ts | 11 + 35 files changed, 1611 insertions(+), 1391 deletions(-) delete mode 100644 src/components/Input/InputTextBase/InputTextBase.tsx delete mode 100644 src/components/Input/__tests__/InputGroup.test.tsx delete mode 100644 src/components/Input/__tests__/InputGroupAddon.test.tsx delete mode 100644 src/components/Input/__tests__/InputText.test.tsx delete mode 100644 src/components/Input/__tests__/InputTextBase.test.tsx delete mode 100644 src/components/Input/__tests__/__snapshots__/InputGroup.test.tsx.snap delete mode 100644 src/components/Input/__tests__/__snapshots__/InputGroupAddon.test.tsx.snap delete mode 100644 src/components/Input/__tests__/__snapshots__/InputText.test.tsx.snap delete mode 100644 src/components/Input/__tests__/__snapshots__/InputTextBase.test.tsx.snap create mode 100644 src/components/Input/core/useFloatingLabel.ts create mode 100644 src/components/Input/core/useInputTextController.ts create mode 100644 src/components/Input/core/usePlaceholderLayout.ts create mode 100644 src/components/Input/deprecated/InputTextBase/InputTextBase.tsx create mode 100644 src/components/Input/deprecated/InputTextBase/InputTextRightContent.tsx rename src/components/Input/{ => deprecated}/InputTextBase/testIds.ts (88%) rename src/components/Input/{ => deprecated}/InputTextBase/types.ts (55%) rename src/components/Input/{ => deprecated}/InputTextBase/useInputStyles.ts (78%) rename src/components/Input/{InputGroup.tsx => deprecated/LegacyInputGroup.tsx} (87%) rename src/components/Input/{InputGroupAddon.tsx => deprecated/LegacyInputGroupAddon.tsx} (92%) rename src/components/Input/{InputText.tsx => deprecated/LegacyInputText.tsx} (51%) create mode 100644 src/components/Input/deprecated/LegacySecureTextEntryButton.tsx create mode 100644 src/components/Input/deprecated/__tests__/InputTextBase.test.tsx create mode 100644 src/components/Input/deprecated/__tests__/InputTextPlaceholder.test.tsx create mode 100644 src/components/Input/deprecated/__tests__/LegacyInputGroup.test.tsx create mode 100644 src/components/Input/deprecated/__tests__/LegacyInputGroupAddon.test.tsx create mode 100644 src/components/Input/deprecated/__tests__/LegacyInputText.test.tsx create mode 100644 src/components/Input/deprecated/resolveInputTextMinHeight.ts create mode 100644 src/components/Input/deprecated/types.ts create mode 100644 src/components/Input/deprecated/useLegacyInputTextWrapper.tsx delete mode 100644 src/components/Input/stories/InputMask.stories.tsx create mode 100644 src/legacy.ts diff --git a/package.json b/package.json index 4afcf667..43ed6de6 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,10 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./legacy": { + "types": "./dist/legacy.d.ts", + "default": "./dist/legacy.js" } }, "scripts": { diff --git a/src/components/Input/InputTextBase/InputTextBase.tsx b/src/components/Input/InputTextBase/InputTextBase.tsx deleted file mode 100644 index dba43fd9..00000000 --- a/src/components/Input/InputTextBase/InputTextBase.tsx +++ /dev/null @@ -1,412 +0,0 @@ -/* eslint-disable max-lines */ - -import { - IconEye, - IconEyeOff, - IconLoader2, - IconLock, - IconX, -} from '@tabler/icons-react-native' - -import { - memo, - useCallback, - useEffect, - useImperativeHandle, - useMemo, - useRef, - useState, -} from 'react' -import { - TextInput, - View, - Text, - type FocusEvent, - type BlurEvent, - TouchableOpacity, - Pressable, - type Insets, -} from 'react-native' -import Animated, { - interpolate, - useAnimatedStyle, - useSharedValue, - withTiming, -} from 'react-native-reanimated' - -import { useLoadingRotationAnimation } from '../../../hooks/useLoadingRotationAnimation' -import { useMakeTestId } from '../../../hooks/useMakeTestId' - -import { - SvgUniversal, - type SvgUniversalTheme, -} from '../../../utils/SvgUniversal' - -import { InputTextBaseTestId } from './testIds' -import type { InputTextBaseProps, RenderTextInputArgs } from './types' -import { inputStyles, useInputContainerMinHeight } from './useInputStyles' - -interface PrivateInputTextBaseProps { - loading?: boolean -} - -/** - * Базовое поле - * @link https://www.figma.com/design/4TYeki0MDLhfPGJstbIicf/UI-kit-PrimeFace-(DS)?node-id=484-5470&m=dev - * @link https://www.figma.com/design/Q1BWgZ7zoV5UzlBOnjW0cM/UI-Kit--DS--v2.1?node-id=24036-56 - * @see InputText - */ -const inputIconUniProps = ({ components }: SvgUniversalTheme) => { - return { color: components.inputtext.root.color } -} - -export const InputTextBase = memo< - InputTextBaseProps & PrivateInputTextBaseProps ->( - // eslint-disable-next-line max-lines-per-function, max-statements - ({ - state, - clearable = true, - secureTextEntry: secureTextEntryProp = false, - inputRef: propsInputRef, - disabled, - containerStyle, - loading, - renderTextInput, - clearButtonAccessibilityLabel, - floatLabel = false, - placeholder, - editable = true, - size, - ...otherProps - // TODO: разделить float label и обычный инпут -> добавить во float label поддержку font scale - // eslint-disable-next-line complexity - }) => { - const styles = inputStyles - const containerMinHeightStyle = useInputContainerMinHeight(size) - const inputRef = useRef(null) - - const [valueState, setValueState] = useState('') - const [isFocused, setIsFocused] = useState(otherProps.autoFocus || false) - const labelAnimation = useSharedValue(0) - - const propsOnFocus = otherProps.onFocus - const propsOnBlur = otherProps.onBlur - const propsOnChangeText = otherProps.onChangeText - - const onFocus = useCallback( - (e: FocusEvent) => { - setIsFocused(true) - propsOnFocus?.(e) - }, - [propsOnFocus] - ) - - const onBlur = useCallback( - (e: BlurEvent) => { - setIsFocused(false) - propsOnBlur?.(e) - }, - [propsOnBlur] - ) - - const onChangeText = useCallback( - (nextValue: string) => { - propsOnChangeText?.(nextValue) - setValueState(nextValue) - }, - [propsOnChangeText] - ) - - const clear = useCallback(() => { - onChangeText('') - }, [onChangeText]) - - const value = useMemo( - () => otherProps.value ?? valueState, - [otherProps.value, valueState] - ) - - const showClearButton = useMemo( - () => clearable && !!value.length && !disabled, - [clearable, disabled, value.length] - ) - - const onContainerPress = useCallback(() => { - inputRef.current?.focus() - }, []) - - const loadingAnimatedStyle = useLoadingRotationAnimation(loading) - - // Extract primitive values before the worklet closure to avoid - // capturing the unistyles HostObject (non-serializable) in the worklet. - const labelTop = styles.label.top - const labelReducedTop = styles.labelReducedSize.top - const labelPaddingVertical = styles.label.paddingVertical - const labelReducedPaddingVertical = styles.labelReducedSize.paddingVertical - const labelFontSize = styles.label.fontSize - const labelReducedFontSize = styles.labelReducedSize.fontSize - const labelFontFamily = styles.label.fontFamily - const labelReducedFontFamily = styles.labelReducedSize.fontFamily - - const labelAnimatedStyle = useAnimatedStyle(() => ({ - top: interpolate( - labelAnimation.value, - [0, 1], - [labelTop, labelReducedTop] - ), - paddingVertical: interpolate( - labelAnimation.value, - [0, 1], - [labelPaddingVertical, labelReducedPaddingVertical] - ), - fontSize: interpolate( - labelAnimation.value, - [0, 1], - [labelFontSize, labelReducedFontSize] - ), - fontFamily: - labelAnimation.value > 0.5 ? labelReducedFontFamily : labelFontFamily, - })) - - useEffect(() => { - labelAnimation.value = withTiming(isFocused || value ? 1 : 0, { - duration: 100, - }) - }, [isFocused, labelAnimation, value]) - - const iconSize = useMemo( - () => (floatLabel ? styles.iconSizeFloatLabel : styles.iconSize), - [floatLabel, styles.iconSize, styles.iconSizeFloatLabel] - ) - - const imperativeHandle = useMemo( - () => - new Proxy( - {}, - { - get(_, prop) { - if (prop === 'clear') { - return clear - } - - const input = inputRef.current - const value = input?.[prop as keyof TextInput] - - return typeof value === 'function' ? value.bind(input) : value - }, - } - ) as TextInput, - [clear] - ) - - useImperativeHandle( - propsInputRef, - () => (inputRef.current ? imperativeHandle : null), - [imperativeHandle] - ) - - const { makeTestId } = useMakeTestId( - otherProps.testID || InputTextBaseTestId.default - ) - - const [userDefinedSecureTextEntry, setUserDefinedSecureTextEntry] = - useState(true) - const secureTextEntry = useMemo( - () => - secureTextEntryProp === 'toggleable' - ? userDefinedSecureTextEntry - : secureTextEntryProp, - [secureTextEntryProp, userDefinedSecureTextEntry] - ) - const toggleUserDefinedSecureTextEntry = useCallback( - () => setUserDefinedSecureTextEntry((old) => !old), - [] - ) - - const showSecureToggle = secureTextEntryProp === 'toggleable' - const hasRightContent = - loading || showClearButton || showSecureToggle || disabled - - const isOnlyButton = useMemo(() => { - return ( - [showClearButton, showSecureToggle].filter((val) => val).length === 1 - ) - }, [showClearButton, showSecureToggle]) - - const rightButtonHitSlop = useMemo( - () => ({ - // Большие вертикальные отступы (100) - чтобы компенсировать возможное - // растягивание инпута по вертикали, при этом область тапа - // гарантированно не выйдет за пределы инпута - top: 100, - bottom: 100, - left: isOnlyButton - ? styles.rightContainer.gap - : styles.rightContainer.gap / 2, - right: isOnlyButton - ? styles.rightContainer.gap - : styles.rightContainer.gap / 2, - }), - [styles.rightContainer.gap, isOnlyButton] - ) - - const texInputProps = useMemo( - () => ({ - ...otherProps, - allowFontScaling: floatLabel ? false : otherProps.allowFontScaling, - placeholder: '', - testID: makeTestId(), - editable: disabled ? false : editable, - secureTextEntry, - style: [ - styles.inputFont, - floatLabel ? styles.floatLabelInput : styles.input, - hasRightContent && styles.inputWithRightContent, - ], - inputRef, - value, - onBlur, - onChangeText, - onFocus, - }), - [ - otherProps, - floatLabel, - makeTestId, - disabled, - editable, - secureTextEntry, - hasRightContent, - value, - onBlur, - onChangeText, - onFocus, - styles.inputFont, - styles.floatLabelInput, - styles.input, - styles.inputWithRightContent, - ] - ) - - const input = useMemo( - () => - renderTextInput ? ( - renderTextInput(texInputProps) - ) : ( - - ), - [renderTextInput, texInputProps] - ) - - return ( - - {floatLabel ? ( - <> - - {placeholder} - - - {input} - - ) : ( - - - {placeholder} - - - {input} - - )} - - {hasRightContent ? ( - - {loading ? ( - - - - ) : null} - - {showClearButton ? ( - - - - ) : null} - - {showSecureToggle ? ( - - - - ) : null} - - {disabled ? ( - - ) : null} - - ) : null} - - ) - } -) diff --git a/src/components/Input/__tests__/InputGroup.test.tsx b/src/components/Input/__tests__/InputGroup.test.tsx deleted file mode 100644 index fbf5929a..00000000 --- a/src/components/Input/__tests__/InputGroup.test.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { render } from '@testing-library/react-native' - -import { InputGroup, type InputGroupProps } from '../InputGroup' - -jest.mock('../InputTextBase/InputTextBase', () => ({ - InputTextBase: 'InputTextBase', -})) - -jest.mock('../InputGroupAddon', () => ({ InputGroupAddon: 'InputGroupAddon' })) - -describe('InputTextBase component functionality tests', () => { - const snapshotCases: Array<[string, InputGroupProps]> = [ - ['left', { left: 'left text' }], - ['right', { right: 'right text' }], - ['left & right', { left: 'left text', right: 'right text' }], - ['no addons, style', { style: { margin: 10 } }], - ] - - test.each(snapshotCases)('%s', (_, props) => { - const { toJSON } = render() - - expect(toJSON()).toMatchSnapshot() - }) -}) diff --git a/src/components/Input/__tests__/InputGroupAddon.test.tsx b/src/components/Input/__tests__/InputGroupAddon.test.tsx deleted file mode 100644 index f86d5e81..00000000 --- a/src/components/Input/__tests__/InputGroupAddon.test.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { IconUser } from '@tabler/icons-react-native' -import { fireEvent, render } from '@testing-library/react-native' - -import { type InputGroupAddonProps, InputGroupAddon } from '../InputGroupAddon' - -jest.mock('react-native/Libraries/Components/Pressable/Pressable', () => ({ - default: 'Pressable', -})) - -describe('InputGroup component tests', () => { - const snapshotCases: Array<[string, InputGroupAddonProps]> = [ - ['content: string, position: left', { content: 'text', position: 'left' }], - [ - 'content: icon, position: right', - { content: IconUser, position: 'right' }, - ], - ['disabled: true', { content: 'text', position: 'left', disabled: true }], - ['disabled: false', { content: 'text', position: 'left', disabled: false }], - ] - - test.each(snapshotCases)('%s', (_, props) => { - const renderedInputText = render() - - expect(renderedInputText.toJSON()).toMatchSnapshot() - }) - - test('should handle onPress', () => { - const onPress = jest.fn() - const { queryByTestId } = render( - - ) - const pressable = queryByTestId('InputGroupAddon_Pressable') - - fireEvent.press(pressable) - - expect(onPress).toHaveBeenCalledWith() - }) -}) diff --git a/src/components/Input/__tests__/InputText.test.tsx b/src/components/Input/__tests__/InputText.test.tsx deleted file mode 100644 index f5ea8044..00000000 --- a/src/components/Input/__tests__/InputText.test.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { render } from '@testing-library/react-native' - -import { InputText } from '../InputText' - -jest.mock('../InputTextBase/InputTextBase', () => ({ - InputTextBase: 'InputTextBase', -})) - -describe('InputText component tests', () => { - test('snapshot', () => { - const { toJSON } = render() - - expect(toJSON()).toMatchSnapshot() - }) -}) diff --git a/src/components/Input/__tests__/InputTextBase.test.tsx b/src/components/Input/__tests__/InputTextBase.test.tsx deleted file mode 100644 index 076f6952..00000000 --- a/src/components/Input/__tests__/InputTextBase.test.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { fireEvent, render, userEvent } from '@testing-library/react-native' - -import { TextInput } from 'react-native-gesture-handler' - -import { semanticTokens } from '../../../theme/tokens' -import { InputTextBase } from '../InputTextBase/InputTextBase' -import { InputTextBaseTestId } from '../InputTextBase/testIds' -import type { RenderTextInputArgs } from '../InputTextBase/types' - -describe('InputTextBase component functionality tests', () => { - test('should NOT be render outline elements in disabled state', () => { - const { queryByTestId } = render() - - expect(queryByTestId('InputTextBaseDangerOutline')).not.toBeOnTheScreen() - expect(queryByTestId('InputTextBaseFocusOutline')).not.toBeOnTheScreen() - }) - - test('should set editable prop correctly', () => { - const { queryByTestId, update } = render() - - expect(queryByTestId('InputTextBase')).toHaveProp('editable', true) - - update() - - expect(queryByTestId('InputTextBase')).toHaveProp('editable', false) - }) - - test('should handle focus event', () => { - const onFocusMock = jest.fn() - const { queryByTestId } = render() - const input = queryByTestId('InputTextBase') - - fireEvent(input, 'focus') - - expect(onFocusMock).toHaveBeenCalledWith(undefined) - }) - - test('should handle blur event', () => { - const onBlurMock = jest.fn() - const { queryByTestId } = render() - const input = queryByTestId('InputTextBase') - - fireEvent(input, 'blur') - - expect(onBlurMock).toHaveBeenCalledWith(undefined) - }) - - test('should handle text change', () => { - const onChangeTextMock = jest.fn() - const { queryByTestId } = render( - - ) - const input = queryByTestId('InputTextBase') - - fireEvent.changeText(input, 'new text') - - expect(onChangeTextMock).toHaveBeenCalledWith('new text') - }) - - test('should show clear button only when value exists', async () => { - const { queryByTestId } = render() - const input = queryByTestId('InputTextBase') - const user = userEvent.setup() - let clearButton = queryByTestId('InputTextBaseClearButton') - - expect(clearButton).not.toBeOnTheScreen() - - await user.type(input, 'text') - clearButton = queryByTestId('InputTextBaseClearButton') - - expect(clearButton).toBeOnTheScreen() - }) - - test('should NOT show clear button when input is disabled', async () => { - const { queryByTestId } = render() - const clearButton = queryByTestId('InputTextBaseClearButton') - - expect(clearButton).not.toBeOnTheScreen() - }) - - test('should clear text when clear button is pressed', async () => { - const { queryByTestId } = render() - const input = queryByTestId('InputTextBase') - const user = userEvent.setup() - - await user.type(input, 'text') - - const clearButton = queryByTestId('InputTextBaseClearButton') - - expect(input).toHaveProp('value', 'text') - - fireEvent.press(clearButton) - - expect(input).toHaveProp('value', '') - }) - - test('should show disabled icon when input is disabled', () => { - const { queryAllByTestId, update } = render() - let [disabledIcon] = queryAllByTestId('InputTextBaseDisabledIcon') - - expect(disabledIcon).toBeFalsy() - - update() - disabledIcon = queryAllByTestId('InputTextBaseDisabledIcon') - - expect(disabledIcon).toBeDefined() - }) - - test('should show loading icon', () => { - const { queryByTestId, update } = render() - let loadingIcon = queryByTestId('InputTextBaseLoading') - - expect(loadingIcon).not.toBeOnTheScreen() - - update() - loadingIcon = queryByTestId('InputTextBaseLoading') - - expect(loadingIcon).toBeOnTheScreen() - expect(loadingIcon).toHaveAnimatedStyle({ transform: [{ rotate: '0deg' }] }) - }) - - test('should pass args to custom render function correctly', () => { - const renderTextInput = jest.fn() - - render() - - expect(renderTextInput).toHaveBeenCalledWith( - expect.objectContaining({ testID: InputTextBaseTestId.default }) - ) - expect(renderTextInput.mock.calls[0]).toMatchSnapshot() - }) - - test('should set clear button accessibility label', () => { - const renderTextInput = (props: RenderTextInputArgs) => ( - - ) - - const { getByTestId } = render( - - ) - const input = getByTestId('MyTextInput') - fireEvent.changeText(input, 'ку') - - const clearButton = getByTestId('InputTextBaseClearButton') - - expect(clearButton.props.accessibilityLabel).toBe('Clear') - }) - - test('should render secured field when secureTextEntry=true', () => { - const { getByTestId } = render() - - expect(getByTestId('InputTextBase')).toHaveProp('secureTextEntry', true) - }) - - test('should show eye icon when secureTextEntry=toggleable', () => { - const { queryByTestId, getByTestId, update } = render() - - // Проверяем что если secureTextEntry не задан, нет кнопки с глазом - expect( - queryByTestId('InputTextBaseSecureInputButton') - ).not.toBeOnTheScreen() - - // Проверяем что если secureTextEntry='toggleable' кнопка с глазом выводится - update() - - const secureInputButton = getByTestId('InputTextBaseSecureInputButton') - - expect(secureInputButton).toBeOnTheScreen() - - // Проверяем что у инпута secureTextEntry=true - const input = getByTestId('InputTextBase') - - expect(input).toHaveProp('secureTextEntry', true) - - // Нажимаем на кнопку с глазом - fireEvent.press(secureInputButton) - - // Проверяем что у инпута secureTextEntry=false - expect(input).toHaveProp('secureTextEntry', false) - }) - - describe('при передачи свойства size', () => { - const containerTestId = 'InputTextBasePressableContainer' - - test('при size=undefined используется размер base', () => { - const { getByTestId } = render() - - expect(getByTestId(containerTestId)).toHaveStyle({ - minHeight: semanticTokens.dimension.size[1100], - }) - }) - - test('при size=base используется размер base', () => { - const { getByTestId } = render() - - expect(getByTestId(containerTestId)).toHaveStyle({ - minHeight: semanticTokens.dimension.size[1100], - }) - }) - - test('при size=large используется размер large', () => { - const { getByTestId } = render() - - expect(getByTestId(containerTestId)).toHaveStyle({ - minHeight: semanticTokens.dimension.size[1300], - }) - }) - - test('при size=xlarge используется размер xlarge', () => { - const { getByTestId } = render() - - expect(getByTestId(containerTestId)).toHaveStyle({ - minHeight: semanticTokens.dimension.size[1400], - }) - }) - - test('при size < base используется размер base', () => { - const { getByTestId } = render() - - expect(getByTestId(containerTestId)).toHaveStyle({ - minHeight: semanticTokens.dimension.size[1100], - }) - }) - - test('при size > base используется размер переданный в size', () => { - const size = 50 - const { getByTestId } = render() - - expect(getByTestId(containerTestId)).toHaveStyle({ minHeight: size }) - }) - }) -}) diff --git a/src/components/Input/__tests__/__snapshots__/InputGroup.test.tsx.snap b/src/components/Input/__tests__/__snapshots__/InputGroup.test.tsx.snap deleted file mode 100644 index 6bec954d..00000000 --- a/src/components/Input/__tests__/__snapshots__/InputGroup.test.tsx.snap +++ /dev/null @@ -1,171 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`InputTextBase component functionality tests left & right 1`] = ` - - - - - - - -`; - -exports[`InputTextBase component functionality tests left 1`] = ` - - - - - - -`; - -exports[`InputTextBase component functionality tests no addons, style 1`] = ` - - - - - -`; - -exports[`InputTextBase component functionality tests right 1`] = ` - - - - - - -`; diff --git a/src/components/Input/__tests__/__snapshots__/InputGroupAddon.test.tsx.snap b/src/components/Input/__tests__/__snapshots__/InputGroupAddon.test.tsx.snap deleted file mode 100644 index 4ea15dc6..00000000 --- a/src/components/Input/__tests__/__snapshots__/InputGroupAddon.test.tsx.snap +++ /dev/null @@ -1,251 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`InputGroup component tests content: icon, position: right 1`] = ` - - - - - - - - -`; - -exports[`InputGroup component tests content: string, position: left 1`] = ` - - - text - - -`; - -exports[`InputGroup component tests disabled: false 1`] = ` - - - text - - -`; - -exports[`InputGroup component tests disabled: true 1`] = ` - - - text - - -`; diff --git a/src/components/Input/__tests__/__snapshots__/InputText.test.tsx.snap b/src/components/Input/__tests__/__snapshots__/InputText.test.tsx.snap deleted file mode 100644 index 07676102..00000000 --- a/src/components/Input/__tests__/__snapshots__/InputText.test.tsx.snap +++ /dev/null @@ -1,15 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`InputText component tests snapshot 1`] = ` - - - -`; diff --git a/src/components/Input/__tests__/__snapshots__/InputTextBase.test.tsx.snap b/src/components/Input/__tests__/__snapshots__/InputTextBase.test.tsx.snap deleted file mode 100644 index fc56cfef..00000000 --- a/src/components/Input/__tests__/__snapshots__/InputTextBase.test.tsx.snap +++ /dev/null @@ -1,39 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`InputTextBase component functionality tests should pass args to custom render function correctly 1`] = ` -[ - { - "allowFontScaling": undefined, - "editable": true, - "inputRef": { - "current": null, - }, - "onBlur": [Function], - "onChangeText": [Function], - "onFocus": [Function], - "placeholder": "", - "secureTextEntry": false, - "style": [ - { - "color": "#2b2e33", - "fontFamily": "Noto Sans", - "fontSize": 16, - "includeFontPadding": false, - "verticalAlign": "middle", - }, - { - "bottom": 0, - "left": 0, - "padding": 0, - "paddingHorizontal": 10, - "position": "absolute", - "right": 0, - "top": 0, - }, - undefined, - ], - "testID": "InputTextBase", - "value": "", - }, -] -`; diff --git a/src/components/Input/core/useFloatingLabel.ts b/src/components/Input/core/useFloatingLabel.ts new file mode 100644 index 00000000..243921fe --- /dev/null +++ b/src/components/Input/core/useFloatingLabel.ts @@ -0,0 +1,113 @@ +import { useCallback, useEffect, useState } from 'react' +import { + type LayoutChangeEvent, + type TextInputProps, + useWindowDimensions, +} from 'react-native' +import { + interpolate, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated' + +export interface FloatingLabelMetrics { + labelTop: number + reducedTop: number + labelPadding: number + reducedPadding: number + fontSize: number + reducedFontSize: number + fontFamily: string + reducedFontFamily: string + inputPaddingTop: number +} + +export const useFloatingLabel = ( + active: boolean, + { + allowFontScaling, + maxFontSizeMultiplier, + }: Pick, + metrics: FloatingLabelMetrics +) => { + const { fontScale } = useWindowDimensions() + const [labelHeight, setLabelHeight] = useState(0) + const [containerHeight, setContainerHeight] = useState(0) + const animation = useSharedValue(0) + const scale = + allowFontScaling === false + ? 1 + : Math.min( + fontScale, + maxFontSizeMultiplier && maxFontSizeMultiplier > 0 + ? maxFontSizeMultiplier + : Infinity + ) + + const { + labelTop, + reducedTop, + labelPadding, + reducedPadding, + fontSize, + reducedFontSize, + fontFamily, + reducedFontFamily, + } = metrics + const centeredTop = containerHeight + ? (containerHeight - labelHeight) / 2 + : labelTop + + const animatedStyle = useAnimatedStyle(() => ({ + top: interpolate(animation.value, [0, 1], [centeredTop, reducedTop]), + paddingVertical: interpolate( + animation.value, + [0, 1], + [labelPadding, reducedPadding] + ), + fontSize: interpolate(animation.value, [0, 1], [fontSize, reducedFontSize]), + fontFamily: animation.value > 0.5 ? reducedFontFamily : fontFamily, + })) + + const animatedContainerStyle = useAnimatedStyle(() => ({ + top: interpolate(animation.value, [0, 1], [centeredTop, reducedTop]), + paddingVertical: interpolate( + animation.value, + [0, 1], + [labelPadding, reducedPadding] + ), + })) + const animatedTextStyle = useAnimatedStyle(() => ({ + fontSize: interpolate(animation.value, [0, 1], [fontSize, reducedFontSize]), + fontFamily: animation.value > 0.5 ? reducedFontFamily : fontFamily, + })) + + useEffect(() => { + animation.value = withTiming(active ? 1 : 0, { duration: 100 }) + }, [active, animation]) + + const onLabelLayout = useCallback((event: LayoutChangeEvent) => { + setLabelHeight(event.nativeEvent.layout.height) + }, []) + const onContainerLayout = useCallback((event: LayoutChangeEvent) => { + setContainerHeight(event.nativeEvent.layout.height) + }, []) + + const reservedHeight = Math.max( + reducedFontSize * scale + reducedPadding * 2, + labelHeight + ) + + return { + animatedStyle, + animatedContainerStyle, + animatedTextStyle, + onLabelLayout, + onContainerLayout, + inputPaddingTop: Math.max( + metrics.inputPaddingTop, + reducedTop + reservedHeight + ), + } +} diff --git a/src/components/Input/core/useInputTextController.ts b/src/components/Input/core/useInputTextController.ts new file mode 100644 index 00000000..1887ddd4 --- /dev/null +++ b/src/components/Input/core/useInputTextController.ts @@ -0,0 +1,95 @@ +import { + useCallback, + useImperativeHandle, + useMemo, + useRef, + useState, + type Ref, +} from 'react' +import type { + BlurEvent, + FocusEvent, + TextInput, + TextInputProps, +} from 'react-native' + +type InputTextControllerProps = Pick< + TextInputProps, + 'value' | 'defaultValue' | 'autoFocus' | 'onFocus' | 'onBlur' | 'onChangeText' +> & { inputRef?: Ref } + +export const useInputTextController = ({ + value: controlledValue, + defaultValue = '', + autoFocus = false, + onFocus: propsOnFocus, + onBlur: propsOnBlur, + onChangeText: propsOnChangeText, + inputRef: forwardedRef, +}: InputTextControllerProps) => { + const inputRef = useRef(null) + const [valueState, setValueState] = useState(defaultValue) + const [isFocused, setIsFocused] = useState(autoFocus) + + const onFocus = useCallback( + (event: FocusEvent) => { + setIsFocused(true) + propsOnFocus?.(event) + }, + [propsOnFocus] + ) + const onBlur = useCallback( + (event: BlurEvent) => { + setIsFocused(false) + propsOnBlur?.(event) + }, + [propsOnBlur] + ) + const onChangeText = useCallback( + (value: string) => { + propsOnChangeText?.(value) + + if (controlledValue === undefined) setValueState(value) + }, + [controlledValue, propsOnChangeText] + ) + const clear = useCallback(() => onChangeText(''), [onChangeText]) + const focus = useCallback(() => inputRef.current?.focus(), []) + + const imperativeHandle = useMemo( + () => + new Proxy( + {}, + { + get(_, prop) { + if (prop === 'clear') { + return clear + } + + const input = inputRef.current + const member = input?.[prop as keyof TextInput] + + return typeof member === 'function' ? member.bind(input) : member + }, + } + ) as TextInput, + [clear] + ) + + useImperativeHandle( + forwardedRef, + () => (inputRef.current ? imperativeHandle : null), + [imperativeHandle] + ) + + return { + inputRef, + value: controlledValue ?? valueState, + isFocused, + onFocus, + onBlur, + onChangeText, + clear, + focus, + } +} diff --git a/src/components/Input/core/usePlaceholderLayout.ts b/src/components/Input/core/usePlaceholderLayout.ts new file mode 100644 index 00000000..2ead6eee --- /dev/null +++ b/src/components/Input/core/usePlaceholderLayout.ts @@ -0,0 +1,11 @@ +import { useCallback, useState } from 'react' +import type { LayoutChangeEvent } from 'react-native' + +export const usePlaceholderLayout = () => { + const [height, setHeight] = useState(0) + const onLayout = useCallback((event: LayoutChangeEvent) => { + setHeight(event.nativeEvent.layout.height) + }, []) + + return { minHeight: height, onLayout } +} diff --git a/src/components/Input/deprecated/InputTextBase/InputTextBase.tsx b/src/components/Input/deprecated/InputTextBase/InputTextBase.tsx new file mode 100644 index 00000000..9ae43aa1 --- /dev/null +++ b/src/components/Input/deprecated/InputTextBase/InputTextBase.tsx @@ -0,0 +1,172 @@ +import { memo } from 'react' +import { Pressable, Text, TextInput, View } from 'react-native' +import Animated from 'react-native-reanimated' + +import { useMakeTestId } from '../../../../hooks/useMakeTestId' +import { useFloatingLabel } from '../../core/useFloatingLabel' +import { useInputTextController } from '../../core/useInputTextController' +import { usePlaceholderLayout } from '../../core/usePlaceholderLayout' + +import { InputTextRightContent } from './InputTextRightContent' +import { InputTextBaseTestId } from './testIds' +import type { + InputTextCoreProps, + InputTextPrivateProps, + RenderTextInputArgs, +} from './types' +import { + inputStyles as styles, + useInputContainerMinHeightStyle, +} from './useInputStyles' + +export const InputTextBase = memo( + ({ + state, + clearable = true, + inputRef, + disabled = false, + containerStyle, + loading, + renderTextInput, + renderRightAccessory, + clearButtonAccessibilityLabel, + floatLabel = false, + placeholder, + editable: editableProp = true, + readOnly: readOnlyProp = false, + size, + ...otherProps + // eslint-disable-next-line complexity -- Независимые состояния поля централизованно собираются перед рендером + }) => { + const controller = useInputTextController({ ...otherProps, inputRef }) + const readOnly = disabled || readOnlyProp || !editableProp + const editable = !readOnly + const danger = state === 'danger' + const showClearButton = clearable && !!controller.value.length && editable + const hasRightContent = + loading || showClearButton || renderRightAccessory || disabled + const containerMinHeightStyle = useInputContainerMinHeightStyle(size) + const floatingLabel = useFloatingLabel( + controller.isFocused || !!controller.value, + otherProps, + { + labelTop: styles.label.top, + reducedTop: styles.labelReducedSize.top, + labelPadding: styles.label.paddingVertical, + reducedPadding: styles.labelReducedSize.paddingVertical, + fontSize: styles.label.fontSize, + reducedFontSize: styles.labelReducedSize.fontSize, + fontFamily: styles.label.fontFamily, + reducedFontFamily: styles.labelReducedSize.fontFamily, + inputPaddingTop: styles.floatLabelInput.paddingTop, + } + ) + const placeholderLayout = usePlaceholderLayout() + const { makeTestId } = useMakeTestId( + otherProps.testID || InputTextBaseTestId.default + ) + + const textInputProps: RenderTextInputArgs = { + ...otherProps, + accessibilityLabel: otherProps.accessibilityLabel ?? placeholder, + accessibilityState: { ...otherProps.accessibilityState, disabled }, + placeholder: '', + testID: makeTestId(), + editable, + readOnly, + style: [ + styles.inputFont, + floatLabel ? styles.floatLabelInput : styles.input, + floatLabel && { paddingTop: floatingLabel.inputPaddingTop }, + hasRightContent && styles.inputWithRightContent, + ], + inputRef: controller.inputRef, + value: controller.value, + onBlur: controller.onBlur, + onChangeText: controller.onChangeText, + onFocus: controller.onFocus, + } + const input = renderTextInput ? ( + renderTextInput(textInputProps) + ) : ( + + ) + const visualLabelProps = { + accessible: false, + accessibilityElementsHidden: true, + importantForAccessibility: 'no-hide-descendants' as const, + allowFontScaling: otherProps.allowFontScaling, + maxFontSizeMultiplier: otherProps.maxFontSizeMultiplier, + } + + return ( + + + {floatLabel ? ( + + {placeholder} + + ) : ( + + {placeholder} + + )} + + {input} + + + {hasRightContent ? ( + + ) : null} + + ) + } +) diff --git a/src/components/Input/deprecated/InputTextBase/InputTextRightContent.tsx b/src/components/Input/deprecated/InputTextBase/InputTextRightContent.tsx new file mode 100644 index 00000000..0dccb63b --- /dev/null +++ b/src/components/Input/deprecated/InputTextBase/InputTextRightContent.tsx @@ -0,0 +1,94 @@ +import { IconLoader2, IconLock, IconX } from '@tabler/icons-react-native' +import { TouchableOpacity, View } from 'react-native' +import Animated from 'react-native-reanimated' + +import { useLoadingRotationAnimation } from '../../../../hooks/useLoadingRotationAnimation' +import type { useMakeTestId } from '../../../../hooks/useMakeTestId' +import { SvgUniversal } from '../../../../utils/SvgUniversal' + +import { InputTextBaseTestId } from './testIds' +import type { RenderInputTextRightAccessory } from './types' +import { inputIconUniProps, inputStyles as styles } from './useInputStyles' + +interface InputTextRightContentProps { + readonly disabled: boolean + readonly loading?: boolean + readonly hasFloatingLabel: boolean + readonly showClearButton: boolean + readonly clearButtonAccessibilityLabel?: string + readonly renderRightAccessory?: RenderInputTextRightAccessory + readonly makeTestId: ReturnType['makeTestId'] + readonly onClear: () => void +} + +export const InputTextRightContent = ({ + disabled, + loading, + hasFloatingLabel, + showClearButton, + clearButtonAccessibilityLabel, + renderRightAccessory, + makeTestId, + onClear, +}: InputTextRightContentProps) => { + const loadingAnimatedStyle = useLoadingRotationAnimation(loading) + const iconSize = hasFloatingLabel + ? styles.iconSizeFloatLabel + : styles.iconSize + const horizontalHitSlop = + showClearButton === !!renderRightAccessory + ? styles.rightContainer.gap / 2 + : styles.rightContainer.gap + const hitSlop = { + // Родитель ограничивает область тапа даже при росте высоты поля + top: 100, + bottom: 100, + left: horizontalHitSlop, + right: horizontalHitSlop, + } + + return ( + + {loading ? ( + + + + ) : null} + + {showClearButton ? ( + + + + ) : null} + + {renderRightAccessory?.({ hitSlop, iconSize, makeTestId })} + + {disabled ? ( + + ) : null} + + ) +} diff --git a/src/components/Input/InputTextBase/testIds.ts b/src/components/Input/deprecated/InputTextBase/testIds.ts similarity index 88% rename from src/components/Input/InputTextBase/testIds.ts rename to src/components/Input/deprecated/InputTextBase/testIds.ts index 52da937b..9a9c1677 100644 --- a/src/components/Input/InputTextBase/testIds.ts +++ b/src/components/Input/deprecated/InputTextBase/testIds.ts @@ -4,7 +4,6 @@ export const InputTextBaseTestId = { dangerOutline: 'DangerOutline', loading: 'Loading', clearButton: 'ClearButton', - secureInputButton: 'SecureInputButton', disabledIcon: 'DisabledIcon', floatingPlaceholder: 'FloatingPlaceholder', placeholder: 'Placeholder', diff --git a/src/components/Input/InputTextBase/types.ts b/src/components/Input/deprecated/InputTextBase/types.ts similarity index 55% rename from src/components/Input/InputTextBase/types.ts rename to src/components/Input/deprecated/InputTextBase/types.ts index 6cc689b3..90e7e767 100644 --- a/src/components/Input/InputTextBase/types.ts +++ b/src/components/Input/deprecated/InputTextBase/types.ts @@ -4,26 +4,23 @@ import type { ViewStyle, TextInput, StyleProp, + Insets, } from 'react-native' +import type { useMakeTestId } from '../../../../hooks/useMakeTestId' + +export type { InputTextCompatibilityProps as InputTextBaseProps } from '../types' + +export type InputTextSize = 'small' | 'base' | 'large' | 'xlarge' + /** @see TextInputProps */ -export interface InputTextBaseProps extends Omit< - TextInputProps, - 'style' | 'secureTextEntry' -> { +export interface InputTextCoreProps extends Omit { /** * Управление отображения иконки очистки поля * @default true */ clearable?: boolean - /** - * Управление режимом скрытия текста - * - `true` — всегда скрывать (как в TextInput) - * - `false` — не скрывать - * - `"toggleable"` — скрытие управляется пользователем через кнопку-глаз - */ - secureTextEntry?: boolean | 'toggleable' - /** Озвучка для кнопки очистки поля */ + /** Локализованное имя кнопки очистки. По умолчанию «Очистить поле» */ clearButtonAccessibilityLabel?: string /** Управление стилем контейнера поля ввода */ containerStyle?: StyleProp @@ -45,12 +42,26 @@ export interface InputTextBaseProps extends Omit< */ floatLabel?: boolean /** - * Управление высотой поля ввода. - * Работает только если floatLabel = false. - * Можно передать число. Если переданное число меньше размера base, будет использован размер base. + * Высота поля без floating label. small и base временно имеют одинаковую высоту. * @default 'base' */ - size?: 'base' | 'medium' | 'large' | 'xlarge' | number + size?: InputTextSize } export type RenderTextInputArgs = TextInputProps & { inputRef: Ref } + +export interface InputTextRightAccessoryArgs { + readonly hitSlop: Insets + readonly iconSize: { readonly width: number; readonly height: number } + readonly makeTestId: ReturnType['makeTestId'] +} + +export type RenderInputTextRightAccessory = ( + props: InputTextRightAccessoryArgs +) => ReactNode + +export interface InputTextPrivateProps { + /** Управление отображением иконки загрузки */ + loading?: boolean + renderRightAccessory?: RenderInputTextRightAccessory +} diff --git a/src/components/Input/InputTextBase/useInputStyles.ts b/src/components/Input/deprecated/InputTextBase/useInputStyles.ts similarity index 78% rename from src/components/Input/InputTextBase/useInputStyles.ts rename to src/components/Input/deprecated/InputTextBase/useInputStyles.ts index 042df69e..14ee4222 100644 --- a/src/components/Input/InputTextBase/useInputStyles.ts +++ b/src/components/Input/deprecated/InputTextBase/useInputStyles.ts @@ -2,29 +2,29 @@ import { useMemo } from 'react' import { StyleSheet } from 'react-native-unistyles' -import type { InputTextBaseProps } from './types' +import type { SvgUniversalTheme } from '../../../../utils/SvgUniversal' -export const useInputContainerMinHeight = ( - size: InputTextBaseProps['size'] = 'base' -) => { - const minHeight = useMemo(() => { - if (typeof size === 'number') { - return Math.max(size, containerMinHeight.base.minHeight) - } +import type { InputTextCoreProps } from './types' - return containerMinHeight[size].minHeight - }, [size]) +export const useInputContainerMinHeightStyle = ( + size: InputTextCoreProps['size'] = 'base' +) => { + const minHeight = useMemo(() => inputSizeStyles[size].minHeight, [size]) return { minHeight } } -const containerMinHeight = StyleSheet.create(({ semantic }) => ({ +export const inputSizeStyles = StyleSheet.create(({ semantic }) => ({ + small: { minHeight: semantic.dimension.size[1100] }, base: { minHeight: semantic.dimension.size[1100] }, - medium: { minHeight: semantic.dimension.size[1200] }, large: { minHeight: semantic.dimension.size[1300] }, xlarge: { minHeight: semantic.dimension.size[1400] }, })) +export const inputIconUniProps = ({ components }: SvgUniversalTheme) => ({ + color: components.inputtext.root.color, +}) + export const inputStyles = StyleSheet.create( ({ components, semantic, fonts }) => ({ container: { @@ -39,35 +39,24 @@ export const inputStyles = StyleSheet.create( outlineWidth: components.inputtext.root.focusRing.width, borderColor: components.inputtext.root.focusBorderColor, }, - containerFloatLabel: { minHeight: 56, maxHeight: 56, height: 56 }, + containerFloatLabel: { minHeight: 56 }, danger: { borderColor: components.inputtext.root.invalidBorderColor }, disabled: { opacity: semantic.effects.opacity[60], borderColor: components.inputtext.root.borderColor, backgroundColor: semantic.colorScheme.color.bg.neutral.weak.disabled, }, - inputContainer: { - flex: 1, - paddingLeft: 2, // отступ для курсора - justifyContent: 'center', - }, + inputContainer: { flex: 1, justifyContent: 'center' }, input: { padding: semantic.dimension.space.none, paddingHorizontal: components.inputtext.root.paddingX, - position: 'absolute', - left: 0, - right: 0, - top: 0, - bottom: 0, }, inputWithRightContent: { paddingRight: 0 }, floatLabelInput: { - flex: 1, paddingHorizontal: components.inputtext.root.paddingX, paddingTop: 26, paddingBottom: 12, borderRadius: semantic.dimension.borderRadius[300], - overflow: 'hidden', }, inputFont: { fontSize: fonts.fontSize[300], @@ -76,7 +65,12 @@ export const inputStyles = StyleSheet.create( fontFamily: fonts.fontFamily.base, verticalAlign: 'middle', }, - placeholder: { paddingHorizontal: components.inputtext.root.paddingX }, + placeholder: { + position: 'absolute', + left: 2, + right: 0, + paddingHorizontal: components.inputtext.root.paddingX, + }, placeholderTextColor: { color: components.inputtext.root.placeholderColor }, rightContainer: { flexDirection: 'row', diff --git a/src/components/Input/InputGroup.tsx b/src/components/Input/deprecated/LegacyInputGroup.tsx similarity index 87% rename from src/components/Input/InputGroup.tsx rename to src/components/Input/deprecated/LegacyInputGroup.tsx index 7775e323..cf3f1765 100644 --- a/src/components/Input/InputGroup.tsx +++ b/src/components/Input/deprecated/LegacyInputGroup.tsx @@ -9,10 +9,14 @@ import { import { StyleSheet } from 'react-native-unistyles' -import { InputGroupAddon, type InputGroupAddonProps } from './InputGroupAddon' -import { InputTextBase } from './InputTextBase/InputTextBase' import type { InputTextBaseProps } from './InputTextBase/types' +import { + LegacyInputGroupAddon as InputGroupAddon, + type LegacyInputGroupAddonProps as InputGroupAddonProps, +} from './LegacyInputGroupAddon' +import { LegacyInputText as InputText } from './LegacyInputText' + /** @see InputTextBaseProps */ interface InputGroupBaseProps { /** Содержимое левого аддона группы */ @@ -23,16 +27,16 @@ interface InputGroupBaseProps { style?: StyleProp } -export type InputGroupProps = InputGroupBaseProps & InputTextBaseProps +export type LegacyInputGroupProps = InputGroupBaseProps & InputTextBaseProps /** * Компонент группы инпута. Позволяет обернуть инпут в группу и добавить кнопки-аддоны слева и справа * @link https://www.figma.com/design/4TYeki0MDLhfPGJstbIicf/UI-kit-PrimeFace-(DS)?node-id=484-5932&m=dev * @link https://www.figma.com/design/Q1BWgZ7zoV5UzlBOnjW0cM/UI-Kit--DS--v2.1?node-id=24037-68 * @see InputTextBase - * @see InputGroupAddon + * @see LegacyInputGroupAddon */ -export const InputGroup = memo( +export const LegacyInputGroup = memo( ({ left, right, @@ -70,7 +74,7 @@ export const InputGroup = memo( ) : null} - ( +export const LegacyInputGroupAddon = memo( ({ content, onPress, position, disabled }) => { return ( } @@ -16,10 +21,19 @@ export interface InputTextProps extends InputTextBaseProps { * @see InputTextBase * @see InputTextBaseProps */ -export const InputText = memo(({ style, ...otherProps }) => { - return ( - - - - ) -}) +export const LegacyInputText = memo( + ({ style, secureTextEntry = false, ...otherProps }) => { + const inputProps = { ...otherProps, secureTextEntry } + const wrapLegacyInputText = useLegacyInputTextWrapper() + + return ( + + {isInputTextCoreProps(inputProps) ? ( + + ) : ( + wrapLegacyInputText(inputProps) + )} + + ) + } +) diff --git a/src/components/Input/deprecated/LegacySecureTextEntryButton.tsx b/src/components/Input/deprecated/LegacySecureTextEntryButton.tsx new file mode 100644 index 00000000..00253784 --- /dev/null +++ b/src/components/Input/deprecated/LegacySecureTextEntryButton.tsx @@ -0,0 +1,39 @@ +import { IconEye, IconEyeOff } from '@tabler/icons-react-native' +import { type Insets, TouchableOpacity } from 'react-native' + +import { SvgUniversal } from '../../../utils/SvgUniversal' + +import { + inputIconUniProps, + inputStyles as styles, +} from './InputTextBase/useInputStyles' + +interface LegacySecureTextEntryButtonProps { + readonly isPasswordHidden: boolean + readonly hitSlop: Insets + readonly iconSize: { readonly width: number; readonly height: number } + readonly testID: string + readonly onPress: () => void +} + +export const LegacySecureTextEntryButton = ({ + isPasswordHidden, + hitSlop, + iconSize, + testID, + onPress, +}: LegacySecureTextEntryButtonProps) => ( + + + +) diff --git a/src/components/Input/deprecated/__tests__/InputTextBase.test.tsx b/src/components/Input/deprecated/__tests__/InputTextBase.test.tsx new file mode 100644 index 00000000..2a4322ba --- /dev/null +++ b/src/components/Input/deprecated/__tests__/InputTextBase.test.tsx @@ -0,0 +1,476 @@ +import { + act, + fireEvent, + render, + userEvent, +} from '@testing-library/react-native' +import { createRef, useImperativeHandle } from 'react' +import { TextInput } from 'react-native' + +import { InputTextBase } from '../InputTextBase/InputTextBase' +import type { + InputTextCoreProps, + RenderTextInputArgs, +} from '../InputTextBase/types' + +describe('InputTextBase', () => { + test.each<{ + name: string + props: InputTextCoreProps + editable: boolean + disabled: boolean + }>([ + { name: 'по умолчанию', props: {}, editable: true, disabled: false }, + { + name: 'при readOnly', + props: { readOnly: true }, + editable: false, + disabled: false, + }, + { + name: 'при editable=false', + props: { editable: false }, + editable: false, + disabled: false, + }, + { + name: 'при disabled', + props: { disabled: true }, + editable: false, + disabled: true, + }, + { + name: 'при disabled и editable=true', + props: { disabled: true, editable: true, readOnly: false }, + editable: false, + disabled: true, + }, + { + name: 'при readOnly и editable=true', + props: { readOnly: true, editable: true }, + editable: false, + disabled: false, + }, + { + name: 'при editable=false и readOnly=false', + props: { editable: false, readOnly: false }, + editable: false, + disabled: false, + }, + { + name: 'при danger', + props: { state: 'danger' }, + editable: true, + disabled: false, + }, + { + name: 'при danger и disabled', + props: { state: 'danger', disabled: true }, + editable: false, + disabled: true, + }, + ])('согласует состояния $name', ({ props, editable, disabled }) => { + const { getByLabelText, queryAllByTestId, queryByRole } = render( + + ) + const input = getByLabelText('Поле') + + expect(input).toHaveProp('editable', editable) + expect(input).toHaveProp('readOnly', !editable) + expect(input).toHaveProp('accessibilityState', { disabled }) + expect(queryAllByTestId('InputTextBaseDisabledIcon').length > 0).toBe( + disabled + ) + expect(!!queryByRole('button', { name: 'Очистить поле' })).toBe(editable) + }) + + test('восстанавливает ввод после изменения readOnly', () => { + const props = { + placeholder: 'Поле', + value: 'Текст', + clearButtonAccessibilityLabel: 'Очистить поле', + } + const { getByLabelText, getByRole, queryByRole, rerender } = render( + + ) + + expect(queryByRole('button')).not.toBeOnTheScreen() + + rerender() + + expect(getByLabelText('Поле')).toHaveProp('editable', true) + expect(getByRole('button', { name: 'Очистить поле' })).toBeOnTheScreen() + }) + + test.each([ + { + name: 'при clearable=false', + clearButtonAccessibilityLabel: 'Очистить поле', + clearable: false, + value: 'Текст', + }, + { + name: 'при пустом значении', + clearButtonAccessibilityLabel: 'Очистить поле', + clearable: true, + value: '', + }, + ])( + 'скрывает очистку $name', + ({ clearButtonAccessibilityLabel, clearable, value }) => { + const { queryByRole } = render( + + ) + + expect(queryByRole('button')).not.toBeOnTheScreen() + } + ) + + test.each([ + { + name: 'без подписи', + clearButtonAccessibilityLabel: undefined, + accessibleName: 'Очистить поле', + }, + { + name: 'с пустой подписью', + clearButtonAccessibilityLabel: '', + accessibleName: 'Очистить поле', + }, + { + name: 'с локализованной подписью потребителя', + clearButtonAccessibilityLabel: 'Clear input', + accessibleName: 'Clear input', + }, + ])( + 'показывает доступную кнопку очистки $name', + ({ clearButtonAccessibilityLabel, accessibleName }) => { + const onChangeText = jest.fn() + const { getByRole } = render( + + ) + + fireEvent.press(getByRole('button', { name: accessibleName })) + + expect(onChangeText).toHaveBeenCalledWith('') + } + ) + + test('обновляет неуправляемое значение при вводе и очистке', async () => { + const onChangeText = jest.fn() + const user = userEvent.setup() + const { getByLabelText, getByRole, queryByRole } = render( + + ) + const input = getByLabelText('Поле') + + expect(queryByRole('button')).not.toBeOnTheScreen() + + await user.type(input, 'Текст') + + expect(input).toHaveDisplayValue('Текст') + expect(onChangeText).toHaveBeenLastCalledWith('Текст') + + await user.press(getByRole('button', { name: 'Очистить поле' })) + + expect(input).toHaveDisplayValue('') + expect(onChangeText).toHaveBeenLastCalledWith('') + expect(queryByRole('button')).not.toBeOnTheScreen() + }) + + test('ожидает обновления управляемого value после ввода и очистки', () => { + const onChangeText = jest.fn() + const props = { + placeholder: 'Поле', + onChangeText, + clearButtonAccessibilityLabel: 'Очистить поле', + } + const { getByLabelText, getByRole, queryByRole, rerender } = render( + + ) + const input = getByLabelText('Поле') + + fireEvent.changeText(input, 'Второе') + + expect(onChangeText).toHaveBeenLastCalledWith('Второе') + expect(input).toHaveDisplayValue('Первое') + + rerender() + + expect(input).toHaveDisplayValue('Второе') + + fireEvent.press(getByRole('button', { name: 'Очистить поле' })) + + expect(onChangeText).toHaveBeenLastCalledWith('') + expect(input).toHaveDisplayValue('Второе') + expect(getByRole('button', { name: 'Очистить поле' })).toBeOnTheScreen() + + rerender() + + expect(input).toHaveDisplayValue('') + expect(queryByRole('button')).not.toBeOnTheScreen() + }) + + test('передаёт события focus и blur', () => { + const onFocus = jest.fn() + const onBlur = jest.fn() + const event = { nativeEvent: { target: 1 } } + const { getByLabelText } = render( + + ) + + fireEvent(getByLabelText('Поле'), 'focus', event) + fireEvent(getByLabelText('Поле'), 'blur', event) + + expect(onFocus).toHaveBeenCalledWith(event) + expect(onBlur).toHaveBeenCalledWith(event) + }) + + test.each([false, true])( + 'использует placeholder как единственное имя при floatLabel=%s', + (floatLabel) => { + const { getByLabelText, queryAllByLabelText, getByTestId } = render( + + ) + const label = getByTestId( + floatLabel + ? 'InputTextBaseFloatingPlaceholder' + : 'InputTextBasePlaceholder', + { includeHiddenElements: true } + ) + + expect(getByLabelText('Адрес')).toHaveProp('placeholder', '') + expect(queryAllByLabelText('Адрес')).toHaveLength(1) + expect(label).toHaveTextContent('Адрес') + expect(label).toHaveProp('accessible', false) + expect(label).toHaveProp('accessibilityElementsHidden', true) + expect(label).toHaveProp( + 'importantForAccessibility', + 'no-hide-descendants' + ) + } + ) + + test.each(['Адрес доставки', ''])( + 'сохраняет явно заданное accessibilityLabel=%s', + (accessibilityLabel) => { + const { getByTestId } = render( + + ) + + expect(getByTestId('InputTextBase')).toHaveProp( + 'accessibilityLabel', + accessibilityLabel + ) + } + ) + + test.each([false, true])( + 'сохраняет остальные accessibilityState при disabled=%s', + (disabled) => { + const { getByLabelText } = render( + + ) + + expect(getByLabelText('Поле')).toHaveProp('accessibilityState', { + disabled, + busy: true, + selected: true, + expanded: false, + checked: 'mixed', + }) + } + ) + + test.each([ + { + name: 'по умолчанию', + allowFontScaling: undefined, + maxFontSizeMultiplier: undefined, + }, + { + name: 'с разрешённым масштабированием', + allowFontScaling: true, + maxFontSizeMultiplier: 2, + }, + { + name: 'с отключённым масштабированием', + allowFontScaling: false, + maxFontSizeMultiplier: 1, + }, + ])( + 'сохраняет настройки масштабирования $name', + ({ allowFontScaling, maxFontSizeMultiplier }) => { + const { getByLabelText, getByTestId } = render( + + ) + const label = getByTestId('InputTextBaseFloatingPlaceholder', { + includeHiddenElements: true, + }) + + for (const element of [getByLabelText('Длинная подпись'), label]) { + expect(element.props.allowFontScaling).toBe(allowFontScaling) + expect(element.props.maxFontSizeMultiplier).toBe(maxFontSizeMultiplier) + } + + expect(label.props.numberOfLines).toBeUndefined() + } + ) + + test('передаёт нормализованные props и события в renderTextInput', () => { + const onChangeText = jest.fn() + const onFocus = jest.fn() + const onBlur = jest.fn() + const renderTextInput = jest.fn( + ({ inputRef, ...props }: RenderTextInputArgs) => ( + + ) + ) + const { getByLabelText, rerender } = render( + + ) + + expect(renderTextInput).toHaveBeenLastCalledWith( + expect.objectContaining({ + value: '123', + placeholder: '', + accessibilityLabel: 'Поле маски', + editable: false, + readOnly: true, + accessibilityState: { busy: true, disabled: true }, + inputRef: expect.objectContaining({ current: expect.anything() }), + onChangeText: expect.any(Function), + onFocus: expect.any(Function), + onBlur: expect.any(Function), + }) + ) + + rerender( + + ) + const input = getByLabelText('Поле маски') + fireEvent.changeText(input, '123-45') + fireEvent(input, 'focus') + fireEvent(input, 'blur') + + expect(onChangeText).toHaveBeenCalledWith('123-45') + expect(input).toHaveDisplayValue('123') + expect(onFocus).toHaveBeenCalledOnce() + expect(onBlur).toHaveBeenCalledOnce() + }) + + test('сохраняет методы inputRef и очищает значение через ref', () => { + const inputRef = createRef() + const focus = jest.fn() + const onChangeText = jest.fn() + const nativeInput = { + focus, + isFocused() { + return this === nativeInput + }, + } as unknown as TextInput + const CustomInput = ({ inputRef: ref, ...props }: RenderTextInputArgs) => { + useImperativeHandle(ref, () => nativeInput) + + return + } + const { getByLabelText, getByTestId } = render( + } + onChangeText={onChangeText} + /> + ) + + expect(inputRef.current?.isFocused()).toBeTrue() + + fireEvent.press(getByTestId('InputTextBasePressableContainer')) + + expect(focus).toHaveBeenCalledOnce() + + fireEvent.changeText(getByLabelText('Поле'), 'Текст') + act(() => inputRef.current?.clear()) + + expect(onChangeText).toHaveBeenLastCalledWith('') + expect(getByLabelText('Поле')).toHaveDisplayValue('') + }) + + test.each([false, true])( + 'передаёт boolean secureTextEntry=%s без toggle', + (secureTextEntry) => { + const { getByTestId, queryByTestId } = render( + + ) + + expect(getByTestId('InputTextBase')).toHaveProp( + 'secureTextEntry', + secureTextEntry + ) + expect( + queryByTestId('InputTextBaseSecureInputButton') + ).not.toBeOnTheScreen() + } + ) + + test('отображает загрузку без блокировки ввода', () => { + const { getByTestId } = render() + + expect(getByTestId('InputTextBaseLoading')).toBeOnTheScreen() + expect(getByTestId('InputTextBase')).toHaveProp('editable', true) + }) +}) diff --git a/src/components/Input/deprecated/__tests__/InputTextPlaceholder.test.tsx b/src/components/Input/deprecated/__tests__/InputTextPlaceholder.test.tsx new file mode 100644 index 00000000..cfc2f663 --- /dev/null +++ b/src/components/Input/deprecated/__tests__/InputTextPlaceholder.test.tsx @@ -0,0 +1,55 @@ +import { fireEvent, render } from '@testing-library/react-native' +import { StyleSheet, TextInput, View } from 'react-native' + +import { InputTextBase } from '../InputTextBase/InputTextBase' +import type { RenderTextInputArgs } from '../InputTextBase/types' + +describe('InputTextBase placeholder', () => { + test.each([ + { name: 'стандартном вводе', renderTextInput: undefined }, + { + name: 'кастомном вводе', + renderTextInput: ({ inputRef, ...props }: RenderTextInputArgs) => ( + + ), + }, + ])('резервирует место для подписи при $name', ({ renderTextInput }) => { + const { getByTestId } = render( + + ) + const placeholder = getByTestId('InputTextBasePlaceholder', { + includeHiddenElements: true, + }) + const inputContainer = () => + getByTestId('InputTextBasePressableContainer').find( + ({ type }: { type: unknown }) => type === View + ) + const reservedHeight = () => + StyleSheet.flatten(inputContainer().props.style).minHeight ?? 0 + const measure = (height: number) => + fireEvent(placeholder, 'layout', { + nativeEvent: { layout: { x: 0, y: 0, width: 200, height } }, + }) + + const firstHeight = 72 + measure(firstHeight) + + expect(reservedHeight()).toBeGreaterThanOrEqual(firstHeight) + + const grownHeight = firstHeight * 2 + measure(grownHeight) + + expect(reservedHeight()).toBeGreaterThanOrEqual(grownHeight) + + const shrunkHeight = firstHeight / 2 + measure(shrunkHeight) + + expect(reservedHeight()).toBeGreaterThanOrEqual(shrunkHeight) + expect(reservedHeight()).toBeLessThan(grownHeight) + }) +}) diff --git a/src/components/Input/deprecated/__tests__/LegacyInputGroup.test.tsx b/src/components/Input/deprecated/__tests__/LegacyInputGroup.test.tsx new file mode 100644 index 00000000..37f7f1a3 --- /dev/null +++ b/src/components/Input/deprecated/__tests__/LegacyInputGroup.test.tsx @@ -0,0 +1,114 @@ +import { fireEvent, render } from '@testing-library/react-native' +import { createRef, useImperativeHandle } from 'react' +import { TextInput } from 'react-native' + +import type { RenderTextInputArgs } from '../InputTextBase/types' +import { + LegacyInputGroup as InputGroup, + type LegacyInputGroupProps as InputGroupProps, +} from '../LegacyInputGroup' + +describe('LegacyInputGroup', () => { + test.each<{ name: string; props: InputGroupProps; addons: number }>([ + { name: 'с левым аддоном', props: { left: 'Слева' }, addons: 1 }, + { name: 'с правым аддоном', props: { right: 'Справа' }, addons: 1 }, + { + name: 'с обоими аддонами', + props: { left: 'Слева', right: 'Справа' }, + addons: 2, + }, + { name: 'без аддонов', props: { style: { margin: 10 } }, addons: 0 }, + ])('отображает поле $name', ({ props, addons }) => { + const { getByLabelText, queryByText, queryAllByTestId } = render( + + ) + + expect(getByLabelText('Поиск')).toHaveDisplayValue('Заказ') + expect(!!queryByText('Слева')).toBe(!!props.left) + expect(!!queryByText('Справа')).toBe(!!props.right) + expect(queryAllByTestId('InputGroupAddon_Pressable')).toHaveLength(addons) + }) + + test.each([ + { + name: 'disabled', + props: { disabled: true, editable: true, readOnly: false }, + disabled: true, + }, + { name: 'readOnly', props: { readOnly: true }, disabled: false }, + { name: 'editable=false', props: { editable: false }, disabled: false }, + ])('наследует запрет ввода при $name', ({ props, disabled }) => { + const { getByLabelText, queryByRole, getAllByTestId } = render( + + ) + + expect(getByLabelText('Поиск')).toHaveProp('editable', false) + expect(getByLabelText('Поиск')).toHaveProp('readOnly', true) + expect(getByLabelText('Поиск')).toHaveProp('accessibilityState', { + disabled, + }) + expect(queryByRole('button')).not.toBeOnTheScreen() + + for (const addon of getAllByTestId('InputGroupAddon_Pressable')) { + expect(addon.props.accessibilityState?.disabled ?? false).toBe(disabled) + } + }) + + test.each<{ name: string; props: InputGroupProps }>([ + { name: 'обычном пути', props: {} }, + { + name: 'legacy-пути', + props: { size: 'medium', secureTextEntry: 'toggleable' }, + }, + ])('фокусирует кастомное поле через аддоны и ref при $name', ({ props }) => { + const focus = jest.fn() + const inputRef = createRef() + const CustomInput = ({ inputRef: ref, ...props }: RenderTextInputArgs) => { + useImperativeHandle(ref, () => ({ focus }) as unknown as TextInput) + + return + } + const { getAllByTestId } = render( + } + right='Справа' + /> + ) + + for (const addon of getAllByTestId('InputGroupAddon_Pressable')) { + fireEvent.press(addon) + } + + inputRef.current?.focus() + + expect(focus).toHaveBeenCalledTimes(3) + }) + + test('передаёт callback очистки и ожидает обновления value', () => { + const onChangeText = jest.fn() + const { getByLabelText, getByRole } = render( + + ) + + fireEvent.press(getByRole('button', { name: 'Очистить поиск' })) + + expect(onChangeText).toHaveBeenCalledWith('') + expect(getByLabelText('Поиск')).toHaveDisplayValue('Заказ') + }) +}) diff --git a/src/components/Input/deprecated/__tests__/LegacyInputGroupAddon.test.tsx b/src/components/Input/deprecated/__tests__/LegacyInputGroupAddon.test.tsx new file mode 100644 index 00000000..332640b6 --- /dev/null +++ b/src/components/Input/deprecated/__tests__/LegacyInputGroupAddon.test.tsx @@ -0,0 +1,27 @@ +import { render, userEvent } from '@testing-library/react-native' + +import { LegacyInputGroupAddon } from '../LegacyInputGroupAddon' + +describe('LegacyInputGroupAddon', () => { + test.each([ + { name: 'активен', disabled: false, calls: 1 }, + { name: 'отключён', disabled: true, calls: 0 }, + ])('обрабатывает нажатие, когда аддон $name', async ({ disabled, calls }) => { + const user = userEvent.setup() + const onPress = jest.fn() + const { getByTestId, getByText } = render( + + ) + + expect(getByText('Дополнение')).toBeOnTheScreen() + + await user.press(getByTestId('InputGroupAddon_Pressable')) + + expect(onPress).toHaveBeenCalledTimes(calls) + }) +}) diff --git a/src/components/Input/deprecated/__tests__/LegacyInputText.test.tsx b/src/components/Input/deprecated/__tests__/LegacyInputText.test.tsx new file mode 100644 index 00000000..d5b2c3e9 --- /dev/null +++ b/src/components/Input/deprecated/__tests__/LegacyInputText.test.tsx @@ -0,0 +1,193 @@ +import { act, fireEvent, render } from '@testing-library/react-native' +import { createRef, useImperativeHandle, useRef } from 'react' +import { TextInput } from 'react-native' + +import type { + InputTextPrivateProps, + RenderTextInputArgs, +} from '../InputTextBase/types' +import { + LegacyInputText as InputText, + type LegacyInputTextProps as InputTextProps, +} from '../LegacyInputText' + +describe('LegacyInputText', () => { + test.each(['medium', 1, 64] as const)( + 'сохраняет ввод при legacy-размере %s', + (size) => { + const onChangeText = jest.fn() + const { getByLabelText } = render( + + ) + + fireEvent.changeText(getByLabelText('Поле'), 'Текст') + + expect(onChangeText).toHaveBeenCalledWith('Текст') + expect(getByLabelText('Поле')).toHaveDisplayValue('Текст') + } + ) + + test('сохраняет выбранную видимость пароля при смене secureTextEntry', () => { + const { getByLabelText, getByTestId, queryByTestId, rerender } = render( + + ) + + fireEvent.press(getByTestId('InputTextBaseSecureInputButton')) + + expect(getByLabelText('Пароль')).toHaveProp('secureTextEntry', false) + + rerender() + + expect(getByLabelText('Пароль')).toHaveProp('secureTextEntry', true) + expect( + queryByTestId('InputTextBaseSecureInputButton') + ).not.toBeOnTheScreen() + + rerender() + + expect(getByLabelText('Пароль')).toHaveProp('secureTextEntry', false) + }) + + test('использует актуальный callback после rerender при вводе и очистке через ref', () => { + const inputRef = createRef() + const initialOnChangeText = jest.fn() + const nextOnChangeText = jest.fn() + const { getByLabelText, rerender } = render( + + ) + + fireEvent.changeText(getByLabelText('Поле'), 'Первое') + + expect(initialOnChangeText).toHaveBeenCalledWith('Первое') + + rerender( + + ) + + expect(getByLabelText('Поле')).toHaveDisplayValue('Первое') + + fireEvent.changeText(getByLabelText('Поле'), 'Второе') + + expect(nextOnChangeText).toHaveBeenCalledWith('Второе') + + act(() => inputRef.current?.clear()) + + expect(nextOnChangeText).toHaveBeenLastCalledWith('') + expect(initialOnChangeText).toHaveBeenCalledOnce() + expect(getByLabelText('Поле')).toHaveDisplayValue('') + }) + + test('сохраняет prefix testID и независимость правых кнопок', () => { + const onChangeText = jest.fn() + const props: InputTextProps & InputTextPrivateProps = { + floatLabel: true, + loading: true, + clearButtonAccessibilityLabel: 'Очистить пароль', + placeholder: 'Пароль', + secureTextEntry: 'toggleable', + testID: 'PasswordField', + value: 'Текст', + onChangeText, + } + const { getByLabelText, getByRole, getByTestId } = render( + + ) + + expect(getByTestId('PasswordFieldLoading')).toBeOnTheScreen() + expect( + getByTestId('PasswordFieldFloatingPlaceholder', { + includeHiddenElements: true, + }) + ).toHaveTextContent('Пароль') + + fireEvent.press(getByTestId('PasswordFieldSecureInputButton')) + + expect(getByLabelText('Пароль')).toHaveProp('secureTextEntry', false) + expect(onChangeText).not.toHaveBeenCalled() + + fireEvent.press(getByRole('button', { name: 'Очистить пароль' })) + + expect(onChangeText).toHaveBeenCalledWith('') + expect(getByLabelText('Пароль')).toHaveDisplayValue('Текст') + }) + + test('сохраняет неуправляемое значение, фокус и ref при смене пути рендера', () => { + const inputRef = createRef() + const onChangeText = jest.fn() + const CustomInput = ({ inputRef: ref, ...props }: RenderTextInputArgs) => { + const focused = useRef(false) + useImperativeHandle( + ref, + () => + ({ + focus: () => { + focused.current = true + }, + isFocused: () => focused.current, + }) as unknown as TextInput + ) + + return + } + const props: InputTextProps = { + inputRef, + placeholder: 'Поле', + renderTextInput: (args) => , + onChangeText, + } + const { getByLabelText, rerender } = render() + + fireEvent.changeText(getByLabelText('Поле'), 'Текст') + act(() => inputRef.current?.focus()) + + expect(inputRef.current?.isFocused()).toBeTrue() + + for (const size of ['medium', 64, 'large'] as const) { + rerender() + + expect(getByLabelText('Поле')).toHaveDisplayValue('Текст') + expect(inputRef.current?.isFocused()).toBeTrue() + } + + rerender() + + expect(getByLabelText('Поле')).toHaveDisplayValue('Текст') + expect(inputRef.current?.isFocused()).toBeTrue() + + act(() => inputRef.current?.clear()) + + expect(onChangeText).toHaveBeenLastCalledWith('') + expect(getByLabelText('Поле')).toHaveDisplayValue('') + }) + + test('переключает legacy-пароль без изменения значения', () => { + const { getByLabelText, getByTestId } = render( + + ) + const input = getByLabelText('Пароль') + const toggle = getByTestId('InputTextBaseSecureInputButton') + + expect(input).toHaveProp('secureTextEntry', true) + + fireEvent.press(toggle) + + expect(input).toHaveProp('secureTextEntry', false) + expect(input).toHaveDisplayValue('Пароль') + + fireEvent.press(toggle) + + expect(input).toHaveProp('secureTextEntry', true) + }) +}) diff --git a/src/components/Input/deprecated/resolveInputTextMinHeight.ts b/src/components/Input/deprecated/resolveInputTextMinHeight.ts new file mode 100644 index 00000000..067f19eb --- /dev/null +++ b/src/components/Input/deprecated/resolveInputTextMinHeight.ts @@ -0,0 +1,20 @@ +import { StyleSheet } from 'react-native-unistyles' + +import type { InputTextBaseProps, InputTextSize } from './InputTextBase/types' + +const legacyStyles = StyleSheet.create(({ semantic }) => ({ + medium: { minHeight: semantic.dimension.size[1200] }, +})) + +export const resolveInputTextMinHeight = ( + size: NonNullable, + sizes: Record +) => { + if (typeof size === 'number') { + return Math.max(size, sizes.base.minHeight) + } + + return size === 'medium' + ? legacyStyles.medium.minHeight + : sizes[size].minHeight +} diff --git a/src/components/Input/deprecated/types.ts b/src/components/Input/deprecated/types.ts new file mode 100644 index 00000000..2e36a4de --- /dev/null +++ b/src/components/Input/deprecated/types.ts @@ -0,0 +1,33 @@ +import type { InputTextCoreProps, InputTextSize } from './InputTextBase/types' + +/** @deprecated Используйте Password после его внедрения */ +export type LegacySecureTextEntry = 'toggleable' + +/** @deprecated Используйте base после согласованной миграции размеров в SuperApp */ +export type LegacyMediumSize = 'medium' + +/** @deprecated Используйте стандартный размер после миграции размеров в SuperApp */ +export type LegacyNumericSize = number + +export interface InputTextCompatibilityProps extends Omit< + InputTextCoreProps, + 'size' | 'secureTextEntry' +> { + /** + * Управление режимом скрытия текста. + * Boolean соответствует TextInput; toggleable сохраняется до перехода на Password. + */ + // eslint-disable-next-line @typescript-eslint/no-deprecated -- Legacy API сохраняется до перехода на Password + secureTextEntry?: boolean | LegacySecureTextEntry + /** + * Высота поля без floating label. small и base временно имеют одинаковую высоту. + * medium и числовой размер устарели; число меньше base ограничивается высотой base. + * @default 'base' + */ + size?: + | InputTextSize + // eslint-disable-next-line @typescript-eslint/no-deprecated -- Размер удаляется вместе с миграцией SuperApp + | LegacyMediumSize + // eslint-disable-next-line @typescript-eslint/no-deprecated -- Размер удаляется вместе с миграцией SuperApp + | LegacyNumericSize +} diff --git a/src/components/Input/deprecated/useLegacyInputTextWrapper.tsx b/src/components/Input/deprecated/useLegacyInputTextWrapper.tsx new file mode 100644 index 00000000..91b7ed31 --- /dev/null +++ b/src/components/Input/deprecated/useLegacyInputTextWrapper.tsx @@ -0,0 +1,65 @@ +import { useCallback, useState } from 'react' + +import { InputTextBase } from './InputTextBase/InputTextBase' +import type { InputTextCoreProps } from './InputTextBase/types' +import { inputSizeStyles } from './InputTextBase/useInputStyles' + +import { LegacySecureTextEntryButton } from './LegacySecureTextEntryButton' +import { resolveInputTextMinHeight } from './resolveInputTextMinHeight' +import type { InputTextCompatibilityProps } from './types' + +export const isInputTextCoreProps = ( + props: InputTextCompatibilityProps +): props is InputTextCoreProps => + props.secureTextEntry !== 'toggleable' && + props.size !== 'medium' && + typeof props.size !== 'number' + +export const useLegacyInputTextWrapper = () => { + const [isPasswordHidden, setIsPasswordHidden] = useState(true) + const toggleSecureTextEntry = useCallback(() => { + setIsPasswordHidden((hidden) => !hidden) + }, []) + + return ({ + size = 'base', + secureTextEntry = false, + containerStyle, + floatLabel, + ...otherProps + }: InputTextCompatibilityProps) => { + const hasLegacySize = typeof size === 'number' || size === 'medium' + const minHeight = + hasLegacySize && !floatLabel + ? resolveInputTextMinHeight(size, inputSizeStyles) + : undefined + + return ( + ( + + ) + : undefined + } + secureTextEntry={ + secureTextEntry === 'toggleable' ? isPasswordHidden : secureTextEntry + } + size={hasLegacySize ? 'base' : size} + /> + ) + } +} diff --git a/src/components/Input/index.ts b/src/components/Input/index.ts index 76de6a00..38fc213a 100644 --- a/src/components/Input/index.ts +++ b/src/components/Input/index.ts @@ -1,5 +1,5 @@ -export { InputGroup } from './InputGroup' -export { InputText } from './InputText' +export { LegacyInputGroup as InputGroup } from './deprecated/LegacyInputGroup' +export { LegacyInputText as InputText } from './deprecated/LegacyInputText' export { InputSwitch } from '../InputSwitch' -export type { InputTextBaseProps } from './InputTextBase/types' +export type { InputTextBaseProps } from './deprecated/InputTextBase/types' export { InputOtp, InputOtpTestId, type InputOtpProps } from '../InputOtp' diff --git a/src/components/Input/stories/InputGroup.stories.tsx b/src/components/Input/stories/InputGroup.stories.tsx index 63f28ecf..3107a2fd 100644 --- a/src/components/Input/stories/InputGroup.stories.tsx +++ b/src/components/Input/stories/InputGroup.stories.tsx @@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react' import { IconLock, IconUser } from '@tabler/icons-react-native' import { useCallback } from 'react' -import { InputGroup } from '../InputGroup' +import { LegacyInputGroup as InputGroup } from '../deprecated/LegacyInputGroup' const meta: Meta = { title: 'Form/InputGroup', diff --git a/src/components/Input/stories/InputMask.stories.tsx b/src/components/Input/stories/InputMask.stories.tsx deleted file mode 100644 index 0a1cf586..00000000 --- a/src/components/Input/stories/InputMask.stories.tsx +++ /dev/null @@ -1,123 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react' -import { - type ComponentProps, - type ComponentType, - type RefObject, - useCallback, - useState, -} from 'react' -import { View } from 'react-native' -import { - MaskedTextInput, - type MaskedTextInputProps, - type MaskedTextInputRef, -} from 'react-native-advanced-input-mask' - -import { Body } from '../../Typography' -import { InputText } from '../InputText' - -type InputTextProps = ComponentProps - -type MaskedInputProps = Omit & - Pick & { - readonly Control: ComponentType - } - -/** - * Пример реализации компонента для ввода текста с маской. - * Основана на пакете react-native-advanced-input-mask - */ -const MaskedInput = ({ - mask, - onChangeText: onChangeTextProp, - Control, - ...other -}: MaskedInputProps) => { - const [value, setValue] = useState('') - - // Коллбэк, адаптированный для использования как с MaskedTextInput, так и с дефолтным TextInput - const onChangeText = useCallback( - ( - formatted: string, - extracted: string, - tailPlacehilder: string, - complete: boolean - ) => { - setValue(formatted) - onChangeTextProp?.( - formatted, - extracted ?? formatted, - tailPlacehilder, - complete - ) - }, - [onChangeTextProp] - ) - - return ( - ( - } - onChangeText={onChangeText} - /> - )} - value={value} - // Передаем коллбэк для поддержания дополнительной логики в разных видах контролов. Например, для очистки поля - onChangeText={onChangeText} - /> - ) -} - -const meta: Meta = { - title: 'Form/InputMask', - args: { - clearable: true, - disabled: false, - state: 'default', - placeholder: 'Placeholder', - floatLabel: false, - mask: '+7 ([000]) [000]-[00]-[00]', - size: 'base', - }, - argTypes: { - state: { control: 'radio', options: ['default', 'danger'] }, - size: { control: 'radio', options: ['base', 'large', 'xlarge'] }, - mask: { - control: 'radio', - options: [ - '+7 ([000]) [000]-[00]-[00]', - '[00]/[00]/[0000]', - '[000]-[000]', - ], - }, - }, - render: (args) => { - const [formatted, setFormatted] = useState('') - const [extracted, setExtracted] = useState('') - - const onChangeText = useCallback((formatted: string, extracted: string) => { - setFormatted(formatted) - setExtracted(extracted) - }, []) - - return ( - - Formatted Text: "{formatted}" - Extracted Text: "{extracted}" - - - ) - }, -} - -export default meta - -type Story = StoryObj - -const MaskedInputStory: Story = {} - -export { MaskedInputStory as InputMask } diff --git a/src/components/Input/stories/InputText.stories.tsx b/src/components/Input/stories/InputText.stories.tsx index 8d7fdb7f..966fdfce 100644 --- a/src/components/Input/stories/InputText.stories.tsx +++ b/src/components/Input/stories/InputText.stories.tsx @@ -2,8 +2,8 @@ import { useArgs } from '@storybook/preview-api' import type { Meta, StoryObj } from '@storybook/react' import { useCallback, useEffect, useState } from 'react' -import { InputText } from '../InputText' -import type { InputTextBase } from '../InputTextBase/InputTextBase' +import type { InputTextBase } from '../deprecated/InputTextBase/InputTextBase' +import { LegacyInputText as InputText } from '../deprecated/LegacyInputText' const meta: Meta = { title: 'Form/InputText', diff --git a/src/legacy.ts b/src/legacy.ts new file mode 100644 index 00000000..34f5c6aa --- /dev/null +++ b/src/legacy.ts @@ -0,0 +1,11 @@ +import './theme' + +export { + LegacyInputText as InputText, + type LegacyInputTextProps as InputTextProps, +} from './components/Input/deprecated/LegacyInputText' +export { + LegacyInputGroup as InputGroup, + type LegacyInputGroupProps as InputGroupProps, +} from './components/Input/deprecated/LegacyInputGroup' +export type { InputTextCompatibilityProps as InputTextBaseProps } from './components/Input/deprecated/types' From 112cc85263620ea5f6d98fd4bddeb3fdeb662ba3 Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Mon, 12 Oct 2026 14:54:04 +0300 Subject: [PATCH 06/17] =?UTF-8?q?feat(input):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20InputCaption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InputCaption/InputCaption.stories.tsx | 27 + .../Input/InputCaption/InputCaption.tsx | 92 ++ .../__tests__/InputCaption.test.tsx | 83 ++ src/components/Input/index.ts | 4 + src/components/Typography/Caption.tsx | 1 + .../Typography/__tests__/Caption.test.tsx | 34 +- .../__snapshots__/Caption.test.tsx.snap | 1167 ----------------- 7 files changed, 228 insertions(+), 1180 deletions(-) create mode 100644 src/components/Input/InputCaption/InputCaption.stories.tsx create mode 100644 src/components/Input/InputCaption/InputCaption.tsx create mode 100644 src/components/Input/InputCaption/__tests__/InputCaption.test.tsx delete mode 100644 src/components/Typography/__tests__/__snapshots__/Caption.test.tsx.snap diff --git a/src/components/Input/InputCaption/InputCaption.stories.tsx b/src/components/Input/InputCaption/InputCaption.stories.tsx new file mode 100644 index 00000000..2fde5977 --- /dev/null +++ b/src/components/Input/InputCaption/InputCaption.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { InputCaption } from './InputCaption' + +const meta: Meta = { + title: 'Input/InputCaption', + component: InputCaption, + args: { children: 'Подсказка к полю', variant: 'base', showIcon: false }, + argTypes: { + children: { control: 'text' }, + variant: { + control: 'radio', + options: ['base', 'success', 'warning', 'danger', 'info'], + }, + showIcon: { control: 'boolean' }, + }, + parameters: { controls: { include: ['children', 'variant', 'showIcon'] } }, + render: (args) => , +} + +export default meta + +type Story = StoryObj + +const InputCaptionStory: Story = { name: 'InputCaption' } + +export { InputCaptionStory as InputCaption } diff --git a/src/components/Input/InputCaption/InputCaption.tsx b/src/components/Input/InputCaption/InputCaption.tsx new file mode 100644 index 00000000..3b44b9ca --- /dev/null +++ b/src/components/Input/InputCaption/InputCaption.tsx @@ -0,0 +1,92 @@ +import { + IconAlertCircle, + IconInfoCircle, + IconCircleCheck, + IconAlertTriangle, + IconCircleX, +} from '@tabler/icons-react-native' +import type { ReactNode } from 'react' +import { View, type ViewProps } from 'react-native' + +import { StyleSheet } from 'react-native-unistyles' + +import { useMakeTestId } from '../../../hooks/useMakeTestId' +import { SvgUniversal, type SvgSource } from '../../../utils/SvgUniversal' +import { Caption } from '../../Typography/Caption' + +export interface InputCaptionProps extends ViewProps { + readonly children: ReactNode + /** + * Оформление подписи и стандартная иконка + * @default 'base' + */ + readonly variant?: 'base' | 'success' | 'warning' | 'danger' | 'info' + /** + * Показывает стандартную или переданную SVG-иконку + * @default false + */ + readonly showIcon?: boolean + /** Переопределяет стандартную иконку выбранного variant */ + readonly Icon?: SvgSource + readonly testID?: string +} + +const icons = { + base: IconAlertCircle, + info: IconInfoCircle, + success: IconCircleCheck, + warning: IconAlertTriangle, + danger: IconCircleX, +} satisfies Record, SvgSource> + +/** + * Подпись поля ввода + * + * @link https://www.figma.com/design/Q1BWgZ7zoV5UzlBOnjW0cM/?node-id=25178-77704 + */ +export const InputCaption = ({ + children, + variant = 'base', + showIcon = false, + Icon, + testID = 'InputCaption', + style, + ...props +}: InputCaptionProps) => { + const { makeTestId } = useMakeTestId(testID) + + return ( + + {showIcon ? ( + ({ + width: semantic.dimension.size[400], + height: semantic.dimension.size[400], + color: + variant === 'base' + ? semantic.colorScheme.color.fg.default + : semantic.colorScheme.color.fg.status[variant].default, + })} + /> + ) : null} + {children} + + ) +} + +const styles = StyleSheet.create(({ semantic }) => ({ + row: { + flexDirection: 'row', + alignItems: 'center', + gap: semantic.dimension.space[50], + }, + text: { flexShrink: 1 }, + base: { color: semantic.colorScheme.color.fg.default }, + success: { color: semantic.colorScheme.color.fg.status.success.default }, + warning: { color: semantic.colorScheme.color.fg.status.warning.default }, + danger: { color: semantic.colorScheme.color.fg.status.danger.default }, + info: { color: semantic.colorScheme.color.fg.status.info.default }, +})) diff --git a/src/components/Input/InputCaption/__tests__/InputCaption.test.tsx b/src/components/Input/InputCaption/__tests__/InputCaption.test.tsx new file mode 100644 index 00000000..1dedf790 --- /dev/null +++ b/src/components/Input/InputCaption/__tests__/InputCaption.test.tsx @@ -0,0 +1,83 @@ +import { render } from '@testing-library/react-native' +import Svg, { Circle, type SvgProps } from 'react-native-svg' + +import { InputCaption } from '../InputCaption' + +const CustomIcon = (props: SvgProps) => ( + + + +) + +describe('InputCaption', () => { + test('по умолчанию показывает текст подписи без иконки', () => { + const { getByText, queryByTestId, queryByRole } = render( + Подсказка к полю + ) + + expect(getByText('Подсказка к полю')).toBeOnTheScreen() + expect(queryByTestId('InputCaptionIcon')).toBeNull() + expect(queryByRole('button')).toBeNull() + }) + + test('showIcon управляет показом независимо от переданного SVG, а очистка Icon возвращает стандартную иконку', () => { + const { getAllByTestId, getByText, queryByTestId, rerender } = render( + + Проверьте адрес + + ) + + expect(queryByTestId('InputCaptionIcon')).toBeNull() + expect(queryByTestId('CustomCaptionCircle')).toBeNull() + + rerender( + + Проверьте адрес + + ) + + expect(getAllByTestId('InputCaptionIcon')[0]).toBeOnTheScreen() + expect(getAllByTestId('InputCaptionIcon')[0]).toHaveProp( + 'accessible', + false + ) + expect(getAllByTestId('CustomCaptionCircle')[0]).toBeOnTheScreen() + + rerender( + + Проверьте адрес + + ) + + expect(getAllByTestId('InputCaptionIcon')[0]).toBeOnTheScreen() + expect(queryByTestId('CustomCaptionCircle')).toBeNull() + + rerender( + + Проверьте адрес + + ) + + expect(queryByTestId('InputCaptionIcon')).toBeNull() + expect(getByText('Проверьте адрес')).toBeOnTheScreen() + }) + + test('передаёт заданные приложением accessibility props подписи', () => { + const { getByRole } = render( + + Попробуйте позже + + ) + + expect(getByRole('alert', { name: 'Ошибка сервера' })).toHaveProp( + 'accessibilityLiveRegion', + 'polite' + ) + }) +}) diff --git a/src/components/Input/index.ts b/src/components/Input/index.ts index 38fc213a..721f9239 100644 --- a/src/components/Input/index.ts +++ b/src/components/Input/index.ts @@ -1,5 +1,9 @@ export { LegacyInputGroup as InputGroup } from './deprecated/LegacyInputGroup' export { LegacyInputText as InputText } from './deprecated/LegacyInputText' +export { + InputCaption, + type InputCaptionProps, +} from './InputCaption/InputCaption' export { InputSwitch } from '../InputSwitch' export type { InputTextBaseProps } from './deprecated/InputTextBase/types' export { InputOtp, InputOtpTestId, type InputOtpProps } from '../InputOtp' diff --git a/src/components/Typography/Caption.tsx b/src/components/Typography/Caption.tsx index c71c7c2b..f6e7a2c8 100644 --- a/src/components/Typography/Caption.tsx +++ b/src/components/Typography/Caption.tsx @@ -77,6 +77,7 @@ const styles = StyleSheet.create(({ semantic, fonts }) => ({ disabled: { opacity: semantic.effects.opacity[60] }, withIconContainer: { flexDirection: 'row', + alignItems: 'center', gap: semantic.dimension.space[100], }, icon: { diff --git a/src/components/Typography/__tests__/Caption.test.tsx b/src/components/Typography/__tests__/Caption.test.tsx index cf980ee8..394698eb 100644 --- a/src/components/Typography/__tests__/Caption.test.tsx +++ b/src/components/Typography/__tests__/Caption.test.tsx @@ -1,24 +1,32 @@ import { IconInfoCircle } from '@tabler/icons-react-native' import { render } from '@testing-library/react-native' -import { Caption, type CaptionProps } from '../Caption' +import { Caption } from '../Caption' -describe('Caption component tests', () => { - const snapshotCases = generatePropsCombinations({ - color: ['default', 'secondary', 'primary'], - disabled: [true, false], - Icon: [undefined, IconInfoCircle], +describe('Caption', () => { + test('отображает текст и передаёт нативные props', () => { + const { getByLabelText, queryByTestId } = render( + + Подсказка к полю + + ) + + expect(getByLabelText('Подсказка')).toHaveTextContent('Подсказка к полю') + expect(getByLabelText('Подсказка')).toHaveProp('numberOfLines', 2) + expect(queryByTestId('CaptionIcon')).not.toBeOnTheScreen() }) - test.each(snapshotCases)('color = $color, disabled = $disabled', (props) => { - const renderedCaption = render(Text) + test('показывает и убирает SVG-иконку при изменении props', () => { + const { getByText, getAllByTestId, queryByTestId, rerender } = render( + Подсказка к полю + ) - expect(renderedCaption.toJSON()).toMatchSnapshot() - }) + expect(getByText('Подсказка к полю')).toBeOnTheScreen() + expect(getAllByTestId('CaptionIcon')[0]).toBeOnTheScreen() - test('default props', () => { - const renderedCaption = render(Text) + rerender(Подсказка к полю) - expect(renderedCaption.toJSON()).toMatchSnapshot() + expect(getByText('Подсказка к полю')).toBeOnTheScreen() + expect(queryByTestId('CaptionIcon')).not.toBeOnTheScreen() }) }) diff --git a/src/components/Typography/__tests__/__snapshots__/Caption.test.tsx.snap b/src/components/Typography/__tests__/__snapshots__/Caption.test.tsx.snap deleted file mode 100644 index 29c94249..00000000 --- a/src/components/Typography/__tests__/__snapshots__/Caption.test.tsx.snap +++ /dev/null @@ -1,1167 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Caption component tests color = default, disabled = false 1`] = ` - - Text - -`; - -exports[`Caption component tests color = default, disabled = false 2`] = ` - - - - - - - - - - Text - - -`; - -exports[`Caption component tests color = default, disabled = true 1`] = ` - - Text - -`; - -exports[`Caption component tests color = default, disabled = true 2`] = ` - - - - - - - - - - Text - - -`; - -exports[`Caption component tests color = primary, disabled = false 1`] = ` - - Text - -`; - -exports[`Caption component tests color = primary, disabled = false 2`] = ` - - - - - - - - - - Text - - -`; - -exports[`Caption component tests color = primary, disabled = true 1`] = ` - - Text - -`; - -exports[`Caption component tests color = primary, disabled = true 2`] = ` - - - - - - - - - - Text - - -`; - -exports[`Caption component tests color = secondary, disabled = false 1`] = ` - - Text - -`; - -exports[`Caption component tests color = secondary, disabled = false 2`] = ` - - - - - - - - - - Text - - -`; - -exports[`Caption component tests color = secondary, disabled = true 1`] = ` - - Text - -`; - -exports[`Caption component tests color = secondary, disabled = true 2`] = ` - - - - - - - - - - Text - - -`; - -exports[`Caption component tests default props 1`] = ` - - Text - -`; From 31e813d691724ef75f6754f01598f90c25c8154f Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Mon, 12 Oct 2026 15:52:10 +0300 Subject: [PATCH 07/17] =?UTF-8?q?feat(input):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20InputGroupAddon?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Input/InputGroup/InputGroupAddon.tsx | 114 ++++++++++++++++++ .../__tests__/InputGroupAddon.test.tsx | 98 +++++++++++++++ .../Input/core/InputGroupContext.ts | 10 ++ src/components/Input/core/styles.ts | 16 +++ src/components/Input/core/types.ts | 2 + src/components/Input/index.ts | 4 + 6 files changed, 244 insertions(+) create mode 100644 src/components/Input/InputGroup/InputGroupAddon.tsx create mode 100644 src/components/Input/InputGroup/__tests__/InputGroupAddon.test.tsx create mode 100644 src/components/Input/core/InputGroupContext.ts create mode 100644 src/components/Input/core/styles.ts create mode 100644 src/components/Input/core/types.ts diff --git a/src/components/Input/InputGroup/InputGroupAddon.tsx b/src/components/Input/InputGroup/InputGroupAddon.tsx new file mode 100644 index 00000000..a6772e01 --- /dev/null +++ b/src/components/Input/InputGroup/InputGroupAddon.tsx @@ -0,0 +1,114 @@ +import { useContext } from 'react' +import { Pressable, Text, type PressableProps } from 'react-native' + +import { StyleSheet } from 'react-native-unistyles' + +import { SvgUniversal, type SvgSource } from '../../../utils/SvgUniversal' + +import { InputGroupContext } from '../core/InputGroupContext' +import { styles as inputStyles } from '../core/styles' + +export interface InputGroupAddonProps extends PressableProps { + /** Имя для экранного диктора; по умолчанию берётся из строкового или числового children. */ + readonly accessibilityLabel?: string + /** SVG-иконка; для кнопки только с иконкой задайте accessibilityLabel */ + readonly Icon?: SvgSource +} + +/** + * Дополнение поля; при переданном onPress становится кнопкой. + * Наследует оформление и disabled группы; может быть отключен отдельно + * + * @link https://www.figma.com/design/Q1BWgZ7zoV5UzlBOnjW0cM/?node-id=436-3089 + */ +export const InputGroupAddon = ({ + children, + Icon, + disabled: disabledProp, + onPress, + style, + testID = 'InputGroupAddon', + ...props +}: InputGroupAddonProps) => { + const group = useContext(InputGroupContext) + const disabled = !!(group?.disabled || disabledProp) + const hasTextChildren = + typeof children === 'string' || typeof children === 'number' + + return ( + [ + styles.addon, + group && styles[group.position], + group?.state === 'danger' && styles.danger, + disabledProp && !group?.disabled && styles.disabled, + typeof style === 'function' ? style(state) : style, + ]} + testID={testID} + onPress={onPress} + > + {({ pressed }) => ( + <> + {Icon ? ( + { + const iconStyle = inputStyles.icon(group?.size) + + return { + width: iconStyle.width, + height: iconStyle.height, + color: components.inputgroup.colorScheme.addon.color, + } + }} + /> + ) : null} + {hasTextChildren ? ( + + {children} + + ) : typeof children === 'function' ? ( + children({ pressed }) + ) : ( + children + )} + + )} + + ) +} + +const styles = StyleSheet.create(({ components, semantic, fonts }) => ({ + addon: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: semantic.dimension.space[100], + padding: components.inputgroup.addon.padding, + backgroundColor: components.inputgroup.colorScheme.addon.background, + borderColor: components.inputtext.root.borderColor, + }, + left: { borderRightWidth: components.inputtext.extend.borderWidth }, + right: { borderLeftWidth: components.inputtext.extend.borderWidth }, + small: { fontSize: components.inputtext.root.sm.fontSize }, + base: { fontSize: fonts.fontSize[300] }, + large: { fontSize: components.inputtext.root.lg.fontSize }, + xlarge: { fontSize: components.inputtext.extend.extXlg.fontSize }, + danger: { borderColor: components.inputtext.root.invalidBorderColor }, + disabled: { opacity: semantic.effects.opacity[60] }, + text: { + fontFamily: fonts.fontFamily.base, + fontSize: fonts.fontSize[300], + color: components.inputgroup.colorScheme.addon.color, + }, +})) diff --git a/src/components/Input/InputGroup/__tests__/InputGroupAddon.test.tsx b/src/components/Input/InputGroup/__tests__/InputGroupAddon.test.tsx new file mode 100644 index 00000000..dd437da4 --- /dev/null +++ b/src/components/Input/InputGroup/__tests__/InputGroupAddon.test.tsx @@ -0,0 +1,98 @@ +import { IconUser } from '@tabler/icons-react-native' +import { render, userEvent } from '@testing-library/react-native' +import { Text } from 'react-native' + +import { InputGroupAddon } from '../InputGroupAddon' + +describe('InputGroupAddon', () => { + test.each([ + { name: 'строки', children: 'Найти' }, + { name: 'числа', children: 42 }, + { name: 'нуля', children: 0 }, + ])('задаёт доступное имя из $name в children', ({ children }) => { + const { getByRole, getByText } = render( + {children} + ) + + expect(getByRole('button', { name: String(children) })).toHaveProp( + 'accessibilityLabel', + String(children) + ) + expect(getByText(String(children))).toBeOnTheScreen() + }) + + test('сохраняет приоритет явно переданного доступного имени', () => { + const { getByRole, getByText } = render( + + Найти + + ) + + expect(getByRole('button', { name: 'Найти заказ' })).toHaveProp( + 'accessibilityLabel', + 'Найти заказ' + ) + expect(getByText('Найти')).toBeOnTheScreen() + }) + + test.each([ + { name: 'ноды', children: Открыть }, + { name: 'render-функции', children: () => Открыть }, + ])('сохраняет доступное имя из текста собственной $name', ({ children }) => { + const { getByRole } = render( + {children} + ) + + expect(getByRole('button', { name: 'Открыть' })).toBeOnTheScreen() + }) + + test('отображает текст и SVG без действия по умолчанию', () => { + const { getByText, queryByRole } = render( + Пользователь + ) + + expect(getByText('Пользователь')).toBeOnTheScreen() + expect(queryByRole('button')).not.toBeOnTheScreen() + }) + + test('вызывает явно переданное действие', async () => { + const onPress = jest.fn() + const { getByRole } = render( + Найти + ) + await userEvent.press(getByRole('button', { name: 'Найти' })) + + expect(onPress).toHaveBeenCalledOnce() + }) + + test('не вызывает действие отключённой добавки', async () => { + const onPress = jest.fn() + const { getByRole } = render( + + Найти + + ) + await userEvent.press(getByRole('button', { name: 'Найти' })) + + expect(onPress).not.toHaveBeenCalled() + }) + + test('сохраняет доступное имя и событие кнопки только с SVG', async () => { + const onPress = jest.fn() + const { getByRole } = render( + + ) + const button = getByRole('button', { name: 'Выбрать получателя' }) + + expect(button).toHaveProp('accessibilityHint', 'Открывает список контактов') + + await userEvent.press(button) + + expect(onPress).toHaveBeenCalledOnce() + }) +}) diff --git a/src/components/Input/core/InputGroupContext.ts b/src/components/Input/core/InputGroupContext.ts new file mode 100644 index 00000000..4d908c70 --- /dev/null +++ b/src/components/Input/core/InputGroupContext.ts @@ -0,0 +1,10 @@ +import { createContext } from 'react' + +import type { InputState, InputTextSize } from './types' + +export const InputGroupContext = createContext<{ + disabled: boolean + size: InputTextSize + state: InputState + position: 'left' | 'right' +} | null>(null) diff --git a/src/components/Input/core/styles.ts b/src/components/Input/core/styles.ts new file mode 100644 index 00000000..ffa99518 --- /dev/null +++ b/src/components/Input/core/styles.ts @@ -0,0 +1,16 @@ +import { StyleSheet } from 'react-native-unistyles' + +import type { InputTextSize } from './types' + +export const styles = StyleSheet.create(({ components, semantic }) => ({ + icon: (size: InputTextSize = 'base') => { + const iconSize = { + small: semantic.dimension.size[400], + base: components.inputtext.extend.iconSize, + large: semantic.dimension.size[500], + xlarge: semantic.dimension.size[600], + }[size] + + return { width: iconSize, height: iconSize } + }, +})) diff --git a/src/components/Input/core/types.ts b/src/components/Input/core/types.ts new file mode 100644 index 00000000..cad581b5 --- /dev/null +++ b/src/components/Input/core/types.ts @@ -0,0 +1,2 @@ +export type InputTextSize = 'small' | 'base' | 'large' | 'xlarge' +export type InputState = 'default' | 'danger' diff --git a/src/components/Input/index.ts b/src/components/Input/index.ts index 721f9239..765c68c7 100644 --- a/src/components/Input/index.ts +++ b/src/components/Input/index.ts @@ -1,5 +1,9 @@ export { LegacyInputGroup as InputGroup } from './deprecated/LegacyInputGroup' export { LegacyInputText as InputText } from './deprecated/LegacyInputText' +export { + InputGroupAddon, + type InputGroupAddonProps, +} from './InputGroup/InputGroupAddon' export { InputCaption, type InputCaptionProps, From 451ecee99947aaf355306fed6256aafc187b311a Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Mon, 12 Oct 2026 18:22:37 +0300 Subject: [PATCH 08/17] =?UTF-8?q?feat(input):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20InputText=20=D1=81=20=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D1=8B=D0=BC=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .storybook/inputLabelControls.ts | 67 ++++ .../Input/InputText/InputText.stories.tsx | 104 +++++++ src/components/Input/InputText/InputText.tsx | 20 ++ .../InputText/InputTextMasked.stories.tsx | 187 +++++++++++ .../InputText/__tests__/InputText.test.tsx | 44 +++ .../__tests__/InputTextAccessibility.test.tsx | 156 ++++++++++ .../InputText/__tests__/InputTextApi.test.tsx | 290 ++++++++++++++++++ .../__tests__/InputTextMasked.test.tsx | 221 +++++++++++++ src/components/Input/InputText/types.ts | 3 + src/components/Input/core/InputActions.tsx | 62 ++++ src/components/Input/core/InputControl.tsx | 145 +++++++++ src/components/Input/core/InputField.tsx | 152 +++++++++ src/components/Input/core/InputLabel.tsx | 147 +++++++++ .../Input/core/InputPlaceholder.tsx | 50 +++ src/components/Input/core/strings.ts | 4 + src/components/Input/core/styles.ts | 138 ++++++++- src/components/Input/core/types.ts | 99 ++++++ src/components/Input/index.ts | 9 +- .../Input/stories/InputText.stories.tsx | 57 ---- 19 files changed, 1895 insertions(+), 60 deletions(-) create mode 100644 .storybook/inputLabelControls.ts create mode 100644 src/components/Input/InputText/InputText.stories.tsx create mode 100644 src/components/Input/InputText/InputText.tsx create mode 100644 src/components/Input/InputText/InputTextMasked.stories.tsx create mode 100644 src/components/Input/InputText/__tests__/InputText.test.tsx create mode 100644 src/components/Input/InputText/__tests__/InputTextAccessibility.test.tsx create mode 100644 src/components/Input/InputText/__tests__/InputTextApi.test.tsx create mode 100644 src/components/Input/InputText/__tests__/InputTextMasked.test.tsx create mode 100644 src/components/Input/InputText/types.ts create mode 100644 src/components/Input/core/InputActions.tsx create mode 100644 src/components/Input/core/InputControl.tsx create mode 100644 src/components/Input/core/InputField.tsx create mode 100644 src/components/Input/core/InputLabel.tsx create mode 100644 src/components/Input/core/InputPlaceholder.tsx create mode 100644 src/components/Input/core/strings.ts delete mode 100644 src/components/Input/stories/InputText.stories.tsx diff --git a/.storybook/inputLabelControls.ts b/.storybook/inputLabelControls.ts new file mode 100644 index 00000000..66860e28 --- /dev/null +++ b/.storybook/inputLabelControls.ts @@ -0,0 +1,67 @@ +import type { Meta } from '@storybook/react' +import { Alert } from 'react-native' + +import type { + InputLabelInfo, + InputLabelProps, +} from '../src/components/Input/core/types' + +type InputLabelControls = { + labelMode: 'none' | 'label' | 'floatLabel' + labelText: string + labelInfo?: InputLabelInfo +} + +export type InputLabelStoryArgs = Omit< + Props, + 'label' | 'floatLabel' | 'labelInfo' +> & + InputLabelControls + +export type InputStoryMeta = Omit, 'component'> & + Pick, 'component'> + +export const inputLabelControls = { + labelMode: { + control: 'radio', + options: ['none', 'label', 'floatLabel'], + description: + 'Режим примера: передать label, floatLabel или не передавать подпись. Не свойство компонента.', + }, + labelText: { + control: 'text', + description: 'Текст для выбранного label или floatLabel в этом примере.', + }, + labelInfo: { + control: 'select', + options: ['none', 'info'], + mapping: { + none: undefined, + info: { + onPress: () => + Alert.alert( + 'Подробнее', + 'Пример справки, которую открывает приложение.' + ), + }, + }, + if: { arg: 'labelMode', eq: 'label' }, + description: + 'Кнопка ⓘ у обычного label. Обработчик примера открывает справку через Alert.', + }, +} as const + +export const getInputLabelProps = ({ + labelMode, + labelText, + labelInfo, +}: InputLabelControls): InputLabelProps => { + switch (labelMode) { + case 'label': + return { label: labelText, labelInfo } + case 'floatLabel': + return { floatLabel: labelText } + case 'none': + return {} + } +} diff --git a/src/components/Input/InputText/InputText.stories.tsx b/src/components/Input/InputText/InputText.stories.tsx new file mode 100644 index 00000000..3163bdf2 --- /dev/null +++ b/src/components/Input/InputText/InputText.stories.tsx @@ -0,0 +1,104 @@ +import { useArgs } from '@storybook/preview-api' +import type { StoryObj } from '@storybook/react' +import { useCallback, useEffect, useState } from 'react' + +import { + getInputLabelProps, + inputLabelControls, + type InputLabelStoryArgs, + type InputStoryMeta, +} from '../../../../.storybook/inputLabelControls' + +import { InputText, type InputTextProps } from './InputText' + +type StoryArgs = InputLabelStoryArgs + +const meta = { + title: 'Input/InputText', + component: InputText, + args: { + labelMode: 'label', + labelText: 'Название', + placeholder: 'Введите текст', + value: '', + size: 'base', + state: 'default', + clearable: true, + disabled: false, + readOnly: false, + required: false, + labelInfo: undefined, + caption: 'Подсказка к полю', + }, + argTypes: { + ...inputLabelControls, + placeholder: { control: 'text' }, + value: { control: 'text' }, + size: { control: 'radio', options: ['small', 'base', 'large', 'xlarge'] }, + state: { control: 'radio', options: ['default', 'danger'] }, + clearable: { control: 'boolean' }, + disabled: { control: 'boolean' }, + readOnly: { control: 'boolean' }, + required: { control: 'boolean' }, + caption: { control: 'text' }, + onChangeText: { action: 'onChangeText' }, + }, + parameters: { + controls: { + include: [ + 'labelMode', + 'labelText', + 'placeholder', + 'value', + 'size', + 'state', + 'clearable', + 'disabled', + 'readOnly', + 'required', + 'labelInfo', + 'caption', + ], + }, + }, + render: (args) => { + const { + labelMode, + labelText, + labelInfo, + onChangeText: onChangeTextAction, + ...props + } = args + const labelProps = getInputLabelProps({ labelMode, labelText, labelInfo }) + const [, updateArgs] = useArgs() + const [value, setValue] = useState(props.value) + + useEffect(() => setValue(props.value), [props.value]) + + const onChangeText = useCallback( + (nextValue: string) => { + setValue(nextValue) + updateArgs({ value: nextValue }) + onChangeTextAction?.(nextValue) + }, + [onChangeTextAction, updateArgs] + ) + + return ( + + ) + }, +} satisfies InputStoryMeta + +export default meta + +type Story = StoryObj + +const InputTextStory: Story = { name: 'InputText' } + +export { InputTextStory as InputText } diff --git a/src/components/Input/InputText/InputText.tsx b/src/components/Input/InputText/InputText.tsx new file mode 100644 index 00000000..e3ee9f0a --- /dev/null +++ b/src/components/Input/InputText/InputText.tsx @@ -0,0 +1,20 @@ +import { memo } from 'react' + +import { InputField } from '../core/InputField' + +import type { InputTextProps } from './types' + +export type { InputTextProps } from './types' + +/** + * Текстовое поле ввода + * + * @example + * + * + * @see InputTextProps + * @link https://www.figma.com/design/Q1BWgZ7zoV5UzlBOnjW0cM/?node-id=25176-76770 + */ +export const InputText = memo((props) => ( + +)) diff --git a/src/components/Input/InputText/InputTextMasked.stories.tsx b/src/components/Input/InputText/InputTextMasked.stories.tsx new file mode 100644 index 00000000..2bd053ca --- /dev/null +++ b/src/components/Input/InputText/InputTextMasked.stories.tsx @@ -0,0 +1,187 @@ +import { useArgs } from '@storybook/preview-api' +import type { StoryObj } from '@storybook/react' +import { + createContext, + type Ref, + useCallback, + useContext, + useEffect, + useImperativeHandle, + useRef, + useState, +} from 'react' +import { TextInput, View, type TextInputProps } from 'react-native' +import { MaskedTextInput } from 'react-native-advanced-input-mask' +import { StyleSheet } from 'react-native-unistyles' + +import { + getInputLabelProps, + type InputLabelStoryArgs, + type InputStoryMeta, +} from '../../../../.storybook/inputLabelControls' +import { Body } from '../../Typography' +import type { RenderTextInputArgs } from '../core/types' + +import { InputText, type InputTextProps } from './InputText' +import inputTextMeta from './InputText.stories' + +type StoryArgs = InputLabelStoryArgs & { mask: string } + +const NativeInputRefContext = createContext>(null) + +const NativeInput = ({ + ref: maskedRef, + ...props +}: TextInputProps & { readonly ref?: Ref }) => { + const inputRef = useContext(NativeInputRefContext) + const nativeRef = useRef(null) + + // У MaskedTextInput свой ограниченный ref; InputText сохраняет нативный TextInput. + useImperativeHandle( + inputRef, + () => nativeRef.current + ) + useImperativeHandle( + maskedRef, + () => nativeRef.current + ) + + return +} + +const MaskedControl = ({ + inputRef, + onChangeText, + onMaskResult, + ...props +}: RenderTextInputArgs & { + readonly mask: string + readonly onMaskResult: (formatted: string, extracted: string) => void +}) => ( + + { + onMaskResult(formatted, extracted) + onChangeText?.(formatted) + }} + /> + +) + +const meta = { + ...inputTextMeta, + title: 'Input/Input Text With Mask', + args: { + ...inputTextMeta.args, + labelText: 'Значение', + placeholder: 'Введите значение', + caption: '', + mask: '+7 ([000]) [000]-[00]-[00]', + }, + argTypes: { + ...inputTextMeta.argTypes, + mask: { + control: 'radio', + options: [ + '+7 ([000]) [000]-[00]-[00]', + '[00]/[00]/[0000]', + '[000]-[000]', + ], + description: + 'Параметр примера для стороннего MaskedTextInput; не свойство InputText.', + }, + }, + parameters: { + ...inputTextMeta.parameters, + controls: { + include: [...inputTextMeta.parameters.controls.include, 'mask'], + }, + docs: { + description: { + component: + 'Маска подключена через renderTextInput. Ниже отображаются formatted и extracted из события маски. После изменения value или mask в Controls результаты доступны при следующем вводе.', + }, + }, + }, +} satisfies InputStoryMeta + +export default meta + +const MaskedStory = { + name: 'Input Text With Mask', + render: (args: StoryArgs) => { + const { + labelMode, + labelText, + labelInfo, + mask, + onChangeText: onChangeTextAction, + ...props + } = args + const labelProps = getInputLabelProps({ labelMode, labelText, labelInfo }) + const [, updateArgs] = useArgs() + const [value, setValue] = useState(props.value) + const [maskResult, setMaskResult] = useState<{ + mask: string + formatted: string + extracted: string + }>() + + useEffect(() => setValue(props.value), [props.value]) + + const result = value + ? maskResult?.mask === mask && maskResult.formatted === value + ? maskResult + : undefined + : { formatted: '', extracted: '' } + + const onChangeText = useCallback( + (nextValue: string) => { + setValue(nextValue) + updateArgs({ value: nextValue }) + onChangeTextAction?.(nextValue) + }, + [onChangeTextAction, updateArgs] + ) + + return ( + + ( + + setMaskResult({ mask, formatted, extracted }) + } + /> + )} + value={value} + onChangeText={onChangeText} + /> + + + Форматированный текст: {result ? `"${result.formatted}"` : '—'} + + + Извлечённый текст: {result ? `"${result.extracted}"` : '—'} + + + + ) + }, +} satisfies StoryObj + +export { MaskedStory as Masked } + +const styles = StyleSheet.create(({ semantic }) => ({ + example: { gap: semantic.dimension.space[200] }, + values: { gap: semantic.dimension.space[100] }, +})) diff --git a/src/components/Input/InputText/__tests__/InputText.test.tsx b/src/components/Input/InputText/__tests__/InputText.test.tsx new file mode 100644 index 00000000..324e8a10 --- /dev/null +++ b/src/components/Input/InputText/__tests__/InputText.test.tsx @@ -0,0 +1,44 @@ +import { fireEvent, render } from '@testing-library/react-native' + +import { InputText, type InputTextProps } from '../..' + +describe('InputText', () => { + test('отображает реальное поле и передаёт публичные props', () => { + const onChangeText = jest.fn() + const props: InputTextProps = { + placeholder: 'Адрес', + accessibilityLabel: 'Адрес доставки', + value: 'Москва', + size: 'small', + secureTextEntry: true, + onChangeText, + } + const { getByLabelText, getByRole } = render() + const input = getByLabelText('Адрес доставки') + + expect(input).toHaveDisplayValue('Москва') + expect(input).toHaveProp('secureTextEntry', true) + + fireEvent.changeText(input, 'Казань') + + expect(onChangeText).toHaveBeenLastCalledWith('Казань') + expect(input).toHaveDisplayValue('Москва') + + fireEvent.press(getByRole('button', { name: 'Очистить поле' })) + + expect(onChangeText).toHaveBeenLastCalledWith('') + }) + + test('передаёт readOnly без визуального disabled', () => { + const { getByLabelText, queryByRole, queryByTestId } = render( + + ) + + expect(getByLabelText('Адрес')).toHaveProp('editable', false) + expect(getByLabelText('Адрес')).toHaveProp('accessibilityState', { + disabled: false, + }) + expect(queryByRole('button')).not.toBeOnTheScreen() + expect(queryByTestId('InputTextBaseDisabledIcon')).not.toBeOnTheScreen() + }) +}) diff --git a/src/components/Input/InputText/__tests__/InputTextAccessibility.test.tsx b/src/components/Input/InputText/__tests__/InputTextAccessibility.test.tsx new file mode 100644 index 00000000..1e6d49f8 --- /dev/null +++ b/src/components/Input/InputText/__tests__/InputTextAccessibility.test.tsx @@ -0,0 +1,156 @@ +import { render, userEvent } from '@testing-library/react-native' +import { TextInput } from 'react-native' + +import { InputText, type InputTextProps } from '../InputText' + +describe('InputText: базовая доступность', () => { + test.each<{ name: string; props: InputTextProps; label: string }>([ + { name: 'обычный label', props: { label: 'Email' }, label: 'Email' }, + { name: 'floatLabel', props: { floatLabel: 'Email' }, label: 'Email' }, + { + name: 'placeholder без label', + props: { placeholder: 'Email' }, + label: 'Email', + }, + { + name: 'явное имя поля', + props: { label: 'Email', accessibilityLabel: 'Рабочий email' }, + label: 'Рабочий email', + }, + ])('сохраняет имя поля: $name', ({ props, label }) => { + const { getByLabelText } = render( + + ) + + expect(getByLabelText(label)).toHaveDisplayValue('name@example.com') + }) + + test('скрывает визуальный floatLabel и placeholder от скринридера', () => { + const { getByLabelText, getByText, queryByRole, rerender } = render( + + ) + + expect(getByLabelText('Email')).toHaveDisplayValue('') + expect(queryByRole('text', { name: 'Email' })).toBeNull() + expect( + getByText('Email', { includeHiddenElements: true }) + ).toBeOnTheScreen() + + rerender() + + expect(getByLabelText('Email')).toHaveDisplayValue('') + expect(queryByRole('text', { name: 'name@example.com' })).toBeNull() + expect( + getByText('name@example.com', { includeHiddenElements: true }) + ).toBeOnTheScreen() + }) + + test('обновляет disabled, сохраняя остальные состояния доступности', () => { + const { getByLabelText, rerender } = render( + + ) + + expect(getByLabelText('Email')).toHaveProp('accessibilityState', { + busy: true, + disabled: true, + }) + + rerender( + + ) + + expect(getByLabelText('Email')).toHaveProp('accessibilityState', { + busy: true, + disabled: false, + }) + expect(getByLabelText('Email')).toHaveProp('editable', false) + }) + + test('передаёт имя и состояние доступности в renderTextInput', () => { + const { getByLabelText } = render( + ( + + )} + /> + ) + + expect(getByLabelText('Телефон')).toHaveProp('accessibilityState', { + disabled: true, + }) + }) + + test.each([ + { + name: 'имя по умолчанию', + accessibilityLabel: undefined, + label: 'Подробнее', + }, + { + name: 'явное имя', + accessibilityLabel: 'Как узнать ИНН', + label: 'Как узнать ИНН', + }, + ])('озвучивает кнопку info: $name', async ({ accessibilityLabel, label }) => { + const onPress = jest.fn() + const { getByRole } = render( + + ) + const button = getByRole('button', { name: label }) + + expect(button).toHaveProp('accessibilityState', { disabled: false }) + + await userEvent.press(button) + + expect(onPress).toHaveBeenCalledOnce() + }) + + test.each([ + { name: 'readOnly', props: { readOnly: true }, disabled: false }, + { name: 'disabled', props: { disabled: true }, disabled: true }, + ])( + 'соблюдает состояние кнопки info при $name', + async ({ props, disabled }) => { + const onPress = jest.fn() + const { getByRole } = render( + + ) + const button = getByRole('button', { name: 'Подробнее' }) + + expect(button).toHaveProp('accessibilityState', { disabled }) + + await userEvent.press(button) + + expect(onPress).toHaveBeenCalledTimes(disabled ? 0 : 1) + } + ) + + test('сохраняет разные testID кнопок info при одинаковых названиях', () => { + const { getAllByRole, getByTestId } = render( + <> + + + + ) + + expect(getAllByRole('button', { name: 'Подробнее' })).toHaveLength(2) + expect(getByTestId('EmailInputLabelInfo')).toHaveProp( + 'accessibilityRole', + 'button' + ) + expect(getByTestId('PhoneInputLabelInfo')).toHaveProp( + 'accessibilityRole', + 'button' + ) + }) +}) diff --git a/src/components/Input/InputText/__tests__/InputTextApi.test.tsx b/src/components/Input/InputText/__tests__/InputTextApi.test.tsx new file mode 100644 index 00000000..193f4231 --- /dev/null +++ b/src/components/Input/InputText/__tests__/InputTextApi.test.tsx @@ -0,0 +1,290 @@ +import { IconCircleX } from '@tabler/icons-react-native' +import { + act, + fireEvent, + render, + userEvent, +} from '@testing-library/react-native' +import { createRef, useImperativeHandle } from 'react' +import { TextInput } from 'react-native' + +import { InputText, InputCaption, type RenderTextInputArgs } from '../..' + +describe('InputText: новый API', () => { + test('разделяет label, placeholder и произвольный caption', () => { + const { getByText, queryByTestId } = render( + + Неверный номер + + } + label='Телефон' + placeholder='+7 999' + /> + ) + + expect( + getByText('+7 999', { includeHiddenElements: true }) + ).toBeOnTheScreen() + expect(getByText('Неверный номер')).toBeOnTheScreen() + expect(queryByTestId('InputTextClearButton')).not.toBeOnTheScreen() + }) + + test('отображает строковый caption и передаёт нативные props поля', () => { + const { getByLabelText, getByText } = render( + + ) + + expect(getByLabelText('Контактный телефон')).toHaveDisplayValue('') + expect(getByText('Получит уведомление')).toBeOnTheScreen() + }) + + test('инициализирует defaultValue один раз и обновляет неконтролируемое поле', async () => { + const onChangeText = jest.fn() + const { rerender, getByTestId } = render( + + ) + + expect(getByTestId('InputText')).toHaveDisplayValue('Москва') + + fireEvent.changeText(getByTestId('InputText'), 'Казань') + rerender( + + ) + + expect(getByTestId('InputText')).toHaveDisplayValue('Казань') + + await userEvent.press(getByTestId('InputTextClearButton')) + + expect(onChangeText).toHaveBeenLastCalledWith('') + expect(getByTestId('InputText')).toHaveDisplayValue('') + }) + + test('сохраняет renderer, стандартный ref и callback очистки controlled value', () => { + const focus = jest.fn() + const onChangeText = jest.fn() + const ref = createRef() + const CustomInput = ({ inputRef, ...props }: RenderTextInputArgs) => { + useImperativeHandle(inputRef, () => ({ focus }) as unknown as TextInput) + + return + } + const { rerender, getByTestId } = render( + } + value='123' + onChangeText={onChangeText} + /> + ) + act(() => { + ref.current?.focus() + ref.current?.clear() + }) + + expect(focus).toHaveBeenCalledOnce() + expect(onChangeText).toHaveBeenLastCalledWith('') + expect(getByTestId('InputText')).toHaveDisplayValue('123') + + rerender( + } + value='' + onChangeText={onChangeText} + /> + ) + + expect(getByTestId('InputText')).toHaveDisplayValue('') + }) + + test('сохраняет отдельную подсказку и callbacks при фокусе floating label', () => { + const onFocus = jest.fn() + const onBlur = jest.fn() + const { getByTestId, rerender } = render( + + ) + const input = getByTestId('InputText') + + expect( + getByTestId('InputTextPlaceholder', { includeHiddenElements: true }) + ).toBeOnTheScreen() + + fireEvent(input, 'focus', { nativeEvent: {} }) + + expect(input).toHaveProp('placeholder', '') + + fireEvent(input, 'blur', { nativeEvent: {} }) + + expect( + getByTestId('InputTextPlaceholder', { includeHiddenElements: true }) + ).toBeOnTheScreen() + expect(onFocus).toHaveBeenCalledOnce() + expect(onBlur).toHaveBeenCalledOnce() + + rerender( + + ) + + expect(getByTestId('InputText')).toHaveDisplayValue('a@b.ru') + expect(getByTestId('InputTextFloatingLabel')).toBeOnTheScreen() + }) + + test.each([ + { name: 'disabled', props: { disabled: true } }, + { name: 'readOnly', props: { readOnly: true } }, + { + name: 'disabled при readOnly=false', + props: { disabled: true, readOnly: false }, + }, + ])('нормализует renderer при $name', ({ props }) => { + const { queryByTestId, getByTestId } = render( + ( + + )} + value='Текст' + /> + ) + + expect(getByTestId('InputText')).toHaveProp('editable', false) + expect(queryByTestId('InputTextClearButton')).not.toBeOnTheScreen() + }) + + test('при вводе controlled value вызывает callback и ждёт обновления props', () => { + const onChangeText = jest.fn() + const { rerender, getByTestId } = render( + + ) + + fireEvent.changeText(getByTestId('InputText'), 'Казань') + + expect(onChangeText).toHaveBeenCalledOnce() + expect(onChangeText).toHaveBeenCalledWith('Казань') + expect(getByTestId('InputText')).toHaveDisplayValue('Москва') + + rerender( + + ) + + expect(getByTestId('InputText')).toHaveDisplayValue('Казань') + }) + + test('clearable=false убирает очистку, а пустое controlled value скрывает её после обновления', async () => { + const onChangeText = jest.fn() + const { rerender, getByTestId, queryByTestId } = render( + + ) + + expect(queryByTestId('InputTextClearButton')).toBeNull() + + rerender( + + ) + await userEvent.press(getByTestId('InputTextClearButton')) + + expect(onChangeText).toHaveBeenCalledOnce() + expect(onChangeText).toHaveBeenCalledWith('') + expect(getByTestId('InputText')).toHaveDisplayValue('Москва') + + rerender() + + expect(queryByTestId('InputTextClearButton')).toBeNull() + expect(onChangeText).toHaveBeenCalledOnce() + }) + + test('сохраняет нативные методы адаптера и очищает uncontrolled value через ref', async () => { + const ref = createRef() + const onChangeText = jest.fn() + const nativeInput = { + focus: jest.fn(), + blur: jest.fn(), + isFocused: jest.fn(() => true), + } + const CustomInput = ({ inputRef, ...props }: RenderTextInputArgs) => { + useImperativeHandle(inputRef, () => nativeInput as unknown as TextInput) + + return + } + const { getByTestId, queryByTestId } = render( + } + onChangeText={onChangeText} + /> + ) + + await userEvent.press(getByTestId('InputTextFrame')) + + expect(nativeInput.focus).toHaveBeenCalledOnce() + + act(() => { + ref.current?.blur() + + expect(ref.current?.isFocused()).toBeTrue() + + ref.current?.clear() + }) + + expect(nativeInput.blur).toHaveBeenCalledOnce() + expect(onChangeText).toHaveBeenCalledOnce() + expect(onChangeText).toHaveBeenCalledWith('') + expect(getByTestId('InputText')).toHaveDisplayValue('') + expect(queryByTestId('InputTextClearButton')).toBeNull() + }) + + test('убирает пустой caption и сохраняет число 0 при обновлении подписи', () => { + const { getByText, queryByTestId, rerender } = render( + + ) + + expect(getByText('Описание')).toBeOnTheScreen() + + rerender() + + expect(queryByTestId('InputCaption')).toBeNull() + + rerender() + + expect(getByText('0')).toBeOnTheScreen() + + rerender() + + expect(queryByTestId('InputCaption')).toBeNull() + }) +}) diff --git a/src/components/Input/InputText/__tests__/InputTextMasked.test.tsx b/src/components/Input/InputText/__tests__/InputTextMasked.test.tsx new file mode 100644 index 00000000..2168be62 --- /dev/null +++ b/src/components/Input/InputText/__tests__/InputTextMasked.test.tsx @@ -0,0 +1,221 @@ +import { + act, + fireEvent, + render, + userEvent, +} from '@testing-library/react-native' +import { type ComponentType, createRef, type Ref, type useRef } from 'react' +import type { TextInput, TextInputProps } from 'react-native' +import type { MaskedTextInputProps } from 'react-native-advanced-input-mask' + +import { Masked } from '../InputTextMasked.stories' + +const MaskedExample = Masked.render +const mockUpdateArgs = jest.fn() + +jest.mock('@storybook/preview-api', () => ({ + useArgs: () => [{}, mockUpdateArgs], +})) + +jest.mock('react-native-advanced-input-mask', () => { + const React = jest.requireActual<{ useRef: typeof useRef }>('react') + const extractedValues: Record = { + '+7 (900) 123-45-67': '9001234567', + '31/12/2026': '31122026', + '123-456': '123456', + } + + return { + MaskedTextInput: ({ + renderTextInputComponent, + onChangeText, + ...props + }: MaskedTextInputProps) => { + const nativeRef = React.useRef(null) + const Control = renderTextInputComponent as ComponentType< + TextInputProps & { ref?: Ref } + > + + return ( + + onChangeText?.(formatted, extractedValues[formatted], '', true) + } + /> + ) + }, + } +}) + +const args = { + labelMode: 'label' as const, + labelText: 'Телефон', + mask: '+7 ([000]) [000]-[00]-[00]', + value: '', + clearable: true, +} + +describe('InputText: пример с маской', () => { + beforeEach(() => jest.clearAllMocks()) + + test('передаёт форматированную строку в Controls и callback, очищает значение', async () => { + const onChangeText = jest.fn() + const { getByTestId, getByRole, getByText } = render( + + ) + + fireEvent.changeText(getByTestId('InputText'), '+7 (900) 123-45-67') + + expect(getByTestId('InputText')).toHaveDisplayValue('+7 (900) 123-45-67') + expect(mockUpdateArgs).toHaveBeenLastCalledWith({ + value: '+7 (900) 123-45-67', + }) + expect(onChangeText).toHaveBeenLastCalledWith('+7 (900) 123-45-67') + expect( + getByText('Форматированный текст: "+7 (900) 123-45-67"') + ).toBeOnTheScreen() + expect(getByText('Извлечённый текст: "9001234567"')).toBeOnTheScreen() + + await userEvent.press(getByRole('button', { name: 'Очистить поле' })) + + expect(getByTestId('InputText')).toHaveDisplayValue('') + expect(mockUpdateArgs).toHaveBeenLastCalledWith({ value: '' }) + expect(onChangeText).toHaveBeenLastCalledWith('') + expect(getByText('Форматированный текст: ""')).toBeOnTheScreen() + expect(getByText('Извлечённый текст: ""')).toBeOnTheScreen() + }) + + test.each([ + { name: 'readOnly', props: { readOnly: true } }, + { name: 'disabled', props: { disabled: true } }, + ])('запрещает редактирование и очистку при $name', ({ props }) => { + const { getByTestId, queryByRole } = render( + + ) + + expect(getByTestId('InputText')).toHaveProp('editable', false) + expect(getByTestId('InputText')).toHaveProp('readOnly', true) + expect(queryByRole('button', { name: 'Очистить поле' })).toBeNull() + }) + + test('сохраняет нативные методы ref и обновляет значение из Controls', () => { + const ref = createRef() + const onChangeText = jest.fn() + const { getByTestId, getByText, rerender } = render( + + ) + + expect(typeof ref.current?.measure).toBe('function') + + rerender( + + ) + + expect(getByTestId('InputText')).toHaveDisplayValue('+7 (999) 123-45-67') + expect(getByText('Форматированный текст: —')).toBeOnTheScreen() + expect(getByText('Извлечённый текст: —')).toBeOnTheScreen() + + act(() => ref.current?.clear()) + + expect(onChangeText).toHaveBeenLastCalledWith('') + expect(getByTestId('InputText')).toHaveDisplayValue('') + expect(getByText('Извлечённый текст: ""')).toBeOnTheScreen() + }) + + test.each([ + { + name: 'дату', + mask: '[00]/[00]/[0000]', + formatted: '31/12/2026', + extracted: '31122026', + }, + { + name: 'индекс', + mask: '[000]-[000]', + formatted: '123-456', + extracted: '123456', + }, + ])( + 'переключает маску на $name, сбрасывает старый extracted и сохраняет нативный ref', + ({ mask, formatted, extracted }) => { + const ref = createRef() + const { getByTestId, getByText, rerender } = render( + + ) + + fireEvent.changeText(getByTestId('InputText'), '+7 (900) 123-45-67') + const nativeInput = ref.current + + rerender() + + expect(getByTestId('InputText')).toHaveProp('mask', mask) + expect(getByText('Форматированный текст: —')).toBeOnTheScreen() + expect(getByText('Извлечённый текст: —')).toBeOnTheScreen() + expect(ref.current).toBe(nativeInput) + + fireEvent.changeText(getByTestId('InputText'), formatted) + + expect(getByTestId('InputText')).toHaveDisplayValue(formatted) + expect( + getByText(`Форматированный текст: "${formatted}"`) + ).toBeOnTheScreen() + expect(getByText(`Извлечённый текст: "${extracted}"`)).toBeOnTheScreen() + expect(mockUpdateArgs).toHaveBeenLastCalledWith({ value: formatted }) + } + ) + + test('переключает подпись из Controls, сохраняя введённое значение и нативный ref', () => { + const ref = createRef() + const labelArgs = { ...args, labelInfo: { onPress: jest.fn() } } + const { + getByDisplayValue, + getByTestId, + queryByTestId, + queryByText, + rerender, + } = render( + + ) + + fireEvent.changeText(getByTestId('MaskedLabelStory'), '+7 (900) 123-45-67') + const nativeInput = ref.current + + rerender( + + ) + + expect(getByTestId('MaskedLabelStoryFloatingLabel')).toHaveTextContent( + 'Телефон' + ) + expect(getByTestId('MaskedLabelStory')).toHaveDisplayValue( + '+7 (900) 123-45-67' + ) + expect(ref.current).toBe(nativeInput) + + rerender( + + ) + + expect(queryByTestId('MaskedLabelStoryFloatingLabel')).toBeNull() + expect(queryByText('Телефон')).toBeNull() + expect(ref.current).toBe(nativeInput) + expect(getByDisplayValue('+7 (900) 123-45-67')).toBeTruthy() + }) +}) diff --git a/src/components/Input/InputText/types.ts b/src/components/Input/InputText/types.ts new file mode 100644 index 00000000..0230a970 --- /dev/null +++ b/src/components/Input/InputText/types.ts @@ -0,0 +1,3 @@ +import type { InputLabelProps, InputNativeProps } from '../core/types' + +export type InputTextProps = InputNativeProps & InputLabelProps diff --git a/src/components/Input/core/InputActions.tsx b/src/components/Input/core/InputActions.tsx new file mode 100644 index 00000000..75264fe7 --- /dev/null +++ b/src/components/Input/core/InputActions.tsx @@ -0,0 +1,62 @@ +import { IconX } from '@tabler/icons-react-native' +import type { ReactNode } from 'react' +import { Pressable, View } from 'react-native' + +import { SvgUniversal } from '../../../utils/SvgUniversal' + +import { inputStrings } from './strings' +import { styles } from './styles' +import type { InputTextSize } from './types' + +interface InputActionsProps { + readonly isClearVisible: boolean + readonly size: InputTextSize + readonly onClear: () => void + readonly children?: ReactNode + readonly testID: string +} + +export const InputActions = ({ + isClearVisible, + size, + onClear, + children, + testID, +}: InputActionsProps) => { + const actionStyle = styles.actions(size) + + return ( + + {isClearVisible ? ( + + { + const iconStyle = styles.icon(size) + + return { + width: iconStyle.width, + height: iconStyle.height, + color: components.inputtext.root.color, + } + }} + /> + + ) : null} + {children} + + ) +} diff --git a/src/components/Input/core/InputControl.tsx b/src/components/Input/core/InputControl.tsx new file mode 100644 index 00000000..833daf5e --- /dev/null +++ b/src/components/Input/core/InputControl.tsx @@ -0,0 +1,145 @@ +import type { ReactNode } from 'react' +import { TextInput, View, type TextInputProps } from 'react-native' + +import { useMakeTestId } from '../../../hooks/useMakeTestId' + +import { InputActions } from './InputActions' +import { InputLabel } from './InputLabel' +import { InputPlaceholder } from './InputPlaceholder' +import { styles } from './styles' +import type { + InputLabelProps, + InputNativeProps, + RenderTextInputArgs, +} from './types' +import { useFloatingLabel } from './useFloatingLabel' +import type { useInputTextController } from './useInputTextController' +import { usePlaceholderLayout } from './usePlaceholderLayout' + +type InputControlProps = Pick< + InputNativeProps & InputLabelProps, + | 'label' + | 'required' + | 'size' + | 'disabled' + | 'clearable' + | 'inputStyle' + | 'renderTextInput' +> & { + readonly controller: ReturnType + readonly nativeProps: TextInputProps + readonly isEditable: boolean + readonly isFloating: boolean + readonly isDanger: boolean + readonly testID: string + readonly action?: ReactNode +} + +const renderNativeInput = ({ inputRef, ...props }: RenderTextInputArgs) => ( + +) + +export const InputControl = ({ + label, + required, + size = 'base', + disabled, + clearable, + inputStyle, + renderTextInput, + controller, + nativeProps, + isEditable, + isFloating, + isDanger, + testID, + action, +}: InputControlProps) => { + const { makeTestId } = useMakeTestId(testID) + const active = controller.isFocused || !!controller.value + const isClearVisible = !!clearable && !!controller.value && isEditable + const hasActions = isClearVisible || !!action + const placeholderLayout = usePlaceholderLayout() + const floatText = styles.floatText(isDanger, size) + const floatingLabel = useFloatingLabel(active, nativeProps, { + labelTop: styles.floatLabel.top, + reducedTop: styles[size].paddingVertical, + labelPadding: styles.floatLabel.paddingVertical, + reducedPadding: styles.floatReduced.paddingVertical, + fontSize: floatText.fontSize, + reducedFontSize: styles.floatReduced.fontSize, + fontFamily: floatText.fontFamily, + reducedFontFamily: styles.floatReduced.fontFamily, + inputPaddingTop: styles.floatInput.paddingTop, + }) + const textStyle = [ + styles.input, + styles[size], + hasActions && styles.inputWithActions, + disabled && styles.disabledText, + isFloating && styles.floatInput, + isFloating && { paddingTop: floatingLabel.inputPaddingTop }, + inputStyle, + ] + const textInputProps: RenderTextInputArgs = { + ...nativeProps, + accessibilityLabel: + nativeProps.accessibilityLabel ?? label ?? nativeProps.placeholder, + accessibilityState: { ...nativeProps.accessibilityState, disabled }, + editable: isEditable, + readOnly: !isEditable, + placeholder: '', + testID, + style: textStyle, + value: controller.value, + inputRef: controller.inputRef, + onFocus: controller.onFocus, + onBlur: controller.onBlur, + onChangeText: controller.onChangeText, + } + + return ( + + + {isFloating && label ? ( + + ) : null} + + {(renderTextInput ?? renderNativeInput)(textInputProps)} + + {hasActions ? ( + + {action} + + ) : null} + + ) +} diff --git a/src/components/Input/core/InputField.tsx b/src/components/Input/core/InputField.tsx new file mode 100644 index 00000000..ca4fff27 --- /dev/null +++ b/src/components/Input/core/InputField.tsx @@ -0,0 +1,152 @@ +import { useMemo, type ReactNode } from 'react' +import { Pressable, View } from 'react-native' + +import { useMakeTestId } from '../../../hooks/useMakeTestId' +import { InputCaption } from '../InputCaption/InputCaption' +import { InputGroupAddon } from '../InputGroup/InputGroupAddon' + +import { InputControl } from './InputControl' +import { InputGroupContext } from './InputGroupContext' +import { InputLabel } from './InputLabel' +import { styles } from './styles' +import type { InputLabelProps, InputNativeProps } from './types' +import { useInputTextController } from './useInputTextController' + +type InputFieldProps = InputNativeProps & + InputLabelProps & { + readonly leftAddon?: ReactNode + readonly rightAddon?: ReactNode + readonly action?: ReactNode + } + +const addonContent = (node: ReactNode) => { + if (node === '') return null + + return typeof node === 'string' || typeof node === 'number' ? ( + {node} + ) : ( + node + ) +} + +const captionContent = (node: ReactNode) => { + if (node === '') return null + + return typeof node === 'string' || typeof node === 'number' ? ( + {node} + ) : ( + node + ) +} + +const frameStyle = ({ + isFocused, + isEditable, + isDanger, + disabled, +}: { + isFocused: boolean + isEditable: boolean + isDanger: boolean + disabled: boolean +}) => [ + styles.frame, + isFocused && styles.focused, + !isEditable && !disabled && styles.readOnly, + isDanger && styles.danger, + isDanger && isFocused && styles.dangerFocused, + disabled && styles.disabled, +] + +export const InputField = ({ + label, + floatLabel, + labelInfo, + caption, + required, + size = 'base', + state = 'default', + disabled = false, + clearable = true, + ref, + style, + inputStyle, + renderTextInput, + leftAddon, + rightAddon, + action, + readOnly = false, + testID = 'InputText', + ...nativeProps +}: InputFieldProps) => { + const controller = useInputTextController({ ...nativeProps, inputRef: ref }) + const isEditable = !disabled && !readOnly + const floating = floatLabel !== undefined + const labelText = floatLabel ?? label + const danger = state === 'danger' + const { makeTestId } = useMakeTestId(testID) + const leftContext = useMemo( + () => ({ disabled, size, state, position: 'left' as const }), + [disabled, size, state] + ) + const rightContext = useMemo( + () => ({ disabled, size, state, position: 'right' as const }), + [disabled, size, state] + ) + + return ( + + {label ? ( + + ) : null} + + + + {addonContent(leftAddon)} + + + + {addonContent(rightAddon)} + + + + {captionContent(caption)} + + ) +} diff --git a/src/components/Input/core/InputLabel.tsx b/src/components/Input/core/InputLabel.tsx new file mode 100644 index 00000000..b6b814e5 --- /dev/null +++ b/src/components/Input/core/InputLabel.tsx @@ -0,0 +1,147 @@ +import { IconInfoCircle } from '@tabler/icons-react-native' +import { Pressable, Text, View, type TextInputProps } from 'react-native' +import Animated from 'react-native-reanimated' + +import { SvgUniversal } from '../../../utils/SvgUniversal' + +import { inputStrings } from './strings' +import { styles } from './styles' +import type { InputLabelInfo, InputTextSize } from './types' +import type { useFloatingLabel } from './useFloatingLabel' + +interface InputLabelProps extends Pick< + TextInputProps, + 'allowFontScaling' | 'maxFontSizeMultiplier' +> { + readonly label: string + readonly labelInfo?: InputLabelInfo + readonly disabled?: boolean + readonly isRequired?: boolean + readonly isDanger?: boolean + readonly floating?: ReturnType + readonly hasActions?: boolean + readonly size?: InputTextSize + readonly infoTestID?: string + readonly testID?: string +} + +export const InputLabel = ({ + label, + labelInfo, + disabled = false, + isRequired, + isDanger = false, + floating, + hasActions = false, + size = 'base', + infoTestID, + testID, + ...textProps +}: InputLabelProps) => { + const content = ( + <> + {floating ? ( + <> + {isRequired ? ( + + * + + ) : null} + + {label} + + + ) : ( + <> + {isRequired ? ( + + * + + ) : null} + + {label} + + + )} + {labelInfo ? ( + + ({ + width: semantic.dimension.size[400], + height: semantic.dimension.size[400], + color: semantic.colorScheme.color.fg.default, + })} + /> + + ) : null} + + ) + + if (floating) { + return ( + + {content} + + ) + } + + return ( + + {content} + + ) +} diff --git a/src/components/Input/core/InputPlaceholder.tsx b/src/components/Input/core/InputPlaceholder.tsx new file mode 100644 index 00000000..6e52ceb4 --- /dev/null +++ b/src/components/Input/core/InputPlaceholder.tsx @@ -0,0 +1,50 @@ +import { + Text, + type TextInputProps, + type StyleProp, + type TextStyle, +} from 'react-native' + +import { styles } from './styles' + +interface InputPlaceholderProps { + readonly nativeProps: TextInputProps + readonly style: StyleProp + readonly isHidden: boolean + readonly isDanger: boolean + readonly testID: string + readonly onLayout?: TextInputProps['onLayout'] +} + +export const InputPlaceholder = ({ + nativeProps, + style, + isHidden, + isDanger, + testID, + onLayout, +}: InputPlaceholderProps) => + nativeProps.placeholder ? ( + + {nativeProps.placeholder} + + ) : null diff --git a/src/components/Input/core/strings.ts b/src/components/Input/core/strings.ts new file mode 100644 index 00000000..2d69b0a4 --- /dev/null +++ b/src/components/Input/core/strings.ts @@ -0,0 +1,4 @@ +export const inputStrings = { + labelInfoButton: 'Подробнее', + clearButton: 'Очистить поле', +} as const diff --git a/src/components/Input/core/styles.ts b/src/components/Input/core/styles.ts index ffa99518..42ff9e7a 100644 --- a/src/components/Input/core/styles.ts +++ b/src/components/Input/core/styles.ts @@ -2,7 +2,143 @@ import { StyleSheet } from 'react-native-unistyles' import type { InputTextSize } from './types' -export const styles = StyleSheet.create(({ components, semantic }) => ({ +export const styles = StyleSheet.create(({ components, semantic, fonts }) => ({ + root: { gap: semantic.dimension.space[100] }, + frame: { + flexDirection: 'row', + alignItems: 'stretch', + borderWidth: components.inputtext.extend.borderWidth, + borderRadius: components.inputtext.root.borderRadius, + borderColor: components.inputtext.root.borderColor, + backgroundColor: components.inputtext.root.background, + }, + focused: { + borderColor: components.inputtext.root.focusBorderColor, + outlineWidth: components.inputtext.root.focusRing.width, + outlineColor: components.inputtext.root.focusRing.color, + }, + // Обрезка на отдельном View сохраняет first responder при смене outline на iOS. + frameClip: { + flex: 1, + minWidth: 0, + flexDirection: 'row', + alignItems: 'stretch', + overflow: 'hidden', + borderRadius: + components.inputtext.root.borderRadius - + components.inputtext.extend.borderWidth, + }, + danger: { borderColor: components.inputtext.root.invalidBorderColor }, + dangerFocused: { + outlineColor: components.inputtext.extend.invalidFocusRing.color, + }, + readOnly: { backgroundColor: components.inputtext.extend.readonlyBackground }, + disabled: { backgroundColor: components.inputtext.root.disabledBackground }, + disabledText: { color: components.inputtext.root.disabledColor }, + control: { flex: 1, minWidth: 0, flexDirection: 'row' }, + inputContainer: { flex: 1, minWidth: 0, justifyContent: 'center' }, + placeholder: { position: 'absolute', left: 0, right: 0 }, + placeholderText: { color: components.inputtext.root.placeholderColor }, + dangerPlaceholder: { + color: components.inputtext.root.invalidPlaceholderColor, + }, + hidden: { opacity: 0 }, + input: { + fontFamily: fonts.fontFamily.base, + color: components.inputtext.root.color, + includeFontPadding: false, + verticalAlign: 'middle', + padding: semantic.dimension.space.none, + }, + inputWithActions: { paddingRight: semantic.dimension.space.none }, + small: { + fontSize: components.inputtext.root.sm.fontSize, + paddingHorizontal: components.inputtext.root.sm.paddingX, + paddingVertical: components.inputtext.root.sm.paddingY, + }, + base: { + fontSize: fonts.fontSize[300], + paddingHorizontal: components.inputtext.root.paddingX, + paddingVertical: components.inputtext.root.paddingY, + }, + large: { + fontSize: components.inputtext.root.lg.fontSize, + paddingHorizontal: components.inputtext.root.lg.paddingX, + paddingVertical: components.inputtext.root.lg.paddingY, + }, + xlarge: { + fontSize: components.inputtext.extend.extXlg.fontSize, + paddingHorizontal: components.inputtext.extend.extXlg.paddingX, + paddingVertical: components.inputtext.extend.extXlg.paddingY, + }, + floatControl: { minHeight: components.floatlabel.extend.height }, + floatInput: { + paddingTop: components.floatlabel.inside.input.paddingTop, + paddingBottom: components.floatlabel.inside.input.paddingBottom, + }, + label: { + fontFamily: fonts.fontFamily.base, + fontSize: fonts.fontSize[200], + fontWeight: fonts.fontWeight.regular, + lineHeight: fonts.lineHeight[400], + letterSpacing: fonts.letterSpacing[500], + color: semantic.colorScheme.color.fg.default, + flexShrink: 1, + }, + labelRow: { + flexDirection: 'row', + alignItems: 'center', + gap: semantic.dimension.space[100], + }, + labelInfo: { + width: semantic.dimension.size[700], + height: semantic.dimension.size[700], + justifyContent: 'center', + alignItems: 'center', + }, + // Color/Service/text-danger из Figma соответствует strong-цвету ошибки в обеих темах + required: { color: semantic.colorScheme.color.border.status.danger.strong }, + labelRequired: { fontSize: fonts.fontSize[300] }, + floatLabel: { + flexDirection: 'row', + alignItems: 'center', + gap: semantic.dimension.space[50], + position: 'absolute', + top: components.floatlabel.root.positionY, + paddingVertical: semantic.dimension.space.none, + }, + floatText: (isDanger: boolean, size: InputTextSize = 'base') => ({ + fontSize: { + small: components.inputtext.root.sm.fontSize, + base: fonts.fontSize[300], + large: components.inputtext.root.lg.fontSize, + xlarge: components.inputtext.extend.extXlg.fontSize, + }[size], + fontFamily: fonts.fontFamily.base, + color: isDanger + ? components.floatlabel.root.invalidColor + : components.floatlabel.root.color, + includeFontPadding: false, + flexShrink: 1, + }), + floatReduced: { + paddingVertical: semantic.dimension.space.none, + fontSize: components.floatlabel.root.active.fontSize, + fontFamily: fonts.fontFamily.base, + }, + actions: (size: InputTextSize = 'base') => ({ + flexDirection: 'row', + alignItems: 'stretch', + paddingLeft: semantic.dimension.space[100], + paddingRight: { + small: components.inputtext.root.sm.paddingX, + base: components.inputtext.root.paddingX, + large: components.inputtext.root.lg.paddingX, + xlarge: components.inputtext.extend.extXlg.paddingX, + }[size], + gap: semantic.dimension.space[100], + }), + action: { justifyContent: 'center', alignItems: 'center' }, icon: (size: InputTextSize = 'base') => { const iconSize = { small: semantic.dimension.size[400], diff --git a/src/components/Input/core/types.ts b/src/components/Input/core/types.ts index cad581b5..c772184e 100644 --- a/src/components/Input/core/types.ts +++ b/src/components/Input/core/types.ts @@ -1,2 +1,101 @@ +import type { ReactNode, Ref } from 'react' +import type { + StyleProp, + TextInput, + TextInputProps, + TextStyle, + ViewStyle, + PressableProps, +} from 'react-native' + export type InputTextSize = 'small' | 'base' | 'large' | 'xlarge' export type InputState = 'default' | 'danger' +export type RenderTextInputArgs = TextInputProps & { inputRef: Ref } + +export interface InputLabelInfo { + /** Обработчик нажатия на иконку ⓘ */ + onPress: NonNullable + /** + * Название кнопки для скринридера + * @default Подробнее + */ + accessibilityLabel?: string +} + +export type InputLabelProps = + | { + /** Постоянная подпись над полем, взаимоисключающая с floatLabel */ + label?: string + floatLabel?: never + labelInfo?: never + } + | { + label: string + floatLabel?: never + /** Иконка ⓘ с обработчиком нажатия; доступно только у обычного label */ + labelInfo?: InputLabelInfo + } + | { + label?: never + labelInfo?: never + /** + * Подпись внутри пустого поля, поднимающаяся при фокусе или наличии значения. + * Взаимоисключающая с label. Отдельный placeholder появляется при фокусе + * пустого поля; дублировать в нём текст floatLabel не требуется. + */ + floatLabel: string + } + +/** + * Общий RN-контракт: для режима чтения используется readOnly вместо editable. + * При заданном value обновляйте его через onChangeText. + * Без value компонент хранит значение сам; defaultValue задаёт начальное значение. + */ +export interface InputNativeProps extends Omit< + TextInputProps, + 'style' | 'editable' | 'children' | 'clearButtonMode' +> { + /** Методы TextInput; clear() вызывает onChangeText('') с учётом controlled-режима. */ + ref?: Ref + /** + * Строка или число оборачиваются в InputCaption; нода отображается как передана. + * Для статуса подписи передайте InputCaption с нужным variant. + */ + caption?: ReactNode + /** Показывает * у подписи */ + required?: boolean + /** @default 'base' */ + size?: InputTextSize + /** + * Валидация поля: default или danger. + * В Figma readonly и disabled соответствуют readOnly и disabled; + * focus отслеживается полем, hover не применяется в RN. + * @default 'default' + */ + state?: InputState + /** + * Отключает поле и все встроенные действия. + * Имеет приоритет над readOnly. + * @default false + */ + disabled?: boolean + /** + * Запрещает редактирование и очистку, сохраняя доступность остальных действий + * @default false + */ + readOnly?: boolean + /** + * Очистка доступна при непустом редактируемом поле и вызывает onChangeText('') + * @default true + */ + clearable?: boolean + /** Внешний контейнер: размеры и отступы */ + style?: StyleProp + /** Стиль нативного ввода */ + inputStyle?: StyleProp + /** + * Адаптер нативного ввода: передайте полученные props и inputRef в свой TextInput. + * editable в этих props вычислен из readOnly и disabled для совместимости адаптеров. + */ + renderTextInput?: (props: RenderTextInputArgs) => ReactNode +} diff --git a/src/components/Input/index.ts b/src/components/Input/index.ts index 765c68c7..d55ac719 100644 --- a/src/components/Input/index.ts +++ b/src/components/Input/index.ts @@ -1,5 +1,5 @@ export { LegacyInputGroup as InputGroup } from './deprecated/LegacyInputGroup' -export { LegacyInputText as InputText } from './deprecated/LegacyInputText' +export { InputText, type InputTextProps } from './InputText/InputText' export { InputGroupAddon, type InputGroupAddonProps, @@ -8,6 +8,11 @@ export { InputCaption, type InputCaptionProps, } from './InputCaption/InputCaption' +export type { + InputTextSize, + InputState, + InputLabelInfo, + RenderTextInputArgs, +} from './core/types' export { InputSwitch } from '../InputSwitch' -export type { InputTextBaseProps } from './deprecated/InputTextBase/types' export { InputOtp, InputOtpTestId, type InputOtpProps } from '../InputOtp' diff --git a/src/components/Input/stories/InputText.stories.tsx b/src/components/Input/stories/InputText.stories.tsx deleted file mode 100644 index 966fdfce..00000000 --- a/src/components/Input/stories/InputText.stories.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import { useArgs } from '@storybook/preview-api' -import type { Meta, StoryObj } from '@storybook/react' -import { useCallback, useEffect, useState } from 'react' - -import type { InputTextBase } from '../deprecated/InputTextBase/InputTextBase' -import { LegacyInputText as InputText } from '../deprecated/LegacyInputText' - -const meta: Meta = { - title: 'Form/InputText', - args: { - clearable: true, - disabled: false, - state: 'default', - placeholder: 'Placeholder', - value: '', - floatLabel: false, - secureTextEntry: false, - loading: false, - editable: true, - size: 'base', - }, - argTypes: { - state: { control: 'radio', options: ['default', 'danger'] }, - secureTextEntry: { - control: 'radio', - options: ['true', 'false', 'toggleable'], - mapping: { true: true, false: false, toggleable: 'toggleable' }, - }, - size: { control: 'radio', options: ['base', 'medium', 'large', 'xlarge'] }, - }, - render: (args) => { - const [, updateArgs] = useArgs() - const [value, setValue] = useState(args.value) - - const onChangeText = useCallback( - (nextValue: string) => { - setValue(nextValue) - updateArgs({ value: nextValue }) - }, - [updateArgs] - ) - - useEffect(() => { - setValue(args.value) - }, [args.value]) - - return - }, -} - -export default meta - -type Story = StoryObj - -const InputTextStory: Story = {} - -export { InputTextStory as InputText } From 33f6ccae4777f570cd1e192c2e24cd414d737791 Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Mon, 12 Oct 2026 19:41:00 +0300 Subject: [PATCH 09/17] =?UTF-8?q?feat(input):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20InputGroup=20=D1=81=20=D0=B1=D0=B0?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=BE=D0=B9=20=D0=B4=D0=BE=D1=81=D1=82=D1=83?= =?UTF-8?q?=D0=BF=D0=BD=D0=BE=D1=81=D1=82=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Input/InputGroup/InputGroup.stories.tsx | 166 ++++++++++++++++ .../Input/InputGroup/InputGroup.tsx | 17 ++ .../Input/InputGroup/InputGroupAddon.tsx | 2 +- .../InputGroup/__tests__/InputGroup.test.tsx | 188 ++++++++++++++++++ src/components/Input/InputGroup/types.ts | 16 ++ src/components/Input/index.ts | 2 +- .../Input/stories/InputGroup.stories.tsx | 56 ------ 7 files changed, 389 insertions(+), 58 deletions(-) create mode 100644 src/components/Input/InputGroup/InputGroup.stories.tsx create mode 100644 src/components/Input/InputGroup/InputGroup.tsx create mode 100644 src/components/Input/InputGroup/__tests__/InputGroup.test.tsx create mode 100644 src/components/Input/InputGroup/types.ts delete mode 100644 src/components/Input/stories/InputGroup.stories.tsx diff --git a/src/components/Input/InputGroup/InputGroup.stories.tsx b/src/components/Input/InputGroup/InputGroup.stories.tsx new file mode 100644 index 00000000..a65b2b08 --- /dev/null +++ b/src/components/Input/InputGroup/InputGroup.stories.tsx @@ -0,0 +1,166 @@ +import { useArgs } from '@storybook/preview-api' +import type { StoryObj } from '@storybook/react' +import { IconSearch } from '@tabler/icons-react-native' +import { useCallback, useEffect, useState } from 'react' + +import { Alert } from 'react-native' + +import { + getInputLabelProps, + inputLabelControls, + type InputLabelStoryArgs, + type InputStoryMeta, +} from '../../../../.storybook/inputLabelControls' +import { + InputCaption, + type InputCaptionProps, +} from '../InputCaption/InputCaption' + +import { InputGroup, type InputGroupProps } from './InputGroup' +import { InputGroupAddon } from './InputGroupAddon' + +const searchOrder = () => + Alert.alert('Поиск заказа', 'Нажата кнопка дополнения') +const rightAddons = { + none: undefined, + suffix: 'шт.', + action: ( + + Найти + + ), + disabledAction: ( + + Найти + + ), +} + +type StoryArgs = Omit, 'rightAddon'> & { + rightAddon: keyof typeof rightAddons + captionVariant: InputCaptionProps['variant'] + captionShowIcon: boolean +} + +const meta = { + title: 'Input/InputGroup', + component: InputGroup, + args: { + labelMode: 'none', + labelText: 'Номер заказа', + placeholder: 'Введите номер', + value: '', + leftAddon: '№', + rightAddon: 'action', + size: 'base', + state: 'default', + disabled: false, + readOnly: false, + clearable: true, + required: false, + labelInfo: undefined, + caption: '', + captionVariant: 'base', + captionShowIcon: false, + }, + argTypes: { + ...inputLabelControls, + placeholder: { control: 'text' }, + value: { control: 'text' }, + size: { control: 'radio', options: ['small', 'base', 'large', 'xlarge'] }, + state: { control: 'radio', options: ['default', 'danger'] }, + disabled: { control: 'boolean' }, + readOnly: { control: 'boolean' }, + clearable: { control: 'boolean' }, + required: { control: 'boolean' }, + leftAddon: { control: 'text' }, + rightAddon: { + control: 'select', + options: Object.keys(rightAddons), + description: + 'InputGroupAddon наследует disabled; readOnly не отключает дополнения.', + }, + caption: { control: 'text' }, + captionVariant: { + control: 'select', + options: ['base', 'success', 'warning', 'danger', 'info'], + if: { arg: 'caption' }, + }, + captionShowIcon: { control: 'boolean', if: { arg: 'caption' } }, + onChangeText: { action: 'onChangeText' }, + }, + parameters: { + controls: { + exclude: [ + 'onChangeText', + 'ref', + 'renderTextInput', + 'style', + 'inputStyle', + 'editable', + 'label', + 'floatLabel', + ], + }, + }, + render: (args) => { + const { + labelMode, + labelText, + labelInfo, + captionVariant, + captionShowIcon, + rightAddon, + onChangeText: onChangeTextAction, + ...props + } = args + const labelProps = getInputLabelProps({ labelMode, labelText, labelInfo }) + const [, updateArgs] = useArgs() + const [value, setValue] = useState(props.value) + + useEffect(() => setValue(props.value), [props.value]) + + const onChangeText = useCallback( + (nextValue: string) => { + setValue(nextValue) + updateArgs({ value: nextValue }) + onChangeTextAction?.(nextValue) + }, + [onChangeTextAction, updateArgs] + ) + + return ( + + {props.caption} + + ) : undefined + } + rightAddon={rightAddons[rightAddon]} + value={value} + onChangeText={onChangeText} + /> + ) + }, +} satisfies InputStoryMeta + +export default meta + +type Story = StoryObj + +const InputGroupStory: Story = { name: 'InputGroup' } + +export { InputGroupStory as InputGroup } diff --git a/src/components/Input/InputGroup/InputGroup.tsx b/src/components/Input/InputGroup/InputGroup.tsx new file mode 100644 index 00000000..a2fe88bc --- /dev/null +++ b/src/components/Input/InputGroup/InputGroup.tsx @@ -0,0 +1,17 @@ +import { memo } from 'react' + +import { InputField } from '../core/InputField' + +import type { InputGroupProps } from './types' + +export type { InputGroupProps } from './types' + +/** + * Поле ввода с дополнениями по краям + * + * @see InputGroupProps + * @link https://www.figma.com/design/Q1BWgZ7zoV5UzlBOnjW0cM/?node-id=437-3505 + */ +export const InputGroup = memo((props) => ( + +)) diff --git a/src/components/Input/InputGroup/InputGroupAddon.tsx b/src/components/Input/InputGroup/InputGroupAddon.tsx index a6772e01..3034e4b8 100644 --- a/src/components/Input/InputGroup/InputGroupAddon.tsx +++ b/src/components/Input/InputGroup/InputGroupAddon.tsx @@ -9,7 +9,7 @@ import { InputGroupContext } from '../core/InputGroupContext' import { styles as inputStyles } from '../core/styles' export interface InputGroupAddonProps extends PressableProps { - /** Имя для экранного диктора; по умолчанию берётся из строкового или числового children. */ + /** Имя для скринридера; по умолчанию берётся из строкового или числового children */ readonly accessibilityLabel?: string /** SVG-иконка; для кнопки только с иконкой задайте accessibilityLabel */ readonly Icon?: SvgSource diff --git a/src/components/Input/InputGroup/__tests__/InputGroup.test.tsx b/src/components/Input/InputGroup/__tests__/InputGroup.test.tsx new file mode 100644 index 00000000..6f42cd0e --- /dev/null +++ b/src/components/Input/InputGroup/__tests__/InputGroup.test.tsx @@ -0,0 +1,188 @@ +import { IconInfoCircle } from '@tabler/icons-react-native' +import { render, userEvent } from '@testing-library/react-native' +import { Pressable, Text } from 'react-native' + +import { InputGroup, InputGroupAddon } from '../..' + +describe('InputGroup: новый API', () => { + test('сохраняет имя, состояние поля и пользовательский testID', () => { + const { getByLabelText, getByRole } = render( + + ) + const input = getByLabelText('Номер заказа') + const info = getByRole('button', { name: 'Подробнее' }) + + expect(input).toHaveDisplayValue('123') + expect(input).toHaveProp('testID', 'OrderInput') + expect(input).toHaveProp('accessibilityState', { + busy: true, + disabled: true, + }) + expect(info).toHaveProp('testID', 'OrderInputLabelInfo') + expect(info).toBeDisabled() + }) + + test('озвучивает очистку и обновляет поле после изменения value', async () => { + const onChangeText = jest.fn() + const props = { label: 'Заказ', leftAddon: '№', onChangeText } + const { getByLabelText, getByRole, queryByRole, rerender } = render( + + ) + + await userEvent.press(getByRole('button', { name: 'Очистить поле' })) + + expect(onChangeText).toHaveBeenLastCalledWith('') + expect(getByLabelText('Заказ')).toHaveDisplayValue('123') + + rerender() + + expect(getByLabelText('Заказ')).toHaveDisplayValue('') + expect(queryByRole('button', { name: 'Очистить поле' })).toBeNull() + }) + + test('размещает добавки и caption с одним полем', () => { + const { getByText, getAllByDisplayValue } = render( + Подсказка} + label='Заказ' + leftAddon='№' + rightAddon={ + Статус + } + value='123' + /> + ) + + expect(getAllByDisplayValue('123')).toHaveLength(1) + expect(getByText('№')).toBeOnTheScreen() + expect(getByText('Статус')).toBeOnTheScreen() + expect(getByText('Подсказка')).toBeOnTheScreen() + }) + + test('оставляет действие доступным при readOnly поля', async () => { + const onPress = jest.fn() + const { getByRole, getByTestId } = render( + Копировать + } + /> + ) + + expect(getByTestId('InputGroup')).toHaveProp('editable', false) + + await userEvent.press(getByRole('button', { name: 'Копировать' })) + + expect(onPress).toHaveBeenCalledOnce() + }) + + test('отключает добавку независимо от поля и наследует общий disabled', async () => { + const onPress = jest.fn() + const { getByRole, rerender, getByTestId } = render( + + Найти + + } + /> + ) + + expect(getByTestId('InputGroup')).toHaveProp('editable', true) + expect(getByRole('button', { name: 'Найти' })).toBeDisabled() + + rerender( + Найти} + /> + ) + + expect(getByTestId('InputGroup')).toHaveProp('editable', false) + + await userEvent.press(getByRole('button', { name: 'Найти' })) + + expect(onPress).not.toHaveBeenCalled() + }) + + test.each([ + { name: 'левое', prop: 'leftAddon' as const }, + { name: 'правое', prop: 'rightAddon' as const }, + ])( + 'убирает пустое $name дополнение, сохраняя число 0 и произвольную ноду', + ({ prop }) => { + const { getByText, getByTestId, queryByTestId, rerender } = render( + + ) + + expect(queryByTestId('InputGroupAddon')).toBeNull() + + rerender() + + expect(getByText('0')).toBeOnTheScreen() + + rerender( + Руб. }} + label='Заказ' + /> + ) + + expect(getByTestId('CustomAddon')).toHaveTextContent('Руб.') + expect(queryByTestId('InputGroupAddon')).toBeNull() + + rerender() + + expect(queryByTestId('CustomAddon')).toBeNull() + } + ) + + test.each([ + { name: 'disabled', disabled: true, readOnly: false, calls: 0 }, + { name: 'readOnly', disabled: false, readOnly: true, calls: 1 }, + ])( + 'сохраняет явно переданное состояние произвольного дополнения при $name', + async ({ disabled, readOnly, calls }) => { + const onPress = jest.fn() + const { getByRole, getByTestId } = render( + + Копировать + + } + /> + ) + const button = getByRole('button', { name: 'Копировать' }) + + expect(button).toHaveProp('accessibilityState', { disabled }) + expect(getByTestId('InputGroup')).toHaveProp('editable', false) + + await userEvent.press(button) + + expect(onPress).toHaveBeenCalledTimes(calls) + } + ) +}) diff --git a/src/components/Input/InputGroup/types.ts b/src/components/Input/InputGroup/types.ts new file mode 100644 index 00000000..052e563b --- /dev/null +++ b/src/components/Input/InputGroup/types.ts @@ -0,0 +1,16 @@ +import type { ReactNode } from 'react' + +import type { InputLabelProps, InputNativeProps } from '../core/types' + +/** + * Строки и числа в дополнениях оборачиваются в InputGroupAddon, ноды передаются как есть. + * В произвольное интерактивное дополнение передайте disabled группы. + * + * @example + * } + * /> + */ +export type InputGroupProps = InputNativeProps & + InputLabelProps & { leftAddon?: ReactNode; rightAddon?: ReactNode } diff --git a/src/components/Input/index.ts b/src/components/Input/index.ts index d55ac719..62f66897 100644 --- a/src/components/Input/index.ts +++ b/src/components/Input/index.ts @@ -1,4 +1,4 @@ -export { LegacyInputGroup as InputGroup } from './deprecated/LegacyInputGroup' +export { InputGroup, type InputGroupProps } from './InputGroup/InputGroup' export { InputText, type InputTextProps } from './InputText/InputText' export { InputGroupAddon, diff --git a/src/components/Input/stories/InputGroup.stories.tsx b/src/components/Input/stories/InputGroup.stories.tsx deleted file mode 100644 index 3107a2fd..00000000 --- a/src/components/Input/stories/InputGroup.stories.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import { useArgs } from '@storybook/preview-api' -import type { Meta, StoryObj } from '@storybook/react' -import { IconLock, IconUser } from '@tabler/icons-react-native' -import { useCallback } from 'react' - -import { LegacyInputGroup as InputGroup } from '../deprecated/LegacyInputGroup' - -const meta: Meta = { - title: 'Form/InputGroup', - args: { - clearable: true, - disabled: false, - state: 'default', - floatLabel: false, - placeholder: 'Placeholder', - value: '', - size: 'base', - }, - argTypes: { - state: { control: 'radio', options: ['default', 'danger'] }, - size: { control: 'radio', options: ['base', 'medium', 'large', 'xlarge'] }, - }, - render: (args) => { - const [, updateArgs] = useArgs() - - const onChangeText = useCallback( - (nextValue: string) => updateArgs({ value: nextValue }), - [updateArgs] - ) - - return - }, -} - -export default meta - -type Story = StoryObj - -const InputGroupStory: Story = { - args: { left: 'left text', right: 'right text' }, - argTypes: { - left: { - options: ['text', 'IconUser', 'IconLock'], - control: { type: 'radio' }, - mapping: { text: 'left text', IconUser, IconLock }, - defaultValue: 'text', - }, - right: { - options: ['text', 'IconUser', 'IconLock'], - control: { type: 'radio' }, - mapping: { text: 'right text', IconUser, IconLock }, - }, - }, -} - -export { InputGroupStory as InputGroup } From c931dd031671f6b8ec5a7adf58a0890f0f4b55dd Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Mon, 12 Oct 2026 19:45:55 +0300 Subject: [PATCH 10/17] =?UTF-8?q?feat(input):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=20Password=20=D1=81=20=D0=B1=D0=B0?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=BE=D0=B9=20=D0=B4=D0=BE=D1=81=D1=82=D1=83?= =?UTF-8?q?=D0=BF=D0=BD=D0=BE=D1=81=D1=82=D1=8C=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Input/Password/Password.stories.tsx | 127 +++++++++++++++ src/components/Input/Password/Password.tsx | 83 ++++++++++ .../Password/__tests__/Password.test.tsx | 150 ++++++++++++++++++ src/components/Input/Password/types.ts | 19 +++ src/components/Input/core/strings.ts | 2 + src/components/Input/index.ts | 1 + 6 files changed, 382 insertions(+) create mode 100644 src/components/Input/Password/Password.stories.tsx create mode 100644 src/components/Input/Password/Password.tsx create mode 100644 src/components/Input/Password/__tests__/Password.test.tsx create mode 100644 src/components/Input/Password/types.ts diff --git a/src/components/Input/Password/Password.stories.tsx b/src/components/Input/Password/Password.stories.tsx new file mode 100644 index 00000000..2fdfeecd --- /dev/null +++ b/src/components/Input/Password/Password.stories.tsx @@ -0,0 +1,127 @@ +import { useArgs } from '@storybook/preview-api' +import type { StoryObj } from '@storybook/react' +import { useCallback, useEffect, useState } from 'react' + +import { + getInputLabelProps, + inputLabelControls, + type InputLabelStoryArgs, + type InputStoryMeta, +} from '../../../../.storybook/inputLabelControls' +import { + InputCaption, + type InputCaptionProps, +} from '../InputCaption/InputCaption' + +import { Password } from './Password' +import type { PasswordProps } from './types' + +type StoryArgs = InputLabelStoryArgs & { + captionVariant: InputCaptionProps['variant'] + captionShowIcon: boolean +} + +const meta: InputStoryMeta = { + title: 'Input/Password', + component: Password, + args: { + labelMode: 'label', + labelText: 'Пароль', + placeholder: 'Введите пароль', + value: '', + size: 'base', + state: 'default', + toggleMask: true, + clearable: false, + disabled: false, + readOnly: false, + required: false, + labelInfo: undefined, + caption: 'Подсказка к полю', + captionVariant: 'base', + captionShowIcon: false, + }, + argTypes: { + ...inputLabelControls, + placeholder: { control: 'text' }, + value: { control: 'text' }, + size: { control: 'radio', options: ['small', 'base', 'large', 'xlarge'] }, + state: { control: 'radio', options: ['default', 'danger'] }, + toggleMask: { control: 'boolean' }, + clearable: { control: 'boolean' }, + disabled: { control: 'boolean' }, + readOnly: { control: 'boolean' }, + required: { control: 'boolean' }, + caption: { control: 'text' }, + captionVariant: { + control: 'select', + options: ['base', 'success', 'warning', 'danger', 'info'], + if: { arg: 'caption' }, + }, + captionShowIcon: { control: 'boolean', if: { arg: 'caption' } }, + onChangeText: { action: 'onChangeText' }, + }, + parameters: { + controls: { + exclude: [ + 'onChangeText', + 'ref', + 'renderTextInput', + 'style', + 'inputStyle', + 'editable', + 'label', + 'floatLabel', + ], + }, + }, + render: (args) => { + const { + labelMode, + labelText, + labelInfo, + captionVariant, + captionShowIcon, + onChangeText: onChangeTextAction, + ...props + } = args + const labelProps = getInputLabelProps({ labelMode, labelText, labelInfo }) + const [, updateArgs] = useArgs() + const [value, setValue] = useState(props.value) + + useEffect(() => setValue(props.value), [props.value]) + + const onChangeText = useCallback( + (nextValue: string) => { + setValue(nextValue) + updateArgs({ value: nextValue }) + onChangeTextAction?.(nextValue) + }, + [onChangeTextAction, updateArgs] + ) + + return ( + + {props.caption} + + ) : undefined + } + value={value} + onChangeText={onChangeText} + /> + ) + }, +} + +export default meta + +type Story = StoryObj + +const PasswordStory: Story = { name: 'Password' } + +export { PasswordStory as Password } diff --git a/src/components/Input/Password/Password.tsx b/src/components/Input/Password/Password.tsx new file mode 100644 index 00000000..a82a1851 --- /dev/null +++ b/src/components/Input/Password/Password.tsx @@ -0,0 +1,83 @@ +import { IconEye, IconEyeOff } from '@tabler/icons-react-native' +import { memo, useState } from 'react' +import { Pressable } from 'react-native' + +import { SvgUniversal } from '../../../utils/SvgUniversal' + +import { InputField } from '../core/InputField' +import { inputStrings } from '../core/strings' +import { styles } from '../core/styles' + +import type { PasswordProps } from './types' + +export type { PasswordProps } from './types' + +/** + * Поле ввода пароля. + * + * @see PasswordProps + * @link https://www.figma.com/design/Q1BWgZ7zoV5UzlBOnjW0cM/?node-id=375-1701 + */ +export const Password = memo( + ({ + toggleMask = true, + disabled = false, + clearable = false, + autoCapitalize = 'none', + autoCorrect = false, + testID = 'Password', + ...props + }) => { + const [visible, setVisible] = useState(false) + const Icon = visible ? IconEyeOff : IconEye + const actionGap = styles.actions(props.size).gap + + return ( + setVisible((current) => !current)} + > + { + const iconStyle = styles.icon(props.size) + + return { + width: iconStyle.width, + height: iconStyle.height, + color: components.password.icon.color, + } + }} + /> + + ) : undefined + } + autoCapitalize={autoCapitalize} + autoCorrect={autoCorrect} + clearable={clearable} + disabled={disabled} + multiline={false} + secureTextEntry={!visible} + testID={testID} + /> + ) + } +) diff --git a/src/components/Input/Password/__tests__/Password.test.tsx b/src/components/Input/Password/__tests__/Password.test.tsx new file mode 100644 index 00000000..b668730f --- /dev/null +++ b/src/components/Input/Password/__tests__/Password.test.tsx @@ -0,0 +1,150 @@ +import { fireEvent, render, userEvent } from '@testing-library/react-native' + +import { Password } from '../..' + +describe('Password', () => { + test.each([ + { + name: 'controlled поля', + readOnly: false, + valueProps: { value: 'secret' }, + }, + { + name: 'controlled readOnly поля', + readOnly: true, + valueProps: { value: 'secret' }, + }, + { + name: 'uncontrolled поля', + readOnly: false, + valueProps: { defaultValue: 'secret' }, + }, + { + name: 'uncontrolled readOnly поля', + readOnly: true, + valueProps: { defaultValue: 'secret' }, + }, + ])( + 'сохраняет переключение видимости после blur для $name', + async ({ readOnly, valueProps }) => { + const onChangeText = jest.fn() + const { getByRole, queryByRole, getByTestId } = render( + + ) + + expect(getByTestId('Password')).toHaveProp('autoCapitalize', 'none') + expect(getByTestId('Password')).toHaveProp('autoCorrect', false) + expect(getByTestId('Password')).toHaveProp('secureTextEntry', true) + expect(getByTestId('Password')).toHaveProp('editable', !readOnly) + expect( + queryByRole('button', { name: 'Очистить поле' }) + ).not.toBeOnTheScreen() + + await userEvent.press(getByRole('button', { name: 'Показать пароль' })) + + expect(getByTestId('Password')).toHaveProp('secureTextEntry', false) + + fireEvent(getByTestId('Password'), 'blur', { nativeEvent: {} }) + fireEvent(getByTestId('Password'), 'focus', { nativeEvent: {} }) + + expect(getByTestId('Password')).toHaveDisplayValue('secret') + expect(getByTestId('Password')).toHaveProp('secureTextEntry', false) + expect(onChangeText).not.toHaveBeenCalled() + + await userEvent.press(getByRole('button', { name: 'Скрыть пароль' })) + + expect(getByTestId('Password')).toHaveProp('secureTextEntry', true) + } + ) + + test('отключает глаз вместе с полем', async () => { + const { getByRole, getByLabelText } = render( + + ) + const field = getByLabelText('Пароль для входа') + const button = getByRole('button', { name: 'Показать пароль' }) + + expect(field).toHaveProp('testID', 'LoginPassword') + expect(field).toHaveProp('accessibilityState', { + busy: true, + disabled: true, + }) + expect(button).toHaveProp('testID', 'LoginPasswordToggleMask') + expect(button).toHaveProp('accessibilityState', { disabled: true }) + + await userEvent.press(button) + + expect(field).toHaveProp('secureTextEntry', true) + }) + + test('очищает controlled value отдельным действием и скрывает toggle по запросу', async () => { + const onChangeText = jest.fn() + const { getByRole, queryByRole, getByTestId, rerender } = render( + + ) + + expect( + queryByRole('button', { name: 'Показать пароль' }) + ).not.toBeOnTheScreen() + + await userEvent.press(getByRole('button', { name: 'Очистить поле' })) + + expect(onChangeText).toHaveBeenCalledWith('') + expect(getByTestId('Password')).toHaveDisplayValue('secret') + + rerender( + + ) + + expect(getByTestId('Password')).toHaveDisplayValue('') + expect( + queryByRole('button', { name: 'Очистить поле' }) + ).not.toBeOnTheScreen() + }) + + test('disabled блокирует переключение без сброса видимости, readOnly сохраняет действие', async () => { + const { getByRole, rerender, getByTestId } = render( + + ) + await userEvent.press(getByRole('button', { name: 'Показать пароль' })) + + rerender() + const button = getByRole('button', { name: 'Скрыть пароль' }) + + expect(button).toHaveProp('accessibilityState', { disabled: true }) + + await userEvent.press(button) + + expect(getByTestId('Password')).toHaveProp('secureTextEntry', false) + + rerender() + await userEvent.press(getByRole('button', { name: 'Скрыть пароль' })) + + expect(getByTestId('Password')).toHaveProp('editable', false) + expect(getByTestId('Password')).toHaveProp('secureTextEntry', true) + expect(getByTestId('Password')).toHaveDisplayValue('secret') + }) +}) diff --git a/src/components/Input/Password/types.ts b/src/components/Input/Password/types.ts new file mode 100644 index 00000000..47a6d5dd --- /dev/null +++ b/src/components/Input/Password/types.ts @@ -0,0 +1,19 @@ +import type { InputLabelProps, InputNativeProps } from '../core/types' + +export type PasswordProps = Omit< + InputNativeProps, + 'secureTextEntry' | 'multiline' | 'clearable' +> & + InputLabelProps & { + /** + * Показывает кнопку очистки непустого редактируемого поля. + * @default false + */ + clearable?: boolean + /** + * Показывает кнопку переключения видимости пароля. + * Переключение видимости сохраняет значение поля. + * @default true + */ + toggleMask?: boolean + } diff --git a/src/components/Input/core/strings.ts b/src/components/Input/core/strings.ts index 2d69b0a4..00154434 100644 --- a/src/components/Input/core/strings.ts +++ b/src/components/Input/core/strings.ts @@ -1,4 +1,6 @@ export const inputStrings = { labelInfoButton: 'Подробнее', clearButton: 'Очистить поле', + showPassword: 'Показать пароль', + hidePassword: 'Скрыть пароль', } as const diff --git a/src/components/Input/index.ts b/src/components/Input/index.ts index 62f66897..1f9a252a 100644 --- a/src/components/Input/index.ts +++ b/src/components/Input/index.ts @@ -8,6 +8,7 @@ export { InputCaption, type InputCaptionProps, } from './InputCaption/InputCaption' +export { Password, type PasswordProps } from './Password/Password' export type { InputTextSize, InputState, From d01a6af492163f66bbbcefde69f60f9c2afae1a4 Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Mon, 12 Oct 2026 20:39:37 +0300 Subject: [PATCH 11/17] =?UTF-8?q?fix(input):=20=D1=83=D0=BB=D1=83=D1=87?= =?UTF-8?q?=D1=88=D0=B5=D0=BD=D0=B0=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=BF=D0=BE=D0=BB=D0=B5=D0=B9?= =?UTF-8?q?=20=D0=B8=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Input/InputCaption/InputCaption.tsx | 12 +- .../__tests__/InputCaption.test.tsx | 15 ++ .../Input/InputGroup/InputGroupAddon.tsx | 6 +- .../InputGroup/__tests__/InputGroup.test.tsx | 2 +- .../__tests__/InputGroupAddon.test.tsx | 19 +++ .../__tests__/InputTextAccessibility.test.tsx | 23 ++- .../InputText/__tests__/InputTextApi.test.tsx | 79 +++++++--- .../__tests__/InputTextMasked.test.tsx | 12 +- src/components/Input/core/InputControl.tsx | 16 +- src/components/Input/core/InputLabel.tsx | 9 +- .../__tests__/InputAccessibility.test.tsx | 142 ++++++++++++++++++ src/components/Input/core/strings.ts | 1 + src/components/Input/core/types.ts | 13 +- 13 files changed, 298 insertions(+), 51 deletions(-) create mode 100644 src/components/Input/core/__tests__/InputAccessibility.test.tsx diff --git a/src/components/Input/InputCaption/InputCaption.tsx b/src/components/Input/InputCaption/InputCaption.tsx index 3b44b9ca..a224bb83 100644 --- a/src/components/Input/InputCaption/InputCaption.tsx +++ b/src/components/Input/InputCaption/InputCaption.tsx @@ -56,7 +56,13 @@ export const InputCaption = ({ const { makeTestId } = useMakeTestId(testID) return ( - + {showIcon ? ( ) : null} - {children} + + {children} + ) } diff --git a/src/components/Input/InputCaption/__tests__/InputCaption.test.tsx b/src/components/Input/InputCaption/__tests__/InputCaption.test.tsx index 1dedf790..5d2b5991 100644 --- a/src/components/Input/InputCaption/__tests__/InputCaption.test.tsx +++ b/src/components/Input/InputCaption/__tests__/InputCaption.test.tsx @@ -20,6 +20,21 @@ describe('InputCaption', () => { expect(queryByRole('button')).toBeNull() }) + test.each([ + { name: 'без иконки', showIcon: false }, + { name: 'с иконкой', showIcon: true }, + ])('объединяет подпись $name в доступный контейнер', ({ showIcon }) => { + const { getByRole, getByTestId, getByText, getAllByRole } = render( + Подсказка к полю + ) + + expect(getByRole('text', { name: 'Подсказка к полю' })).toBe( + getByTestId('InputCaption') + ) + expect(getAllByRole('text')).toHaveLength(1) + expect(getByText('Подсказка к полю')).toHaveProp('accessible', false) + }) + test('showIcon управляет показом независимо от переданного SVG, а очистка Icon возвращает стандартную иконку', () => { const { getAllByTestId, getByText, queryByTestId, rerender } = render( diff --git a/src/components/Input/InputGroup/InputGroupAddon.tsx b/src/components/Input/InputGroup/InputGroupAddon.tsx index 3034e4b8..deb973f4 100644 --- a/src/components/Input/InputGroup/InputGroupAddon.tsx +++ b/src/components/Input/InputGroup/InputGroupAddon.tsx @@ -8,7 +8,10 @@ import { SvgUniversal, type SvgSource } from '../../../utils/SvgUniversal' import { InputGroupContext } from '../core/InputGroupContext' import { styles as inputStyles } from '../core/styles' -export interface InputGroupAddonProps extends PressableProps { +export interface InputGroupAddonProps extends Omit< + PressableProps, + 'aria-disabled' +> { /** Имя для скринридера; по умолчанию берётся из строкового или числового children */ readonly accessibilityLabel?: string /** SVG-иконка; для кнопки только с иконкой задайте accessibilityLabel */ @@ -45,6 +48,7 @@ export const InputGroupAddon = ({ accessibilityRole={onPress ? 'button' : undefined} accessibilityState={{ ...props.accessibilityState, disabled }} accessible={!!onPress} + aria-disabled={disabled} disabled={disabled} style={(state) => [ styles.addon, diff --git a/src/components/Input/InputGroup/__tests__/InputGroup.test.tsx b/src/components/Input/InputGroup/__tests__/InputGroup.test.tsx index 6f42cd0e..50607f8a 100644 --- a/src/components/Input/InputGroup/__tests__/InputGroup.test.tsx +++ b/src/components/Input/InputGroup/__tests__/InputGroup.test.tsx @@ -19,7 +19,7 @@ describe('InputGroup: новый API', () => { /> ) const input = getByLabelText('Номер заказа') - const info = getByRole('button', { name: 'Подробнее' }) + const info = getByRole('button', { name: 'Заказ. Подробнее' }) expect(input).toHaveDisplayValue('123') expect(input).toHaveProp('testID', 'OrderInput') diff --git a/src/components/Input/InputGroup/__tests__/InputGroupAddon.test.tsx b/src/components/Input/InputGroup/__tests__/InputGroupAddon.test.tsx index dd437da4..c33658c8 100644 --- a/src/components/Input/InputGroup/__tests__/InputGroupAddon.test.tsx +++ b/src/components/Input/InputGroup/__tests__/InputGroupAddon.test.tsx @@ -5,6 +5,25 @@ import { Text } from 'react-native' import { InputGroupAddon } from '../InputGroupAddon' describe('InputGroupAddon', () => { + test('вычисляет доступный disabled из своего disabled, сохраняя остальные состояния', () => { + const onPress = jest.fn() + const { getByRole } = render( + + Найти + + ) + + expect(getByRole('button', { name: 'Найти' })).toHaveProp( + 'accessibilityState', + { disabled: true, busy: true } + ) + }) + test.each([ { name: 'строки', children: 'Найти' }, { name: 'числа', children: 42 }, diff --git a/src/components/Input/InputText/__tests__/InputTextAccessibility.test.tsx b/src/components/Input/InputText/__tests__/InputTextAccessibility.test.tsx index 1e6d49f8..59649cb2 100644 --- a/src/components/Input/InputText/__tests__/InputTextAccessibility.test.tsx +++ b/src/components/Input/InputText/__tests__/InputTextAccessibility.test.tsx @@ -86,12 +86,12 @@ describe('InputText: базовая доступность', () => { { name: 'имя по умолчанию', accessibilityLabel: undefined, - label: 'Подробнее', + label: 'ИНН. Подробнее', }, { name: 'явное имя', accessibilityLabel: 'Как узнать ИНН', - label: 'Как узнать ИНН', + label: 'ИНН. Как узнать ИНН', }, ])('озвучивает кнопку info: $name', async ({ accessibilityLabel, label }) => { const onPress = jest.fn() @@ -117,7 +117,7 @@ describe('InputText: базовая доступность', () => { const { getByRole } = render( ) - const button = getByRole('button', { name: 'Подробнее' }) + const button = getByRole('button', { name: 'Email. Подробнее' }) expect(button).toHaveProp('accessibilityState', { disabled }) @@ -127,8 +127,8 @@ describe('InputText: базовая доступность', () => { } ) - test('сохраняет разные testID кнопок info при одинаковых названиях', () => { - const { getAllByRole, getByTestId } = render( + test('различает контекст и testID кнопок info у разных полей', () => { + const { getByRole } = render( <> { ) - expect(getAllByRole('button', { name: 'Подробнее' })).toHaveLength(2) - expect(getByTestId('EmailInputLabelInfo')).toHaveProp( - 'accessibilityRole', - 'button' + expect(getByRole('button', { name: 'Email. Подробнее' })).toHaveProp( + 'testID', + 'EmailInputLabelInfo' ) - expect(getByTestId('PhoneInputLabelInfo')).toHaveProp( - 'accessibilityRole', - 'button' + expect(getByRole('button', { name: 'Телефон. Подробнее' })).toHaveProp( + 'testID', + 'PhoneInputLabelInfo' ) }) }) diff --git a/src/components/Input/InputText/__tests__/InputTextApi.test.tsx b/src/components/Input/InputText/__tests__/InputTextApi.test.tsx index 193f4231..b18d2efc 100644 --- a/src/components/Input/InputText/__tests__/InputTextApi.test.tsx +++ b/src/components/Input/InputText/__tests__/InputTextApi.test.tsx @@ -12,7 +12,7 @@ import { InputText, InputCaption, type RenderTextInputArgs } from '../..' describe('InputText: новый API', () => { test('разделяет label, placeholder и произвольный caption', () => { - const { getByText, queryByTestId } = render( + const { getByText, queryByRole, getByTestId } = render( { /> ) + expect(getByTestId('InputText')).toHaveProp( + 'accessibilityLabel', + 'Телефон, обязательное поле' + ) expect( getByText('+7 999', { includeHiddenElements: true }) ).toBeOnTheScreen() - expect(getByText('Неверный номер')).toBeOnTheScreen() - expect(queryByTestId('InputTextClearButton')).not.toBeOnTheScreen() + expect( + getByText('Неверный номер', { includeHiddenElements: true }) + ).toBeOnTheScreen() + expect(queryByRole('button')).not.toBeOnTheScreen() }) - test('отображает строковый caption и передаёт нативные props поля', () => { + test('отображает caption и обычный label, сохраняя имя поля для скринридера', () => { const { getByLabelText, getByText } = render( { ) expect(getByLabelText('Контактный телефон')).toHaveDisplayValue('') - expect(getByText('Получит уведомление')).toBeOnTheScreen() + expect( + getByText('Телефон', { includeHiddenElements: true }) + ).toBeOnTheScreen() + expect( + getByText('Получит уведомление', { includeHiddenElements: true }) + ).toBeOnTheScreen() }) test('инициализирует defaultValue один раз и обновляет неконтролируемое поле', async () => { const onChangeText = jest.fn() - const { rerender, getByTestId } = render( + const { getByRole, rerender, getByTestId } = render( { expect(getByTestId('InputText')).toHaveDisplayValue('Казань') - await userEvent.press(getByTestId('InputTextClearButton')) + await userEvent.press(getByRole('button', { name: 'Очистить поле' })) expect(onChangeText).toHaveBeenLastCalledWith('') expect(getByTestId('InputText')).toHaveDisplayValue('') @@ -117,7 +128,7 @@ describe('InputText: новый API', () => { test('сохраняет отдельную подсказку и callbacks при фокусе floating label', () => { const onFocus = jest.fn() const onBlur = jest.fn() - const { getByTestId, rerender } = render( + const { getByLabelText, getByTestId, rerender } = render( { onFocus={onFocus} /> ) - const input = getByTestId('InputText') + const input = getByLabelText('Email') expect( getByTestId('InputTextPlaceholder', { includeHiddenElements: true }) @@ -152,19 +163,20 @@ describe('InputText: новый API', () => { /> ) - expect(getByTestId('InputText')).toHaveDisplayValue('a@b.ru') + expect(getByLabelText('Email')).toHaveDisplayValue('a@b.ru') expect(getByTestId('InputTextFloatingLabel')).toBeOnTheScreen() }) test.each([ - { name: 'disabled', props: { disabled: true } }, - { name: 'readOnly', props: { readOnly: true } }, + { name: 'disabled', props: { disabled: true }, disabled: true }, + { name: 'readOnly', props: { readOnly: true }, disabled: false }, { name: 'disabled при readOnly=false', props: { disabled: true, readOnly: false }, + disabled: true, }, - ])('нормализует renderer при $name', ({ props }) => { - const { queryByTestId, getByTestId } = render( + ])('нормализует renderer при $name', ({ props, disabled }) => { + const { queryByRole, getByTestId } = render( { ) expect(getByTestId('InputText')).toHaveProp('editable', false) - expect(queryByTestId('InputTextClearButton')).not.toBeOnTheScreen() + expect(getByTestId('InputText')).toHaveProp('accessibilityState', { + disabled, + }) + expect(queryByRole('button')).not.toBeOnTheScreen() }) test('при вводе controlled value вызывает callback и ждёт обновления props', () => { @@ -200,7 +215,7 @@ describe('InputText: новый API', () => { test('clearable=false убирает очистку, а пустое controlled value скрывает её после обновления', async () => { const onChangeText = jest.fn() - const { rerender, getByTestId, queryByTestId } = render( + const { getByRole, queryByRole, rerender, getByTestId } = render( { /> ) - expect(queryByTestId('InputTextClearButton')).toBeNull() + expect(queryByRole('button', { name: 'Очистить поле' })).toBeNull() rerender( ) - await userEvent.press(getByTestId('InputTextClearButton')) + await userEvent.press(getByRole('button', { name: 'Очистить поле' })) expect(onChangeText).toHaveBeenCalledOnce() expect(onChangeText).toHaveBeenCalledWith('') @@ -222,7 +237,7 @@ describe('InputText: новый API', () => { rerender() - expect(queryByTestId('InputTextClearButton')).toBeNull() + expect(queryByRole('button', { name: 'Очистить поле' })).toBeNull() expect(onChangeText).toHaveBeenCalledOnce() }) @@ -239,7 +254,7 @@ describe('InputText: новый API', () => { return } - const { getByTestId, queryByTestId } = render( + const { getByTestId, queryByRole } = render( { expect(onChangeText).toHaveBeenCalledOnce() expect(onChangeText).toHaveBeenCalledWith('') expect(getByTestId('InputText')).toHaveDisplayValue('') - expect(queryByTestId('InputTextClearButton')).toBeNull() + expect(queryByRole('button', { name: 'Очистить поле' })).toBeNull() }) test('убирает пустой caption и сохраняет число 0 при обновлении подписи', () => { @@ -273,7 +288,9 @@ describe('InputText: новый API', () => { ) - expect(getByText('Описание')).toBeOnTheScreen() + expect( + getByText('Описание', { includeHiddenElements: true }) + ).toBeOnTheScreen() rerender() @@ -281,10 +298,26 @@ describe('InputText: новый API', () => { rerender() - expect(getByText('0')).toBeOnTheScreen() + expect(getByText('0', { includeHiddenElements: true })).toBeOnTheScreen() rerender() expect(queryByTestId('InputCaption')).toBeNull() }) + + test('сохраняет переданное доступное состояние, нормализуя только disabled', () => { + const { getByTestId } = render( + + ) + + expect(getByTestId('InputText')).toHaveProp('accessibilityState', { + busy: true, + selected: true, + disabled: true, + }) + }) }) diff --git a/src/components/Input/InputText/__tests__/InputTextMasked.test.tsx b/src/components/Input/InputText/__tests__/InputTextMasked.test.tsx index 2168be62..781d6942 100644 --- a/src/components/Input/InputText/__tests__/InputTextMasked.test.tsx +++ b/src/components/Input/InputText/__tests__/InputTextMasked.test.tsx @@ -176,14 +176,21 @@ describe('InputText: пример с маской', () => { const labelArgs = { ...args, labelInfo: { onPress: jest.fn() } } const { getByDisplayValue, + getByLabelText, + getByRole, getByTestId, queryByTestId, + queryByRole, queryByText, rerender, } = render( ) + expect( + getByRole('button', { name: 'Телефон. Подробнее' }) + ).toBeOnTheScreen() + fireEvent.changeText(getByTestId('MaskedLabelStory'), '+7 (900) 123-45-67') const nativeInput = ref.current @@ -199,9 +206,8 @@ describe('InputText: пример с маской', () => { expect(getByTestId('MaskedLabelStoryFloatingLabel')).toHaveTextContent( 'Телефон' ) - expect(getByTestId('MaskedLabelStory')).toHaveDisplayValue( - '+7 (900) 123-45-67' - ) + expect(getByLabelText('Телефон')).toHaveDisplayValue('+7 (900) 123-45-67') + expect(queryByRole('button', { name: 'Телефон. Подробнее' })).toBeNull() expect(ref.current).toBe(nativeInput) rerender( diff --git a/src/components/Input/core/InputControl.tsx b/src/components/Input/core/InputControl.tsx index 833daf5e..1d3bcf31 100644 --- a/src/components/Input/core/InputControl.tsx +++ b/src/components/Input/core/InputControl.tsx @@ -6,6 +6,7 @@ import { useMakeTestId } from '../../../hooks/useMakeTestId' import { InputActions } from './InputActions' import { InputLabel } from './InputLabel' import { InputPlaceholder } from './InputPlaceholder' +import { inputStrings } from './strings' import { styles } from './styles' import type { InputLabelProps, @@ -81,10 +82,19 @@ export const InputControl = ({ isFloating && { paddingTop: floatingLabel.inputPaddingTop }, inputStyle, ] + const { 'aria-label': ariaLabel, ...inputNativeProps } = nativeProps + const inputName = + ariaLabel ?? + nativeProps.accessibilityLabel ?? + label ?? + nativeProps.placeholder + const accessibilityLabel = required + ? [inputName, inputStrings.requiredField].filter(Boolean).join(', ') + : inputName const textInputProps: RenderTextInputArgs = { - ...nativeProps, - accessibilityLabel: - nativeProps.accessibilityLabel ?? label ?? nativeProps.placeholder, + ...inputNativeProps, + 'aria-disabled': disabled, + accessibilityLabel, accessibilityState: { ...nativeProps.accessibilityState, disabled }, editable: isEditable, readOnly: !isEditable, diff --git a/src/components/Input/core/InputLabel.tsx b/src/components/Input/core/InputLabel.tsx index b6b814e5..b78d1929 100644 --- a/src/components/Input/core/InputLabel.tsx +++ b/src/components/Input/core/InputLabel.tsx @@ -25,6 +25,11 @@ interface InputLabelProps extends Pick< readonly testID?: string } +export const getLabelInfoAccessibilityLabel = ( + label: string, + labelInfo: InputLabelInfo +) => `${label}. ${labelInfo.accessibilityLabel ?? inputStrings.labelInfoButton}` + export const InputLabel = ({ label, labelInfo, @@ -93,9 +98,7 @@ export const InputLabel = ({ )} {labelInfo ? ( { + test.each( + components.flatMap(({ name, Component }) => [ + { name: `${name}: label`, Component, labelProps: { label: 'Email' } }, + { + name: `${name}: floatLabel`, + Component, + labelProps: { floatLabel: 'Email' }, + }, + ]) + )( + 'обновляет маркер и доступную обязательность в $name', + ({ Component, labelProps }) => { + const { getByText, getByTestId, queryByText, rerender } = render( + + ) + + expect( + getByText('*', { includeHiddenElements: true }) + ).toBeOnTheScreen() + expect(getByTestId('RequiredInput')).toHaveProp( + 'accessibilityLabel', + 'Email, обязательное поле' + ) + + rerender( + + ) + + expect(queryByText('*', { includeHiddenElements: true })).toBeNull() + expect( + getByText('Email', { includeHiddenElements: true }) + ).toBeOnTheScreen() + expect(getByTestId('RequiredInput')).toHaveProp( + 'accessibilityLabel', + 'Email' + ) + expect( + getByTestId('RequiredInput').props.accessibilityHint + ).toBeUndefined() + } + ) + + test.each(components)( + 'доступен без label и floatLabel в $name', + ({ Component }) => { + const { getByLabelText, queryByTestId, queryByRole } = render( + + ) + + expect( + getByLabelText('Введите email, обязательное поле') + ).toHaveDisplayValue('') + expect(getByLabelText('Введите email, обязательное поле')).toHaveProp( + 'accessibilityLabel', + 'Введите email, обязательное поле' + ) + expect(queryByTestId('UnlabeledInputFloatingLabel')).toBeNull() + expect(queryByRole('button', { name: 'Email. Подробнее' })).toBeNull() + } + ) + + test.each([ + { + name: 'accessibilityLabel', + props: { accessibilityLabel: 'Контактный email' }, + }, + { + name: 'aria-label', + props: { + 'aria-label': 'Контактный email', + accessibilityLabel: 'Запасное имя', + }, + }, + ])( + 'сохраняет обязательность при явном имени $name и отключённом hint', + ({ props }) => { + const { getByLabelText, rerender } = render( + + ) + + expect(getByLabelText('Контактный email, обязательное поле')).toHaveProp( + 'accessibilityHint', + '' + ) + + rerender() + + expect(getByLabelText('Контактный email')).toHaveProp( + 'accessibilityHint', + '' + ) + } + ) + + test.each(components)( + 'вычисляет доступный disabled только из disabled компонента в $name', + ({ Component }) => { + const aliasedProps = { 'aria-disabled': false } + const { getByLabelText, rerender } = render( + + ) + + expect(getByLabelText('Email')).toHaveProp('accessibilityState', { + disabled: true, + busy: true, + }) + + rerender( + + ) + + expect(getByLabelText('Email')).toHaveProp('accessibilityState', { + disabled: false, + busy: true, + }) + } + ) +}) diff --git a/src/components/Input/core/strings.ts b/src/components/Input/core/strings.ts index 00154434..ae1ffc5e 100644 --- a/src/components/Input/core/strings.ts +++ b/src/components/Input/core/strings.ts @@ -1,6 +1,7 @@ export const inputStrings = { labelInfoButton: 'Подробнее', clearButton: 'Очистить поле', + requiredField: 'обязательное поле', showPassword: 'Показать пароль', hidePassword: 'Скрыть пароль', } as const diff --git a/src/components/Input/core/types.ts b/src/components/Input/core/types.ts index c772184e..9134a576 100644 --- a/src/components/Input/core/types.ts +++ b/src/components/Input/core/types.ts @@ -16,7 +16,7 @@ export interface InputLabelInfo { /** Обработчик нажатия на иконку ⓘ */ onPress: NonNullable /** - * Название кнопки для скринридера + * Название действия для скринридера; добавляется после текста label * @default Подробнее */ accessibilityLabel?: string @@ -53,16 +53,23 @@ export type InputLabelProps = */ export interface InputNativeProps extends Omit< TextInputProps, - 'style' | 'editable' | 'children' | 'clearButtonMode' + 'style' | 'editable' | 'children' | 'clearButtonMode' | 'aria-disabled' > { /** Методы TextInput; clear() вызывает onChangeText('') с учётом controlled-режима. */ ref?: Ref + /** + * Имя для скринридера; по умолчанию label, floatLabel или placeholder + * При required добавляется «обязательное поле» + */ + accessibilityLabel?: TextInputProps['accessibilityLabel'] /** * Строка или число оборачиваются в InputCaption; нода отображается как передана. * Для статуса подписи передайте InputCaption с нужным variant. */ caption?: ReactNode - /** Показывает * у подписи */ + /** + * Показывает * у подписи и добавляет «обязательное поле» к имени поля для скринридера + */ required?: boolean /** @default 'base' */ size?: InputTextSize From b8491667a0bc64f09dd5f625532541dbca105657 Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Mon, 12 Oct 2026 20:58:16 +0300 Subject: [PATCH 12/17] =?UTF-8?q?fix(input):=20=D1=83=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=D0=BE=20=D0=BE=D0=B7=D0=B2=D1=83=D1=87=D0=B8?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=BB=D0=B5=D0=B9?= =?UTF-8?q?=20=D0=B8=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InputText/__tests__/InputTextApi.test.tsx | 3 +- src/components/Input/core/InputControl.tsx | 44 ++++-- src/components/Input/core/InputLabel.tsx | 51 ++++-- .../__tests__/InputAccessibility.test.tsx | 42 ++++- .../Input/core/__tests__/InputLabel.test.tsx | 147 ++++++++++++++++++ src/components/Input/core/strings.ts | 1 + src/components/Input/core/types.ts | 9 +- 7 files changed, 262 insertions(+), 35 deletions(-) create mode 100644 src/components/Input/core/__tests__/InputLabel.test.tsx diff --git a/src/components/Input/InputText/__tests__/InputTextApi.test.tsx b/src/components/Input/InputText/__tests__/InputTextApi.test.tsx index b18d2efc..2886838f 100644 --- a/src/components/Input/InputText/__tests__/InputTextApi.test.tsx +++ b/src/components/Input/InputText/__tests__/InputTextApi.test.tsx @@ -39,7 +39,7 @@ describe('InputText: новый API', () => { }) test('отображает caption и обычный label, сохраняя имя поля для скринридера', () => { - const { getByLabelText, getByText } = render( + const { getByLabelText, getByText, queryByRole } = render( { ) expect(getByLabelText('Контактный телефон')).toHaveDisplayValue('') + expect(queryByRole('text', { name: 'Телефон' })).toBeNull() expect( getByText('Телефон', { includeHiddenElements: true }) ).toBeOnTheScreen() diff --git a/src/components/Input/core/InputControl.tsx b/src/components/Input/core/InputControl.tsx index 1d3bcf31..c839601e 100644 --- a/src/components/Input/core/InputControl.tsx +++ b/src/components/Input/core/InputControl.tsx @@ -40,6 +40,33 @@ const renderNativeInput = ({ inputRef, ...props }: RenderTextInputArgs) => ( ) +// VoiceOver позволяет отключить hint, поэтому обязательность и ошибка входят в имя поля +const getAccessibilityLabel = ({ + nativeProps, + label, + required, + isDanger, +}: Pick< + InputControlProps, + 'nativeProps' | 'label' | 'required' | 'isDanger' +>) => { + const name = + nativeProps['aria-label'] ?? + nativeProps.accessibilityLabel ?? + label ?? + nativeProps.placeholder + + return required || isDanger + ? [ + name, + required ? inputStrings.requiredField : undefined, + isDanger ? inputStrings.fieldError : undefined, + ] + .filter(Boolean) + .join(', ') + : name +} + export const InputControl = ({ label, required, @@ -82,19 +109,16 @@ export const InputControl = ({ isFloating && { paddingTop: floatingLabel.inputPaddingTop }, inputStyle, ] - const { 'aria-label': ariaLabel, ...inputNativeProps } = nativeProps - const inputName = - ariaLabel ?? - nativeProps.accessibilityLabel ?? - label ?? - nativeProps.placeholder - const accessibilityLabel = required - ? [inputName, inputStrings.requiredField].filter(Boolean).join(', ') - : inputName + const { 'aria-label': _ariaLabel, ...inputNativeProps } = nativeProps const textInputProps: RenderTextInputArgs = { ...inputNativeProps, 'aria-disabled': disabled, - accessibilityLabel, + accessibilityLabel: getAccessibilityLabel({ + nativeProps, + label, + required, + isDanger, + }), accessibilityState: { ...nativeProps.accessibilityState, disabled }, editable: isEditable, readOnly: !isEditable, diff --git a/src/components/Input/core/InputLabel.tsx b/src/components/Input/core/InputLabel.tsx index b78d1929..91c8a625 100644 --- a/src/components/Input/core/InputLabel.tsx +++ b/src/components/Input/core/InputLabel.tsx @@ -25,11 +25,6 @@ interface InputLabelProps extends Pick< readonly testID?: string } -export const getLabelInfoAccessibilityLabel = ( - label: string, - labelInfo: InputLabelInfo -) => `${label}. ${labelInfo.accessibilityLabel ?? inputStrings.labelInfoButton}` - export const InputLabel = ({ label, labelInfo, @@ -91,21 +86,20 @@ export const InputLabel = ({ * ) : null} - + {label} )} {labelInfo ? ( - + - + ) : null} ) @@ -142,8 +136,31 @@ export const InputLabel = ({ ) } + if (labelInfo) { + return ( + + {content} + + ) + } + return ( - + {content} ) diff --git a/src/components/Input/core/__tests__/InputAccessibility.test.tsx b/src/components/Input/core/__tests__/InputAccessibility.test.tsx index a68a3202..7159142a 100644 --- a/src/components/Input/core/__tests__/InputAccessibility.test.tsx +++ b/src/components/Input/core/__tests__/InputAccessibility.test.tsx @@ -9,6 +9,44 @@ const components = [ ] describe('Input: доступное имя и состояние', () => { + test.each( + components.flatMap(({ name, Component }) => + [false, true].map((required) => ({ + name: `${name}: required=${String(required)}`, + Component, + required, + })) + ) + )( + 'обновляет признак ошибки в имени поля при изменении state в $name', + ({ Component, required }) => { + const label = required + ? 'Контактный email, обязательное поле' + : 'Контактный email' + const props = { + required, + 'aria-label': 'Контактный email', + accessibilityLabel: 'Запасное имя', + accessibilityHint: '', + label: 'Email', + value: 'name@', + } + const { getByLabelText, rerender } = render( + + ) + + expect(getByLabelText(`${label}, ошибка`)).toHaveDisplayValue('name@') + expect(getByLabelText(`${label}, ошибка`)).toHaveProp( + 'accessibilityHint', + '' + ) + + rerender() + + expect(getByLabelText(label)).toHaveDisplayValue('name@') + } + ) + test.each( components.flatMap(({ name, Component }) => [ { name: `${name}: label`, Component, labelProps: { label: 'Email' } }, @@ -25,9 +63,7 @@ describe('Input: доступное имя и состояние', () => { ) - expect( - getByText('*', { includeHiddenElements: true }) - ).toBeOnTheScreen() + expect(getByText('*', { includeHiddenElements: true })).toBeOnTheScreen() expect(getByTestId('RequiredInput')).toHaveProp( 'accessibilityLabel', 'Email, обязательное поле' diff --git a/src/components/Input/core/__tests__/InputLabel.test.tsx b/src/components/Input/core/__tests__/InputLabel.test.tsx new file mode 100644 index 00000000..4e1593cc --- /dev/null +++ b/src/components/Input/core/__tests__/InputLabel.test.tsx @@ -0,0 +1,147 @@ +import { render, userEvent } from '@testing-library/react-native' + +import { InputCaption, InputText, InputGroup, Password } from '../..' + +const components = [ + { name: 'InputText', Component: InputText }, + { name: 'InputGroup', Component: InputGroup }, + { name: 'Password', Component: Password }, +] + +describe('Input: подпись', () => { + test.each(components)( + 'сохраняет видимый label без отдельной остановки скринридера в $name', + ({ Component }) => { + const { getByLabelText, getByText, queryByRole, queryByText } = render( + + ) + + expect(getByLabelText('Email')).toHaveDisplayValue('') + expect( + getByText('Email', { includeHiddenElements: true }) + ).toBeOnTheScreen() + expect(queryByText('Email')).toBeNull() + expect(queryByRole('button', { name: 'Email. Подробнее' })).toBeNull() + } + ) + + test.each(components)( + 'объединяет label и иконку в одну кнопку при добавлении info в $name', + async ({ Component }) => { + const onPress = jest.fn() + const { getByRole, getAllByRole, getByLabelText, queryByRole, rerender } = + render() + + rerender( + + ) + + const button = getByRole('button', { name: 'Email. Подробнее' }) + + expect(getAllByRole('button', { name: 'Email. Подробнее' })).toHaveLength( + 1 + ) + expect(button).toHaveProp('testID', 'InputLabelLabelInfo') + expect(queryByRole('text', { name: 'Email' })).toBeNull() + expect(getByLabelText('Email')).toHaveDisplayValue('') + + await userEvent.press(button) + + expect(onPress).toHaveBeenCalledOnce() + + rerender() + + expect(queryByRole('button', { name: 'Email. Подробнее' })).toBeNull() + expect(getByLabelText('Email')).toHaveDisplayValue('') + } + ) + + test.each(components)( + 'добавляет label к пользовательскому названию действия info в $name', + ({ Component }) => { + const { getByRole, getByLabelText } = render( + + ) + + expect( + getByRole('button', { name: 'ИНН. Как узнать ИНН' }) + ).toBeOnTheScreen() + expect(getByLabelText('ИНН')).toHaveDisplayValue('') + } + ) + + test.each(components)( + 'не создаёт отдельную остановку для floatLabel в $name', + ({ Component }) => { + const { getByLabelText, getByText, queryByText } = render( + + ) + + expect(getByLabelText('Email')).toHaveDisplayValue('name@example.com') + expect( + getByText('Email', { includeHiddenElements: true }) + ).toBeOnTheScreen() + expect(queryByText('Email')).toBeNull() + } + ) + + test.each( + components.flatMap(({ name, Component }) => + [undefined, 'Своя подсказка', ''].map((accessibilityHint) => ({ + name: `${name}: hint ${String(accessibilityHint)}`, + Component, + accessibilityHint, + })) + ) + )( + 'передаёт только заданный hint и сохраняет отдельный Caption в $name', + ({ Component, accessibilityHint }) => { + const { getByLabelText, getByRole } = render( + Введите рабочий email} + label='Email' + labelInfo={{ onPress: jest.fn() }} + /> + ) + + expect( + getByLabelText('Email, обязательное поле').props.accessibilityHint + ).toBe(accessibilityHint) + expect( + getByRole('text', { name: 'Введите рабочий email' }) + ).toBeOnTheScreen() + expect( + getByRole('button', { name: 'Email. Подробнее' }).props + .accessibilityHint + ).toBeUndefined() + } + ) + + test.each([ + { name: 'readOnly', props: { readOnly: true }, disabled: false }, + { name: 'disabled', props: { disabled: true }, disabled: true }, + ])( + 'соблюдает доступность кнопки справки при $name', + async ({ props, disabled }) => { + const onPress = jest.fn() + const { getByRole } = render( + + ) + const button = getByRole('button', { name: 'Email. Подробнее' }) + + expect(button).toHaveProp('accessibilityState', { disabled }) + + await userEvent.press(button) + + expect(onPress).toHaveBeenCalledTimes(disabled ? 0 : 1) + } + ) +}) diff --git a/src/components/Input/core/strings.ts b/src/components/Input/core/strings.ts index ae1ffc5e..60bbf225 100644 --- a/src/components/Input/core/strings.ts +++ b/src/components/Input/core/strings.ts @@ -2,6 +2,7 @@ export const inputStrings = { labelInfoButton: 'Подробнее', clearButton: 'Очистить поле', requiredField: 'обязательное поле', + fieldError: 'ошибка', showPassword: 'Показать пароль', hidePassword: 'Скрыть пароль', } as const diff --git a/src/components/Input/core/types.ts b/src/components/Input/core/types.ts index 9134a576..a93103c2 100644 --- a/src/components/Input/core/types.ts +++ b/src/components/Input/core/types.ts @@ -13,7 +13,7 @@ export type InputState = 'default' | 'danger' export type RenderTextInputArgs = TextInputProps & { inputRef: Ref } export interface InputLabelInfo { - /** Обработчик нажатия на иконку ⓘ */ + /** Обработчик нажатия на label с иконкой ⓘ */ onPress: NonNullable /** * Название действия для скринридера; добавляется после текста label @@ -24,7 +24,7 @@ export interface InputLabelInfo { export type InputLabelProps = | { - /** Постоянная подпись над полем, взаимоисключающая с floatLabel */ + /** Постоянная подпись над полем; скринридер читает её в имени поля */ label?: string floatLabel?: never labelInfo?: never @@ -32,7 +32,7 @@ export type InputLabelProps = | { label: string floatLabel?: never - /** Иконка ⓘ с обработчиком нажатия; доступно только у обычного label */ + /** Label с иконкой ⓘ читается отдельной кнопкой; имя поля сохраняется */ labelInfo?: InputLabelInfo } | { @@ -59,12 +59,13 @@ export interface InputNativeProps extends Omit< ref?: Ref /** * Имя для скринридера; по умолчанию label, floatLabel или placeholder - * При required добавляется «обязательное поле» + * При required добавляется «обязательное поле», при danger — «ошибка» */ accessibilityLabel?: TextInputProps['accessibilityLabel'] /** * Строка или число оборачиваются в InputCaption; нода отображается как передана. * Для статуса подписи передайте InputCaption с нужным variant. + * Подпись доступна скринридеру отдельно от поля */ caption?: ReactNode /** From 36727babe0697ba53e45f54138d5cee8798fe748 Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Tue, 13 Oct 2026 12:19:06 +0300 Subject: [PATCH 13/17] =?UTF-8?q?docs(input):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=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 --- .storybook/inputDocumentation.ts | 9 ++ README.md | 1 + src/components/Input/Input.md | 13 +++ .../Input/InputCaption/InputCaption.md | 44 +++++++++ .../InputCaption/InputCaption.stories.tsx | 7 +- src/components/Input/InputGroup/InputGroup.md | 50 +++++++++++ .../Input/InputGroup/InputGroup.stories.tsx | 2 + .../Input/InputGroup/InputGroupAddon.md | 42 +++++++++ src/components/Input/InputText/InputText.md | 90 +++++++++++++++++++ .../Input/InputText/InputText.stories.tsx | 2 + src/components/Input/Password/Password.md | 57 ++++++++++++ .../Input/Password/Password.stories.tsx | 2 + 12 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 .storybook/inputDocumentation.ts create mode 100644 src/components/Input/Input.md create mode 100644 src/components/Input/InputCaption/InputCaption.md create mode 100644 src/components/Input/InputGroup/InputGroup.md create mode 100644 src/components/Input/InputGroup/InputGroupAddon.md create mode 100644 src/components/Input/InputText/InputText.md create mode 100644 src/components/Input/Password/Password.md diff --git a/.storybook/inputDocumentation.ts b/.storybook/inputDocumentation.ts new file mode 100644 index 00000000..5630f979 --- /dev/null +++ b/.storybook/inputDocumentation.ts @@ -0,0 +1,9 @@ +const documentationRoot = + 'https://github.com/cdek-it/react-native-ui-kit/blob/main/src/components/Input' + +export const inputDocumentationNotes = { + InputText: `[Документация InputText](${documentationRoot}/InputText/InputText.md)`, + InputGroup: `[Документация InputGroup](${documentationRoot}/InputGroup/InputGroup.md)`, + Password: `[Документация Password](${documentationRoot}/Password/Password.md)`, + InputCaption: `[Документация InputCaption](${documentationRoot}/InputCaption/InputCaption.md)`, +} diff --git a/README.md b/README.md index c7e4c615..2e623326 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ Документация компонентов: +- [Поля ввода](src/components/Input/Input.md) - [Skeleton](src/components/Skeleton/Skeleton.md) ## Installation diff --git a/src/components/Input/Input.md b/src/components/Input/Input.md new file mode 100644 index 00000000..d0bf098f --- /dev/null +++ b/src/components/Input/Input.md @@ -0,0 +1,13 @@ +# Поля ввода + +- [InputText](./InputText/InputText.md) — текстовый ввод, подписи и сообщения +- [InputGroup](./InputGroup/InputGroup.md) — поле с дополнениями слева и справа +- [Password](./Password/Password.md) — пароль с переключением видимости + +## Вспомогательные компоненты + +- [InputCaption](./InputCaption/InputCaption.md) — текстовая подпись с + визуальным статусом + +Общие правила ввода и доступности описаны в InputText. Интерактивные примеры +доступны в Storybook, в разделе Input. diff --git a/src/components/Input/InputCaption/InputCaption.md b/src/components/Input/InputCaption/InputCaption.md new file mode 100644 index 00000000..025a827d --- /dev/null +++ b/src/components/Input/InputCaption/InputCaption.md @@ -0,0 +1,44 @@ +# InputCaption + +Дополнительное сообщение с визуальным статусом + +## Использование + +```tsx +import { InputCaption, InputText } from '@cdek-it/react-native-ui-kit' + +export function AddressField() { + return ( + + Улица, дом и квартира + + } + /> + ) +} +``` + +В `caption` поля строка или число автоматически оформляются как InputCaption. +Для статуса или иконки можно передать компонент явно. Его `variant` задаётся +независимо от `state` поля. + +## Props + +- `children` — содержимое сообщения. +- `variant` — `base | info | success | warning | danger`; по умолчанию `base`. + Определяет оформление и стандартную иконку. +- `showIcon` — показывает иконку; по умолчанию `false`. +- `Icon` — SVG, переопределяющий стандартную иконку. Для показа нужен + `showIcon`. +- `style` — стиль контейнера сообщения. + +Полный контракт — [InputCaptionProps](./InputCaption.tsx). + +## Доступность + +Сообщение озвучивается отдельно от поля; SVG декоративна. Подсказка поля +задаётся через `accessibilityHint` у [InputText](../InputText/InputText.md); +содержимое InputCaption автоматически в неё не переносится. diff --git a/src/components/Input/InputCaption/InputCaption.stories.tsx b/src/components/Input/InputCaption/InputCaption.stories.tsx index 2fde5977..b0ba46f5 100644 --- a/src/components/Input/InputCaption/InputCaption.stories.tsx +++ b/src/components/Input/InputCaption/InputCaption.stories.tsx @@ -1,5 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react' +import { inputDocumentationNotes } from '../../../../.storybook/inputDocumentation' + import { InputCaption } from './InputCaption' const meta: Meta = { @@ -14,7 +16,10 @@ const meta: Meta = { }, showIcon: { control: 'boolean' }, }, - parameters: { controls: { include: ['children', 'variant', 'showIcon'] } }, + parameters: { + notes: inputDocumentationNotes.InputCaption, + controls: { include: ['children', 'variant', 'showIcon'] }, + }, render: (args) => , } diff --git a/src/components/Input/InputGroup/InputGroup.md b/src/components/Input/InputGroup/InputGroup.md new file mode 100644 index 00000000..8dd4665a --- /dev/null +++ b/src/components/Input/InputGroup/InputGroup.md @@ -0,0 +1,50 @@ +# InputGroup + +Поле ввода с дополнениями слева и справа: единицами измерения, SVG или +действиями. + +## Использование + +```tsx +import { InputGroup } from '@cdek-it/react-native-ui-kit' + +export function PriceField() { + return +} +``` + +Строки и числа оформляются автоматически. Для SVG и действий доступен +[InputGroupAddon](./InputGroupAddon.md): + +```tsx +import { InputGroup, InputGroupAddon } from '@cdek-it/react-native-ui-kit' + +export function OrderField({ onSearch }: { onSearch: () => void }) { + return ( + Найти} + /> + ) +} +``` + +Дополнения, `label`, `floatLabel` и `caption` необязательны. `onSearch` — +обработчик приложения. + +## Props + +- `leftAddon` — дополнение слева (`ReactNode`). +- `rightAddon` — дополнение справа (`ReactNode`). + +Остальные props, обработчики и `ref` — как у +[InputText](../InputText/InputText.md). + +Полный контракт — [InputGroupProps](./types.ts). + +## Поведение дополнений + +InputGroupAddon наследует оформление и `disabled` группы. Для собственной +интерактивной ноды `disabled` задаётся явно тем же значением, что у группы. +`readOnly` запрещает редактирование поля и сохраняет действия дополнений. diff --git a/src/components/Input/InputGroup/InputGroup.stories.tsx b/src/components/Input/InputGroup/InputGroup.stories.tsx index a65b2b08..f3702d62 100644 --- a/src/components/Input/InputGroup/InputGroup.stories.tsx +++ b/src/components/Input/InputGroup/InputGroup.stories.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from 'react' import { Alert } from 'react-native' +import { inputDocumentationNotes } from '../../../../.storybook/inputDocumentation' import { getInputLabelProps, inputLabelControls, @@ -99,6 +100,7 @@ const meta = { onChangeText: { action: 'onChangeText' }, }, parameters: { + notes: inputDocumentationNotes.InputGroup, controls: { exclude: [ 'onChangeText', diff --git a/src/components/Input/InputGroup/InputGroupAddon.md b/src/components/Input/InputGroup/InputGroupAddon.md new file mode 100644 index 00000000..9ab34db7 --- /dev/null +++ b/src/components/Input/InputGroup/InputGroupAddon.md @@ -0,0 +1,42 @@ +# InputGroupAddon + +Дополнение InputGroup с текстом, SVG или действием + +## Использование + +```tsx +import { InputGroup, InputGroupAddon } from '@cdek-it/react-native-ui-kit' + +export function SearchField({ onSearch }: { onSearch: () => void }) { + return ( + Найти} + /> + ) +} +``` + +Дополнение с `onPress` становится кнопкой; без обработчика используется как +декоративная часть поля. + +## Props + +- `children` — строка, число или собственная разметка. Текст оформляется + автоматически. +- `Icon` — SVG-иконка; может использоваться вместе с `children`. +- `onPress` — обработчик действия. +- `disabled` — отключает дополнение. При `disabled` группы дополнение также + отключено независимо от собственного значения. +- `accessibilityLabel` — имя кнопки; по умолчанию берётся из строкового или + числового `children`. + +Нативные PressableProps поддерживаются с ограничениями из +[InputGroupAddonProps](./InputGroupAddon.tsx). + +## Доступность + +Для кнопки только с SVG или собственной разметкой требуется +`accessibilityLabel`. Дополнение без действия отдельно не озвучивается. Значимое +для ввода содержимое, например единица измерения, может быть включено в +`accessibilityLabel` поля. diff --git a/src/components/Input/InputText/InputText.md b/src/components/Input/InputText/InputText.md new file mode 100644 index 00000000..e9ae2afc --- /dev/null +++ b/src/components/Input/InputText/InputText.md @@ -0,0 +1,90 @@ +# InputText + +Текстовое поле с постоянной или плавающей подписью и дополнительным сообщением. + +## Использование + +```tsx +import { useState } from 'react' +import { InputText } from '@cdek-it/react-native-ui-kit' + +export function NameField() { + const [name, setName] = useState('') + + return +} +``` + +С `value` отображаемое значение определяется этим prop, а `onChangeText` +сообщает об изменении. Без `value` поле хранит значение самостоятельно; +`defaultValue` задаёт начальное значение. + +### Режимы label + +- `label` — постоянная подпись над полем. +- `floatLabel` — подпись внутри пустого поля, поднимающаяся при фокусе или + вводе. +- `label` с `labelInfo` — нажимаемая строка с иконкой ⓘ; вызывает переданный + `labelInfo.onPress`. С `floatLabel` действие недоступно. +- Без `label` и `floatLabel` — поле без подписи и отступа под неё. + +`label` и `floatLabel` взаимоисключающие. С `floatLabel` отдельный `placeholder` +появляется при фокусе пустого поля; дублирование текста подписи не требуется. + +```tsx +import { InputText } from '@cdek-it/react-native-ui-kit' + +export function LabelModes({ onInfoPress }: { onInfoPress: () => void }) { + return ( + <> + + + + + + ) +} +``` + +`onInfoPress` — обработчик приложения, в котором может быть реализован показ +подсказки. Название действия для скринридера задаётся через +`labelInfo.accessibilityLabel`; по умолчанию используется «Подробнее». + +## Props + +- `label` и `floatLabel` — взаимоисключающие необязательные подписи. +- `labelInfo` — действие для постоянного `label`. +- `caption` — дополнительное сообщение (`ReactNode`). Для статуса и иконки можно + использовать [InputCaption](../InputCaption/InputCaption.md); строка и число + оформляются автоматически, пустая строка не занимает место. Статус подписи + задаётся независимо от `state` поля. +- `state` — `default | danger`; состояние валидации, по умолчанию `default`. +- `disabled` — отключает ввод и встроенные действия. `readOnly` запрещает ввод и + очистку, сохраняя остальные действия. Приоритет у `disabled`. +- `required` — добавляет маркер обязательности и описание для скринридера. +- `size` — `small | base | large | xlarge`; по умолчанию `base`. +- `clearable` — включает очистку непустого редактируемого поля; по умолчанию + `true`. +- `style` — стиль внешнего контейнера; `inputStyle` — стиль нативного ввода. +- `ref` — методы RN TextInput. +- `renderTextInput` — адаптер ввода. Полученные props и `inputRef` необходимы + для подключения собственного TextInput. + +Нативные props TextInput поддерживаются с ограничениями из +[InputTextProps](./types.ts). Для запрета редактирования используется +`readOnly`; `editable` исключён из API. + +## Доступность + +Имя поля определяется первым заданным значением: `aria-label`, +`accessibilityLabel`, `label`, `floatLabel`, `placeholder`. Обязательность и +ошибка дополняют имя. + +Подпись без действия отдельно не озвучивается. При `labelInfo` вся строка +подписи доступна кнопкой «Текст label. Подробнее»; подпись сохраняется и в имени +поля. InputCaption озвучивается отдельно, `accessibilityHint` задаётся явно. + +## Связанные компоненты + +- [InputGroup](../InputGroup/InputGroup.md) — дополнения слева и справа. +- [Password](../Password/Password.md) — пароль с переключением видимости. diff --git a/src/components/Input/InputText/InputText.stories.tsx b/src/components/Input/InputText/InputText.stories.tsx index 3163bdf2..01a11c4c 100644 --- a/src/components/Input/InputText/InputText.stories.tsx +++ b/src/components/Input/InputText/InputText.stories.tsx @@ -2,6 +2,7 @@ import { useArgs } from '@storybook/preview-api' import type { StoryObj } from '@storybook/react' import { useCallback, useEffect, useState } from 'react' +import { inputDocumentationNotes } from '../../../../.storybook/inputDocumentation' import { getInputLabelProps, inputLabelControls, @@ -44,6 +45,7 @@ const meta = { onChangeText: { action: 'onChangeText' }, }, parameters: { + notes: inputDocumentationNotes.InputText, controls: { include: [ 'labelMode', diff --git a/src/components/Input/Password/Password.md b/src/components/Input/Password/Password.md new file mode 100644 index 00000000..e9ed7fd3 --- /dev/null +++ b/src/components/Input/Password/Password.md @@ -0,0 +1,57 @@ +# Password + +Парольное поле с переключением видимости. + +## Использование + +```tsx +import { useState } from 'react' +import { Password } from '@cdek-it/react-native-ui-kit' + +export function PasswordField() { + const [password, setPassword] = useState('') + + return +} +``` + +Пароль изначально скрыт. Переключение видимости сохраняет значение и не вызывает +`onChangeText`. + +## Props + +- `toggleMask` — показывает кнопку переключения видимости; по умолчанию `true`. +- `clearable` — показывает кнопку очистки; по умолчанию `false`. + +Остальные props, обработчики и `ref` — как у +[InputText](../InputText/InputText.md). `secureTextEntry` и `multiline` +исключены из API Password. + +Полный контракт — [PasswordProps](./types.ts). + +## Автозаполнение + +Автозаполнение настраивается нативными props под сценарий приложения: + +```tsx +import { Password } from '@cdek-it/react-native-ui-kit' + +export function LoginPasswordField() { + return ( + + ) +} +``` + +Для нового пароля доступны `autoComplete='new-password'` и +`textContentType='newPassword'`. + +## Доступность + +Кнопка озвучивается как «Показать пароль» или «Скрыть пароль» в зависимости от +текущего действия. `disabled` отключает поле и кнопку; `readOnly` сохраняет +переключение видимости. diff --git a/src/components/Input/Password/Password.stories.tsx b/src/components/Input/Password/Password.stories.tsx index 2fdfeecd..2a999d2b 100644 --- a/src/components/Input/Password/Password.stories.tsx +++ b/src/components/Input/Password/Password.stories.tsx @@ -2,6 +2,7 @@ import { useArgs } from '@storybook/preview-api' import type { StoryObj } from '@storybook/react' import { useCallback, useEffect, useState } from 'react' +import { inputDocumentationNotes } from '../../../../.storybook/inputDocumentation' import { getInputLabelProps, inputLabelControls, @@ -62,6 +63,7 @@ const meta: InputStoryMeta = { onChangeText: { action: 'onChangeText' }, }, parameters: { + notes: inputDocumentationNotes.Password, controls: { exclude: [ 'onChangeText', From 1e93f61ba216cc403ad88e67a3d536ca7aba23a7 Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Mon, 19 Oct 2026 14:10:45 +0300 Subject: [PATCH 14/17] =?UTF-8?q?fix(input):=20=D1=81=D1=82=D0=B0=D0=B1?= =?UTF-8?q?=D0=B8=D0=BB=D0=B8=D0=B7=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D1=8B=20=D0=B2=D1=8B=D1=81=D0=BE=D1=82=D0=B0=20=D0=B8=20=D0=BC?= =?UTF-8?q?=D0=B0=D1=81=D1=88=D1=82=D0=B0=D0=B1=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BE=D0=B4=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Input/core/InputControl.tsx | 11 +- src/components/Input/core/InputField.tsx | 17 ++- src/components/Input/core/InputLabel.tsx | 15 +- .../__tests__/InputFloatingLabel.test.tsx | 129 ++++++++++++++++++ .../Input/core/__tests__/InputLabel.test.tsx | 30 ++++ src/components/Input/core/styles.ts | 24 ++-- src/components/Input/core/useFloatingLabel.ts | 20 +-- .../Input/core/useInputFontScale.ts | 17 +++ 8 files changed, 223 insertions(+), 40 deletions(-) create mode 100644 src/components/Input/core/__tests__/InputFloatingLabel.test.tsx create mode 100644 src/components/Input/core/useInputFontScale.ts diff --git a/src/components/Input/core/InputControl.tsx b/src/components/Input/core/InputControl.tsx index c839601e..9f79b210 100644 --- a/src/components/Input/core/InputControl.tsx +++ b/src/components/Input/core/InputControl.tsx @@ -88,10 +88,10 @@ export const InputControl = ({ const isClearVisible = !!clearable && !!controller.value && isEditable const hasActions = isClearVisible || !!action const placeholderLayout = usePlaceholderLayout() - const floatText = styles.floatText(isDanger, size) + const floatText = styles.floatText(isDanger) const floatingLabel = useFloatingLabel(active, nativeProps, { labelTop: styles.floatLabel.top, - reducedTop: styles[size].paddingVertical, + reducedTop: styles.floatReduced.top, labelPadding: styles.floatLabel.paddingVertical, reducedPadding: styles.floatReduced.paddingVertical, fontSize: floatText.fontSize, @@ -106,7 +106,10 @@ export const InputControl = ({ hasActions && styles.inputWithActions, disabled && styles.disabledText, isFloating && styles.floatInput, - isFloating && { paddingTop: floatingLabel.inputPaddingTop }, + isFloating && + floatingLabel.fontScale > 1 && { + paddingTop: floatingLabel.inputPaddingTop, + }, inputStyle, ] const { 'aria-label': _ariaLabel, ...inputNativeProps } = nativeProps @@ -133,7 +136,7 @@ export const InputControl = ({ } return ( - + diff --git a/src/components/Input/core/InputLabel.tsx b/src/components/Input/core/InputLabel.tsx index 91c8a625..2320eca2 100644 --- a/src/components/Input/core/InputLabel.tsx +++ b/src/components/Input/core/InputLabel.tsx @@ -8,6 +8,7 @@ import { inputStrings } from './strings' import { styles } from './styles' import type { InputLabelInfo, InputTextSize } from './types' import type { useFloatingLabel } from './useFloatingLabel' +import { useInputFontScale } from './useInputFontScale' interface InputLabelProps extends Pick< TextInputProps, @@ -38,6 +39,7 @@ export const InputLabel = ({ testID, ...textProps }: InputLabelProps) => { + const fontScale = useInputFontScale(textProps) const content = ( <> {floating ? ( @@ -50,7 +52,7 @@ export const InputLabel = ({ importantForAccessibility='no-hide-descendants' pointerEvents='none' style={[ - styles.floatText(isDanger, size), + styles.floatText(isDanger), styles.required, floating.animatedTextStyle, ]} @@ -63,11 +65,9 @@ export const InputLabel = ({ accessibilityElementsHidden accessible={false} importantForAccessibility='no-hide-descendants' + numberOfLines={floating.fontScale > 1 ? undefined : 1} pointerEvents='none' - style={[ - styles.floatText(isDanger, size), - floating.animatedTextStyle, - ]} + style={[styles.floatText(isDanger), floating.animatedTextStyle]} > {label} @@ -76,6 +76,7 @@ export const InputLabel = ({ <> {isRequired ? ( ) : null} )} {labelInfo ? ( - + ('react-native') + +const mockFontScale = (fontScale: number) => + jest + .spyOn(ReactNative, 'useWindowDimensions') + .mockReturnValue({ width: 390, height: 844, scale: 3, fontScale }) + +describe('Input: floating label', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + test('сохраняет ввод при изменении масштаба обычной подписи без перезапуска', async () => { + mockFontScale(1) + const user = userEvent.setup() + const onChangeText = jest.fn() + const props = { label: 'Email', maxFontSizeMultiplier: 2, onChangeText } + const { getByLabelText, getByText, rerender } = render( + + ) + const input = getByLabelText('Email') + + await user.type(input, 'test@example.com') + + for (const fontScale of [2, 1]) { + mockFontScale(fontScale) + rerender() + + expect(getByLabelText('Email')).toBe(input) + expect(input).toHaveDisplayValue('test@example.com') + expect( + getByText('Email', { includeHiddenElements: true }) + ).toBeOnTheScreen() + } + + expect(onChangeText).toHaveBeenLastCalledWith('test@example.com') + }) + + test.each(['small', 'base', 'large', 'xlarge'])( + 'сохраняет поле и подпись при измерении, фокусе, вводе и очистке в размере %s', + async (size) => { + mockFontScale(1) + const user = userEvent.setup() + const onFocus = jest.fn() + const onBlur = jest.fn() + const { getByLabelText, getByTestId, getByRole, getByText } = render( + + ) + const input = getByLabelText('Email') + const label = getByText('Email', { includeHiddenElements: true }) + const labelContainer = getByTestId('FloatingInputFloatingLabel', { + includeHiddenElements: true, + }) + + fireEvent(labelContainer, 'layout', { + nativeEvent: { layout: { x: 0, y: 0, width: 150, height: 26 } }, + }) + await user.type(input, 'test@example.com') + fireEvent(labelContainer, 'layout', { + nativeEvent: { layout: { x: 0, y: 0, width: 150, height: 15 } }, + }) + + expect(input).toHaveDisplayValue('test@example.com') + expect(getByLabelText('Email')).toBe(input) + expect(label).toHaveProp('numberOfLines', 1) + + await user.press(getByRole('button', { name: 'Очистить поле' })) + fireEvent(input, 'blur', { nativeEvent: {} }) + + expect(input).toHaveDisplayValue('') + expect(label).toBeOnTheScreen() + expect(getByLabelText('Email')).toBe(input) + expect(onFocus).toHaveBeenCalledWith( + expect.objectContaining({ nativeEvent: expect.any(Object) }) + ) + expect(onBlur).toHaveBeenCalledWith({ nativeEvent: {} }) + } + ) + + test.each([ + { name: 'обычный шрифт', fontScale: 1, props: {}, lines: 1 }, + { name: 'шрифт 200%', fontScale: 2, props: {}, lines: undefined }, + { + name: 'отключённое масштабирование', + fontScale: 2, + props: { allowFontScaling: false }, + lines: 1, + }, + { + name: 'ограничение масштаба до 100%', + fontScale: 2, + props: { maxFontSizeMultiplier: 1 }, + lines: 1, + }, + { + name: 'неограниченный масштаб', + fontScale: 2, + props: { maxFontSizeMultiplier: 0 }, + lines: undefined, + }, + ])( + 'учитывает $name при переносе длинной подписи', + ({ fontScale, props, lines }) => { + mockFontScale(fontScale) + const label = 'Длинная подпись поля, для которой требуется больше места' + const { getByText, getByLabelText } = render( + + ) + + const floatingLabel = getByText(label, { includeHiddenElements: true }) + + expect(floatingLabel.props.numberOfLines).toBe(lines) + expect(getByLabelText(label)).toHaveDisplayValue('') + } + ) +}) diff --git a/src/components/Input/core/__tests__/InputLabel.test.tsx b/src/components/Input/core/__tests__/InputLabel.test.tsx index 4e1593cc..4613ea0f 100644 --- a/src/components/Input/core/__tests__/InputLabel.test.tsx +++ b/src/components/Input/core/__tests__/InputLabel.test.tsx @@ -9,6 +9,36 @@ const components = [ ] describe('Input: подпись', () => { + test.each(components)( + 'передаёт настройки масштабирования обычной подписи в $name', + ({ Component }) => { + const { getByText, getByLabelText, rerender } = render( + + ) + const label = getByText('Email', { includeHiddenElements: true }) + + expect(label).toHaveProp('allowFontScaling', true) + expect(label).toHaveProp('maxFontSizeMultiplier', 2) + expect(getByLabelText('Email')).toHaveProp('allowFontScaling', true) + expect(getByLabelText('Email')).toHaveProp('maxFontSizeMultiplier', 2) + + rerender( + + ) + + const updatedLabel = getByText('Email', { includeHiddenElements: true }) + + expect(updatedLabel).toHaveProp('allowFontScaling', false) + expect(updatedLabel).toHaveProp('maxFontSizeMultiplier', 1) + expect(getByLabelText('Email')).toHaveProp('allowFontScaling', false) + expect(getByLabelText('Email')).toHaveProp('maxFontSizeMultiplier', 1) + } + ) + test.each(components)( 'сохраняет видимый label без отдельной остановки скринридера в $name', ({ Component }) => { diff --git a/src/components/Input/core/styles.ts b/src/components/Input/core/styles.ts index 42ff9e7a..b9728255 100644 --- a/src/components/Input/core/styles.ts +++ b/src/components/Input/core/styles.ts @@ -12,6 +12,10 @@ export const styles = StyleSheet.create(({ components, semantic, fonts }) => ({ borderColor: components.inputtext.root.borderColor, backgroundColor: components.inputtext.root.background, }, + floatingFrame: (fontScale: number) => ({ + minHeight: semantic.dimension.size[1300], + height: fontScale <= 1 ? semantic.dimension.size[1300] : undefined, + }), focused: { borderColor: components.inputtext.root.focusBorderColor, outlineWidth: components.inputtext.root.focusRing.width, @@ -71,9 +75,10 @@ export const styles = StyleSheet.create(({ components, semantic, fonts }) => ({ paddingHorizontal: components.inputtext.extend.extXlg.paddingX, paddingVertical: components.inputtext.extend.extXlg.paddingY, }, - floatControl: { minHeight: components.floatlabel.extend.height }, floatInput: { - paddingTop: components.floatlabel.inside.input.paddingTop, + fontSize: fonts.fontSize[200], + lineHeight: fonts.lineHeight[200], + paddingTop: semantic.dimension.space[700], paddingBottom: components.floatlabel.inside.input.paddingBottom, }, label: { @@ -107,13 +112,9 @@ export const styles = StyleSheet.create(({ components, semantic, fonts }) => ({ top: components.floatlabel.root.positionY, paddingVertical: semantic.dimension.space.none, }, - floatText: (isDanger: boolean, size: InputTextSize = 'base') => ({ - fontSize: { - small: components.inputtext.root.sm.fontSize, - base: fonts.fontSize[300], - large: components.inputtext.root.lg.fontSize, - xlarge: components.inputtext.extend.extXlg.fontSize, - }[size], + floatText: (isDanger: boolean) => ({ + fontSize: fonts.fontSize[200], + lineHeight: fonts.lineHeight[200], fontFamily: fonts.fontFamily.base, color: isDanger ? components.floatlabel.root.invalidColor @@ -122,10 +123,15 @@ export const styles = StyleSheet.create(({ components, semantic, fonts }) => ({ flexShrink: 1, }), floatReduced: { + top: semantic.dimension.space[300], paddingVertical: semantic.dimension.space.none, fontSize: components.floatlabel.root.active.fontSize, fontFamily: fonts.fontFamily.base, }, + floatLabelInfo: { + width: semantic.dimension.size[400], + height: semantic.dimension.size[400], + }, actions: (size: InputTextSize = 'base') => ({ flexDirection: 'row', alignItems: 'stretch', diff --git a/src/components/Input/core/useFloatingLabel.ts b/src/components/Input/core/useFloatingLabel.ts index 243921fe..a1223c7a 100644 --- a/src/components/Input/core/useFloatingLabel.ts +++ b/src/components/Input/core/useFloatingLabel.ts @@ -1,9 +1,5 @@ import { useCallback, useEffect, useState } from 'react' -import { - type LayoutChangeEvent, - type TextInputProps, - useWindowDimensions, -} from 'react-native' +import type { LayoutChangeEvent, TextInputProps } from 'react-native' import { interpolate, useAnimatedStyle, @@ -11,6 +7,8 @@ import { withTiming, } from 'react-native-reanimated' +import { useInputFontScale } from './useInputFontScale' + export interface FloatingLabelMetrics { labelTop: number reducedTop: number @@ -31,19 +29,10 @@ export const useFloatingLabel = ( }: Pick, metrics: FloatingLabelMetrics ) => { - const { fontScale } = useWindowDimensions() + const scale = useInputFontScale({ allowFontScaling, maxFontSizeMultiplier }) const [labelHeight, setLabelHeight] = useState(0) const [containerHeight, setContainerHeight] = useState(0) const animation = useSharedValue(0) - const scale = - allowFontScaling === false - ? 1 - : Math.min( - fontScale, - maxFontSizeMultiplier && maxFontSizeMultiplier > 0 - ? maxFontSizeMultiplier - : Infinity - ) const { labelTop, @@ -100,6 +89,7 @@ export const useFloatingLabel = ( ) return { + fontScale: scale, animatedStyle, animatedContainerStyle, animatedTextStyle, diff --git a/src/components/Input/core/useInputFontScale.ts b/src/components/Input/core/useInputFontScale.ts new file mode 100644 index 00000000..7aa18af7 --- /dev/null +++ b/src/components/Input/core/useInputFontScale.ts @@ -0,0 +1,17 @@ +import { type TextInputProps, useWindowDimensions } from 'react-native' + +export const useInputFontScale = ({ + allowFontScaling, + maxFontSizeMultiplier, +}: Pick) => { + const { fontScale } = useWindowDimensions() + + return allowFontScaling === false + ? 1 + : Math.min( + fontScale, + maxFontSizeMultiplier && maxFontSizeMultiplier > 0 + ? maxFontSizeMultiplier + : Infinity + ) +} From dc71d80d5ea2067d71a4a98993fee147acbc6f09 Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Tue, 20 Oct 2026 14:05:59 +0300 Subject: [PATCH 15/17] =?UTF-8?q?docs(input):=20=D1=83=D1=82=D0=BE=D1=87?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B0=20=D0=B8=20=D1=83=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=D0=B0=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D0=BE=D0=BB=D0=B5?= =?UTF-8?q?=D0=B9=20=D0=B2=D0=B2=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .storybook/inputDocumentation.ts | 1 - README.md | 4 +- src/components/Input/InputGroup/InputGroup.md | 23 ++--- src/components/Input/InputText/InputText.md | 85 +++++++++---------- .../Input/InputText/InputText.stories.tsx | 2 - src/components/Input/Password/Password.md | 29 ++----- src/components/Input/core/types.ts | 6 +- 7 files changed, 66 insertions(+), 84 deletions(-) diff --git a/.storybook/inputDocumentation.ts b/.storybook/inputDocumentation.ts index 5630f979..d9eb156e 100644 --- a/.storybook/inputDocumentation.ts +++ b/.storybook/inputDocumentation.ts @@ -2,7 +2,6 @@ const documentationRoot = 'https://github.com/cdek-it/react-native-ui-kit/blob/main/src/components/Input' export const inputDocumentationNotes = { - InputText: `[Документация InputText](${documentationRoot}/InputText/InputText.md)`, InputGroup: `[Документация InputGroup](${documentationRoot}/InputGroup/InputGroup.md)`, Password: `[Документация Password](${documentationRoot}/Password/Password.md)`, InputCaption: `[Документация InputCaption](${documentationRoot}/InputCaption/InputCaption.md)`, diff --git a/README.md b/README.md index 2e623326..f3a0cd2d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ Документация компонентов: -- [Поля ввода](src/components/Input/Input.md) +- [InputText](src/components/Input/InputText/InputText.md) +- [InputGroup](src/components/Input/InputGroup/InputGroup.md) +- [Password](src/components/Input/Password/Password.md) - [Skeleton](src/components/Skeleton/Skeleton.md) ## Installation diff --git a/src/components/Input/InputGroup/InputGroup.md b/src/components/Input/InputGroup/InputGroup.md index 8dd4665a..265294ad 100644 --- a/src/components/Input/InputGroup/InputGroup.md +++ b/src/components/Input/InputGroup/InputGroup.md @@ -5,17 +5,6 @@ ## Использование -```tsx -import { InputGroup } from '@cdek-it/react-native-ui-kit' - -export function PriceField() { - return -} -``` - -Строки и числа оформляются автоматически. Для SVG и действий доступен -[InputGroupAddon](./InputGroupAddon.md): - ```tsx import { InputGroup, InputGroupAddon } from '@cdek-it/react-native-ui-kit' @@ -30,17 +19,21 @@ export function OrderField({ onSearch }: { onSearch: () => void }) { } ``` -Дополнения, `label`, `floatLabel` и `caption` необязательны. `onSearch` — -обработчик приложения. +Строки и числа оформляются автоматически. Для SVG и действий доступен +[InputGroupAddon](./InputGroupAddon.md). В примере `onSearch` — обработчик +приложения. ## Props -- `leftAddon` — дополнение слева (`ReactNode`). -- `rightAddon` — дополнение справа (`ReactNode`). +- `leftAddon` и `rightAddon` — необязательные дополнения слева и справа + (`ReactNode`). Остальные props, обработчики и `ref` — как у [InputText](../InputText/InputText.md). +Режимы подписей — в разделе +[«Режимы label»](../InputText/InputText.md#режимы-label). + Полный контракт — [InputGroupProps](./types.ts). ## Поведение дополнений diff --git a/src/components/Input/InputText/InputText.md b/src/components/Input/InputText/InputText.md index e9ae2afc..f8210984 100644 --- a/src/components/Input/InputText/InputText.md +++ b/src/components/Input/InputText/InputText.md @@ -8,71 +8,64 @@ import { useState } from 'react' import { InputText } from '@cdek-it/react-native-ui-kit' -export function NameField() { - const [name, setName] = useState('') +export function InputFields({ showInfo }: { showInfo: () => void }) { + const [phone, setPhone] = useState('') - return -} -``` - -С `value` отображаемое значение определяется этим prop, а `onChangeText` -сообщает об изменении. Без `value` поле хранит значение самостоятельно; -`defaultValue` задаёт начальное значение. - -### Режимы label - -- `label` — постоянная подпись над полем. -- `floatLabel` — подпись внутри пустого поля, поднимающаяся при фокусе или - вводе. -- `label` с `labelInfo` — нажимаемая строка с иконкой ⓘ; вызывает переданный - `labelInfo.onPress`. С `floatLabel` действие недоступно. -- Без `label` и `floatLabel` — поле без подписи и отступа под неё. - -`label` и `floatLabel` взаимоисключающие. С `floatLabel` отдельный `placeholder` -появляется при фокусе пустого поля; дублирование текста подписи не требуется. - -```tsx -import { InputText } from '@cdek-it/react-native-ui-kit' - -export function LabelModes({ onInfoPress }: { onInfoPress: () => void }) { return ( <> - - - - + + ) } ``` -`onInfoPress` — обработчик приложения, в котором может быть реализован показ -подсказки. Название действия для скринридера задаётся через -`labelInfo.accessibilityLabel`; по умолчанию используется «Подробнее». +`showInfo` — обработчик приложения, например для показа подсказки. + +С `value` обновляйте значение через `onChangeText`. Без `value` поле хранит его +самостоятельно, а `defaultValue` задаёт начальное значение. + +## Режимы label + +- `label` — постоянная подпись над полем. +- `floatLabel` — подпись внутри поля, поднимающаяся при фокусе или наличии + текста. +- `labelInfo` — делает постоянный `label` кнопкой с иконкой ⓘ и обработчиком + `labelInfo.onPress`. С `floatLabel` недоступен. + +`label` и `floatLabel` взаимоисключающие и необязательные. Без них место под +подпись не резервируется. С `floatLabel` отдельный `placeholder` появляется при +фокусе пустого поля. Дублировать в нём текст подписи не требуется. ## Props -- `label` и `floatLabel` — взаимоисключающие необязательные подписи. -- `labelInfo` — действие для постоянного `label`. - `caption` — дополнительное сообщение (`ReactNode`). Для статуса и иконки можно - использовать [InputCaption](../InputCaption/InputCaption.md); строка и число + использовать [InputCaption](../InputCaption/InputCaption.md). Строка и число оформляются автоматически, пустая строка не занимает место. Статус подписи задаётся независимо от `state` поля. -- `state` — `default | danger`; состояние валидации, по умолчанию `default`. +- `state` — `default | danger`, по умолчанию `default`. Ошибка не запрещает + ввод. - `disabled` — отключает ввод и встроенные действия. `readOnly` запрещает ввод и очистку, сохраняя остальные действия. Приоритет у `disabled`. - `required` — добавляет маркер обязательности и описание для скринридера. -- `size` — `small | base | large | xlarge`; по умолчанию `base`. -- `clearable` — включает очистку непустого редактируемого поля; по умолчанию +- `size` — `small | base | large | xlarge`, по умолчанию `base`. С `floatLabel` + высота и типографика не зависят от `size` согласно макетам. +- `clearable` — включает очистку непустого редактируемого поля, по умолчанию `true`. -- `style` — стиль внешнего контейнера; `inputStyle` — стиль нативного ввода. +- `inputStyle` — стиль нативного ввода. `style` применяется к внешнему + контейнеру. - `ref` — методы RN TextInput. - `renderTextInput` — адаптер ввода. Полученные props и `inputRef` необходимы для подключения собственного TextInput. Нативные props TextInput поддерживаются с ограничениями из [InputTextProps](./types.ts). Для запрета редактирования используется -`readOnly`; `editable` исключён из API. +`readOnly`, а `editable` исключён из API. ## Доступность @@ -80,9 +73,13 @@ export function LabelModes({ onInfoPress }: { onInfoPress: () => void }) { `accessibilityLabel`, `label`, `floatLabel`, `placeholder`. Обязательность и ошибка дополняют имя. -Подпись без действия отдельно не озвучивается. При `labelInfo` вся строка -подписи доступна кнопкой «Текст label. Подробнее»; подпись сохраняется и в имени -поля. InputCaption озвучивается отдельно, `accessibilityHint` задаётся явно. +Подпись без действия отдельно не озвучивается. При `labelInfo` она доступна +кнопкой «Текст label. Подробнее» и сохраняется в имени поля. Название действия +«Подробнее» меняется через `labelInfo.accessibilityLabel`. + +InputCaption озвучивается отдельно, `accessibilityHint` задаётся явно. + +Увеличенный системный шрифт может увеличивать высоту поля. ## Связанные компоненты diff --git a/src/components/Input/InputText/InputText.stories.tsx b/src/components/Input/InputText/InputText.stories.tsx index 01a11c4c..3163bdf2 100644 --- a/src/components/Input/InputText/InputText.stories.tsx +++ b/src/components/Input/InputText/InputText.stories.tsx @@ -2,7 +2,6 @@ import { useArgs } from '@storybook/preview-api' import type { StoryObj } from '@storybook/react' import { useCallback, useEffect, useState } from 'react' -import { inputDocumentationNotes } from '../../../../.storybook/inputDocumentation' import { getInputLabelProps, inputLabelControls, @@ -45,7 +44,6 @@ const meta = { onChangeText: { action: 'onChangeText' }, }, parameters: { - notes: inputDocumentationNotes.InputText, controls: { include: [ 'labelMode', diff --git a/src/components/Input/Password/Password.md b/src/components/Input/Password/Password.md index e9ed7fd3..469de1c7 100644 --- a/src/components/Input/Password/Password.md +++ b/src/components/Input/Password/Password.md @@ -20,38 +20,27 @@ export function PasswordField() { ## Props -- `toggleMask` — показывает кнопку переключения видимости; по умолчанию `true`. -- `clearable` — показывает кнопку очистки; по умолчанию `false`. +- `toggleMask` — показывает кнопку переключения видимости, по умолчанию `true`. +- `clearable` — включает очистку непустого редактируемого поля, по умолчанию + `false`. Остальные props, обработчики и `ref` — как у [InputText](../InputText/InputText.md). `secureTextEntry` и `multiline` исключены из API Password. +Режимы подписей — в разделе +[«Режимы label»](../InputText/InputText.md#режимы-label). + Полный контракт — [PasswordProps](./types.ts). ## Автозаполнение -Автозаполнение настраивается нативными props под сценарий приложения: - -```tsx -import { Password } from '@cdek-it/react-native-ui-kit' - -export function LoginPasswordField() { - return ( - - ) -} -``` - -Для нового пароля доступны `autoComplete='new-password'` и +Для текущего пароля задайте `autoComplete='current-password'` и +`textContentType='password'`. Для нового — `autoComplete='new-password'` и `textContentType='newPassword'`. ## Доступность Кнопка озвучивается как «Показать пароль» или «Скрыть пароль» в зависимости от -текущего действия. `disabled` отключает поле и кнопку; `readOnly` сохраняет +текущего действия. `disabled` отключает поле и кнопку. `readOnly` сохраняет переключение видимости. diff --git a/src/components/Input/core/types.ts b/src/components/Input/core/types.ts index a93103c2..f1f0cd70 100644 --- a/src/components/Input/core/types.ts +++ b/src/components/Input/core/types.ts @@ -72,7 +72,11 @@ export interface InputNativeProps extends Omit< * Показывает * у подписи и добавляет «обязательное поле» к имени поля для скринридера */ required?: boolean - /** @default 'base' */ + /** + * С floatLabel высота и типографика не зависят от size согласно макету + * Увеличенный системный шрифт может увеличивать высоту поля + * @default 'base' + */ size?: InputTextSize /** * Валидация поля: default или danger. From 80706486e6a480c5b0aa037585066d64a710b852 Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Fri, 23 Oct 2026 15:32:28 +0300 Subject: [PATCH 16/17] =?UTF-8?q?fix(input):=20=D0=B8=D1=81=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D0=BE=D1=82=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=B6=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D0=B2=D0=B0=D1=8E=D1=89=D0=B5=D0=B3=D0=BE=20=D0=BB=D0=B5=D0=B9?= =?UTF-8?q?=D0=B1=D0=BB=D0=B0=20=D0=B8=20=D0=BF=D1=80=D0=B8=D0=B2=D1=8F?= =?UTF-8?q?=D0=B7=D0=BA=D0=B8=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Input/core/InputActions.tsx | 6 +- src/components/Input/core/InputControl.tsx | 23 ++---- src/components/Input/core/InputField.tsx | 7 +- src/components/Input/core/InputLabel.tsx | 11 +-- src/components/Input/core/styles.ts | 59 +++++++++----- src/components/Input/core/useFloatingLabel.ts | 77 ++++++------------- .../InputTextBase/InputTextBase.tsx | 17 +--- .../InputTextBase/useLegacyFloatingLabel.ts | 71 +++++++++++++++++ 8 files changed, 156 insertions(+), 115 deletions(-) create mode 100644 src/components/Input/deprecated/InputTextBase/useLegacyFloatingLabel.ts diff --git a/src/components/Input/core/InputActions.tsx b/src/components/Input/core/InputActions.tsx index 75264fe7..50047126 100644 --- a/src/components/Input/core/InputActions.tsx +++ b/src/components/Input/core/InputActions.tsx @@ -10,6 +10,7 @@ import type { InputTextSize } from './types' interface InputActionsProps { readonly isClearVisible: boolean + readonly isFloating: boolean readonly size: InputTextSize readonly onClear: () => void readonly children?: ReactNode @@ -18,12 +19,13 @@ interface InputActionsProps { export const InputActions = ({ isClearVisible, + isFloating, size, onClear, children, testID, }: InputActionsProps) => { - const actionStyle = styles.actions(size) + const actionStyle = styles.actions(size, isFloating) return ( @@ -45,7 +47,7 @@ export const InputActions = ({ accessible={false} source={IconX} uniProps={({ components }) => { - const iconStyle = styles.icon(size) + const iconStyle = styles.icon(size, isFloating) return { width: iconStyle.width, diff --git a/src/components/Input/core/InputControl.tsx b/src/components/Input/core/InputControl.tsx index 9f79b210..31381ed1 100644 --- a/src/components/Input/core/InputControl.tsx +++ b/src/components/Input/core/InputControl.tsx @@ -88,28 +88,14 @@ export const InputControl = ({ const isClearVisible = !!clearable && !!controller.value && isEditable const hasActions = isClearVisible || !!action const placeholderLayout = usePlaceholderLayout() - const floatText = styles.floatText(isDanger) - const floatingLabel = useFloatingLabel(active, nativeProps, { - labelTop: styles.floatLabel.top, - reducedTop: styles.floatReduced.top, - labelPadding: styles.floatLabel.paddingVertical, - reducedPadding: styles.floatReduced.paddingVertical, - fontSize: floatText.fontSize, - reducedFontSize: styles.floatReduced.fontSize, - fontFamily: floatText.fontFamily, - reducedFontFamily: styles.floatReduced.fontFamily, - inputPaddingTop: styles.floatInput.paddingTop, - }) + const floatingLabel = useFloatingLabel(active, nativeProps) const textStyle = [ styles.input, styles[size], - hasActions && styles.inputWithActions, disabled && styles.disabledText, isFloating && styles.floatInput, - isFloating && - floatingLabel.fontScale > 1 && { - paddingTop: floatingLabel.inputPaddingTop, - }, + hasActions && styles.inputWithActions, + isFloating && { paddingTop: floatingLabel.inputPaddingTop }, inputStyle, ] const { 'aria-label': _ariaLabel, ...inputNativeProps } = nativeProps @@ -140,6 +126,7 @@ export const InputControl = ({ ) : null} @@ -170,6 +156,7 @@ export const InputControl = ({ {hasActions ? ( { const frameStyle = ({ isFocused, + isFilled, isEditable, isDanger, disabled, }: { isFocused: boolean + isFilled: boolean isEditable: boolean isDanger: boolean disabled: boolean }) => [ styles.frame, + isFilled && styles.filled, isFocused && styles.focused, + isFilled && isFocused && styles.filledFocused, !isEditable && !disabled && styles.readOnly, isDanger && styles.danger, isDanger && isFocused && styles.dangerFocused, @@ -117,6 +121,7 @@ export const InputField = ({ style={[ frameStyle({ isFocused: controller.isFocused, + isFilled: floating && !!controller.value, isEditable, isDanger: danger, disabled, @@ -126,7 +131,7 @@ export const InputField = ({ testID={makeTestId('Frame')} onPress={controller.focus} > - + {addonContent(leftAddon)} diff --git a/src/components/Input/core/InputLabel.tsx b/src/components/Input/core/InputLabel.tsx index 2320eca2..1e900233 100644 --- a/src/components/Input/core/InputLabel.tsx +++ b/src/components/Input/core/InputLabel.tsx @@ -6,7 +6,7 @@ import { SvgUniversal } from '../../../utils/SvgUniversal' import { inputStrings } from './strings' import { styles } from './styles' -import type { InputLabelInfo, InputTextSize } from './types' +import type { InputLabelInfo } from './types' import type { useFloatingLabel } from './useFloatingLabel' import { useInputFontScale } from './useInputFontScale' @@ -21,7 +21,6 @@ interface InputLabelProps extends Pick< readonly isDanger?: boolean readonly floating?: ReturnType readonly hasActions?: boolean - readonly size?: InputTextSize readonly infoTestID?: string readonly testID?: string } @@ -34,7 +33,6 @@ export const InputLabel = ({ isDanger = false, floating, hasActions = false, - size = 'base', infoTestID, testID, ...textProps @@ -52,8 +50,7 @@ export const InputLabel = ({ importantForAccessibility='no-hide-descendants' pointerEvents='none' style={[ - styles.floatText(isDanger), - styles.required, + styles.floatText(isDanger, true), floating.animatedTextStyle, ]} > @@ -124,10 +121,10 @@ export const InputLabel = ({ style={[ styles.floatLabel, { - left: styles[size].paddingHorizontal, + left: styles.floatInput.paddingHorizontal, right: hasActions ? styles.inputWithActions.paddingRight - : styles[size].paddingHorizontal, + : styles.floatInput.paddingHorizontal, }, floating.animatedContainerStyle, ]} diff --git a/src/components/Input/core/styles.ts b/src/components/Input/core/styles.ts index b9728255..a830cba1 100644 --- a/src/components/Input/core/styles.ts +++ b/src/components/Input/core/styles.ts @@ -15,7 +15,12 @@ export const styles = StyleSheet.create(({ components, semantic, fonts }) => ({ floatingFrame: (fontScale: number) => ({ minHeight: semantic.dimension.size[1300], height: fontScale <= 1 ? semantic.dimension.size[1300] : undefined, + borderRadius: semantic.dimension.borderRadius[400], }), + filled: { backgroundColor: components.inputtext.root.filledBackground }, + filledFocused: { + backgroundColor: components.inputtext.root.filledFocusBackground, + }, focused: { borderColor: components.inputtext.root.focusBorderColor, outlineWidth: components.inputtext.root.focusRing.width, @@ -32,6 +37,11 @@ export const styles = StyleSheet.create(({ components, semantic, fonts }) => ({ components.inputtext.root.borderRadius - components.inputtext.extend.borderWidth, }, + floatingFrameClip: { + borderRadius: + semantic.dimension.borderRadius[400] - + components.inputtext.extend.borderWidth, + }, danger: { borderColor: components.inputtext.root.invalidBorderColor }, dangerFocused: { outlineColor: components.inputtext.extend.invalidFocusRing.color, @@ -41,6 +51,7 @@ export const styles = StyleSheet.create(({ components, semantic, fonts }) => ({ disabledText: { color: components.inputtext.root.disabledColor }, control: { flex: 1, minWidth: 0, flexDirection: 'row' }, inputContainer: { flex: 1, minWidth: 0, justifyContent: 'center' }, + floatingInputContainer: { alignSelf: 'center' }, placeholder: { position: 'absolute', left: 0, right: 0 }, placeholderText: { color: components.inputtext.root.placeholderColor }, dangerPlaceholder: { @@ -77,8 +88,10 @@ export const styles = StyleSheet.create(({ components, semantic, fonts }) => ({ }, floatInput: { fontSize: fonts.fontSize[200], + fontWeight: fonts.fontWeight.regular, lineHeight: fonts.lineHeight[200], - paddingTop: semantic.dimension.space[700], + paddingHorizontal: semantic.dimension.space[350], + paddingTop: semantic.dimension.space[300] + fonts.lineHeight[200], paddingBottom: components.floatlabel.inside.input.paddingBottom, }, label: { @@ -112,13 +125,14 @@ export const styles = StyleSheet.create(({ components, semantic, fonts }) => ({ top: components.floatlabel.root.positionY, paddingVertical: semantic.dimension.space.none, }, - floatText: (isDanger: boolean) => ({ + floatText: (isDanger: boolean, isRequired = false) => ({ fontSize: fonts.fontSize[200], - lineHeight: fonts.lineHeight[200], + lineHeight: fonts.lineHeight[400], fontFamily: fonts.fontFamily.base, - color: isDanger - ? components.floatlabel.root.invalidColor - : components.floatlabel.root.color, + color: + isDanger || isRequired + ? components.floatlabel.root.invalidColor + : components.floatlabel.root.color, includeFontPadding: false, flexShrink: 1, }), @@ -126,32 +140,37 @@ export const styles = StyleSheet.create(({ components, semantic, fonts }) => ({ top: semantic.dimension.space[300], paddingVertical: semantic.dimension.space.none, fontSize: components.floatlabel.root.active.fontSize, + lineHeight: fonts.lineHeight[200], fontFamily: fonts.fontFamily.base, }, floatLabelInfo: { width: semantic.dimension.size[400], height: semantic.dimension.size[400], }, - actions: (size: InputTextSize = 'base') => ({ + actions: (size: InputTextSize = 'base', floating = false) => ({ flexDirection: 'row', alignItems: 'stretch', paddingLeft: semantic.dimension.space[100], - paddingRight: { - small: components.inputtext.root.sm.paddingX, - base: components.inputtext.root.paddingX, - large: components.inputtext.root.lg.paddingX, - xlarge: components.inputtext.extend.extXlg.paddingX, - }[size], + paddingRight: floating + ? semantic.dimension.space[350] + : { + small: components.inputtext.root.sm.paddingX, + base: components.inputtext.root.paddingX, + large: components.inputtext.root.lg.paddingX, + xlarge: components.inputtext.extend.extXlg.paddingX, + }[size], gap: semantic.dimension.space[100], }), action: { justifyContent: 'center', alignItems: 'center' }, - icon: (size: InputTextSize = 'base') => { - const iconSize = { - small: semantic.dimension.size[400], - base: components.inputtext.extend.iconSize, - large: semantic.dimension.size[500], - xlarge: semantic.dimension.size[600], - }[size] + icon: (size: InputTextSize = 'base', floating = false) => { + const iconSize = floating + ? semantic.dimension.size[450] + : { + small: semantic.dimension.size[400], + base: components.inputtext.extend.iconSize, + large: semantic.dimension.size[500], + xlarge: semantic.dimension.size[600], + }[size] return { width: iconSize, height: iconSize } }, diff --git a/src/components/Input/core/useFloatingLabel.ts b/src/components/Input/core/useFloatingLabel.ts index a1223c7a..ba00a959 100644 --- a/src/components/Input/core/useFloatingLabel.ts +++ b/src/components/Input/core/useFloatingLabel.ts @@ -7,69 +7,42 @@ import { withTiming, } from 'react-native-reanimated' +import { styles } from './styles' import { useInputFontScale } from './useInputFontScale' -export interface FloatingLabelMetrics { - labelTop: number - reducedTop: number - labelPadding: number - reducedPadding: number - fontSize: number - reducedFontSize: number - fontFamily: string - reducedFontFamily: string - inputPaddingTop: number -} - export const useFloatingLabel = ( active: boolean, - { - allowFontScaling, - maxFontSizeMultiplier, - }: Pick, - metrics: FloatingLabelMetrics + nativeProps: Pick< + TextInputProps, + 'allowFontScaling' | 'maxFontSizeMultiplier' + > ) => { - const scale = useInputFontScale({ allowFontScaling, maxFontSizeMultiplier }) + const scale = useInputFontScale(nativeProps) const [labelHeight, setLabelHeight] = useState(0) const [containerHeight, setContainerHeight] = useState(0) const animation = useSharedValue(0) - const { - labelTop, - reducedTop, - labelPadding, - reducedPadding, - fontSize, - reducedFontSize, - fontFamily, - reducedFontFamily, - } = metrics + const label = styles.floatText(false) + const fontSize = label.fontSize + const lineHeight = label.lineHeight + const reducedFontSize = styles.floatReduced.fontSize + const reducedLineHeight = styles.floatReduced.lineHeight + const reducedTop = styles.floatReduced.top + const centeredOffset = (reducedTop - styles.floatInput.paddingBottom) / 2 const centeredTop = containerHeight - ? (containerHeight - labelHeight) / 2 - : labelTop - - const animatedStyle = useAnimatedStyle(() => ({ - top: interpolate(animation.value, [0, 1], [centeredTop, reducedTop]), - paddingVertical: interpolate( - animation.value, - [0, 1], - [labelPadding, reducedPadding] - ), - fontSize: interpolate(animation.value, [0, 1], [fontSize, reducedFontSize]), - fontFamily: animation.value > 0.5 ? reducedFontFamily : fontFamily, - })) + ? (containerHeight - labelHeight) / 2 + centeredOffset + : styles.floatLabel.top const animatedContainerStyle = useAnimatedStyle(() => ({ top: interpolate(animation.value, [0, 1], [centeredTop, reducedTop]), - paddingVertical: interpolate( - animation.value, - [0, 1], - [labelPadding, reducedPadding] - ), })) const animatedTextStyle = useAnimatedStyle(() => ({ fontSize: interpolate(animation.value, [0, 1], [fontSize, reducedFontSize]), - fontFamily: animation.value > 0.5 ? reducedFontFamily : fontFamily, + lineHeight: interpolate( + animation.value, + [0, 1], + [lineHeight, reducedLineHeight] + ), })) useEffect(() => { @@ -83,20 +56,18 @@ export const useFloatingLabel = ( setContainerHeight(event.nativeEvent.layout.height) }, []) - const reservedHeight = Math.max( - reducedFontSize * scale + reducedPadding * 2, - labelHeight - ) + const reducedHeight = reducedLineHeight * scale + const reservedHeight = + scale <= 1 ? reducedHeight : Math.max(reducedHeight, labelHeight) return { fontScale: scale, - animatedStyle, animatedContainerStyle, animatedTextStyle, onLabelLayout, onContainerLayout, inputPaddingTop: Math.max( - metrics.inputPaddingTop, + styles.floatInput.paddingTop, reducedTop + reservedHeight ), } diff --git a/src/components/Input/deprecated/InputTextBase/InputTextBase.tsx b/src/components/Input/deprecated/InputTextBase/InputTextBase.tsx index 9ae43aa1..bbf4054d 100644 --- a/src/components/Input/deprecated/InputTextBase/InputTextBase.tsx +++ b/src/components/Input/deprecated/InputTextBase/InputTextBase.tsx @@ -3,7 +3,6 @@ import { Pressable, Text, TextInput, View } from 'react-native' import Animated from 'react-native-reanimated' import { useMakeTestId } from '../../../../hooks/useMakeTestId' -import { useFloatingLabel } from '../../core/useFloatingLabel' import { useInputTextController } from '../../core/useInputTextController' import { usePlaceholderLayout } from '../../core/usePlaceholderLayout' @@ -18,6 +17,7 @@ import { inputStyles as styles, useInputContainerMinHeightStyle, } from './useInputStyles' +import { useLegacyFloatingLabel } from './useLegacyFloatingLabel' export const InputTextBase = memo( ({ @@ -46,20 +46,9 @@ export const InputTextBase = memo( const hasRightContent = loading || showClearButton || renderRightAccessory || disabled const containerMinHeightStyle = useInputContainerMinHeightStyle(size) - const floatingLabel = useFloatingLabel( + const floatingLabel = useLegacyFloatingLabel( controller.isFocused || !!controller.value, - otherProps, - { - labelTop: styles.label.top, - reducedTop: styles.labelReducedSize.top, - labelPadding: styles.label.paddingVertical, - reducedPadding: styles.labelReducedSize.paddingVertical, - fontSize: styles.label.fontSize, - reducedFontSize: styles.labelReducedSize.fontSize, - fontFamily: styles.label.fontFamily, - reducedFontFamily: styles.labelReducedSize.fontFamily, - inputPaddingTop: styles.floatLabelInput.paddingTop, - } + otherProps ) const placeholderLayout = usePlaceholderLayout() const { makeTestId } = useMakeTestId( diff --git a/src/components/Input/deprecated/InputTextBase/useLegacyFloatingLabel.ts b/src/components/Input/deprecated/InputTextBase/useLegacyFloatingLabel.ts new file mode 100644 index 00000000..e8ee3a5d --- /dev/null +++ b/src/components/Input/deprecated/InputTextBase/useLegacyFloatingLabel.ts @@ -0,0 +1,71 @@ +import { useCallback, useEffect, useState } from 'react' +import type { LayoutChangeEvent, TextInputProps } from 'react-native' +import { + interpolate, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated' + +import { useInputFontScale } from '../../core/useInputFontScale' + +import { inputStyles as styles } from './useInputStyles' + +export const useLegacyFloatingLabel = ( + active: boolean, + nativeProps: Pick< + TextInputProps, + 'allowFontScaling' | 'maxFontSizeMultiplier' + > +) => { + const scale = useInputFontScale(nativeProps) + const [labelHeight, setLabelHeight] = useState(0) + const [containerHeight, setContainerHeight] = useState(0) + const animation = useSharedValue(0) + const reducedTop = styles.labelReducedSize.top + const labelPadding = styles.label.paddingVertical + const reducedPadding = styles.labelReducedSize.paddingVertical + const fontSize = styles.label.fontSize + const reducedFontSize = styles.labelReducedSize.fontSize + const fontFamily = styles.label.fontFamily + const reducedFontFamily = styles.labelReducedSize.fontFamily + const centeredTop = containerHeight + ? (containerHeight - labelHeight) / 2 + : styles.label.top + + const animatedStyle = useAnimatedStyle(() => ({ + top: interpolate(animation.value, [0, 1], [centeredTop, reducedTop]), + paddingVertical: interpolate( + animation.value, + [0, 1], + [labelPadding, reducedPadding] + ), + fontSize: interpolate(animation.value, [0, 1], [fontSize, reducedFontSize]), + fontFamily: animation.value > 0.5 ? reducedFontFamily : fontFamily, + })) + + useEffect(() => { + animation.value = withTiming(active ? 1 : 0, { duration: 100 }) + }, [active, animation]) + + const onLabelLayout = useCallback((event: LayoutChangeEvent) => { + setLabelHeight(event.nativeEvent.layout.height) + }, []) + const onContainerLayout = useCallback((event: LayoutChangeEvent) => { + setContainerHeight(event.nativeEvent.layout.height) + }, []) + const reservedHeight = Math.max( + reducedFontSize * scale + reducedPadding * 2, + labelHeight + ) + + return { + animatedStyle, + onLabelLayout, + onContainerLayout, + inputPaddingTop: Math.max( + styles.floatLabelInput.paddingTop, + reducedTop + reservedHeight + ), + } +} From a0a71db4a9093c33d8e6e23797c7bea222ccc506 Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Fri, 23 Oct 2026 16:13:17 +0300 Subject: [PATCH 17/17] =?UTF-8?q?refactor(input):=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D0=B5=D0=BD=D1=8B=20=D1=80=D0=B0=D1=81=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0=20=D0=BF=D0=BE=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=B8=20=D0=BE=D1=84=D0=BE=D1=80=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Input/Password/Password.tsx | 62 ++++++---------- .../Password/__tests__/Password.test.tsx | 46 ++++++++++++ src/components/Input/core/InputActions.tsx | 64 ----------------- src/components/Input/core/InputControl.tsx | 43 +++++++----- src/components/Input/core/InputField.tsx | 16 +++-- src/components/Input/core/InputIconButton.tsx | 70 +++++++++++++++++++ .../Input/core/InputTrailingContent.tsx | 17 +++++ src/components/Input/core/styles.ts | 42 +++++------ src/components/Input/core/types.ts | 1 + 9 files changed, 212 insertions(+), 149 deletions(-) delete mode 100644 src/components/Input/core/InputActions.tsx create mode 100644 src/components/Input/core/InputIconButton.tsx create mode 100644 src/components/Input/core/InputTrailingContent.tsx diff --git a/src/components/Input/Password/Password.tsx b/src/components/Input/Password/Password.tsx index a82a1851..a75b9fc6 100644 --- a/src/components/Input/Password/Password.tsx +++ b/src/components/Input/Password/Password.tsx @@ -1,12 +1,9 @@ import { IconEye, IconEyeOff } from '@tabler/icons-react-native' import { memo, useState } from 'react' -import { Pressable } from 'react-native' - -import { SvgUniversal } from '../../../utils/SvgUniversal' import { InputField } from '../core/InputField' +import { InputIconButton } from '../core/InputIconButton' import { inputStrings } from '../core/strings' -import { styles } from '../core/styles' import type { PasswordProps } from './types' @@ -30,51 +27,36 @@ export const Password = memo( }) => { const [visible, setVisible] = useState(false) const Icon = visible ? IconEyeOff : IconEye - const actionGap = styles.actions(props.size).gap return ( setVisible((current) => !current)} - > - { - const iconStyle = styles.icon(props.size) - - return { - width: iconStyle.width, - height: iconStyle.height, - color: components.password.icon.color, - } - }} - /> - - ) : undefined - } autoCapitalize={autoCapitalize} autoCorrect={autoCorrect} clearable={clearable} disabled={disabled} multiline={false} + renderTrailingAction={ + toggleMask + ? (variant) => ( + + components.password.icon.color + } + icon={Icon} + testID={`${testID}ToggleMask`} + variant={variant} + onPress={() => setVisible((current) => !current)} + /> + ) + : undefined + } secureTextEntry={!visible} testID={testID} /> diff --git a/src/components/Input/Password/__tests__/Password.test.tsx b/src/components/Input/Password/__tests__/Password.test.tsx index b668730f..1ad6c179 100644 --- a/src/components/Input/Password/__tests__/Password.test.tsx +++ b/src/components/Input/Password/__tests__/Password.test.tsx @@ -125,6 +125,52 @@ describe('Password', () => { ).not.toBeOnTheScreen() }) + test('разделяет очистку и переключение маски в поле с плавающей подписью', async () => { + const onChangeText = jest.fn() + const { getByRole, getByTestId, queryByRole, rerender } = render( + + ) + + await userEvent.press(getByRole('button', { name: 'Показать пароль' })) + + expect(getByTestId('Password')).toHaveProp('secureTextEntry', false) + expect(onChangeText).not.toHaveBeenCalled() + + await userEvent.press(getByRole('button', { name: 'Очистить поле' })) + + expect(onChangeText).toHaveBeenCalledOnce() + expect(onChangeText).toHaveBeenCalledWith('') + expect(getByTestId('Password')).toHaveDisplayValue('secret') + expect(getByTestId('Password')).toHaveProp('secureTextEntry', false) + + rerender( + + ) + + expect( + queryByRole('button', { name: 'Очистить поле' }) + ).not.toBeOnTheScreen() + expect(getByTestId('Password')).toHaveDisplayValue('') + expect(getByTestId('Password')).toHaveProp('secureTextEntry', false) + + await userEvent.press(getByRole('button', { name: 'Скрыть пароль' })) + + expect(getByTestId('Password')).toHaveProp('secureTextEntry', true) + expect(onChangeText).toHaveBeenCalledOnce() + }) + test('disabled блокирует переключение без сброса видимости, readOnly сохраняет действие', async () => { const { getByRole, rerender, getByTestId } = render( diff --git a/src/components/Input/core/InputActions.tsx b/src/components/Input/core/InputActions.tsx deleted file mode 100644 index 50047126..00000000 --- a/src/components/Input/core/InputActions.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { IconX } from '@tabler/icons-react-native' -import type { ReactNode } from 'react' -import { Pressable, View } from 'react-native' - -import { SvgUniversal } from '../../../utils/SvgUniversal' - -import { inputStrings } from './strings' -import { styles } from './styles' -import type { InputTextSize } from './types' - -interface InputActionsProps { - readonly isClearVisible: boolean - readonly isFloating: boolean - readonly size: InputTextSize - readonly onClear: () => void - readonly children?: ReactNode - readonly testID: string -} - -export const InputActions = ({ - isClearVisible, - isFloating, - size, - onClear, - children, - testID, -}: InputActionsProps) => { - const actionStyle = styles.actions(size, isFloating) - - return ( - - {isClearVisible ? ( - - { - const iconStyle = styles.icon(size, isFloating) - - return { - width: iconStyle.width, - height: iconStyle.height, - color: components.inputtext.root.color, - } - }} - /> - - ) : null} - {children} - - ) -} diff --git a/src/components/Input/core/InputControl.tsx b/src/components/Input/core/InputControl.tsx index 31381ed1..3e7f5f55 100644 --- a/src/components/Input/core/InputControl.tsx +++ b/src/components/Input/core/InputControl.tsx @@ -1,16 +1,19 @@ +import { IconX } from '@tabler/icons-react-native' import type { ReactNode } from 'react' import { TextInput, View, type TextInputProps } from 'react-native' import { useMakeTestId } from '../../../hooks/useMakeTestId' -import { InputActions } from './InputActions' +import { InputIconButton } from './InputIconButton' import { InputLabel } from './InputLabel' import { InputPlaceholder } from './InputPlaceholder' +import { InputTrailingContent } from './InputTrailingContent' import { inputStrings } from './strings' import { styles } from './styles' import type { InputLabelProps, InputNativeProps, + InputVisualVariant, RenderTextInputArgs, } from './types' import { useFloatingLabel } from './useFloatingLabel' @@ -21,7 +24,6 @@ type InputControlProps = Pick< InputNativeProps & InputLabelProps, | 'label' | 'required' - | 'size' | 'disabled' | 'clearable' | 'inputStyle' @@ -30,10 +32,10 @@ type InputControlProps = Pick< readonly controller: ReturnType readonly nativeProps: TextInputProps readonly isEditable: boolean - readonly isFloating: boolean + readonly variant: InputVisualVariant readonly isDanger: boolean readonly testID: string - readonly action?: ReactNode + readonly trailingAction?: ReactNode } const renderNativeInput = ({ inputRef, ...props }: RenderTextInputArgs) => ( @@ -70,7 +72,6 @@ const getAccessibilityLabel = ({ export const InputControl = ({ label, required, - size = 'base', disabled, clearable, inputStyle, @@ -78,22 +79,22 @@ export const InputControl = ({ controller, nativeProps, isEditable, - isFloating, + variant, isDanger, testID, - action, + trailingAction, }: InputControlProps) => { const { makeTestId } = useMakeTestId(testID) const active = controller.isFocused || !!controller.value const isClearVisible = !!clearable && !!controller.value && isEditable - const hasActions = isClearVisible || !!action + const isFloating = variant === 'floating' + const hasActions = isClearVisible || !!trailingAction const placeholderLayout = usePlaceholderLayout() const floatingLabel = useFloatingLabel(active, nativeProps) const textStyle = [ styles.input, - styles[size], + isFloating ? styles.floatInput : styles[variant], disabled && styles.disabledText, - isFloating && styles.floatInput, hasActions && styles.inputWithActions, isFloating && { paddingTop: floatingLabel.inputPaddingTop }, inputStyle, @@ -154,15 +155,19 @@ export const InputControl = ({ {(renderTextInput ?? renderNativeInput)(textInputProps)} {hasActions ? ( - - {action} - + + {isClearVisible ? ( + components.inputtext.root.color} + icon={IconX} + testID={makeTestId('ClearButton')} + variant={variant} + onPress={controller.clear} + /> + ) : null} + {trailingAction} + ) : null} ) diff --git a/src/components/Input/core/InputField.tsx b/src/components/Input/core/InputField.tsx index ea6a8e7a..84cb6376 100644 --- a/src/components/Input/core/InputField.tsx +++ b/src/components/Input/core/InputField.tsx @@ -9,7 +9,11 @@ import { InputControl } from './InputControl' import { InputGroupContext } from './InputGroupContext' import { InputLabel } from './InputLabel' import { styles } from './styles' -import type { InputLabelProps, InputNativeProps } from './types' +import type { + InputLabelProps, + InputNativeProps, + InputVisualVariant, +} from './types' import { useInputFontScale } from './useInputFontScale' import { useInputTextController } from './useInputTextController' @@ -17,7 +21,7 @@ type InputFieldProps = InputNativeProps & InputLabelProps & { readonly leftAddon?: ReactNode readonly rightAddon?: ReactNode - readonly action?: ReactNode + readonly renderTrailingAction?: (variant: InputVisualVariant) => ReactNode } const addonContent = (node: ReactNode) => { @@ -79,7 +83,7 @@ export const InputField = ({ renderTextInput, leftAddon, rightAddon, - action, + renderTrailingAction, readOnly = false, testID = 'InputText', ...nativeProps @@ -87,6 +91,7 @@ export const InputField = ({ const controller = useInputTextController({ ...nativeProps, inputRef: ref }) const isEditable = !disabled && !readOnly const floating = floatLabel !== undefined + const variant = floating ? 'floating' : size const fontScale = useInputFontScale(nativeProps) const labelText = floatLabel ?? label const danger = state === 'danger' @@ -136,20 +141,19 @@ export const InputField = ({ {addonContent(leftAddon)} {addonContent(rightAddon)} diff --git a/src/components/Input/core/InputIconButton.tsx b/src/components/Input/core/InputIconButton.tsx new file mode 100644 index 00000000..695e7b5a --- /dev/null +++ b/src/components/Input/core/InputIconButton.tsx @@ -0,0 +1,70 @@ +import { Pressable } from 'react-native' +import { useUnistyles } from 'react-native-unistyles' + +import { + SvgUniversal, + type SvgSource, + type SvgUniversalProps, + type SvgUniversalTheme, +} from '../../../utils/SvgUniversal' + +import { styles } from './styles' +import type { InputVisualVariant } from './types' + +interface InputIconButtonProps { + readonly variant: InputVisualVariant + readonly icon: SvgSource + readonly getIconColor: ( + theme: SvgUniversalTheme + ) => SvgUniversalProps['color'] + readonly accessibilityLabel: string + readonly disabled?: boolean + readonly testID: string + readonly onPress: () => void +} + +export const InputIconButton = ({ + variant, + icon, + getIconColor, + accessibilityLabel, + disabled = false, + testID, + onPress, +}: InputIconButtonProps) => { + const { theme } = useUnistyles() + const horizontalHitSlop = theme.semantic.dimension.space[100] + const verticalHitSlop = theme.semantic.dimension.space[200] + + return ( + + { + const iconStyle = styles.icon(variant) + + return { + width: iconStyle.width, + height: iconStyle.height, + color: getIconColor(theme), + } + }} + /> + + ) +} diff --git a/src/components/Input/core/InputTrailingContent.tsx b/src/components/Input/core/InputTrailingContent.tsx new file mode 100644 index 00000000..542270e4 --- /dev/null +++ b/src/components/Input/core/InputTrailingContent.tsx @@ -0,0 +1,17 @@ +import type { ReactNode } from 'react' +import { View } from 'react-native' + +import { styles } from './styles' +import type { InputVisualVariant } from './types' + +interface InputTrailingContentProps { + readonly variant: InputVisualVariant + readonly children: ReactNode +} + +export const InputTrailingContent = ({ + variant, + children, +}: InputTrailingContentProps) => ( + {children} +) diff --git a/src/components/Input/core/styles.ts b/src/components/Input/core/styles.ts index a830cba1..49e98534 100644 --- a/src/components/Input/core/styles.ts +++ b/src/components/Input/core/styles.ts @@ -1,6 +1,6 @@ import { StyleSheet } from 'react-native-unistyles' -import type { InputTextSize } from './types' +import type { InputVisualVariant } from './types' export const styles = StyleSheet.create(({ components, semantic, fonts }) => ({ root: { gap: semantic.dimension.space[100] }, @@ -147,30 +147,32 @@ export const styles = StyleSheet.create(({ components, semantic, fonts }) => ({ width: semantic.dimension.size[400], height: semantic.dimension.size[400], }, - actions: (size: InputTextSize = 'base', floating = false) => ({ + trailingContent: (variant: InputVisualVariant) => ({ flexDirection: 'row', alignItems: 'stretch', paddingLeft: semantic.dimension.space[100], - paddingRight: floating - ? semantic.dimension.space[350] - : { - small: components.inputtext.root.sm.paddingX, - base: components.inputtext.root.paddingX, - large: components.inputtext.root.lg.paddingX, - xlarge: components.inputtext.extend.extXlg.paddingX, - }[size], + paddingRight: + variant === 'floating' + ? semantic.dimension.space[350] + : { + small: components.inputtext.root.sm.paddingX, + base: components.inputtext.root.paddingX, + large: components.inputtext.root.lg.paddingX, + xlarge: components.inputtext.extend.extXlg.paddingX, + }[variant], gap: semantic.dimension.space[100], }), - action: { justifyContent: 'center', alignItems: 'center' }, - icon: (size: InputTextSize = 'base', floating = false) => { - const iconSize = floating - ? semantic.dimension.size[450] - : { - small: semantic.dimension.size[400], - base: components.inputtext.extend.iconSize, - large: semantic.dimension.size[500], - xlarge: semantic.dimension.size[600], - }[size] + iconButton: { justifyContent: 'center', alignItems: 'center' }, + icon: (variant: InputVisualVariant = 'base') => { + const iconSize = + variant === 'floating' + ? semantic.dimension.size[450] + : { + small: semantic.dimension.size[400], + base: components.inputtext.extend.iconSize, + large: semantic.dimension.size[500], + xlarge: semantic.dimension.size[600], + }[variant] return { width: iconSize, height: iconSize } }, diff --git a/src/components/Input/core/types.ts b/src/components/Input/core/types.ts index f1f0cd70..d448d916 100644 --- a/src/components/Input/core/types.ts +++ b/src/components/Input/core/types.ts @@ -9,6 +9,7 @@ import type { } from 'react-native' export type InputTextSize = 'small' | 'base' | 'large' | 'xlarge' +export type InputVisualVariant = InputTextSize | 'floating' export type InputState = 'default' | 'danger' export type RenderTextInputArgs = TextInputProps & { inputRef: Ref }