diff --git a/README.md b/README.md
index c7e4c615..f0198929 100644
--- a/README.md
+++ b/README.md
@@ -4,8 +4,10 @@
Документация компонентов:
+- [Tag](src/components/Tag/Tag.md)
- [Skeleton](src/components/Skeleton/Skeleton.md)
+
## Installation
**Yarn**
diff --git a/app.config.ts b/app.config.ts
index 0f258318..5da91aef 100644
--- a/app.config.ts
+++ b/app.config.ts
@@ -23,6 +23,7 @@ const internalFontsPlugin = [
fontFamily: 'Noto Sans',
fontDefinitions: [
{ path: './assets/fonts/NotoSans-Bold.ttf', weight: 700 },
+ { path: './assets/fonts/NotoSans-SemiBold.ttf', weight: 600 },
{ path: './assets/fonts/NotoSans-Regular.ttf', weight: 400 },
],
},
@@ -34,6 +35,7 @@ const internalFontsPlugin = [
'./assets/fonts/TTFellows-DemiBoldItalic.ttf',
'./assets/fonts/TTFellows-Regular.ttf',
'./assets/fonts/NotoSans-Regular.ttf',
+ './assets/fonts/NotoSans-SemiBold.ttf',
'./assets/fonts/NotoSans-Bold.ttf',
],
},
diff --git a/design-tokens/input/tokens.json b/design-tokens/input/tokens.json
index 52924de5..d5c3447a 100644
--- a/design-tokens/input/tokens.json
+++ b/design-tokens/input/tokens.json
@@ -4777,7 +4777,7 @@
"light": {
"primary": {
"background": "{color.bg.brand.strong.default}",
- "color": "{color.fg.default}"
+ "color": "{color.fg.on.brand.default}"
},
"secondary": {
"background": "{color.bg.neutral.medium.default}",
@@ -4819,14 +4819,14 @@
},
"primary": {
"background": "{color.bg.brand.strong.default}",
- "color": "{color.fg.inverse.default}"
+ "color": "{color.fg.on.brand.default}"
},
"secondary": {
- "background": "{color.bg.neutral.strong.hover}",
- "color": "{color.fg.inverse.default}"
+ "background": "{color.bg.neutral.medium.default}",
+ "color": "{color.fg.default}"
},
"success": {
- "background": "{color.bg.brand.strong.hover}",
+ "background": "{color.bg.status.success.strong.hover}",
"color": "{color.fg.on.status.success.default}"
},
"warn": {
@@ -4837,7 +4837,7 @@
},
"root": {
"fontSize": "{fonts.fontSize.100}",
- "fontWeight": "{fonts.fontWeight.regular}",
+ "fontWeight": "{fonts.fontWeight.demibold}",
"padding": "{dimension.space.100} {dimension.space.200}",
"gap": "{dimension.space.100}",
"borderRadius": "{dimension.borderRadius.200}",
diff --git a/src/components/Tag/Tag.md b/src/components/Tag/Tag.md
new file mode 100644
index 00000000..62feef69
--- /dev/null
+++ b/src/components/Tag/Tag.md
@@ -0,0 +1,40 @@
+# Tag
+
+Текстовая метка для статуса или категории с необязательной иконкой.
+
+## Использование
+
+```tsx
+import { Tag } from '@cdek-it/react-native-ui-kit'
+
+export function DeliveryStatus() {
+ return
+}
+```
+
+## Props
+
+- `text` — обязательный текст метки
+- `severity` — `primary | info | success | warning | danger | secondary`; по
+ умолчанию `primary`. Определяет оформление и стандартную иконку. Устаревший
+ `basic` поддерживается как алиас `primary`
+- `rounded` — полное скругление углов; по умолчанию `false`
+- `showIcon` — показывает иконку; по умолчанию `true`
+- `Icon` — SVG типа `SvgSource`, переопределяющая стандартную иконку
+
+Полный контракт — [TagProps](./Tag.tsx).
+
+## Доступность
+
+Текст масштабируется с системным шрифтом и остаётся однострочным; при нехватке
+ширины сокращается многоточием.
+
+Скринридер озвучивает видимый текст или заданный `accessibilityLabel` без
+дублирования. Иконка декоративная.
+
+Для обработки нажатия используйте интерактивного родителя. Доступное имя и роль
+задавайте родителю, оставляя Tag без собственного `accessibilityLabel`.
+
+## Макет
+
+[Tag в Figma](https://www.figma.com/design/Q1BWgZ7zoV5UzlBOnjW0cM/UI-Kit--DS--v2.1?node-id=21-1238&m=dev)
diff --git a/src/components/Tag/Tag.stories.ts b/src/components/Tag/Tag.stories.ts
index e46ee0e5..313d9b54 100644
--- a/src/components/Tag/Tag.stories.ts
+++ b/src/components/Tag/Tag.stories.ts
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react'
import {
IconAlertTriangle,
IconCheck,
+ IconCircleCheck,
IconCircleX,
IconInfoCircle,
} from '@tabler/icons-react-native'
@@ -10,26 +11,24 @@ import { Tag } from './Tag'
const icons = {
IconCheck,
+ IconCircleCheck,
IconInfoCircle,
IconAlertTriangle,
IconCircleX,
- none: undefined,
+ default: undefined,
}
const meta: Meta = {
title: 'Misc/Tag',
component: Tag,
- args: {
- text: 'Tag',
- rounded: false,
- severity: 'basic',
- showIcon: true,
- Icon: IconCheck,
- },
+ args: { text: 'Tag', rounded: false, severity: 'primary', showIcon: true },
argTypes: {
+ text: { control: 'text' },
+ rounded: { control: 'boolean' },
+ showIcon: { control: 'boolean' },
severity: {
control: 'radio',
- options: ['basic', 'info', 'success', 'warning', 'danger', 'secondary'],
+ options: ['primary', 'info', 'success', 'warning', 'danger', 'secondary'],
},
Icon: { control: 'radio', options: Object.keys(icons), mapping: icons },
},
diff --git a/src/components/Tag/Tag.tsx b/src/components/Tag/Tag.tsx
index 8bd6f913..6b513526 100644
--- a/src/components/Tag/Tag.tsx
+++ b/src/components/Tag/Tag.tsx
@@ -1,8 +1,17 @@
+import {
+ IconAlertTriangle,
+ IconCheck,
+ IconCircleCheck,
+ IconCircleX,
+ IconInfoCircle,
+} from '@tabler/icons-react-native'
import { memo } from 'react'
import {
View,
Text,
+ useWindowDimensions,
type ViewStyle,
+ type TextStyle,
type StyleProp,
type AccessibilityProps,
type ViewProps,
@@ -10,9 +19,11 @@ import {
import { StyleSheet } from 'react-native-unistyles'
-import type { ComponentTokens } from '../../theme/tokens/index'
import { type SvgSource, SvgUniversal } from '../../utils/SvgUniversal'
+/** @deprecated Используйте primary. Будет удалён в 2.0 после миграции SuperApp */
+type DeprecatedTagSeverity = 'basic'
+
export interface TagProps
extends AccessibilityProps, Pick {
/** Текст */
@@ -23,9 +34,16 @@ export interface TagProps
/**
* Выбор варианта стиля компонента
- * @default 'basic'
+ * @default 'primary'
*/
- severity?: 'basic' | 'info' | 'success' | 'warning' | 'danger' | 'secondary'
+ severity?:
+ | 'primary'
+ | 'info'
+ | 'success'
+ | 'warning'
+ | 'danger'
+ | 'secondary'
+ | DeprecatedTagSeverity
/**
* Показать или скрыть иконку внутри компонента
@@ -36,39 +54,51 @@ export interface TagProps
/** Дополнительная стилизация для контейнера компонента */
style?: StyleProp
- /** SVG-иконка */
+ /** SVG-иконка. Заменяет стандартную иконку выбранного severity */
Icon?: SvgSource
}
-type TagSeverity = NonNullable
+type TagSeverity = Exclude<
+ NonNullable,
+ DeprecatedTagSeverity
+>
-const tagIconColor: Record string> = {
- basic: (t) => t.tag.colorScheme.primary.color,
- info: (t) => t.tag.colorScheme.info.color,
- success: (t) => t.tag.colorScheme.success.color,
- warning: (t) => t.tag.colorScheme.warn.color,
- danger: (t) => t.tag.colorScheme.danger.color,
- secondary: (t) => t.tag.colorScheme.secondary.color,
+const tagIcons: Record = {
+ primary: IconCheck,
+ info: IconInfoCircle,
+ success: IconCircleCheck,
+ warning: IconAlertTriangle,
+ danger: IconCircleX,
+ secondary: IconCircleX,
}
/**
* Используется для маркировки элементов интерфейса
- * @see https://www.figma.com/design/4TYeki0MDLhfPGJstbIicf/UI-kit-PrimeFace-(DS)?node-id=484-4921
- * @see https://www.figma.com/design/Q1BWgZ7zoV5UzlBOnjW0cM/UI-Kit--DS--v2.1?node-id=24043-13865
+ * Внутри интерактивного родителя задавайте доступное имя родителю,
+ * оставляя Tag без собственного accessibilityLabel
+ * @see https://www.figma.com/design/Q1BWgZ7zoV5UzlBOnjW0cM/UI-Kit--DS--v2.1?node-id=21-1238
*/
export const Tag = memo(
({
text,
rounded,
- severity = 'basic',
+ severity = 'primary',
showIcon = true,
style,
Icon,
testID,
collapsable,
+ accessibilityLabel,
...rest
}) => {
- tagStyles.useVariants({ severity })
+ // На iOS пересоздание Text сбрасывает измерения при смене масштаба шрифта.
+ const { fontScale } = useWindowDimensions()
+ const resolvedSeverity = severity === 'basic' ? 'primary' : severity
+ const colorSchemeKey =
+ resolvedSeverity === 'warning' ? 'warn' : resolvedSeverity
+ const hasAccessibilityLabel = accessibilityLabel !== undefined
+
+ tagStyles.useVariants({ severity: resolvedSeverity })
return (
(
style={style}
testID={testID || TagTestId.root}
{...rest}
+ accessibilityLabel={accessibilityLabel}
+ accessibilityRole={hasAccessibilityLabel ? 'text' : undefined}
+ accessible={hasAccessibilityLabel}
>
- {showIcon && Icon ? (
+ {showIcon ? (
{
- return { color: tagIconColor[severity](components) }
- }}
+ uniProps={({ components }) => ({
+ color: components.tag.colorScheme[colorSchemeKey].color,
+ width: components.tag.icon.size,
+ height: components.tag.icon.size,
+ })}
/>
) : null}
({
borderRadius: components.tag.root.borderRadius,
variants: {
severity: {
- basic: {
+ primary: {
backgroundColor: components.tag.colorScheme.primary.background,
},
info: { backgroundColor: components.tag.colorScheme.info.background },
@@ -136,16 +181,17 @@ const tagStyles = StyleSheet.create(({ components, fonts }) => ({
},
},
roundedContainer: { borderRadius: components.tag.root.roundedBorderRadius },
- icon: { width: components.tag.icon.size, height: components.tag.icon.size },
text: {
flexShrink: 1,
fontSize: components.tag.root.fontSize,
includeFontPadding: false,
verticalAlign: 'middle',
- fontFamily: fonts.fontFamily.heading,
+ fontFamily: fonts.fontFamily.base,
+ fontWeight: components.tag.root.fontWeight as TextStyle['fontWeight'],
+ lineHeight: fonts.lineHeight[250],
variants: {
severity: {
- basic: { color: components.tag.colorScheme.primary.color },
+ primary: { color: components.tag.colorScheme.primary.color },
info: { color: components.tag.colorScheme.info.color },
success: { color: components.tag.colorScheme.success.color },
warning: { color: components.tag.colorScheme.warn.color },
diff --git a/src/components/Tag/__tests__/Tag.test.tsx b/src/components/Tag/__tests__/Tag.test.tsx
index 9d1b0282..f94eac39 100644
--- a/src/components/Tag/__tests__/Tag.test.tsx
+++ b/src/components/Tag/__tests__/Tag.test.tsx
@@ -1,40 +1,180 @@
import { IconCheck } from '@tabler/icons-react-native'
-import { render } from '@testing-library/react-native'
+import {
+ isHiddenFromAccessibility,
+ render,
+ screen,
+ userEvent,
+} from '@testing-library/react-native'
+import { Pressable } from 'react-native'
+import Svg, { type SvgProps } from 'react-native-svg'
-import { Tag, type TagProps } from '../Tag'
+import { Tag, TagTestId } from '../Tag'
+
+const CustomIcon = (props: SvgProps) => (
+
+)
describe('Tag', () => {
- const defaultProps: TagProps = { text: 'Tag' }
- const snapshotCases: Array<[string, Partial]> = [
- ['default', {}],
- [
- 'severity = secondary, rounded = false, showIcon = true, with Icon',
- {
- severity: 'secondary',
- rounded: false,
- showIcon: true,
- Icon: IconCheck,
- },
- ],
- [
- 'severity = basic, rounded = true, with Icon',
- { severity: 'basic', rounded: true, Icon: IconCheck },
- ],
- [
- 'severity = info, showIcon = false, with Icon',
- { severity: 'info', Icon: IconCheck },
- ],
- [
- 'severity = success, with custom styles',
- { severity: 'success', style: { margin: 10 } },
- ],
- ['severity = warning', { severity: 'warning' }],
- ['severity = danger', { severity: 'danger' }],
- ]
-
- test.each(snapshotCases)('snapshot: %s', (_, props) => {
- const { toJSON } = render()
-
- expect(toJSON()).toMatchSnapshot()
+ test.each([
+ { name: 'алиасе basic', severity: 'basic' },
+ { name: 'severity warning', severity: 'warning' },
+ ] as const)(
+ 'отображает текст и стандартную иконку при $name',
+ ({ severity }) => {
+ render()
+
+ expect(screen.getByRole('text', { name: 'Доставлен' })).toBeOnTheScreen()
+ expect(
+ screen.getAllByTestId(TagTestId.icon, { includeHiddenElements: true })
+ .length
+ ).toBeGreaterThan(0)
+ }
+ )
+
+ test('заменяет стандартную иконку через Icon и восстанавливает её после удаления пропа', () => {
+ const { rerender } = render()
+
+ expect(
+ screen.getAllByTestId(TagTestId.icon, { includeHiddenElements: true })
+ .length
+ ).toBeGreaterThan(0)
+
+ rerender()
+
+ expect(
+ screen.getAllByTestId('CustomTagIcon', { includeHiddenElements: true })
+ .length
+ ).toBeGreaterThan(0)
+ expect(
+ screen.queryByTestId(TagTestId.icon, { includeHiddenElements: true })
+ ).toBeNull()
+
+ rerender()
+
+ expect(
+ screen.getAllByTestId(TagTestId.icon, { includeHiddenElements: true })
+ .length
+ ).toBeGreaterThan(0)
+ expect(
+ screen.queryAllByTestId('CustomTagIcon', { includeHiddenElements: true })
+ ).toHaveLength(0)
+ expect(screen.getByRole('text', { name: 'В пути' })).toBeOnTheScreen()
+ })
+
+ test('скрывает переданную иконку при showIcon=false', () => {
+ render()
+
+ expect(
+ screen.queryByTestId(TagTestId.icon, { includeHiddenElements: true })
+ ).toBeNull()
+ expect(screen.getByRole('text', { name: 'Доставлен' })).toBeOnTheScreen()
+ })
+
+ test('скрывает стандартную иконку и восстанавливает её при изменении showIcon', () => {
+ const { rerender } = render()
+
+ expect(
+ screen.queryByTestId(TagTestId.icon, { includeHiddenElements: true })
+ ).toBeNull()
+
+ rerender()
+
+ expect(
+ screen.getAllByTestId(TagTestId.icon, { includeHiddenElements: true })
+ .length
+ ).toBeGreaterThan(0)
+ })
+
+ test('передаёт testID, collapsable и accessibilityHint корневому контейнеру', () => {
+ render(
+
+ )
+
+ const root = screen.getByRole('text', { name: 'Статус заказа' })
+
+ expect(root).toBe(screen.getByTestId('OrderStatus'))
+ expect(root).toHaveProp('collapsable', false)
+ expect(root).toHaveProp('accessibilityHint', 'Текущий этап доставки')
+ })
+
+ test('оставляет видимый текст единственным доступным текстовым элементом без accessibilityLabel', () => {
+ render()
+
+ expect(screen.getAllByRole('text')).toHaveLength(1)
+ expect(screen.getByRole('text', { name: 'Доставлен' })).toBeOnTheScreen()
+ expect(screen.getByTestId(TagTestId.root)).toHaveProp('accessible', false)
+ })
+
+ test('использует accessibilityLabel как единое имя и скрывает дочерний текст', () => {
+ const { rerender } = render(
+
+ )
+
+ expect(screen.getAllByRole('text')).toHaveLength(1)
+ expect(screen.getByRole('text', { name: 'Статус заказа: доставлен' })).toBe(
+ screen.getByTestId(TagTestId.root)
+ )
+ expect(screen.queryByRole('text', { name: 'Доставлен' })).toBeNull()
+ expect(
+ screen.getByText('Доставлен', { includeHiddenElements: true })
+ ).toBeOnTheScreen()
+
+ rerender()
+
+ expect(screen.getAllByRole('text')).toHaveLength(1)
+ expect(screen.getByRole('text', { name: 'Доставлен' })).toBeOnTheScreen()
+ expect(
+ screen.queryByRole('text', { name: 'Статус заказа: доставлен' })
+ ).toBeNull()
+ })
+
+ test('скрывает декоративную иконку от accessibility', () => {
+ render()
+
+ const icons = screen.getAllByTestId(TagTestId.icon, {
+ includeHiddenElements: true,
+ })
+
+ for (const icon of icons) {
+ expect(isHiddenFromAccessibility(icon)).toBeTrue()
+ }
+
+ expect(screen.queryByTestId(TagTestId.icon)).toBeNull()
+ expect(screen.getAllByRole('text')).toHaveLength(1)
+ })
+
+ test('позволяет интерактивному родителю задавать имя и обрабатывать нажатие', async () => {
+ const onPress = jest.fn()
+ const user = userEvent.setup()
+
+ render(
+
+
+
+ )
+
+ await user.press(
+ screen.getByRole('button', { name: 'Открыть заказ: доставлен' })
+ )
+
+ expect(onPress).toHaveBeenCalledOnce()
+ expect(screen.getByTestId(TagTestId.root)).toHaveProp('accessible', false)
+ expect(screen.getByTestId(TagTestId.root)).not.toHaveProp(
+ 'accessibilityLabel'
+ )
})
})
diff --git a/src/components/Tag/__tests__/__snapshots__/Tag.test.tsx.snap b/src/components/Tag/__tests__/__snapshots__/Tag.test.tsx.snap
deleted file mode 100644
index ff2517c9..00000000
--- a/src/components/Tag/__tests__/__snapshots__/Tag.test.tsx.snap
+++ /dev/null
@@ -1,539 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Tag snapshot: default 1`] = `
-
-
-
- Tag
-
-
-
-`;
-
-exports[`Tag snapshot: severity = basic, rounded = true, with Icon 1`] = `
-
-
-
-
-
-
-
-
- Tag
-
-
-
-`;
-
-exports[`Tag snapshot: severity = danger 1`] = `
-
-
-
- Tag
-
-
-
-`;
-
-exports[`Tag snapshot: severity = info, showIcon = false, with Icon 1`] = `
-
-
-
-
-
-
-
-
- Tag
-
-
-
-`;
-
-exports[`Tag snapshot: severity = secondary, rounded = false, showIcon = true, with Icon 1`] = `
-
-
-
-
-
-
-
-
- Tag
-
-
-
-`;
-
-exports[`Tag snapshot: severity = success, with custom styles 1`] = `
-
-
-
- Tag
-
-
-
-`;
-
-exports[`Tag snapshot: severity = warning 1`] = `
-
-
-
- Tag
-
-
-
-`;
diff --git a/src/theme/tokens/components/dark.json b/src/theme/tokens/components/dark.json
index 74af7db0..b604ef0c 100644
--- a/src/theme/tokens/components/dark.json
+++ b/src/theme/tokens/components/dark.json
@@ -2582,13 +2582,13 @@
"danger": { "background": "#8d2218", "color": "#fff0f0" },
"info": { "background": "#18538d", "color": "#f0f9ff" },
"primary": { "background": "#44e858", "color": "#2b2e33" },
- "secondary": { "background": "#e2e2e4", "color": "#2b2e33" },
- "success": { "background": "#77f48a", "color": "#f0fff3" },
+ "secondary": { "background": "#404348", "color": "#ffffff" },
+ "success": { "background": "#1dc831", "color": "#f0fff3" },
"warn": { "background": "#9d6d0e", "color": "#fff9f0" }
},
"root": {
"fontSize": 12,
- "fontWeight": 400,
+ "fontWeight": 600,
"paddingTop": 4,
"paddingRight": 8,
"paddingBottom": 4,
diff --git a/src/theme/tokens/components/light.json b/src/theme/tokens/components/light.json
index 4faa2ed6..f14c1e2e 100644
--- a/src/theme/tokens/components/light.json
+++ b/src/theme/tokens/components/light.json
@@ -2588,7 +2588,7 @@
},
"root": {
"fontSize": 12,
- "fontWeight": 400,
+ "fontWeight": 600,
"paddingTop": 4,
"paddingRight": 8,
"paddingBottom": 4,