Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/components/Button/Button.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Button

Кнопка с текстом или иконкой и необязательным бейджем.

## Использование

```tsx
import { IconShoppingCart } from '@tabler/icons-react-native'
import { Badge, Button } from '@cdek-it/react-native-ui-kit'

export function OpenCartButton({ onPress }: { onPress: () => void }) {
return (
<Button
Icon={IconShoppingCart}
label='Корзина'
badge={<Badge severity='danger'>3</Badge>}
accessibilityLabel='Корзина. 3 товара'
onPress={onPress}
/>
)
}
```

## Props

### Основные

- `label` — текст кнопки. Обязателен для кнопки с текстом
- `variant` — оформление Button: `primary | secondary | tertiary | text | link`.
По умолчанию `primary`
- `size` — `small | base | large | xlarge`. По умолчанию `base`
- `rounded` — полное скругление углов. По умолчанию `false`
- `loading` — показывает индикатор вместо иконки. По умолчанию `false`
- `badge` — готовый элемент `Badge` типа `ReactElement<BadgeProps>`. Размещается
в верхнем правом углу, не меняет размер кнопки и остаётся видимым при
`loading` и `disabled`
- `pressableRef` — ссылка на корневой нативный элемент кнопки

### Иконка

- `Icon` — SVG типа `SvgSource`. Необязателен рядом с текстом
- `iconOnly` — кнопка только с иконкой. Требует `Icon`, исключает `label` и
`iconPosition`
- `iconPosition` — `prefix | postfix`. По умолчанию `prefix`

**При `iconOnly` обязательно задавайте `accessibilityLabel`**, чтобы у кнопки
было доступное имя.

### ButtonSeverity

Кнопка с семантическим цветом. Поддерживает общие props Button, включая `badge`.
Для оформления используйте:

- `severity` — обязательный `info | success | warning | danger`
- `appearance` — `filled | outlined | text`. По умолчанию `filled`

Иконку задавайте через `Icon`. Значение `severity` не подставляет её
автоматически.

```tsx
import { ButtonSeverity } from '@cdek-it/react-native-ui-kit'

export function DeleteButton() {
return (
<ButtonSeverity label='Удалить' severity='danger' appearance='outlined' />
)
}
```

Полный контракт — [ButtonProps и ButtonSeverityProps](./types.ts).

## Макет

