From 1d7be7480bfb1136fe97a8f24d1d28f961ccc4f6 Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Wed, 28 Oct 2026 13:18:04 +0300 Subject: [PATCH 1/4] =?UTF-8?q?fix(tokens):=20=D0=BE=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D1=82=D0=BE=D0=BA=D0=B5=D0=BD?= =?UTF-8?q?=D1=8B=20RadioButton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design-tokens/input/tokens.json | 12 ++++++------ src/theme/tokens/components/dark.json | 12 ++++++------ src/theme/tokens/components/light.json | 8 ++++---- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/design-tokens/input/tokens.json b/design-tokens/input/tokens.json index fae360fd..c5dfef2f 100644 --- a/design-tokens/input/tokens.json +++ b/design-tokens/input/tokens.json @@ -4038,15 +4038,15 @@ "height": "{dimension.size.700}", "background": "{color.bg.surface.default.default}", "checkedBackground": "{color.bg.neutral.strong.default}", - "checkedHoverBackground": "{color.bg.neutral.strong.hover}", + "checkedHoverBackground": "{color.bg.neutral.strong.active}", "disabledBackground": "{color.bg.surface.canvas.hover}", "filledBackground": "{color.bg.surface.default.default}", "borderColor": "{color.border.neutral.strong}", "hoverBorderColor": "{color.border.neutral.bold.default}", - "focusBorderColor": "{color.border.neutral.strong}", + "focusBorderColor": "{color.border.brand.strong}", "checkedBorderColor": "{color.border.neutral.bold.default}", - "checkedHoverBorderColor": "{color.border.neutral.bold.default}", - "checkedFocusBorderColor": "{color.border.neutral.bold.default}", + "checkedHoverBorderColor": "{color.bg.neutral.strong.active}", + "checkedFocusBorderColor": "{color.border.brand.strong}", "checkedDisabledBorderColor": "{color.border.neutral.strong}", "invalidBorderColor": "{color.border.status.danger.strong}", "shadow": "{effects.elevation.none}", @@ -4084,8 +4084,8 @@ }, "icon": { "size": "{dimension.size.350}", - "checkedColor": "{color.fg.on.fill.default}", - "checkedHoverColor": "{color.fg.on.fill.hover}", + "checkedColor": "{color.fg.inverse.default}", + "checkedHoverColor": "{color.fg.inverse.active}", "disabledColor": "{color.fg.muted}", "sm": { "size": "{dimension.size.200}" }, "lg": { "size": "{dimension.size.450}" } diff --git a/src/theme/tokens/components/dark.json b/src/theme/tokens/components/dark.json index c938a186..64da8e5e 100644 --- a/src/theme/tokens/components/dark.json +++ b/src/theme/tokens/components/dark.json @@ -2053,15 +2053,15 @@ "height": 24, "background": "#404348", "checkedBackground": "#f0f0f1", - "checkedHoverBackground": "#e2e2e4", + "checkedHoverBackground": "#cecfd2", "disabledBackground": "#404348", "filledBackground": "#404348", "borderColor": "#56595f", "hoverBorderColor": "#f0f0f1", - "focusBorderColor": "#56595f", + "focusBorderColor": "#77f48a", "checkedBorderColor": "#f0f0f1", - "checkedHoverBorderColor": "#f0f0f1", - "checkedFocusBorderColor": "#f0f0f1", + "checkedHoverBorderColor": "#cecfd2", + "checkedFocusBorderColor": "#77f48a", "checkedDisabledBorderColor": "#56595f", "invalidBorderColor": "#f47f77", "shadow": "none", @@ -2087,8 +2087,8 @@ "lg": { "width": 20, "height": 20 }, "icon": { "size": 12, - "checkedColor": "#ffffff", - "checkedHoverColor": "#ffffff", + "checkedColor": "#2b2e33", + "checkedHoverColor": "#181a1f", "disabledColor": "#a2a5a9", "sm": { "size": 8 }, "lg": { "size": 16 } diff --git a/src/theme/tokens/components/light.json b/src/theme/tokens/components/light.json index 951cb468..8acbd890 100644 --- a/src/theme/tokens/components/light.json +++ b/src/theme/tokens/components/light.json @@ -2053,15 +2053,15 @@ "height": 24, "background": "#ffffff", "checkedBackground": "#2b2e33", - "checkedHoverBackground": "#404348", + "checkedHoverBackground": "#56595f", "disabledBackground": "#e2e2e4", "filledBackground": "#ffffff", "borderColor": "#cecfd2", "hoverBorderColor": "#2b2e33", - "focusBorderColor": "#cecfd2", + "focusBorderColor": "#1dc831", "checkedBorderColor": "#2b2e33", - "checkedHoverBorderColor": "#2b2e33", - "checkedFocusBorderColor": "#2b2e33", + "checkedHoverBorderColor": "#56595f", + "checkedFocusBorderColor": "#1dc831", "checkedDisabledBorderColor": "#cecfd2", "invalidBorderColor": "#db3424", "shadow": "none", From 87af3a7c7eb6fcef2f75520f43e8906808ae4195 Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Thu, 29 Oct 2026 16:36:08 +0300 Subject: [PATCH 2/4] =?UTF-8?q?feat(radio-button):=20=D0=BE=D0=B1=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20API=20=D0=B8=20=D0=B4?= =?UTF-8?q?=D0=BE=D1=81=D1=82=D1=83=D0=BF=D0=BD=D0=BE=D1=81=D1=82=D1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RadioButton/RadioButton.styles.ts | 98 +++ src/components/RadioButton/RadioButton.tsx | 277 +++--- .../RadioButton.accessibility.test.tsx | 81 ++ .../__tests__/RadioButton.test.tsx | 184 +++- .../__snapshots__/RadioButton.test.tsx.snap | 817 ------------------ 5 files changed, 484 insertions(+), 973 deletions(-) create mode 100644 src/components/RadioButton/RadioButton.styles.ts create mode 100644 src/components/RadioButton/__tests__/RadioButton.accessibility.test.tsx delete mode 100644 src/components/RadioButton/__tests__/__snapshots__/RadioButton.test.tsx.snap diff --git a/src/components/RadioButton/RadioButton.styles.ts b/src/components/RadioButton/RadioButton.styles.ts new file mode 100644 index 00000000..aa775133 --- /dev/null +++ b/src/components/RadioButton/RadioButton.styles.ts @@ -0,0 +1,98 @@ +import { StyleSheet } from 'react-native-unistyles' + +export const styles = StyleSheet.create( + ({ components: { radiobutton }, semantic, fonts }) => ({ + root: (multilineText: boolean) => ({ + flexDirection: 'row', + alignItems: multilineText ? 'flex-start' : 'center', + alignSelf: 'flex-start', + flexShrink: 1, + gap: semantic.dimension.space[300], + }), + circle: ( + checked: boolean, + active: boolean, + disabled: boolean, + focused: boolean, + danger: boolean, + firstLineHeight: number + ) => ({ + width: radiobutton.root.width, + height: radiobutton.root.height, + marginTop: Math.max(0, (firstLineHeight - radiobutton.root.height) / 2), + flexShrink: 0, + alignItems: 'center', + justifyContent: 'center', + borderRadius: semantic.dimension.borderRadius.max, + borderWidth: semantic.dimension.borderWidth[100], + borderColor: disabled + ? checked + ? radiobutton.root.checkedDisabledBorderColor + : radiobutton.root.borderColor + : danger + ? radiobutton.root.invalidBorderColor + : focused + ? checked + ? radiobutton.root.checkedFocusBorderColor + : radiobutton.root.focusBorderColor + : active + ? checked + ? radiobutton.root.checkedHoverBorderColor + : radiobutton.root.hoverBorderColor + : checked + ? radiobutton.root.checkedBorderColor + : radiobutton.root.borderColor, + backgroundColor: disabled + ? radiobutton.root.disabledBackground + : checked + ? active + ? radiobutton.root.checkedHoverBackground + : radiobutton.root.checkedBackground + : radiobutton.root.background, + transitionProperty: ['borderColor', 'backgroundColor'], + transitionDuration: radiobutton.root.transitionDuration, + }), + center: (checked: boolean, active: boolean, disabled: boolean) => ({ + width: radiobutton.icon.size, + height: radiobutton.icon.size, + borderRadius: semantic.dimension.borderRadius.max, + opacity: checked ? 1 : 0, + backgroundColor: disabled + ? radiobutton.icon.disabledColor + : active + ? radiobutton.icon.checkedHoverColor + : radiobutton.icon.checkedColor, + transitionProperty: ['opacity', 'backgroundColor'], + transitionDuration: radiobutton.root.transitionDuration, + }), + ring: (danger: boolean) => ({ + position: 'absolute', + inset: + -radiobutton.root.focusRing.width - semantic.dimension.borderWidth[100], + borderRadius: semantic.dimension.borderRadius.max, + borderWidth: radiobutton.root.focusRing.width, + borderColor: danger + ? semantic.colorScheme.color.border.status.danger.focus + : radiobutton.root.focusRing.color, + }), + content: (firstLineHeight: number) => ({ + flexShrink: 1, + paddingTop: + firstLineHeight > 0 + ? Math.max(0, (radiobutton.root.height - firstLineHeight) / 2) + : 0, + }), + label: { + fontSize: fonts.fontSize[200], + lineHeight: fonts.lineHeight[400], + letterSpacing: fonts.letterSpacing[500], + color: semantic.colorScheme.color.fg.default, + }, + caption: { + fontSize: fonts.fontSize[200], + lineHeight: fonts.lineHeight[300], + letterSpacing: fonts.letterSpacing[200], + color: semantic.colorScheme.color.fg.muted, + }, + }) +) diff --git a/src/components/RadioButton/RadioButton.tsx b/src/components/RadioButton/RadioButton.tsx index e5fddbd0..47b19229 100644 --- a/src/components/RadioButton/RadioButton.tsx +++ b/src/components/RadioButton/RadioButton.tsx @@ -1,142 +1,187 @@ -import { memo, useCallback, useState } from 'react' -import { - type AccessibilityProps, - Pressable, - View, - type ViewProps, -} from 'react-native' +import { memo, useState } from 'react' +import { Pressable, type PressableProps, View } from 'react-native' import Animated from 'react-native-reanimated' +import { useUnistyles } from 'react-native-unistyles' -import { StyleSheet } from 'react-native-unistyles' +import { Body, Caption } from '../Typography' -const AnimatedPressable = Animated.createAnimatedComponent(Pressable) +import { styles } from './RadioButton.styles' -export interface RadioButtonProps - extends AccessibilityProps, Pick { - /** Обработчик нажатия на кнопку */ - onPress: () => void +export interface RadioButtonProps extends Omit< + PressableProps, + 'children' | 'accessible' | 'accessibilityRole' | 'role' | 'aria-checked' +> { /** - * true, если необходим компонент в активном состоянии + * Выбран ли вариант. Меняется только при обновлении prop * @default false */ checked?: boolean /** - * Управление доступностью компонента + * Блокирует взаимодействие * @default false */ disabled?: boolean - /** Выбор состояния компонента */ - state?: 'default' | 'danger' + /** + * Оформление ошибки + * @default false + */ + danger?: boolean + /** + * Обработчик нажатия. Выбором и исключением остальных вариантов управляет + * потребитель. Повторное нажатие не снимает checked самостоятельно + */ + onPress?: PressableProps['onPress'] + /** Текст справа от круга. По умолчанию задаёт доступное имя */ + label?: string + /** Подпись под label. Без label не отображается */ + caption?: string + /** + * Расширяет область нажатия без изменения размера компонента. + * Ограничена границами родителя + * @default 10 + */ + hitSlop?: PressableProps['hitSlop'] } +/** + * Управляемая радиокнопка с необязательными label и caption. + * Вся строка — единая область нажатия и один accessibility-элемент. + * @see https://www.figma.com/design/Q1BWgZ7zoV5UzlBOnjW0cM/UI-Kit--DS--v2.1?node-id=17-13535 + */ export const RadioButton = memo( ({ - onPress, checked = false, disabled = false, - state = 'default', - testID, + danger = false, + label, + caption, + style, + testID = 'RadioButton_Pressable', + hitSlop = 10, + accessibilityLabel, + accessibilityLabelledBy, + accessibilityHint, + accessibilityState, + onFocus, + onBlur, + onHoverIn, + onHoverOut, ...rest }) => { - const [pressed, setPressed] = useState(false) - - radioStyles.useVariants({ - checked: checked ? 'true' : 'false', - pressed: pressed ? 'true' : 'false', - state: state === 'danger' ? 'danger' : undefined, - disabled: disabled ? 'true' : 'false', - }) + const [focused, setFocused] = useState(false) + const [hovered, setHovered] = useState(false) + // Обход Unistyles: нативное обновление цветов при смене темы + // key={rt.themeName} пересоздаёт только внутренний Animated.View с актуальной палитрой + // https://github.com/jpudysz/react-native-unistyles/issues/1007 исправление в Unistyles 3.6.0 + const { rt } = useUnistyles() + const [labelFirstLineHeight, setLabelFirstLineHeight] = useState(0) + const [multilineLabel, setMultilineLabel] = useState(false) + const [multilineCaption, setMultilineCaption] = useState(false) + const visibleCaption = label === undefined ? undefined : caption + const multilineText = + label !== undefined && + (multilineLabel || (visibleCaption !== undefined && multilineCaption)) + const firstLineHeight = multilineText ? labelFirstLineHeight : 0 - const onPressIn = useCallback(() => setPressed(true), []) - const onPressOut = useCallback(() => setPressed(false), []) - - return ( - - + { + setMultilineLabel(lines.length > 1) + setLabelFirstLineHeight(lines[0]?.height ?? 0) + }} > - - + {label} + + {visibleCaption !== undefined && ( + + setMultilineCaption(lines.length > 1) + } + > + {visibleCaption} + + )} ) + + return ( + [ + styles.root(multilineText), + typeof style === 'function' ? style(pressState) : style, + ]} + testID={testID} + onBlur={(event) => { + setFocused(false) + onBlur?.(event) + }} + onFocus={(event) => { + setFocused(true) + onFocus?.(event) + }} + onHoverIn={(event) => { + setHovered(true) + onHoverIn?.(event) + }} + onHoverOut={(event) => { + setHovered(false) + onHoverOut?.(event) + }} + > + {({ pressed }) => { + const active = pressed || hovered + + return ( + <> + + {!disabled && (focused || danger) ? ( + + ) : null} + + + {textContent} + + ) + }} + + ) } ) - -const radioStyles = StyleSheet.create(({ components, semantic }) => ({ - container: { - width: components.radiobutton.root.width, - height: components.radiobutton.root.height, - borderRadius: semantic.dimension.borderRadius.max, - borderWidth: semantic.dimension.borderWidth[100], - alignItems: 'center', - justifyContent: 'center', - transitionProperty: ['borderColor', 'backgroundColor'], - transitionDuration: 100, - variants: { - checked: { - true: { - borderColor: components.radiobutton.root.checkedBorderColor, - backgroundColor: components.radiobutton.root.checkedBackground, - }, - false: { - borderColor: components.radiobutton.root.borderColor, - backgroundColor: components.radiobutton.root.background, - }, - }, - pressed: { true: {}, false: {} }, - disabled: { - true: { - borderColor: components.radiobutton.root.borderColor, - backgroundColor: components.radiobutton.root.disabledBackground, - opacity: semantic.effects.opacity[60], - }, - false: {}, - }, - state: { danger: {}, default: {} }, - }, - compoundVariants: [ - { - checked: 'true', - pressed: 'true', - styles: { - borderColor: components.radiobutton.root.checkedHoverBorderColor, - backgroundColor: components.radiobutton.root.checkedHoverBackground, - }, - }, - { - checked: 'true', - disabled: 'true', - styles: { - borderColor: components.radiobutton.root.checkedBorderColor, - backgroundColor: components.radiobutton.root.checkedBackground, - }, - }, - { - state: 'danger', - disabled: 'false', - checked: 'false', - styles: { borderColor: components.radiobutton.root.invalidBorderColor }, - }, - { - checked: 'false', - pressed: 'true', - styles: { borderColor: components.radiobutton.root.hoverBorderColor }, - }, - ], - }, - center: { - width: components.radiobutton.icon.size, - height: components.radiobutton.icon.size, - borderRadius: semantic.dimension.borderRadius.max, - backgroundColor: components.radiobutton.root.background, - transitionProperty: ['opacity'], - transitionDuration: 100, - variants: { checked: { false: { opacity: 0 }, true: { opacity: 1 } } }, - }, -})) diff --git a/src/components/RadioButton/__tests__/RadioButton.accessibility.test.tsx b/src/components/RadioButton/__tests__/RadioButton.accessibility.test.tsx new file mode 100644 index 00000000..a1e08709 --- /dev/null +++ b/src/components/RadioButton/__tests__/RadioButton.accessibility.test.tsx @@ -0,0 +1,81 @@ +import { render } from '@testing-library/react-native' + +import { RadioButton } from '..' + +describe('RadioButton — доступность', () => { + test('представляет строку одним доступным radio при label и caption', () => { + const screen = render( + + ) + + expect(screen.getAllByRole('radio')).toHaveLength(1) + expect( + screen.getByRole('radio', { name: 'Доставка курьером' }) + ).toBeOnTheScreen() + expect(screen.getByHintText('Привезём заказ')).toBeOnTheScreen() + expect(screen.queryByText('Доставка курьером')).toBeNull() + expect(screen.queryByText('Привезём заказ')).toBeNull() + }) + + test('сохраняет busy и применяет состояния из props при противоречивом accessibilityState', () => { + const screen = render( + + ) + + expect( + screen.getByRole('radio', { checked: true, disabled: true, busy: true }) + ).toBeOnTheScreen() + }) + + test('использует явное имя и подсказку при передаче нативных accessibility props', () => { + const screen = render( + + ) + + expect( + screen.getByRole('radio', { name: 'Выбрать доставку курьером' }) + ).toBeOnTheScreen() + expect(screen.getByHintText('Дополнительная информация')).toBeOnTheScreen() + }) + + test('использует явное доступное имя при отсутствии label', () => { + const screen = render( + + ) + + expect( + screen.getByRole('radio', { name: 'Доставка курьером' }) + ).toBeOnTheScreen() + }) + + test('передаёт связь с внешним текстом без автоматического имени при accessibilityLabelledBy', () => { + const screen = render( + + ) + const control = screen.getByRole('radio') + + expect(control).toHaveProp('accessibilityLabelledBy', 'DeliveryMethodLabel') + expect(control.props.accessibilityLabel).toBeUndefined() + }) + + test.each([ + { name: 'label', props: { caption: 'Подпись' } }, + { name: 'caption', props: { label: 'Доставка курьером' } }, + ])('не задаёт подсказку из caption при отсутствии $name', ({ props }) => { + const screen = render() + + expect(screen.getByRole('radio').props.accessibilityHint).toBeUndefined() + }) +}) diff --git a/src/components/RadioButton/__tests__/RadioButton.test.tsx b/src/components/RadioButton/__tests__/RadioButton.test.tsx index 4da1a8e0..fa38bfb6 100644 --- a/src/components/RadioButton/__tests__/RadioButton.test.tsx +++ b/src/components/RadioButton/__tests__/RadioButton.test.tsx @@ -1,45 +1,149 @@ -import { render } from '@testing-library/react-native' +import { fireEvent, render, userEvent } from '@testing-library/react-native' -import { RadioButton, type RadioButtonProps } from '../RadioButton' +import { RadioButton, type RadioButtonProps } from '..' describe('RadioButton', () => { - const defaultProps: RadioButtonProps = { onPress: jest.fn() } - - describe('snapshots', () => { - const snapshotCases: Array<[string, Partial]> = [ - ['default', {}], - [ - 'checked = true, disabled = false, state = danger', - { checked: true, disabled: false, state: 'danger' }, - ], - [ - 'checked = false, disabled = false, state = danger', - { checked: false, disabled: false, state: 'danger' }, - ], - - [ - 'checked = true, disabled = false, state = default', - { checked: true, disabled: false, state: 'default' }, - ], - [ - 'checked = false, disabled = false, state = default', - { checked: false, disabled: false, state: 'default' }, - ], - - [ - 'checked = true, disabled = true, state = default', - { checked: true, disabled: true, state: 'default' }, - ], - [ - 'checked = false, disabled = true, state = default', - { checked: false, disabled: true, state: 'default' }, - ], - ] - - test.each(snapshotCases)('%s', (_, props) => { - const { toJSON } = render() - - expect(toJSON()).toMatchSnapshot() - }) + test.each([false, true])( + 'вызывает onPress без самостоятельного изменения checked при checked=%s', + async (checked) => { + const onPress = jest.fn() + const screen = render( + + ) + + await userEvent.press( + screen.getByRole('radio', { name: 'Доставка курьером' }) + ) + + expect(onPress).toHaveBeenCalledOnce() + expect(screen.getByRole('radio', { checked })).toBeOnTheScreen() + + screen.rerender( + + ) + + expect(screen.getByRole('radio', { checked: !checked })).toBeOnTheScreen() + } + ) + + test.each([false, true])( + 'не вызывает обработчики при disabled и danger=%s', + async (danger) => { + const onPress = jest.fn() + const onPressIn = jest.fn() + const onPressOut = jest.fn() + const screen = render( + + ) + + await userEvent.press(screen.getByRole('radio')) + + for (const callback of [onPress, onPressIn, onPressOut]) { + expect(callback).not.toHaveBeenCalled() + } + } + ) + + test('вызывает прежний onPress при использовании существующих props', async () => { + const props: RadioButtonProps = { onPress: jest.fn() } + const screen = render() + + await userEvent.press( + screen.getByRole('radio', { checked: false, disabled: false }) + ) + + expect(props.onPress).toHaveBeenCalledOnce() + expect(screen.getByTestId('RadioButton_Pressable')).toBeOnTheScreen() + }) + + test.each([ + 'press', + 'pressIn', + 'pressOut', + 'focus', + 'blur', + 'hoverIn', + 'hoverOut', + ] as const)( + 'передаёт исходное событие пользовательскому обработчику при %s', + (name) => { + const callback = jest.fn() + const props = { [`on${name[0].toUpperCase()}${name.slice(1)}`]: callback } + const screen = render() + const event = { nativeEvent: { timestamp: 42 } } + + fireEvent(screen.getByRole('radio'), name, event) + + expect(callback).toHaveBeenCalledExactlyOnceWith(event) + } + ) + + test('передаёт pressed в callback style при нажатии и отпускании', async () => { + const style = jest.fn(() => ({ opacity: 0.5 })) + const screen = render() + + await userEvent.press(screen.getByRole('radio')) + + expect(style).toHaveBeenCalledWith( + expect.objectContaining({ pressed: true }) + ) + expect(style).toHaveBeenLastCalledWith( + expect.objectContaining({ pressed: false }) + ) + }) + + test('показывает label и caption при передаче текста', () => { + const screen = render( + + ) + + expect(screen.getByRole('radio')).toHaveTextContent( + 'Доставка курьеромПривезём заказ' + ) + }) + + test('скрывает caption при удалении label', () => { + const screen = render( + + ) + + screen.rerender() + + expect(screen.getByRole('radio')).toHaveTextContent('') + }) + + test('передаёт нативные props при их указании', () => { + const screen = render( + + ) + + expect(screen.getByTestId('DeliveryRadio')).toHaveProp( + 'nativeID', + 'DeliveryMethod' + ) + }) + + test.each([ + { name: 'по умолчанию', hitSlop: undefined, expected: 10 }, + { name: 'без расширения', hitSlop: 0, expected: 0 }, + { + name: 'с разными отступами', + hitSlop: { top: 4, bottom: 8, left: 12, right: 16 }, + expected: { top: 4, bottom: 8, left: 12, right: 16 }, + }, + ])('задаёт область нажатия $name', ({ hitSlop, expected }) => { + const screen = render() + + expect(screen.getByRole('radio')).toHaveProp('hitSlop', expected) }) }) diff --git a/src/components/RadioButton/__tests__/__snapshots__/RadioButton.test.tsx.snap b/src/components/RadioButton/__tests__/__snapshots__/RadioButton.test.tsx.snap deleted file mode 100644 index 2d4cc630..00000000 --- a/src/components/RadioButton/__tests__/__snapshots__/RadioButton.test.tsx.snap +++ /dev/null @@ -1,817 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`RadioButton snapshots checked = false, disabled = false, state = danger 1`] = ` - - - - - -`; - -exports[`RadioButton snapshots checked = false, disabled = false, state = default 1`] = ` - - - - - -`; - -exports[`RadioButton snapshots checked = false, disabled = true, state = default 1`] = ` - - - - - -`; - -exports[`RadioButton snapshots checked = true, disabled = false, state = danger 1`] = ` - - - - - -`; - -exports[`RadioButton snapshots checked = true, disabled = false, state = default 1`] = ` - - - - - -`; - -exports[`RadioButton snapshots checked = true, disabled = true, state = default 1`] = ` - - - - - -`; - -exports[`RadioButton snapshots default 1`] = ` - - - - - -`; From 6385c40b448d3396f102e6889461572e3d71144d Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Thu, 29 Oct 2026 16:36:15 +0300 Subject: [PATCH 3/4] =?UTF-8?q?docs(radio-button):=20=D0=B4=D0=BE=D0=B1?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=B4=D0=BE=D0=BA=D1=83?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/RadioButton/RadioButton.md | 66 +++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/components/RadioButton/RadioButton.md diff --git a/src/components/RadioButton/RadioButton.md b/src/components/RadioButton/RadioButton.md new file mode 100644 index 00000000..fc851e74 --- /dev/null +++ b/src/components/RadioButton/RadioButton.md @@ -0,0 +1,66 @@ +# RadioButton + +Управляемая радиокнопка с необязательными label и caption для выбора одного +варианта. + +## Использование + +```tsx +import { useState } from 'react' +import { RadioButton } from '@cdek-it/react-native-ui-kit' + +export function Example() { + const [checked, setChecked] = useState(false) + + return ( + setChecked(true)} + /> + ) +} +``` + +## Props + +- `checked` — выбран ли вариант. По умолчанию `false`. Отображение меняется + только после обновления prop потребителем +- `onPress` — необязательный нативный обработчик нажатия. Потребитель выбирает + вариант и снимает выбор с остальных. Повторное нажатие также вызывает + обработчик, но компонент не переключает checked самостоятельно +- `disabled` — блокирует взаимодействие. По умолчанию `false` +- `danger` — оформление ошибки. По умолчанию `false` +- `label` — текст справа от круга. Без него отображается только круг +- `caption` — подпись под label. Без label не отображается + +Пользовательский `style` объединяется с внутренним и поддерживает +callback-форму. + +Полный контракт — [RadioButtonProps](./RadioButton.tsx). + +Область нажатия расширена через `hitSlop` (по умолчанию 10) без изменения +размера компонента. Она ограничена границами родителя. + +## Доступность + +Вся строка — один элемент с ролью radio и состояниями checked и disabled. Label +задаёт доступное имя, caption — подсказку. Их можно переопределить через +`accessibilityLabel` и `accessibilityHint`. + +Без label передайте доступное имя через `accessibilityLabel` или свяжите +радиокнопку с внешним текстом через `accessibilityLabelledBy` на Android. При +использовании accessibilityLabelledBy автоматическое имя из label не задаётся. +Родителю группы вариантов задайте `accessibilityRole='radiogroup'`. Ошибку +группы показывайте одним сообщением, передавая `danger` всем вариантам. + +Текст переносится и масштабируется с системным шрифтом. + +В составной карточке нажатие и доступность принадлежат внешнему Pressable. +Обёртка вложенного RadioButton блокирует его касания и скрывает от доступности; +для самого RadioButton задаётся `focusable={false}`. Пример — +[Overview](./stories/RadioButtonOverview.stories.tsx). + +## Макет + +[RadioButton в Figma](https://www.figma.com/design/Q1BWgZ7zoV5UzlBOnjW0cM/UI-Kit--DS--v2.1?node-id=17-13535) From cb7e550f7066c4273461760621d839be2200b682 Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Thu, 29 Oct 2026 16:36:16 +0300 Subject: [PATCH 4/4] =?UTF-8?q?feat(storybook):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D1=8B=D0=B5=20=D0=BF=D1=80?= =?UTF-8?q?=D0=B8=D0=BC=D0=B5=D1=80=D1=8B=20RadioButton?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RadioButton/RadioButton.stories.ts | 17 ---- .../stories/RadioButton.stories.tsx | 54 +++++++++++ .../stories/RadioButtonGroupExample.tsx | 59 +++++++++++ .../stories/RadioButtonOverview.stories.tsx | 38 ++++++++ .../stories/RadioCardGroupExample.tsx | 97 +++++++++++++++++++ tsconfig.build.json | 1 + 6 files changed, 249 insertions(+), 17 deletions(-) delete mode 100644 src/components/RadioButton/RadioButton.stories.ts create mode 100644 src/components/RadioButton/stories/RadioButton.stories.tsx create mode 100644 src/components/RadioButton/stories/RadioButtonGroupExample.tsx create mode 100644 src/components/RadioButton/stories/RadioButtonOverview.stories.tsx create mode 100644 src/components/RadioButton/stories/RadioCardGroupExample.tsx diff --git a/src/components/RadioButton/RadioButton.stories.ts b/src/components/RadioButton/RadioButton.stories.ts deleted file mode 100644 index d47ce90c..00000000 --- a/src/components/RadioButton/RadioButton.stories.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react' - -import { RadioButton } from './RadioButton' - -const meta: Meta = { - title: 'Form/RadioButton', - component: RadioButton, - args: { checked: false, disabled: false, state: 'default' }, - argTypes: { state: { control: 'radio', options: ['default', 'danger'] } }, -} - -export default meta - -type Story = StoryObj - -const RadioButtonStory: Story = { args: {} } -export { RadioButtonStory as RadioButton } diff --git a/src/components/RadioButton/stories/RadioButton.stories.tsx b/src/components/RadioButton/stories/RadioButton.stories.tsx new file mode 100644 index 00000000..28d6a3e9 --- /dev/null +++ b/src/components/RadioButton/stories/RadioButton.stories.tsx @@ -0,0 +1,54 @@ +import { useArgs } from '@storybook/preview-api' +import type { Meta, StoryObj } from '@storybook/react' + +import { RadioButton, type RadioButtonProps } from '../RadioButton' + +const meta: Meta = { + title: 'Form/RadioButton', + tags: ['version:2.0'], + component: RadioButton, + args: { + checked: false, + disabled: false, + danger: false, + label: 'Курьером', + caption: 'Привезём заказ по указанному адресу', + }, + argTypes: { + checked: { control: 'boolean' }, + disabled: { control: 'boolean' }, + danger: { control: 'boolean' }, + label: { control: 'text' }, + caption: { control: 'text', if: { arg: 'label', truthy: true } }, + onPress: { action: 'onPress' }, + }, + parameters: { + controls: { + include: ['theme', 'checked', 'disabled', 'danger', 'label', 'caption'], + }, + }, + render: ({ label, caption, ...args }) => { + const [, updateArgs] = useArgs() + + return ( + { + args.onPress?.(event) + updateArgs({ checked: true }) + }} + /> + ) + }, +} + +export default meta + +type Story = StoryObj + +const RadioButtonStory: Story = { name: 'RadioButton' } + +export { RadioButtonStory as RadioButton } diff --git a/src/components/RadioButton/stories/RadioButtonGroupExample.tsx b/src/components/RadioButton/stories/RadioButtonGroupExample.tsx new file mode 100644 index 00000000..eb5e1013 --- /dev/null +++ b/src/components/RadioButton/stories/RadioButtonGroupExample.tsx @@ -0,0 +1,59 @@ +import { useState } from 'react' +import { View } from 'react-native' +import { StyleSheet } from 'react-native-unistyles' + +import { Button } from '../../Button' + +import { RadioButton, type RadioButtonProps } from '../RadioButton' + +const deliveryOptions = [ + { + value: 'courier', + label: 'Курьером', + caption: 'Привезём заказ по указанному адресу', + }, + { value: 'pickup', label: 'В пункт выдачи' }, + { value: 'post', label: 'Почтой России', disabled: true }, +] + +type ExampleProps = Pick + +export const RadioButtonGroupExample = ({ onPress }: ExampleProps) => { + const [selectedValue, setSelectedValue] = useState(null) + + return ( + + + {deliveryOptions.map(({ value, label, caption, disabled }) => ( + { + onPress?.(event) + setSelectedValue(value) + }} + /> + ))} + +