@titan-design/react-ui 0.1.0 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/README.md +47 -1
  2. package/dist/{chunk-MGW37MPO.mjs → chunk-UXVRPOME.mjs} +173 -3
  3. package/dist/chunk-UXVRPOME.mjs.map +1 -0
  4. package/dist/index.d.mts +101 -12
  5. package/dist/index.d.ts +101 -12
  6. package/dist/index.js +1267 -468
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +734 -112
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/theme/index.d.mts +86 -1
  11. package/dist/theme/index.d.ts +86 -1
  12. package/dist/theme/index.js +166 -0
  13. package/dist/theme/index.js.map +1 -1
  14. package/dist/theme/index.mjs +1 -1
  15. package/package.json +22 -6
  16. package/src/components/custom/DateTime/DateTime.stories.tsx +180 -0
  17. package/src/components/custom/DateTime/DateTime.test.tsx +152 -0
  18. package/src/components/custom/DateTime/DateTime.tsx +235 -0
  19. package/src/components/custom/DateTime/index.ts +2 -0
  20. package/src/components/custom/EmptyState/EmptyState.stories.tsx +99 -0
  21. package/src/components/custom/EmptyState/EmptyState.test.tsx +106 -0
  22. package/src/components/custom/EmptyState/EmptyState.tsx +68 -0
  23. package/src/components/custom/EmptyState/index.ts +2 -0
  24. package/src/components/custom/Metric/Metric.stories.tsx +133 -0
  25. package/src/components/custom/Metric/Metric.test.tsx +120 -0
  26. package/src/components/custom/Metric/Metric.tsx +92 -0
  27. package/src/components/custom/Metric/index.ts +2 -0
  28. package/src/components/custom/Sidebar/Sidebar.stories.tsx +227 -0
  29. package/src/components/custom/Sidebar/Sidebar.test.tsx +185 -0
  30. package/src/components/custom/Sidebar/Sidebar.tsx +257 -0
  31. package/src/components/custom/Sidebar/index.ts +18 -0
  32. package/src/components/custom/Table/Table.stories.tsx +481 -0
  33. package/src/components/custom/Table/Table.test.tsx +531 -0
  34. package/src/components/custom/Table/Table.tsx +696 -0
  35. package/src/components/custom/Table/index.ts +22 -0
  36. package/src/components/custom/Typography/Typography.stories.tsx +153 -0
  37. package/src/components/custom/Typography/Typography.test.tsx +107 -0
  38. package/src/components/custom/Typography/Typography.tsx +218 -0
  39. package/src/components/custom/Typography/index.ts +19 -0
  40. package/src/components/custom/Workout/RestTimer.stories.tsx +71 -0
  41. package/src/components/custom/Workout/RestTimer.test.tsx +145 -0
  42. package/src/components/custom/Workout/RestTimer.tsx +172 -0
  43. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +144 -0
  44. package/src/components/custom/Workout/SupersetWrapper.test.tsx +130 -0
  45. package/src/components/custom/Workout/SupersetWrapper.tsx +63 -0
  46. package/src/components/custom/Workout/index.ts +2 -0
  47. package/src/components/custom/index.ts +9 -0
  48. package/src/components/custom/stepper/Stepper.stories.tsx +245 -0
  49. package/src/components/custom/stepper/Stepper.test.tsx +230 -0
  50. package/src/components/custom/stepper/Stepper.tsx +250 -0
  51. package/src/components/custom/stepper/index.ts +10 -0
  52. package/src/components/index.ts +5 -0
  53. package/src/components/ui/alert/Alert.stories.tsx +154 -0
  54. package/src/components/ui/alert/Alert.test.tsx +202 -0
  55. package/src/components/ui/alert/Alert.tsx +170 -0
  56. package/src/components/ui/alert/index.ts +8 -0
  57. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +299 -0
  58. package/src/components/ui/autocomplete/Autocomplete.test.tsx +247 -0
  59. package/src/components/ui/autocomplete/Autocomplete.tsx +332 -0
  60. package/src/components/ui/autocomplete/index.ts +2 -0
  61. package/src/components/ui/avatar/Avatar.stories.tsx +105 -0
  62. package/src/components/ui/avatar/Avatar.test.tsx +152 -0
  63. package/src/components/ui/avatar/Avatar.tsx +176 -0
  64. package/src/components/ui/avatar/index.ts +2 -0
  65. package/src/components/ui/badge/Badge.stories.tsx +100 -0
  66. package/src/components/ui/badge/Badge.test.tsx +103 -0
  67. package/src/components/ui/badge/Badge.tsx +119 -0
  68. package/src/components/ui/badge/index.ts +3 -0
  69. package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +70 -0
  70. package/src/components/ui/breadcrumbs/Breadcrumbs.test.tsx +156 -0
  71. package/src/components/ui/breadcrumbs/Breadcrumbs.tsx +127 -0
  72. package/src/components/ui/breadcrumbs/index.ts +2 -0
  73. package/src/components/ui/button/Button.stories.tsx +216 -0
  74. package/src/components/ui/button/Button.test.tsx +132 -0
  75. package/src/components/ui/button/Button.tsx +297 -0
  76. package/src/components/ui/button/index.ts +10 -0
  77. package/src/components/ui/card/Card.stories.tsx +443 -0
  78. package/src/components/ui/card/Card.test.tsx +294 -0
  79. package/src/components/ui/card/Card.tsx +401 -0
  80. package/src/components/ui/card/index.ts +20 -0
  81. package/src/components/ui/checkbox/Checkbox.stories.tsx +126 -0
  82. package/src/components/ui/checkbox/Checkbox.test.tsx +152 -0
  83. package/src/components/ui/checkbox/Checkbox.tsx +163 -0
  84. package/src/components/ui/checkbox/index.ts +2 -0
  85. package/src/components/ui/chip/Chip.stories.tsx +112 -0
  86. package/src/components/ui/chip/Chip.test.tsx +119 -0
  87. package/src/components/ui/chip/Chip.tsx +151 -0
  88. package/src/components/ui/chip/index.ts +2 -0
  89. package/src/components/ui/collapse/Collapse.stories.tsx +313 -0
  90. package/src/components/ui/collapse/Collapse.test.tsx +298 -0
  91. package/src/components/ui/collapse/Collapse.tsx +275 -0
  92. package/src/components/ui/collapse/index.ts +18 -0
  93. package/src/components/ui/data-row/DataRow.stories.tsx +78 -0
  94. package/src/components/ui/data-row/DataRow.test.tsx +78 -0
  95. package/src/components/ui/data-row/DataRow.tsx +29 -0
  96. package/src/components/ui/data-row/index.ts +2 -0
  97. package/src/components/ui/divider/Divider.stories.tsx +71 -0
  98. package/src/components/ui/divider/Divider.test.tsx +55 -0
  99. package/src/components/ui/divider/Divider.tsx +37 -0
  100. package/src/components/ui/divider/index.ts +2 -0
  101. package/src/components/ui/drawer/Drawer.stories.tsx +228 -0
  102. package/src/components/ui/drawer/Drawer.test.tsx +142 -0
  103. package/src/components/ui/drawer/Drawer.tsx +228 -0
  104. package/src/components/ui/drawer/index.ts +9 -0
  105. package/src/components/ui/form-field/FormField.stories.tsx +260 -0
  106. package/src/components/ui/form-field/FormField.test.tsx +280 -0
  107. package/src/components/ui/form-field/FormField.tsx +308 -0
  108. package/src/components/ui/form-field/index.ts +2 -0
  109. package/src/components/ui/help-tip/HelpTip.stories.tsx +250 -0
  110. package/src/components/ui/help-tip/HelpTip.test.tsx +198 -0
  111. package/src/components/ui/help-tip/HelpTip.tsx +229 -0
  112. package/src/components/ui/help-tip/index.ts +2 -0
  113. package/src/components/ui/icon-box/IconBox.stories.tsx +139 -0
  114. package/src/components/ui/icon-box/IconBox.test.tsx +120 -0
  115. package/src/components/ui/icon-box/IconBox.tsx +57 -0
  116. package/src/components/ui/icon-box/index.ts +1 -0
  117. package/src/components/ui/index.ts +38 -0
  118. package/src/components/ui/indicator/Indicator.stories.tsx +74 -0
  119. package/src/components/ui/indicator/Indicator.test.tsx +55 -0
  120. package/src/components/ui/indicator/Indicator.tsx +73 -0
  121. package/src/components/ui/indicator/index.ts +2 -0
  122. package/src/components/ui/input/Input.stories.tsx +192 -0
  123. package/src/components/ui/input/Input.test.tsx +86 -0
  124. package/src/components/ui/input/Input.tsx +273 -0
  125. package/src/components/ui/input/index.ts +2 -0
  126. package/src/components/ui/link/Link.stories.tsx +78 -0
  127. package/src/components/ui/link/Link.test.tsx +94 -0
  128. package/src/components/ui/link/Link.tsx +86 -0
  129. package/src/components/ui/link/index.ts +2 -0
  130. package/src/components/ui/list-item/ListItem.stories.tsx +146 -0
  131. package/src/components/ui/list-item/ListItem.test.tsx +128 -0
  132. package/src/components/ui/list-item/ListItem.tsx +89 -0
  133. package/src/components/ui/list-item/index.ts +14 -0
  134. package/src/components/ui/menu/Menu.stories.tsx +180 -0
  135. package/src/components/ui/menu/Menu.test.tsx +353 -0
  136. package/src/components/ui/menu/Menu.tsx +222 -0
  137. package/src/components/ui/menu/index.ts +2 -0
  138. package/src/components/ui/modal/Modal.stories.tsx +350 -0
  139. package/src/components/ui/modal/Modal.test.tsx +159 -0
  140. package/src/components/ui/modal/Modal.tsx +254 -0
  141. package/src/components/ui/modal/index.ts +19 -0
  142. package/src/components/ui/pill/Pill.stories.tsx +71 -0
  143. package/src/components/ui/pill/Pill.test.tsx +69 -0
  144. package/src/components/ui/pill/Pill.tsx +104 -0
  145. package/src/components/ui/pill/index.ts +2 -0
  146. package/src/components/ui/popover/Popover.stories.tsx +190 -0
  147. package/src/components/ui/popover/Popover.test.tsx +419 -0
  148. package/src/components/ui/popover/Popover.tsx +237 -0
  149. package/src/components/ui/popover/index.ts +2 -0
  150. package/src/components/ui/progress/Progress.stories.tsx +190 -0
  151. package/src/components/ui/progress/Progress.test.tsx +210 -0
  152. package/src/components/ui/progress/Progress.tsx +339 -0
  153. package/src/components/ui/progress/index.ts +9 -0
  154. package/src/components/ui/radio/Radio.stories.tsx +199 -0
  155. package/src/components/ui/radio/Radio.test.tsx +166 -0
  156. package/src/components/ui/radio/Radio.tsx +200 -0
  157. package/src/components/ui/radio/index.ts +2 -0
  158. package/src/components/ui/section/Section.stories.tsx +111 -0
  159. package/src/components/ui/section/Section.test.tsx +121 -0
  160. package/src/components/ui/section/Section.tsx +52 -0
  161. package/src/components/ui/section/index.ts +6 -0
  162. package/src/components/ui/select/Select.stories.tsx +286 -0
  163. package/src/components/ui/select/Select.test.tsx +200 -0
  164. package/src/components/ui/select/Select.tsx +243 -0
  165. package/src/components/ui/select/index.ts +2 -0
  166. package/src/components/ui/skeleton/Skeleton.stories.tsx +153 -0
  167. package/src/components/ui/skeleton/Skeleton.test.tsx +162 -0
  168. package/src/components/ui/skeleton/Skeleton.tsx +229 -0
  169. package/src/components/ui/skeleton/index.ts +16 -0
  170. package/src/components/ui/spinner/Spinner.stories.tsx +67 -0
  171. package/src/components/ui/spinner/Spinner.test.tsx +67 -0
  172. package/src/components/ui/spinner/Spinner.tsx +72 -0
  173. package/src/components/ui/spinner/index.ts +2 -0
  174. package/src/components/ui/stack/Stack.stories.tsx +127 -0
  175. package/src/components/ui/stack/Stack.test.tsx +184 -0
  176. package/src/components/ui/stack/Stack.tsx +76 -0
  177. package/src/components/ui/stack/index.ts +2 -0
  178. package/src/components/ui/surface/Surface.stories.tsx +91 -0
  179. package/src/components/ui/surface/Surface.test.tsx +63 -0
  180. package/src/components/ui/surface/Surface.tsx +51 -0
  181. package/src/components/ui/surface/index.ts +1 -0
  182. package/src/components/ui/switch/Switch.stories.tsx +111 -0
  183. package/src/components/ui/switch/Switch.test.tsx +112 -0
  184. package/src/components/ui/switch/Switch.tsx +103 -0
  185. package/src/components/ui/switch/index.ts +2 -0
  186. package/src/components/ui/tabs/Tabs.stories.tsx +213 -0
  187. package/src/components/ui/tabs/Tabs.test.tsx +192 -0
  188. package/src/components/ui/tabs/Tabs.tsx +253 -0
  189. package/src/components/ui/tabs/index.ts +16 -0
  190. package/src/components/ui/toast/Toast.stories.tsx +255 -0
  191. package/src/components/ui/toast/Toast.test.tsx +207 -0
  192. package/src/components/ui/toast/Toast.tsx +327 -0
  193. package/src/components/ui/toast/index.ts +9 -0
  194. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +415 -0
  195. package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +224 -0
  196. package/src/components/ui/toolbar-button/ToolbarButton.tsx +307 -0
  197. package/src/components/ui/toolbar-button/index.ts +7 -0
  198. package/src/components/ui/tooltip/Tooltip.stories.tsx +126 -0
  199. package/src/components/ui/tooltip/Tooltip.test.tsx +262 -0
  200. package/src/components/ui/tooltip/Tooltip.tsx +234 -0
  201. package/src/components/ui/tooltip/index.ts +2 -0
  202. package/src/index.ts +10 -0
  203. package/src/stories/PresetShowcase.stories.tsx +462 -0
  204. package/src/stories/ThemePresets.stories.tsx +85 -0
  205. package/src/test/setup.ts +19 -0
  206. package/src/theme/Colors.stories.tsx +258 -0
  207. package/src/theme/config.ts +193 -0
  208. package/src/theme/elevation.stories.tsx +346 -0
  209. package/src/theme/elevation.test.ts +27 -0
  210. package/src/theme/elevation.ts +567 -0
  211. package/src/theme/global.css +129 -1
  212. package/src/theme/index.ts +40 -0
  213. package/src/theme/presets/apply.ts +97 -0
  214. package/src/theme/presets/audiobook.ts +93 -0
  215. package/src/theme/presets/default.ts +6 -0
  216. package/src/theme/presets/index.ts +4 -0
  217. package/src/theme/presets/presets.test.ts +51 -0
  218. package/src/theme/presets/types.ts +76 -0
  219. package/src/theme/shadows.stories.tsx +523 -0
  220. package/src/theme/shadows.ts +439 -0
  221. package/src/theme/tokens/index.ts +2 -0
  222. package/src/theme/tokens/primitives.ts +354 -0
  223. package/src/theme/tokens/semantic.ts +381 -0
  224. package/src/types/jest-axe.d.ts +16 -0
  225. package/src/types/nativewind.d.ts +54 -0
  226. package/src/utils/avatar-color.test.ts +30 -0
  227. package/src/utils/avatar-color.ts +20 -0
  228. package/src/utils/cn.ts +14 -0
  229. package/src/utils/colors.ts +140 -0
  230. package/src/utils/form.ts +188 -0
  231. package/src/utils/index.ts +29 -0
  232. package/src/utils/useTheme.ts +72 -0
  233. package/src/web-jsx/jsx-dev-runtime.ts +28 -0
  234. package/src/web-jsx/jsx-runtime.ts +41 -0
  235. package/tailwind.config.js +53 -3
  236. package/dist/chunk-MGW37MPO.mjs.map +0 -1