[Button в Figma](https://www.figma.com/design/Q1BWgZ7zoV5UzlBOnjW0cM/UI-Kit--DS--v2.1?node-id=160-5223)
59 changes: 10 additions & 49 deletions src/components/Button/ButtonBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,63 +1,24 @@
import { memo } from 'react'
import { View } from 'react-native'

import { StyleSheet } from 'react-native-unistyles'

import { Badge, type BadgeProps } from '../Badge'

import { BaseButton } from './internal/BaseButton'
import { adaptButtonProps } from './internal/deprecated/adaptButtonProps'
import { getButtonStyles } from './internal/getButtonStyles'
import { Button } from './Button'
import type { ButtonBadgeProps, ButtonBaseVariant, ButtonProps } from './types'

/**
* Button component with badge
* @param size - button size
* @param rounded - rounded corners
* @param loading - button loading state
* @param variant - button variant
* @param disabled - button disabled state
* @param iconOnly - button with only Icon
* @param iconPosition - icon position
* @param Icon - Tabler icon
* @param label - button label
* @param style - external style control for component
* @param badgeSeverity - badge component severity level
* @param badgeLabel - text label inside badge
* @see BaseButton
*/
/** @deprecated Используйте Button с пропом badge. Удаляется в версии 2.0. */
export const ButtonBadge = memo<
ButtonProps<ButtonBaseVariant> & ButtonBadgeProps
>(({ badgeLabel, badgeSeverity, variant = 'primary', ...props }) => {
const badgeCommonProps = {
severity: badgeSeverity,
testID: ButtonBadgeTestId.badge,
}
ButtonProps<ButtonBaseVariant> & ButtonBadgeProps & { badge?: never }

Check warning on line 10 in src/components/Button/ButtonBadge.tsx

View workflow job for this annotation

GitHub Actions / lint

`ButtonBadgeProps` is deprecated. Используйте Button с пропом badge. Удаляется в версии 2.0
>(({ badgeLabel, badgeSeverity, ...props }) => {
const badgeProps: BadgeProps = badgeLabel
? { ...badgeCommonProps, children: badgeLabel, style: styles.badge }
: { ...badgeCommonProps, dot: true, style: styles.badgeDot }
? { severity: badgeSeverity, children: badgeLabel }
: { severity: badgeSeverity, dot: true }

return (
<View style={styles.container}>
<BaseButton
resolveStyles={getButtonStyles(variant)}
{...adaptButtonProps(props)}
/>

<Badge {...badgeProps} />
</View>
<Button
{...props}
badge={<Badge {...badgeProps} testID={ButtonBadgeTestId.badge} />}
/>
)
})

const styles = StyleSheet.create(() => ({
container: { alignSelf: 'flex-start' },
badgeDot: { position: 'absolute', top: 0, right: -0.5 },
badge: {
position: 'absolute',
top: 0,
right: 0,
transform: [{ translateX: '50%' }, { translateY: '-50%' }],
},
}))

export const ButtonBadgeTestId = { badge: 'Badge' }
52 changes: 24 additions & 28 deletions src/components/Button/__tests__/ButtonBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -1,41 +1,37 @@
import { render } from '@testing-library/react-native'
import { render, within } from '@testing-library/react-native'

import { ButtonBadge, ButtonBadgeTestId } from '../ButtonBadge'

describe('ButtonBadge', () => {
test('отображает текст кнопки и бейджа', () => {
test('преобразует badgeLabel в текстовый бейдж', () => {
const { getByText } = render(
<ButtonBadge badgeLabel='Badge' badgeSeverity='danger' label='Button' />

Check warning on line 8 in src/components/Button/__tests__/ButtonBadge.test.tsx

View workflow job for this annotation

GitHub Actions / lint

`ButtonBadge` is deprecated. Используйте Button с пропом badge. Удаляется в версии 2.0
)

expect(getByText('Button')).toBeOnTheScreen()
expect(getByText('Badge')).toBeOnTheScreen()
expect(
getByText('Badge', { includeHiddenElements: true })
).toBeOnTheScreen()
})

test('отображает бейдж без текста в режиме dot', () => {
const { getByTestId, queryByText } = render(
<ButtonBadge badgeSeverity='info' label='Button' />
)
test.each([undefined, ''])(
'отображает точку при badgeLabel=%p',
(badgeLabel) => {
const { getByTestId } = render(
<ButtonBadge

Check warning on line 20 in src/components/Button/__tests__/ButtonBadge.test.tsx

View workflow job for this annotation

GitHub Actions / lint

`ButtonBadge` is deprecated. Используйте Button с пропом badge. Удаляется в версии 2.0
badgeLabel={badgeLabel}
badgeSeverity='info'
label='Button'
/>
)

expect(getByTestId(ButtonBadgeTestId.badge)).toBeOnTheScreen()
expect(queryByText('Badge')).not.toBeOnTheScreen()
})
const badge = getByTestId(ButtonBadgeTestId.badge, {
includeHiddenElements: true,
})

test.each([
{ loading: true },
{ disabled: true },
{ loading: true, disabled: true },
])('сохраняет бейдж при %j', (state) => {
const { getByText, getAllByRole } = render(
<ButtonBadge
badgeLabel='3'
badgeSeverity='danger'
label='Уведомления'
{...state}
/>
)

expect(getByText('3')).toBeOnTheScreen()
expect(getAllByRole('button')).toHaveLength(1)
})
expect(badge).toBeOnTheScreen()
expect(
within(badge).queryAllByText(/.*/, { includeHiddenElements: true })
).toHaveLength(0)
}
)
})
61 changes: 20 additions & 41 deletions src/components/Button/__tests__/ButtonInteraction.test.tsx
Original file line number Diff line number Diff line change
@@ -1,48 +1,27 @@
import { IconArrowDownRight } from '@tabler/icons-react-native'
import { fireEvent, render, userEvent } from '@testing-library/react-native'
import type { PressableProps } from 'react-native'
import { createRef } from 'react'
import type { PressableProps, View } from 'react-native'

import { Button } from '../Button'
import { ButtonBadge } from '../ButtonBadge'
import { ButtonSeverity } from '../ButtonSeverity'
import type { ButtonIconPosition } from '../types'

type InteractionProps = PressableProps & {
loading?: boolean
iconPosition?: ButtonIconPosition
}

describe.each([
{
name: 'Button',
createButton: (props: InteractionProps) => (
<Button Icon={IconArrowDownRight} label='Сохранить' {...props} />
),
},
{
name: 'ButtonBadge',
createButton: (props: InteractionProps) => (
<ButtonBadge
Icon={IconArrowDownRight}
badgeLabel='3'
badgeSeverity='danger'
label='Сохранить'
{...props}
/>
),
},
{
name: 'ButtonSeverity',
createButton: (props: InteractionProps) => (
<ButtonSeverity
Icon={IconArrowDownRight}
label='Сохранить'
severity='danger'
{...props}
/>
),
},
])('$name interactions', ({ createButton }) => {
import type { BaseButtonProps } from '../types'

type InteractionProps = PressableProps &
Pick<BaseButtonProps<never>, 'loading' | 'iconPosition' | 'pressableRef'>

describe('Button interactions', () => {
const createButton = (props: InteractionProps) => (
<Button Icon={IconArrowDownRight} label='Сохранить' {...props} />
)

test('передаёт pressableRef корневому нативному элементу', () => {
const pressableRef = createRef<View>()
render(createButton({ pressableRef }))

expect(pressableRef.current).not.toBeNull()
expect(pressableRef.current?.props.accessibilityRole).toBe('button')
})

test.each([
{ name: 'default', disabled: false, loading: false },
{ name: 'disabled', disabled: true, loading: false },
Expand Down
13 changes: 0 additions & 13 deletions src/components/Button/__tests__/ButtonSeverity.test.tsx

This file was deleted.

70 changes: 70 additions & 0 deletions src/components/Button/__tests__/ButtonWithBadge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { render } from '@testing-library/react-native'

import { Badge } from '../../Badge'

import { Button } from '../Button'
import { ButtonSeverity } from '../ButtonSeverity'
import type { BaseButtonProps } from '../types'

type TestButtonProps = Pick<
BaseButtonProps<never>,
'badge' | 'loading' | 'disabled' | 'accessibilityLabel'
>

describe.each([
{
name: 'Button',
createButton: (props: TestButtonProps) => (
<Button label='Корзина' {...props} />
),
},
{
name: 'ButtonSeverity',
createButton: (props: TestButtonProps) => (
<ButtonSeverity label='Корзина' severity='danger' {...props} />
),
},
])('$name с badge', ({ createButton }) => {
test('обновляет бейдж, не изменяя заданное доступное имя кнопки', () => {
const { getByRole, getByText, queryByRole, queryByText, rerender } = render(
createButton({
badge: <Badge accessibilityLabel='Счётчик'>3</Badge>,
accessibilityLabel: 'Открыть корзину',
})
)

expect(getByText('3', { includeHiddenElements: true })).toBeOnTheScreen()
expect(getByRole('button')).toHaveAccessibleName('Открыть корзину')
expect(queryByRole('text', { name: 'Счётчик' })).toBeNull()

rerender(
createButton({
badge: <Badge>4</Badge>,
accessibilityLabel: 'Открыть корзину',
})
)

expect(queryByText('3', { includeHiddenElements: true })).toBeNull()
expect(getByText('4', { includeHiddenElements: true })).toBeOnTheScreen()
expect(getByRole('button')).toHaveAccessibleName('Открыть корзину')

rerender(createButton({}))

expect(queryByText('4', { includeHiddenElements: true })).toBeNull()
expect(getByRole('button')).toHaveAccessibleName('Корзина')
})

test.each([
{ name: 'loading', loading: true },
{ name: 'disabled', disabled: true },
])('сохраняет точку при $name', ({ name: _name, ...state }) => {
const { getByRole, getByTestId } = render(
createButton({ badge: <Badge dot testID='CartBadge' />, ...state })
)

expect(
getByTestId('CartBadge', { includeHiddenElements: true })
).toBeOnTheScreen()
expect(getByRole('button')).toBeDisabled()
})
})
Loading
Loading