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/inputDocumentation.ts b/.storybook/inputDocumentation.ts new file mode 100644 index 00000000..d9eb156e --- /dev/null +++ b/.storybook/inputDocumentation.ts @@ -0,0 +1,8 @@ +const documentationRoot = + 'https://github.com/cdek-it/react-native-ui-kit/blob/main/src/components/Input' + +export const inputDocumentationNotes = { + InputGroup: `[Документация InputGroup](${documentationRoot}/InputGroup/InputGroup.md)`, + Password: `[Документация Password](${documentationRoot}/Password/Password.md)`, + InputCaption: `[Документация InputCaption](${documentationRoot}/InputCaption/InputCaption.md)`, +} 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/.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/README.md b/README.md index c7e4c615..f3a0cd2d 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,9 @@ Документация компонентов: +- [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/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/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/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/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/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 new file mode 100644 index 00000000..b0ba46f5 --- /dev/null +++ b/src/components/Input/InputCaption/InputCaption.stories.tsx @@ -0,0 +1,32 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { inputDocumentationNotes } from '../../../../.storybook/inputDocumentation' + +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: { + notes: inputDocumentationNotes.InputCaption, + 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..a224bb83 --- /dev/null +++ b/src/components/Input/InputCaption/InputCaption.tsx @@ -0,0 +1,100 @@ +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..5d2b5991 --- /dev/null +++ b/src/components/Input/InputCaption/__tests__/InputCaption.test.tsx @@ -0,0 +1,98 @@ +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.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( + + Проверьте адрес + + ) + + 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/InputGroup/InputGroup.md b/src/components/Input/InputGroup/InputGroup.md new file mode 100644 index 00000000..265294ad --- /dev/null +++ b/src/components/Input/InputGroup/InputGroup.md @@ -0,0 +1,43 @@ +# InputGroup + +Поле ввода с дополнениями слева и справа: единицами измерения, SVG или +действиями. + +## Использование + +```tsx +import { InputGroup, InputGroupAddon } from '@cdek-it/react-native-ui-kit' + +export function OrderField({ onSearch }: { onSearch: () => void }) { + return ( + Найти} + /> + ) +} +``` + +Строки и числа оформляются автоматически. Для SVG и действий доступен +[InputGroupAddon](./InputGroupAddon.md). В примере `onSearch` — обработчик +приложения. + +## Props + +- `leftAddon` и `rightAddon` — необязательные дополнения слева и справа + (`ReactNode`). + +Остальные props, обработчики и `ref` — как у +[InputText](../InputText/InputText.md). + +Режимы подписей — в разделе +[«Режимы label»](../InputText/InputText.md#режимы-label). + +Полный контракт — [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 new file mode 100644 index 00000000..f3702d62 --- /dev/null +++ b/src/components/Input/InputGroup/InputGroup.stories.tsx @@ -0,0 +1,168 @@ +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 { inputDocumentationNotes } from '../../../../.storybook/inputDocumentation' +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: { + notes: inputDocumentationNotes.InputGroup, + 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.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/InputGroup/InputGroupAddon.tsx b/src/components/Input/InputGroup/InputGroupAddon.tsx new file mode 100644 index 00000000..deb973f4 --- /dev/null +++ b/src/components/Input/InputGroup/InputGroupAddon.tsx @@ -0,0 +1,118 @@ +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 Omit< + PressableProps, + 'aria-disabled' +> { + /** Имя для скринридера; по умолчанию берётся из строкового или числового 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__/InputGroup.test.tsx b/src/components/Input/InputGroup/__tests__/InputGroup.test.tsx new file mode 100644 index 00000000..50607f8a --- /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/__tests__/InputGroupAddon.test.tsx b/src/components/Input/InputGroup/__tests__/InputGroupAddon.test.tsx new file mode 100644 index 00000000..c33658c8 --- /dev/null +++ b/src/components/Input/InputGroup/__tests__/InputGroupAddon.test.tsx @@ -0,0 +1,117 @@ +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('вычисляет доступный 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 }, + { 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/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/InputText/InputText.md b/src/components/Input/InputText/InputText.md new file mode 100644 index 00000000..f8210984 --- /dev/null +++ b/src/components/Input/InputText/InputText.md @@ -0,0 +1,87 @@ +# InputText + +Текстовое поле с постоянной или плавающей подписью и дополнительным сообщением. + +## Использование + +```tsx +import { useState } from 'react' +import { InputText } from '@cdek-it/react-native-ui-kit' + +export function InputFields({ showInfo }: { showInfo: () => void }) { + const [phone, setPhone] = useState('') + + return ( + <> + + + + ) +} +``` + +`showInfo` — обработчик приложения, например для показа подсказки. + +С `value` обновляйте значение через `onChangeText`. Без `value` поле хранит его +самостоятельно, а `defaultValue` задаёт начальное значение. + +## Режимы label + +- `label` — постоянная подпись над полем. +- `floatLabel` — подпись внутри поля, поднимающаяся при фокусе или наличии + текста. +- `labelInfo` — делает постоянный `label` кнопкой с иконкой ⓘ и обработчиком + `labelInfo.onPress`. С `floatLabel` недоступен. + +`label` и `floatLabel` взаимоисключающие и необязательные. Без них место под +подпись не резервируется. С `floatLabel` отдельный `placeholder` появляется при +фокусе пустого поля. Дублировать в нём текст подписи не требуется. + +## Props + +- `caption` — дополнительное сообщение (`ReactNode`). Для статуса и иконки можно + использовать [InputCaption](../InputCaption/InputCaption.md). Строка и число + оформляются автоматически, пустая строка не занимает место. Статус подписи + задаётся независимо от `state` поля. +- `state` — `default | danger`, по умолчанию `default`. Ошибка не запрещает + ввод. +- `disabled` — отключает ввод и встроенные действия. `readOnly` запрещает ввод и + очистку, сохраняя остальные действия. Приоритет у `disabled`. +- `required` — добавляет маркер обязательности и описание для скринридера. +- `size` — `small | base | large | xlarge`, по умолчанию `base`. С `floatLabel` + высота и типографика не зависят от `size` согласно макетам. +- `clearable` — включает очистку непустого редактируемого поля, по умолчанию + `true`. +- `inputStyle` — стиль нативного ввода. `style` применяется к внешнему + контейнеру. +- `ref` — методы RN TextInput. +- `renderTextInput` — адаптер ввода. Полученные props и `inputRef` необходимы + для подключения собственного TextInput. + +Нативные props TextInput поддерживаются с ограничениями из +[InputTextProps](./types.ts). Для запрета редактирования используется +`readOnly`, а `editable` исключён из API. + +## Доступность + +Имя поля определяется первым заданным значением: `aria-label`, +`accessibilityLabel`, `label`, `floatLabel`, `placeholder`. Обязательность и +ошибка дополняют имя. + +Подпись без действия отдельно не озвучивается. При `labelInfo` она доступна +кнопкой «Текст label. Подробнее» и сохраняется в имени поля. Название действия +«Подробнее» меняется через `labelInfo.accessibilityLabel`. + +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 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..59649cb2 --- /dev/null +++ b/src/components/Input/InputText/__tests__/InputTextAccessibility.test.tsx @@ -0,0 +1,155 @@ +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: 'Email. Подробнее' }) + + expect(button).toHaveProp('accessibilityState', { disabled }) + + await userEvent.press(button) + + expect(onPress).toHaveBeenCalledTimes(disabled ? 0 : 1) + } + ) + + test('различает контекст и testID кнопок info у разных полей', () => { + const { getByRole } = render( + <> + + + + ) + + expect(getByRole('button', { name: 'Email. Подробнее' })).toHaveProp( + 'testID', + 'EmailInputLabelInfo' + ) + 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 new file mode 100644 index 00000000..2886838f --- /dev/null +++ b/src/components/Input/InputText/__tests__/InputTextApi.test.tsx @@ -0,0 +1,324 @@ +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, queryByRole, getByTestId } = render( + + Неверный номер + + } + label='Телефон' + placeholder='+7 999' + /> + ) + + expect(getByTestId('InputText')).toHaveProp( + 'accessibilityLabel', + 'Телефон, обязательное поле' + ) + expect( + getByText('+7 999', { includeHiddenElements: true }) + ).toBeOnTheScreen() + expect( + getByText('Неверный номер', { includeHiddenElements: true }) + ).toBeOnTheScreen() + expect(queryByRole('button')).not.toBeOnTheScreen() + }) + + test('отображает caption и обычный label, сохраняя имя поля для скринридера', () => { + const { getByLabelText, getByText, queryByRole } = render( + + ) + + expect(getByLabelText('Контактный телефон')).toHaveDisplayValue('') + expect(queryByRole('text', { name: 'Телефон' })).toBeNull() + expect( + getByText('Телефон', { includeHiddenElements: true }) + ).toBeOnTheScreen() + expect( + getByText('Получит уведомление', { includeHiddenElements: true }) + ).toBeOnTheScreen() + }) + + test('инициализирует defaultValue один раз и обновляет неконтролируемое поле', async () => { + const onChangeText = jest.fn() + const { getByRole, rerender, getByTestId } = render( + + ) + + expect(getByTestId('InputText')).toHaveDisplayValue('Москва') + + fireEvent.changeText(getByTestId('InputText'), 'Казань') + rerender( + + ) + + expect(getByTestId('InputText')).toHaveDisplayValue('Казань') + + await userEvent.press(getByRole('button', { name: 'Очистить поле' })) + + 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 { getByLabelText, getByTestId, rerender } = render( + + ) + const input = getByLabelText('Email') + + 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(getByLabelText('Email')).toHaveDisplayValue('a@b.ru') + expect(getByTestId('InputTextFloatingLabel')).toBeOnTheScreen() + }) + + test.each([ + { 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, disabled }) => { + const { queryByRole, getByTestId } = render( + ( + + )} + value='Текст' + /> + ) + + expect(getByTestId('InputText')).toHaveProp('editable', false) + expect(getByTestId('InputText')).toHaveProp('accessibilityState', { + disabled, + }) + expect(queryByRole('button')).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 { getByRole, queryByRole, rerender, getByTestId } = render( + + ) + + expect(queryByRole('button', { name: 'Очистить поле' })).toBeNull() + + rerender( + + ) + await userEvent.press(getByRole('button', { name: 'Очистить поле' })) + + expect(onChangeText).toHaveBeenCalledOnce() + expect(onChangeText).toHaveBeenCalledWith('') + expect(getByTestId('InputText')).toHaveDisplayValue('Москва') + + rerender() + + expect(queryByRole('button', { name: 'Очистить поле' })).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, queryByRole } = 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(queryByRole('button', { name: 'Очистить поле' })).toBeNull() + }) + + test('убирает пустой caption и сохраняет число 0 при обновлении подписи', () => { + const { getByText, queryByTestId, rerender } = render( + + ) + + expect( + getByText('Описание', { includeHiddenElements: true }) + ).toBeOnTheScreen() + + rerender() + + expect(queryByTestId('InputCaption')).toBeNull() + + rerender() + + 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 new file mode 100644 index 00000000..781d6942 --- /dev/null +++ b/src/components/Input/InputText/__tests__/InputTextMasked.test.tsx @@ -0,0 +1,227 @@ +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, + 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 + + rerender( + + ) + + expect(getByTestId('MaskedLabelStoryFloatingLabel')).toHaveTextContent( + 'Телефон' + ) + expect(getByLabelText('Телефон')).toHaveDisplayValue('+7 (900) 123-45-67') + expect(queryByRole('button', { name: 'Телефон. Подробнее' })).toBeNull() + 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/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/Password/Password.md b/src/components/Input/Password/Password.md new file mode 100644 index 00000000..469de1c7 --- /dev/null +++ b/src/components/Input/Password/Password.md @@ -0,0 +1,46 @@ +# 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. + +Режимы подписей — в разделе +[«Режимы label»](../InputText/InputText.md#режимы-label). + +Полный контракт — [PasswordProps](./types.ts). + +## Автозаполнение + +Для текущего пароля задайте `autoComplete='current-password'` и +`textContentType='password'`. Для нового — `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 new file mode 100644 index 00000000..2a999d2b --- /dev/null +++ b/src/components/Input/Password/Password.stories.tsx @@ -0,0 +1,129 @@ +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, + 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: { + notes: inputDocumentationNotes.Password, + 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..a75b9fc6 --- /dev/null +++ b/src/components/Input/Password/Password.tsx @@ -0,0 +1,65 @@ +import { IconEye, IconEyeOff } from '@tabler/icons-react-native' +import { memo, useState } from 'react' + +import { InputField } from '../core/InputField' +import { InputIconButton } from '../core/InputIconButton' +import { inputStrings } from '../core/strings' + +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 + + return ( + ( + + 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 new file mode 100644 index 00000000..1ad6c179 --- /dev/null +++ b/src/components/Input/Password/__tests__/Password.test.tsx @@ -0,0 +1,196 @@ +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('разделяет очистку и переключение маски в поле с плавающей подписью', 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( + + ) + 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/__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/InputControl.tsx b/src/components/Input/core/InputControl.tsx new file mode 100644 index 00000000..3e7f5f55 --- /dev/null +++ b/src/components/Input/core/InputControl.tsx @@ -0,0 +1,174 @@ +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 { 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' +import type { useInputTextController } from './useInputTextController' +import { usePlaceholderLayout } from './usePlaceholderLayout' + +type InputControlProps = Pick< + InputNativeProps & InputLabelProps, + | 'label' + | 'required' + | 'disabled' + | 'clearable' + | 'inputStyle' + | 'renderTextInput' +> & { + readonly controller: ReturnType + readonly nativeProps: TextInputProps + readonly isEditable: boolean + readonly variant: InputVisualVariant + readonly isDanger: boolean + readonly testID: string + readonly trailingAction?: ReactNode +} + +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, + disabled, + clearable, + inputStyle, + renderTextInput, + controller, + nativeProps, + isEditable, + variant, + isDanger, + testID, + trailingAction, +}: InputControlProps) => { + const { makeTestId } = useMakeTestId(testID) + const active = controller.isFocused || !!controller.value + const isClearVisible = !!clearable && !!controller.value && isEditable + const isFloating = variant === 'floating' + const hasActions = isClearVisible || !!trailingAction + const placeholderLayout = usePlaceholderLayout() + const floatingLabel = useFloatingLabel(active, nativeProps) + const textStyle = [ + styles.input, + isFloating ? styles.floatInput : styles[variant], + disabled && styles.disabledText, + hasActions && styles.inputWithActions, + isFloating && { paddingTop: floatingLabel.inputPaddingTop }, + inputStyle, + ] + const { 'aria-label': _ariaLabel, ...inputNativeProps } = nativeProps + const textInputProps: RenderTextInputArgs = { + ...inputNativeProps, + 'aria-disabled': disabled, + accessibilityLabel: getAccessibilityLabel({ + nativeProps, + label, + required, + isDanger, + }), + 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 ? ( + + {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 new file mode 100644 index 00000000..84cb6376 --- /dev/null +++ b/src/components/Input/core/InputField.tsx @@ -0,0 +1,166 @@ +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, + InputVisualVariant, +} from './types' +import { useInputFontScale } from './useInputFontScale' +import { useInputTextController } from './useInputTextController' + +type InputFieldProps = InputNativeProps & + InputLabelProps & { + readonly leftAddon?: ReactNode + readonly rightAddon?: ReactNode + readonly renderTrailingAction?: (variant: InputVisualVariant) => 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, + 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, + 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, + renderTrailingAction, + readOnly = false, + testID = 'InputText', + ...nativeProps +}: InputFieldProps) => { + 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' + 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/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/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/InputLabel.tsx b/src/components/Input/core/InputLabel.tsx new file mode 100644 index 00000000..1e900233 --- /dev/null +++ b/src/components/Input/core/InputLabel.tsx @@ -0,0 +1,167 @@ +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 } from './types' +import type { useFloatingLabel } from './useFloatingLabel' +import { useInputFontScale } from './useInputFontScale' + +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 infoTestID?: string + readonly testID?: string +} + +export const InputLabel = ({ + label, + labelInfo, + disabled = false, + isRequired, + isDanger = false, + floating, + hasActions = false, + infoTestID, + testID, + ...textProps +}: InputLabelProps) => { + const fontScale = useInputFontScale(textProps) + const content = ( + <> + {floating ? ( + <> + {isRequired ? ( + + * + + ) : null} + 1 ? undefined : 1} + pointerEvents='none' + style={[styles.floatText(isDanger), floating.animatedTextStyle]} + > + {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} + + ) + } + + if (labelInfo) { + 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/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/__tests__/InputAccessibility.test.tsx b/src/components/Input/core/__tests__/InputAccessibility.test.tsx new file mode 100644 index 00000000..7159142a --- /dev/null +++ b/src/components/Input/core/__tests__/InputAccessibility.test.tsx @@ -0,0 +1,178 @@ +import { render } from '@testing-library/react-native' + +import { InputText, InputGroup, Password } from '../..' + +const components = [ + { name: 'InputText', Component: InputText }, + { name: 'InputGroup', Component: InputGroup }, + { name: 'Password', Component: Password }, +] + +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' } }, + { + 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/__tests__/InputFloatingLabel.test.tsx b/src/components/Input/core/__tests__/InputFloatingLabel.test.tsx new file mode 100644 index 00000000..95b07863 --- /dev/null +++ b/src/components/Input/core/__tests__/InputFloatingLabel.test.tsx @@ -0,0 +1,129 @@ +import { fireEvent, render, userEvent } from '@testing-library/react-native' +import type { useWindowDimensions } from 'react-native' + +import { InputText, type InputTextSize } from '../..' + +const ReactNative = jest.requireActual<{ + useWindowDimensions: typeof useWindowDimensions +}>('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 new file mode 100644 index 00000000..4613ea0f --- /dev/null +++ b/src/components/Input/core/__tests__/InputLabel.test.tsx @@ -0,0 +1,177 @@ +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)( + 'передаёт настройки масштабирования обычной подписи в $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 }) => { + 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 new file mode 100644 index 00000000..60bbf225 --- /dev/null +++ b/src/components/Input/core/strings.ts @@ -0,0 +1,8 @@ +export const inputStrings = { + labelInfoButton: 'Подробнее', + clearButton: 'Очистить поле', + requiredField: 'обязательное поле', + fieldError: 'ошибка', + showPassword: 'Показать пароль', + hidePassword: 'Скрыть пароль', +} as const diff --git a/src/components/Input/core/styles.ts b/src/components/Input/core/styles.ts new file mode 100644 index 00000000..49e98534 --- /dev/null +++ b/src/components/Input/core/styles.ts @@ -0,0 +1,179 @@ +import { StyleSheet } from 'react-native-unistyles' + +import type { InputVisualVariant } from './types' + +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, + }, + 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, + 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, + }, + floatingFrameClip: { + borderRadius: + semantic.dimension.borderRadius[400] - + 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' }, + floatingInputContainer: { alignSelf: '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, + }, + floatInput: { + fontSize: fonts.fontSize[200], + fontWeight: fonts.fontWeight.regular, + lineHeight: fonts.lineHeight[200], + paddingHorizontal: semantic.dimension.space[350], + paddingTop: semantic.dimension.space[300] + fonts.lineHeight[200], + 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, isRequired = false) => ({ + fontSize: fonts.fontSize[200], + lineHeight: fonts.lineHeight[400], + fontFamily: fonts.fontFamily.base, + color: + isDanger || isRequired + ? components.floatlabel.root.invalidColor + : components.floatlabel.root.color, + includeFontPadding: false, + flexShrink: 1, + }), + floatReduced: { + 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], + }, + trailingContent: (variant: InputVisualVariant) => ({ + flexDirection: 'row', + alignItems: 'stretch', + paddingLeft: semantic.dimension.space[100], + 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], + }), + 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 new file mode 100644 index 00000000..d448d916 --- /dev/null +++ b/src/components/Input/core/types.ts @@ -0,0 +1,114 @@ +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 InputVisualVariant = InputTextSize | 'floating' +export type InputState = 'default' | 'danger' +export type RenderTextInputArgs = TextInputProps & { inputRef: Ref } + +export interface InputLabelInfo { + /** Обработчик нажатия на label с иконкой ⓘ */ + onPress: NonNullable + /** + * Название действия для скринридера; добавляется после текста label + * @default Подробнее + */ + accessibilityLabel?: string +} + +export type InputLabelProps = + | { + /** Постоянная подпись над полем; скринридер читает её в имени поля */ + 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' | 'aria-disabled' +> { + /** Методы TextInput; clear() вызывает onChangeText('') с учётом controlled-режима. */ + ref?: Ref + /** + * Имя для скринридера; по умолчанию label, floatLabel или placeholder + * При required добавляется «обязательное поле», при danger — «ошибка» + */ + accessibilityLabel?: TextInputProps['accessibilityLabel'] + /** + * Строка или число оборачиваются в InputCaption; нода отображается как передана. + * Для статуса подписи передайте InputCaption с нужным variant. + * Подпись доступна скринридеру отдельно от поля + */ + caption?: ReactNode + /** + * Показывает * у подписи и добавляет «обязательное поле» к имени поля для скринридера + */ + required?: boolean + /** + * С floatLabel высота и типографика не зависят от size согласно макету + * Увеличенный системный шрифт может увеличивать высоту поля + * @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/core/useFloatingLabel.ts b/src/components/Input/core/useFloatingLabel.ts new file mode 100644 index 00000000..ba00a959 --- /dev/null +++ b/src/components/Input/core/useFloatingLabel.ts @@ -0,0 +1,74 @@ +import { useCallback, useEffect, useState } from 'react' +import type { LayoutChangeEvent, TextInputProps } from 'react-native' +import { + interpolate, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated' + +import { styles } from './styles' +import { useInputFontScale } from './useInputFontScale' + +export const useFloatingLabel = ( + 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 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 + centeredOffset + : styles.floatLabel.top + + const animatedContainerStyle = useAnimatedStyle(() => ({ + top: interpolate(animation.value, [0, 1], [centeredTop, reducedTop]), + })) + const animatedTextStyle = useAnimatedStyle(() => ({ + fontSize: interpolate(animation.value, [0, 1], [fontSize, reducedFontSize]), + lineHeight: interpolate( + animation.value, + [0, 1], + [lineHeight, reducedLineHeight] + ), + })) + + 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 reducedHeight = reducedLineHeight * scale + const reservedHeight = + scale <= 1 ? reducedHeight : Math.max(reducedHeight, labelHeight) + + return { + fontScale: scale, + animatedContainerStyle, + animatedTextStyle, + onLabelLayout, + onContainerLayout, + inputPaddingTop: Math.max( + styles.floatInput.paddingTop, + reducedTop + reservedHeight + ), + } +} 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 + ) +} 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..bbf4054d --- /dev/null +++ b/src/components/Input/deprecated/InputTextBase/InputTextBase.tsx @@ -0,0 +1,161 @@ +import { memo } from 'react' +import { Pressable, Text, TextInput, View } from 'react-native' +import Animated from 'react-native-reanimated' + +import { useMakeTestId } from '../../../../hooks/useMakeTestId' +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' +import { useLegacyFloatingLabel } from './useLegacyFloatingLabel' + +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 = useLegacyFloatingLabel( + controller.isFocused || !!controller.value, + otherProps + ) + 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/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 + ), + } +} 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 fc5a27d3..1f9a252a 100644 --- a/src/components/Input/index.ts +++ b/src/components/Input/index.ts @@ -1,9 +1,19 @@ -export { InputGroup } from './InputGroup' -export { InputText } from './InputText' -export { InputSwitch } from './InputSwitch' -export type { InputTextBaseProps } from './InputTextBase/types' +export { InputGroup, type InputGroupProps } from './InputGroup/InputGroup' +export { InputText, type InputTextProps } from './InputText/InputText' export { - InputOtp, - InputOtpTestId, - type InputOtpProps, -} from './InputOtp/InputOtp' + InputGroupAddon, + type InputGroupAddonProps, +} from './InputGroup/InputGroupAddon' +export { + InputCaption, + type InputCaptionProps, +} from './InputCaption/InputCaption' +export { Password, type PasswordProps } from './Password/Password' +export type { + InputTextSize, + InputState, + InputLabelInfo, + RenderTextInputArgs, +} from './core/types' +export { InputSwitch } from '../InputSwitch' +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 deleted file mode 100644 index 63f28ecf..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 { InputGroup } from '../InputGroup' - -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 } 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 deleted file mode 100644 index 8d7fdb7f..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 { InputText } from '../InputText' -import type { InputTextBase } from '../InputTextBase/InputTextBase' - -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 } 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 diff --git a/src/components/Input/InputSwitch/InputSwitch.stories.tsx b/src/components/InputSwitch/InputSwitch.stories.tsx similarity index 51% rename from src/components/Input/InputSwitch/InputSwitch.stories.tsx rename to src/components/InputSwitch/InputSwitch.stories.tsx index d131da1a..2b655e58 100644 --- a/src/components/Input/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/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 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, 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 - -`; 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' 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" } }