@@ -0,0 +1,152 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { Avatar, AvatarBadge, AvatarGroup } from './Avatar'
5
+
6
+ describe('Avatar', () => {
7
+ it('renders correctly with default props', () => {
8
+ render(<Avatar />)
9
+ expect(screen.getByRole('img')).toBeInTheDocument()
10
+ })
11
+
12
+ it('renders fallback text', () => {
13
+ render(<Avatar fallback="JD" />)
14
+ expect(screen.getByText('JD')).toBeInTheDocument()
15
+ })
16
+
17
+ it('uses fallback as accessibility label when no alt provided', () => {
18
+ render(<Avatar fallback="JD" />)
19
+ expect(screen.getByRole('img')).toHaveAttribute('aria-label', 'JD')
20
+ })
21
+
22
+ it('uses alt as accessibility label', () => {
23
+ render(<Avatar alt="John Doe" fallback="JD" />)
24
+ expect(screen.getByRole('img')).toHaveAttribute('aria-label', 'John Doe')
25
+ })
26
+
27
+ it('uses default accessibility label when no alt or fallback', () => {
28
+ render(<Avatar />)
29
+ expect(screen.getByRole('img')).toHaveAttribute('aria-label', 'Avatar')
30
+ })
31
+
32
+ it('renders with image source', () => {
33
+ render(<Avatar source={{ uri: 'https://example.com/avatar.jpg' }} alt="User" />)
34
+ const imgs = screen.getAllByRole('img')
35
+ expect(imgs.length).toBeGreaterThan(0)
36
+ })
37
+
38
+ it('renders with all size options', () => {
39
+ const sizes = ['xs', 'sm', 'md', 'lg', 'xl', '2xl'] as const
40
+ sizes.forEach((size) => {
41
+ const { unmount } = render(<Avatar size={size} fallback="AB" />)
42
+ expect(screen.getByRole('img')).toBeInTheDocument()
43
+ unmount()
44
+ })
45
+ })
46
+
47
+ it('renders empty placeholder when no source or fallback', () => {
48
+ const { container } = render(<Avatar />)
49
+ expect(container.firstChild).toBeInTheDocument()
50
+ })
51
+
52
+ it('applies custom className', () => {
53
+ render(<Avatar className="extra" />)
54
+ expect(screen.getByRole('img')).toBeInTheDocument()
55
+ })
56
+
57
+ describe('colorFromName', () => {
58
+ it('renders initials from colorFromName', () => {
59
+ render(<Avatar colorFromName="John Doe" />)
60
+ expect(screen.getByText('JD')).toBeInTheDocument()
61
+ })
62
+
63
+ it('generates deterministic color from name', () => {
64
+ const { container } = render(<Avatar colorFromName="Alice" />)
65
+ expect(container.firstChild).toBeInTheDocument()
66
+ })
67
+
68
+ it('colorFromName fallback is overridden by explicit fallback', () => {
69
+ render(<Avatar colorFromName="John Doe" fallback="XX" />)
70
+ expect(screen.getByText('XX')).toBeInTheDocument()
71
+ })
72
+ })
73
+
74
+ describe('AvatarBadge', () => {
75
+ it('renders with default success color', () => {
76
+ const { container } = render(<AvatarBadge />)
77
+ expect(container.firstChild).toBeInTheDocument()
78
+ })
79
+
80
+ it('renders with all color options', () => {
81
+ const colors = ['success', 'error', 'warning', 'default'] as const
82
+ colors.forEach((color) => {
83
+ const { unmount } = render(<AvatarBadge color={color} />)
84
+ unmount()
85
+ })
86
+ })
87
+
88
+ it('accepts custom className', () => {
89
+ const { container } = render(<AvatarBadge className="extra" />)
90
+ expect(container.firstChild).toBeInTheDocument()
91
+ })
92
+ })
93
+
94
+ describe('AvatarGroup', () => {
95
+ it('renders children avatars', () => {
96
+ render(
97
+ <AvatarGroup>
98
+ <Avatar fallback="A" />
99
+ <Avatar fallback="B" />
100
+ </AvatarGroup>
101
+ )
102
+ expect(screen.getByText('A')).toBeInTheDocument()
103
+ expect(screen.getByText('B')).toBeInTheDocument()
104
+ })
105
+
106
+ it('limits visible avatars with max prop', () => {
107
+ render(
108
+ <AvatarGroup max={2}>
109
+ <Avatar fallback="A" />
110
+ <Avatar fallback="B" />
111
+ <Avatar fallback="C" />
112
+ <Avatar fallback="D" />
113
+ </AvatarGroup>
114
+ )
115
+ expect(screen.getByText('A')).toBeInTheDocument()
116
+ expect(screen.getByText('B')).toBeInTheDocument()
117
+ expect(screen.getByText('+2')).toBeInTheDocument()
118
+ })
119
+
120
+ it('shows no +N indicator when all avatars fit', () => {
121
+ render(
122
+ <AvatarGroup max={5}>
123
+ <Avatar fallback="A" />
124
+ <Avatar fallback="B" />
125
+ </AvatarGroup>
126
+ )
127
+ expect(screen.queryByText(/\+/)).not.toBeInTheDocument()
128
+ })
129
+
130
+ it('applies custom className', () => {
131
+ const { container } = render(
132
+ <AvatarGroup className="extra">
133
+ <Avatar fallback="A" />
134
+ </AvatarGroup>
135
+ )
136
+ expect(container.firstChild).toBeInTheDocument()
137
+ })
138
+ })
139
+
140
+ describe('accessibility', () => {
141
+ it('has no accessibility violations', async () => {
142
+ const { container } = render(<Avatar fallback="JD" alt="John Doe" />)
143
+ const results = await axe(container)
144
+ expect(results).toHaveNoViolations()
145
+ })
146
+
147
+ it('has correct image role', () => {
148
+ render(<Avatar fallback="JD" />)
149
+ expect(screen.getByRole('img')).toBeInTheDocument()
150
+ })
151
+ })
152
+ })
@@ -0,0 +1,176 @@
1
+ import React from 'react'
2
+ import { View, Text, Image, type ViewProps, type ImageSourcePropType } from 'react-native'
3
+ import { cn } from '../../../utils/cn'
4
+ import { avatarColor, getInitials } from '../../../utils/avatar-color'
5
+
6
+ export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
7
+
8
+ export interface AvatarProps extends ViewProps {
9
+ /** Avatar size */
10
+ size?: AvatarSize
11
+ /** Image source */
12
+ source?: ImageSourcePropType
13
+ /** Fallback text (usually initials) */
14
+ fallback?: string
15
+ /** Alt text for accessibility */
16
+ alt?: string
17
+ /** Generate background color and initials from a name */
18
+ colorFromName?: string
19
+ /** Additional className */
20
+ className?: string
21
+ }
22
+
23
+ const sizeStyles: Record<AvatarSize, { container: string; text: string; image: string }> = {
24
+ xs: { container: 'w-6 h-6', text: 'text-xs', image: 'w-6 h-6' },
25
+ sm: { container: 'w-8 h-8', text: 'text-xs', image: 'w-8 h-8' },
26
+ md: { container: 'w-10 h-10', text: 'text-sm', image: 'w-10 h-10' },
27
+ lg: { container: 'w-12 h-12', text: 'text-base', image: 'w-12 h-12' },
28
+ xl: { container: 'w-16 h-16', text: 'text-lg', image: 'w-16 h-16' },
29
+ '2xl': { container: 'w-20 h-20', text: 'text-xl', image: 'w-20 h-20' },
30
+ }
31
+
32
+ /**
33
+ * Avatar component for user/entity representation.
34
+ *
35
+ * @example
36
+ * // With image
37
+ * <Avatar source={{ uri: 'https://example.com/avatar.jpg' }} alt="User" />
38
+ *
39
+ * // With fallback initials
40
+ * <Avatar fallback="JD" />
41
+ *
42
+ * // With badge
43
+ * <AvatarGroup>
44
+ * <Avatar source={source1} />
45
+ * <Avatar source={source2} />
46
+ * <AvatarBadge />
47
+ * </AvatarGroup>
48
+ */
49
+ export function Avatar({
50
+ size = 'md',
51
+ source,
52
+ fallback,
53
+ alt,
54
+ colorFromName,
55
+ className,
56
+ ...props
57
+ }: AvatarProps) {
58
+ const styles = sizeStyles[size]
59
+ const hasImage = source !== undefined
60
+
61
+ const resolvedFallback = fallback ?? (colorFromName ? getInitials(colorFromName) : undefined)
62
+ const nameColor = colorFromName ? avatarColor(colorFromName) : undefined
63
+
64
+ return (
65
+ <View
66
+ accessibilityRole="image"
67
+ accessibilityLabel={alt || resolvedFallback || 'Avatar'}
68
+ className={cn(
69
+ 'items-center justify-center rounded-full overflow-hidden',
70
+ !nameColor && 'bg-border-strong',
71
+ styles.container,
72
+ className
73
+ )}
74
+ style={nameColor ? { backgroundColor: nameColor } : undefined}
75
+ {...props}
76
+ >
77
+ {hasImage ? (
78
+ <Image
79
+ source={source}
80
+ className={cn('rounded-full', styles.image)}
81
+ accessibilityLabel={alt}
82
+ />
83
+ ) : resolvedFallback ? (
84
+ <Text className={cn('font-semibold text-text-inverse', styles.text)}>
85
+ {resolvedFallback}
86
+ </Text>
87
+ ) : (
88
+ <View className="w-full h-full bg-surface-raised" />
89
+ )}
90
+ </View>
91
+ )
92
+ }
93
+
94
+ export interface AvatarBadgeProps {
95
+ /** Badge color */
96
+ color?: 'success' | 'error' | 'warning' | 'default'
97
+ /** Additional className */
98
+ className?: string
99
+ }
100
+
101
+ const badgeColors: Record<string, string> = {
102
+ success: 'bg-status-success',
103
+ error: 'bg-status-error',
104
+ warning: 'bg-status-warning',
105
+ default: 'bg-border-strong',
106
+ }
107
+
108
+ /**
109
+ * Status badge for Avatar (e.g., online indicator).
110
+ */
111
+ export function AvatarBadge({ color = 'success', className }: AvatarBadgeProps) {
112
+ return (
113
+ <View
114
+ className={cn(
115
+ 'absolute bottom-0 right-0 w-3 h-3 rounded-full border-2 border-surface-base',
116
+ badgeColors[color],
117
+ className
118
+ )}
119
+ />
120
+ )
121
+ }
122
+
123
+ export interface AvatarGroupProps {
124
+ children: React.ReactNode
125
+ /** Maximum avatars to show before +N indicator */
126
+ max?: number
127
+ /** Size of avatars */
128
+ size?: AvatarSize
129
+ /** Additional className */
130
+ className?: string
131
+ }
132
+
133
+ /**
134
+ * Group of avatars with stacking effect.
135
+ */
136
+ export function AvatarGroup({
137
+ children,
138
+ max,
139
+ size = 'md',
140
+ className,
141
+ }: AvatarGroupProps) {
142
+ const childArray = React.Children.toArray(children)
143
+ const visibleChildren = max ? childArray.slice(0, max) : childArray
144
+ const remainingCount = max ? Math.max(0, childArray.length - max) : 0
145
+
146
+ return (
147
+ <View className={cn('flex-row', className)}>
148
+ {visibleChildren.map((child, index) => (
149
+ <View
150
+ key={index}
151
+ className={cn(
152
+ 'border-2 border-surface-base rounded-full',
153
+ index > 0 && '-ml-2'
154
+ )}
155
+ >
156
+ {React.isValidElement(child)
157
+ ? React.cloneElement(child as React.ReactElement<any>, { size })
158
+ : child}
159
+ </View>
160
+ ))}
161
+ {remainingCount > 0 && (
162
+ <View
163
+ className={cn(
164
+ 'border-2 border-surface-base rounded-full -ml-2',
165
+ 'items-center justify-center bg-surface-raised',
166
+ sizeStyles[size].container
167
+ )}
168
+ >
169
+ <Text className={cn('font-semibold text-text-secondary', sizeStyles[size].text)}>
170
+ +{remainingCount}
171
+ </Text>
172
+ </View>
173
+ )}
174
+ </View>
175
+ )
176
+ }
@@ -0,0 +1,2 @@
1
+ export { Avatar, AvatarBadge, AvatarGroup } from './Avatar'
2
+ export type { AvatarProps, AvatarBadgeProps, AvatarGroupProps, AvatarSize } from './Avatar'
@@ -0,0 +1,100 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { Badge, BadgeText } from './Badge'
4
+
5
+ const meta: Meta<typeof Badge> = {
6
+ title: 'Components/Badge',
7
+ component: Badge,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ variant: {
11
+ control: 'select',
12
+ options: ['solid', 'subtle', 'outline'],
13
+ description: 'Visual variant',
14
+ },
15
+ color: {
16
+ control: 'select',
17
+ options: ['default', 'primary', 'secondary', 'success', 'error', 'warning', 'info'],
18
+ description: 'Color scheme',
19
+ },
20
+ size: {
21
+ control: 'select',
22
+ options: ['sm', 'md', 'lg'],
23
+ description: 'Badge size',
24
+ },
25
+ },
26
+ }
27
+
28
+ export default meta
29
+ type Story = StoryObj<typeof Badge>
30
+
31
+ export const Default: Story = {
32
+ args: {
33
+ variant: 'subtle',
34
+ color: 'default',
35
+ size: 'md',
36
+ children: 'Badge',
37
+ },
38
+ }
39
+
40
+ export const AllVariants: Story = {
41
+ render: () => (
42
+ <View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
43
+ <Badge variant="solid" color="primary">Solid</Badge>
44
+ <Badge variant="subtle" color="primary">Subtle</Badge>
45
+ <Badge variant="outline" color="primary">Outline</Badge>
46
+ </View>
47
+ ),
48
+ }
49
+
50
+ export const AllColors: Story = {
51
+ render: () => (
52
+ <View style={{ gap: 12 }}>
53
+ <View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
54
+ <Badge variant="solid" color="default">Default</Badge>
55
+ <Badge variant="solid" color="primary">Primary</Badge>
56
+ <Badge variant="solid" color="secondary">Secondary</Badge>
57
+ <Badge variant="solid" color="success">Success</Badge>
58
+ <Badge variant="solid" color="error">Error</Badge>
59
+ <Badge variant="solid" color="warning">Warning</Badge>
60
+ <Badge variant="solid" color="info">Info</Badge>
61
+ </View>
62
+ <View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
63
+ <Badge variant="subtle" color="default">Default</Badge>
64
+ <Badge variant="subtle" color="primary">Primary</Badge>
65
+ <Badge variant="subtle" color="secondary">Secondary</Badge>
66
+ <Badge variant="subtle" color="success">Success</Badge>
67
+ <Badge variant="subtle" color="error">Error</Badge>
68
+ <Badge variant="subtle" color="warning">Warning</Badge>
69
+ <Badge variant="subtle" color="info">Info</Badge>
70
+ </View>
71
+ <View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
72
+ <Badge variant="outline" color="default">Default</Badge>
73
+ <Badge variant="outline" color="primary">Primary</Badge>
74
+ <Badge variant="outline" color="secondary">Secondary</Badge>
75
+ <Badge variant="outline" color="success">Success</Badge>
76
+ <Badge variant="outline" color="error">Error</Badge>
77
+ <Badge variant="outline" color="warning">Warning</Badge>
78
+ <Badge variant="outline" color="info">Info</Badge>
79
+ </View>
80
+ </View>
81
+ ),
82
+ }
83
+
84
+ export const AllSizes: Story = {
85
+ render: () => (
86
+ <View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
87
+ <Badge size="sm" color="primary">Small</Badge>
88
+ <Badge size="md" color="primary">Medium</Badge>
89
+ <Badge size="lg" color="primary">Large</Badge>
90
+ </View>
91
+ ),
92
+ }
93
+
94
+ export const WithBadgeText: Story = {
95
+ render: () => (
96
+ <Badge variant="solid" color="success">
97
+ <BadgeText>Active</BadgeText>
98
+ </Badge>
99
+ ),
100
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { Badge, BadgeText } from './Badge'
5
+
6
+ describe('Badge', () => {
7
+ it('renders string children correctly', () => {
8
+ render(<Badge>Active</Badge>)
9
+ expect(screen.getByText('Active')).toBeInTheDocument()
10
+ })
11
+
12
+ it('renders BadgeText compound component', () => {
13
+ render(
14
+ <Badge>
15
+ <BadgeText>Status</BadgeText>
16
+ </Badge>
17
+ )
18
+ expect(screen.getByText('Status')).toBeInTheDocument()
19
+ })
20
+
21
+ it('applies default variant (subtle) and color (default)', () => {
22
+ const { container } = render(<Badge>Default</Badge>)
23
+ expect(container.firstChild).toBeInTheDocument()
24
+ })
25
+
26
+ it('renders with all variant options', () => {
27
+ const variants = ['solid', 'subtle', 'outline'] as const
28
+ variants.forEach((variant) => {
29
+ const { unmount } = render(<Badge variant={variant}>Test</Badge>)
30
+ expect(screen.getByText('Test')).toBeInTheDocument()
31
+ unmount()
32
+ })
33
+ })
34
+
35
+ it('renders with all color options', () => {
36
+ const colors = ['default', 'primary', 'secondary', 'success', 'error', 'warning', 'info'] as const
37
+ colors.forEach((color) => {
38
+ const { unmount } = render(<Badge color={color}>Test</Badge>)
39
+ expect(screen.getByText('Test')).toBeInTheDocument()
40
+ unmount()
41
+ })
42
+ })
43
+
44
+ it('renders with all size options', () => {
45
+ const sizes = ['sm', 'md', 'lg'] as const
46
+ sizes.forEach((size) => {
47
+ const { unmount } = render(<Badge size={size}>Test</Badge>)
48
+ expect(screen.getByText('Test')).toBeInTheDocument()
49
+ unmount()
50
+ })
51
+ })
52
+
53
+ it('applies custom className', () => {
54
+ const { container } = render(<Badge className="custom-class">Test</Badge>)
55
+ expect(container.firstChild).toBeInTheDocument()
56
+ })
57
+
58
+ it('passes additional props through', () => {
59
+ render(<Badge testID="badge-test">Test</Badge>)
60
+ expect(screen.getByText('Test')).toBeInTheDocument()
61
+ })
62
+
63
+ describe('BadgeText', () => {
64
+ it('renders children', () => {
65
+ render(<BadgeText>Label</BadgeText>)
66
+ expect(screen.getByText('Label')).toBeInTheDocument()
67
+ })
68
+
69
+ it('accepts custom className', () => {
70
+ render(<BadgeText className="extra">Label</BadgeText>)
71
+ expect(screen.getByText('Label')).toBeInTheDocument()
72
+ })
73
+ })
74
+
75
+ describe('dot indicator', () => {
76
+ it('renders dot when dot prop is true', () => {
77
+ const { container } = render(<Badge dot color="success">Active</Badge>)
78
+ expect(container.firstChild).toBeInTheDocument()
79
+ })
80
+
81
+ it('renders dot with explicit dotColor', () => {
82
+ const { container } = render(<Badge dot dotColor="warning">Pending</Badge>)
83
+ expect(container.firstChild).toBeInTheDocument()
84
+ })
85
+
86
+ it('does not render dot by default', () => {
87
+ const { container } = render(<Badge>No dot</Badge>)
88
+ expect(container.firstChild).toBeInTheDocument()
89
+ })
90
+ })
91
+
92
+ describe('accessibility', () => {
93
+ it('has no accessibility violations', async () => {
94
+ const { container } = render(
95
+ <Badge color="success">
96
+ <BadgeText>Active</BadgeText>
97
+ </Badge>
98
+ )
99
+ const results = await axe(container)
100
+ expect(results).toHaveNoViolations()
101
+ })
102
+ })
103
+ })
@@ -0,0 +1,119 @@
1
+ import React from 'react'
2
+ import { View, Text, type ViewProps } from 'react-native'
3
+ import { cn } from '../../../utils/cn'
4
+ import { Indicator, type IndicatorColor } from '../indicator'
5
+
6
+ export type BadgeVariant = 'solid' | 'subtle' | 'outline'
7
+ export type BadgeColor = 'default' | 'primary' | 'secondary' | 'success' | 'error' | 'warning' | 'info'
8
+ export type BadgeSize = 'sm' | 'md' | 'lg'
9
+
10
+ export interface BadgeProps extends ViewProps {
11
+ /** Visual variant */
12
+ variant?: BadgeVariant
13
+ /** Color scheme */
14
+ color?: BadgeColor
15
+ /** Size */
16
+ size?: BadgeSize
17
+ /** Show a leading indicator dot */
18
+ dot?: boolean
19
+ /** Color for the dot indicator (defaults to match badge color) */
20
+ dotColor?: IndicatorColor
21
+ /** Additional className */
22
+ className?: string
23
+ children?: React.ReactNode
24
+ }
25
+
26
+ const variantColorStyles: Record<BadgeVariant, Record<BadgeColor, string>> = {
27
+ solid: {
28
+ default: 'bg-border-strong text-text-inverse',
29
+ primary: 'bg-brand-primary text-text-inverse',
30
+ secondary: 'bg-brand-secondary text-text-inverse',
31
+ success: 'bg-status-success text-text-inverse',
32
+ error: 'bg-status-error text-text-inverse',
33
+ warning: 'bg-status-warning text-text-inverse',
34
+ info: 'bg-status-info text-text-inverse',
35
+ },
36
+ subtle: {
37
+ default: 'bg-surface-raised text-text-secondary',
38
+ primary: 'bg-brand-primary-subtle text-brand-primary',
39
+ secondary: 'bg-brand-secondary-subtle text-brand-secondary',
40
+ success: 'bg-status-success-subtle text-status-success',
41
+ error: 'bg-status-error-subtle text-status-error',
42
+ warning: 'bg-status-warning-subtle text-status-warning',
43
+ info: 'bg-status-info-subtle text-status-info',
44
+ },
45
+ outline: {
46
+ default: 'border border-border-strong text-text-secondary',
47
+ primary: 'border border-brand-primary text-brand-primary',
48
+ secondary: 'border border-brand-secondary text-brand-secondary',
49
+ success: 'border border-status-success text-status-success',
50
+ error: 'border border-status-error text-status-error',
51
+ warning: 'border border-status-warning text-status-warning',
52
+ info: 'border border-status-info text-status-info',
53
+ },
54
+ }
55
+
56
+ const sizeStyles: Record<BadgeSize, string> = {
57
+ sm: 'px-1.5 py-0.5 text-xs',
58
+ md: 'px-2 py-0.5 text-xs',
59
+ lg: 'px-2.5 py-1 text-sm',
60
+ }
61
+
62
+ /**
63
+ * Badge component for status indicators and labels.
64
+ *
65
+ * @example
66
+ * <Badge color="success">Active</Badge>
67
+ * <Badge variant="outline" color="warning">Pending</Badge>
68
+ */
69
+ export function Badge({
70
+ variant = 'subtle',
71
+ color = 'default',
72
+ size = 'md',
73
+ dot,
74
+ dotColor,
75
+ className,
76
+ children,
77
+ ...props
78
+ }: BadgeProps) {
79
+ return (
80
+ <View
81
+ className={cn(
82
+ 'inline-flex items-center justify-center rounded-full font-medium',
83
+ variantColorStyles[variant][color],
84
+ sizeStyles[size],
85
+ className
86
+ )}
87
+ {...props}
88
+ >
89
+ {dot && (
90
+ <Indicator
91
+ size="xs"
92
+ color={dotColor ?? (color === 'default' || color === 'secondary' ? 'default' : color as IndicatorColor)}
93
+ className="mr-0.5"
94
+ />
95
+ )}
96
+ {typeof children === 'string' ? (
97
+ <Text className="text-inherit font-medium">{children}</Text>
98
+ ) : (
99
+ children
100
+ )}
101
+ </View>
102
+ )
103
+ }
104
+
105
+ export interface BadgeTextProps {
106
+ children: React.ReactNode
107
+ className?: string
108
+ }
109
+
110
+ /**
111
+ * Text component for Badge content.
112
+ */
113
+ export function BadgeText({ children, className }: BadgeTextProps) {
114
+ return (
115
+ <Text className={cn('text-inherit font-medium', className)}>
116
+ {children}
117
+ </Text>
118
+ )
119
+ }
@@ -0,0 +1,3 @@
1
+ export { Badge, BadgeText } from './Badge'
2
+ export type { BadgeProps, BadgeTextProps, BadgeVariant, BadgeColor, BadgeSize } from './Badge'
3
+ export type { IndicatorColor } from '../indicator'