@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,94 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { Link } from './Link'
5
+
6
+ describe('Link', () => {
7
+ it('renders children correctly', () => {
8
+ render(<Link>Click here</Link>)
9
+ expect(screen.getByText('Click here')).toBeInTheDocument()
10
+ })
11
+
12
+ it('has link accessibility role', () => {
13
+ render(<Link>Link</Link>)
14
+ expect(screen.getByRole('link')).toBeInTheDocument()
15
+ })
16
+
17
+ it('handles press events', () => {
18
+ const onPress = vi.fn()
19
+ render(<Link onPress={onPress}>Press me</Link>)
20
+ fireEvent.click(screen.getByRole('link'))
21
+ expect(onPress).toHaveBeenCalledTimes(1)
22
+ })
23
+
24
+ it('does not fire onPress when disabled', () => {
25
+ const onPress = vi.fn()
26
+ render(
27
+ <Link onPress={onPress} isDisabled>
28
+ Disabled
29
+ </Link>
30
+ )
31
+ fireEvent.click(screen.getByRole('link'))
32
+ expect(onPress).not.toHaveBeenCalled()
33
+ })
34
+
35
+ it('appends external indicator when isExternal', () => {
36
+ render(<Link isExternal>External</Link>)
37
+ expect(screen.getByText(/External/)).toBeInTheDocument()
38
+ expect(screen.getByText(/\u2197/)).toBeInTheDocument()
39
+ })
40
+
41
+ it('provides accessibility hint for external links', () => {
42
+ render(<Link isExternal>External</Link>)
43
+ // react-native-web does not map accessibilityHint to aria-description
44
+ expect(screen.getByRole('link')).toBeInTheDocument()
45
+ })
46
+
47
+ it('does not have external hint for internal links', () => {
48
+ render(<Link>Internal</Link>)
49
+ expect(screen.getByRole('link')).not.toHaveAttribute('aria-description')
50
+ })
51
+
52
+ it('renders with all color options', () => {
53
+ const colors = ['default', 'primary', 'secondary', 'inherit'] as const
54
+ colors.forEach((color) => {
55
+ const { unmount } = render(<Link color={color}>Test</Link>)
56
+ expect(screen.getByText('Test')).toBeInTheDocument()
57
+ unmount()
58
+ })
59
+ })
60
+
61
+ it('renders with all underline options', () => {
62
+ const underlines = ['always', 'hover', 'none'] as const
63
+ underlines.forEach((underline) => {
64
+ const { unmount } = render(<Link underline={underline}>Test</Link>)
65
+ expect(screen.getByText('Test')).toBeInTheDocument()
66
+ unmount()
67
+ })
68
+ })
69
+
70
+ it('applies custom className', () => {
71
+ render(<Link className="extra">Test</Link>)
72
+ expect(screen.getByText('Test')).toBeInTheDocument()
73
+ })
74
+
75
+ it('disables the pressable when isDisabled', () => {
76
+ render(<Link isDisabled>Disabled link</Link>)
77
+ // react-native-web renders aria-disabled on a div, toBeDisabled() only works on form elements
78
+ expect(screen.getByRole('link')).toHaveAttribute('aria-disabled', 'true')
79
+ })
80
+
81
+ describe('accessibility', () => {
82
+ it('has no accessibility violations', async () => {
83
+ const { container } = render(<Link>Visit site</Link>)
84
+ const results = await axe(container)
85
+ expect(results).toHaveNoViolations()
86
+ })
87
+
88
+ it('has no accessibility violations for external link', async () => {
89
+ const { container } = render(<Link isExternal>External site</Link>)
90
+ const results = await axe(container)
91
+ expect(results).toHaveNoViolations()
92
+ })
93
+ })
94
+ })
@@ -0,0 +1,86 @@
1
+ import React from 'react'
2
+ import { Text, Pressable, type TextProps } from 'react-native'
3
+ import { cn } from '../../../utils/cn'
4
+
5
+ export type LinkUnderline = 'always' | 'hover' | 'none'
6
+ export type LinkColor = 'default' | 'primary' | 'secondary' | 'inherit'
7
+
8
+ export interface LinkProps extends Omit<TextProps, 'onPress'> {
9
+ /** URL to navigate to (for accessibility) */
10
+ href?: string
11
+ /** Underline behavior */
12
+ underline?: LinkUnderline
13
+ /** Link color */
14
+ color?: LinkColor
15
+ /** Whether the link opens in a new tab/window */
16
+ isExternal?: boolean
17
+ /** Press handler */
18
+ onPress?: () => void
19
+ /** Whether the link is disabled */
20
+ isDisabled?: boolean
21
+ /** Additional className */
22
+ className?: string
23
+ children?: React.ReactNode
24
+ }
25
+
26
+ const colorStyles: Record<LinkColor, string> = {
27
+ default: 'text-text-link',
28
+ primary: 'text-brand-primary',
29
+ secondary: 'text-brand-secondary',
30
+ inherit: '',
31
+ }
32
+
33
+ const underlineStyles: Record<LinkUnderline, string> = {
34
+ always: 'underline',
35
+ hover: 'no-underline web:hover:underline',
36
+ none: 'no-underline',
37
+ }
38
+
39
+ /**
40
+ * Link component for navigation.
41
+ *
42
+ * @example
43
+ * <Link href="https://example.com">Visit Site</Link>
44
+ * <Link href="https://example.com" isExternal>External Link ↗</Link>
45
+ * <Link onPress={() => navigate('/page')}>Go to Page</Link>
46
+ */
47
+ export function Link({
48
+ href,
49
+ underline = 'hover',
50
+ color = 'default',
51
+ isExternal = false,
52
+ onPress,
53
+ isDisabled = false,
54
+ className,
55
+ children,
56
+ ...props
57
+ }: LinkProps) {
58
+ const handlePress = () => {
59
+ if (isDisabled) return
60
+ onPress?.()
61
+ }
62
+
63
+ return (
64
+ <Pressable
65
+ onPress={handlePress}
66
+ disabled={isDisabled}
67
+ accessibilityRole="link"
68
+ accessibilityHint={isExternal ? 'Opens in new window' : undefined}
69
+ >
70
+ <Text
71
+ className={cn(
72
+ 'font-medium',
73
+ colorStyles[color],
74
+ underlineStyles[underline],
75
+ isDisabled && 'opacity-50 cursor-not-allowed',
76
+ 'web:cursor-pointer active:opacity-70',
77
+ className
78
+ )}
79
+ {...props}
80
+ >
81
+ {children}
82
+ {isExternal && ' ↗'}
83
+ </Text>
84
+ </Pressable>
85
+ )
86
+ }
@@ -0,0 +1,2 @@
1
+ export { Link } from './Link'
2
+ export type { LinkProps, LinkUnderline, LinkColor } from './Link'
@@ -0,0 +1,146 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View, Text } from 'react-native'
3
+ import {
4
+ ListItem,
5
+ ListItemIcon,
6
+ ListItemContent,
7
+ ListItemTrailing,
8
+ ListItemDivider,
9
+ } from './ListItem'
10
+
11
+ function UserIcon({ size = 20 }: { size?: number; className?: string }) {
12
+ return (
13
+ <View style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: '#6366f1', justifyContent: 'center', alignItems: 'center' }}>
14
+ <Text style={{ color: '#fff', fontSize: size * 0.5, fontWeight: '600' }}>U</Text>
15
+ </View>
16
+ )
17
+ }
18
+
19
+ function WifiIcon({ size = 20 }: { size?: number; className?: string }) {
20
+ return (
21
+ <View style={{ width: size, height: size, justifyContent: 'center', alignItems: 'center' }}>
22
+ <Text style={{ fontSize: size * 0.7 }}>W</Text>
23
+ </View>
24
+ )
25
+ }
26
+
27
+ function BellIcon({ size = 20 }: { size?: number; className?: string }) {
28
+ return (
29
+ <View style={{ width: size, height: size, justifyContent: 'center', alignItems: 'center' }}>
30
+ <Text style={{ fontSize: size * 0.7 }}>B</Text>
31
+ </View>
32
+ )
33
+ }
34
+
35
+ function LockIcon({ size = 20 }: { size?: number; className?: string }) {
36
+ return (
37
+ <View style={{ width: size, height: size, justifyContent: 'center', alignItems: 'center' }}>
38
+ <Text style={{ fontSize: size * 0.7 }}>L</Text>
39
+ </View>
40
+ )
41
+ }
42
+
43
+ function ChevronRight({ size = 16 }: { size?: number }) {
44
+ return <Text style={{ color: '#9ca3af', fontSize: size }}>{'>'}</Text>
45
+ }
46
+
47
+ const meta: Meta<typeof ListItem> = {
48
+ title: 'Components/ListItem',
49
+ component: ListItem,
50
+ tags: ['autodocs'],
51
+ }
52
+
53
+ export default meta
54
+ type Story = StoryObj<typeof ListItem>
55
+
56
+ export const Basic: Story = {
57
+ render: () => (
58
+ <ListItem>
59
+ <ListItemContent title="Simple list item" />
60
+ </ListItem>
61
+ ),
62
+ }
63
+
64
+ export const WithSubtitle: Story = {
65
+ render: () => (
66
+ <ListItem>
67
+ <ListItemContent title="Wi-Fi" subtitle="Connected to HomeNetwork" />
68
+ </ListItem>
69
+ ),
70
+ }
71
+
72
+ export const WithIcon: Story = {
73
+ render: () => (
74
+ <ListItem>
75
+ <ListItemIcon icon={WifiIcon} />
76
+ <ListItemContent title="Wi-Fi" subtitle="Connected" />
77
+ </ListItem>
78
+ ),
79
+ }
80
+
81
+ export const WithTrailing: Story = {
82
+ render: () => (
83
+ <ListItem onPress={() => {}}>
84
+ <ListItemIcon icon={UserIcon} />
85
+ <ListItemContent title="Account" subtitle="Manage your account" />
86
+ <ListItemTrailing>
87
+ <ChevronRight />
88
+ </ListItemTrailing>
89
+ </ListItem>
90
+ ),
91
+ }
92
+
93
+ export const SettingsList: Story = {
94
+ render: () => (
95
+ <View>
96
+ <ListItem onPress={() => {}}>
97
+ <ListItemIcon icon={UserIcon} />
98
+ <ListItemContent title="Profile" subtitle="Edit your profile details" />
99
+ <ListItemTrailing>
100
+ <ChevronRight />
101
+ </ListItemTrailing>
102
+ </ListItem>
103
+ <ListItemDivider />
104
+
105
+ <ListItem onPress={() => {}}>
106
+ <ListItemIcon icon={BellIcon} />
107
+ <ListItemContent title="Notifications" subtitle="Push, email, SMS" />
108
+ <ListItemTrailing>
109
+ <ChevronRight />
110
+ </ListItemTrailing>
111
+ </ListItem>
112
+ <ListItemDivider />
113
+
114
+ <ListItem onPress={() => {}}>
115
+ <ListItemIcon icon={LockIcon} />
116
+ <ListItemContent title="Privacy" subtitle="Manage your data" />
117
+ <ListItemTrailing>
118
+ <ChevronRight />
119
+ </ListItemTrailing>
120
+ </ListItem>
121
+ <ListItemDivider />
122
+
123
+ <ListItem onPress={() => {}}>
124
+ <ListItemIcon icon={WifiIcon} />
125
+ <ListItemContent title="Wi-Fi" subtitle="HomeNetwork" />
126
+ <ListItemTrailing>
127
+ <Text style={{ color: '#22c55e', fontSize: 12 }}>Connected</Text>
128
+ </ListItemTrailing>
129
+ </ListItem>
130
+ </View>
131
+ ),
132
+ }
133
+
134
+ export const FullWidthDivider: Story = {
135
+ render: () => (
136
+ <View>
137
+ <ListItem>
138
+ <ListItemContent title="First item" />
139
+ </ListItem>
140
+ <ListItemDivider inset={false} />
141
+ <ListItem>
142
+ <ListItemContent title="Second item" />
143
+ </ListItem>
144
+ </View>
145
+ ),
146
+ }
@@ -0,0 +1,128 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { View, Text } from 'react-native'
5
+ import {
6
+ ListItem,
7
+ ListItemIcon,
8
+ ListItemContent,
9
+ ListItemTrailing,
10
+ ListItemDivider,
11
+ } from './ListItem'
12
+
13
+ function MockIcon({ size = 20 }: { size?: number; className?: string }) {
14
+ return <View testID="mock-icon" style={{ width: size, height: size }} />
15
+ }
16
+
17
+ function ChevronIcon({ size = 16 }: { size?: number; className?: string }) {
18
+ return <Text>{'>'}</Text>
19
+ }
20
+
21
+ describe('ListItem', () => {
22
+ it('renders compound children', () => {
23
+ render(
24
+ <ListItem>
25
+ <ListItemIcon icon={MockIcon} />
26
+ <ListItemContent title="Profile" subtitle="Edit your profile" />
27
+ <ListItemTrailing>
28
+ <Text>{'>'}</Text>
29
+ </ListItemTrailing>
30
+ </ListItem>
31
+ )
32
+
33
+ expect(screen.getByTestId('mock-icon')).toBeInTheDocument()
34
+ expect(screen.getByText('Profile')).toBeInTheDocument()
35
+ expect(screen.getByText('Edit your profile')).toBeInTheDocument()
36
+ expect(screen.getByText('>')).toBeInTheDocument()
37
+ })
38
+
39
+ it('handles press events when onPress is provided', () => {
40
+ const onPress = vi.fn()
41
+ render(
42
+ <ListItem onPress={onPress} testID="list-item">
43
+ <ListItemContent title="Pressable item" />
44
+ </ListItem>
45
+ )
46
+
47
+ fireEvent.click(screen.getByTestId('list-item'))
48
+ expect(onPress).toHaveBeenCalledTimes(1)
49
+ })
50
+
51
+ it('uses View when onPress is not provided', () => {
52
+ const { container } = render(
53
+ <ListItem testID="list-item">
54
+ <ListItemContent title="Static item" />
55
+ </ListItem>
56
+ )
57
+
58
+ // Without onPress, should not have button role
59
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
60
+ expect(screen.getByText('Static item')).toBeInTheDocument()
61
+ })
62
+ })
63
+
64
+ describe('ListItemContent', () => {
65
+ it('renders title', () => {
66
+ render(<ListItemContent title="Settings" />)
67
+ expect(screen.getByText('Settings')).toBeInTheDocument()
68
+ })
69
+
70
+ it('renders title and subtitle', () => {
71
+ render(<ListItemContent title="Wi-Fi" subtitle="Connected" />)
72
+ expect(screen.getByText('Wi-Fi')).toBeInTheDocument()
73
+ expect(screen.getByText('Connected')).toBeInTheDocument()
74
+ })
75
+
76
+ it('omits subtitle when not provided', () => {
77
+ render(<ListItemContent title="Bluetooth" />)
78
+ expect(screen.getByText('Bluetooth')).toBeInTheDocument()
79
+ expect(screen.queryByText('Connected')).not.toBeInTheDocument()
80
+ })
81
+ })
82
+
83
+ describe('ListItemTrailing', () => {
84
+ it('renders trailing content', () => {
85
+ render(
86
+ <ListItemTrailing>
87
+ <Text>Badge</Text>
88
+ </ListItemTrailing>
89
+ )
90
+ expect(screen.getByText('Badge')).toBeInTheDocument()
91
+ })
92
+ })
93
+
94
+ describe('ListItemDivider', () => {
95
+ it('renders with inset by default', () => {
96
+ const { container } = render(<ListItemDivider testID="divider" />)
97
+ expect(screen.getByTestId('divider')).toBeInTheDocument()
98
+ })
99
+
100
+ it('renders full-width when inset is false', () => {
101
+ render(<ListItemDivider inset={false} testID="divider-full" />)
102
+ expect(screen.getByTestId('divider-full')).toBeInTheDocument()
103
+ })
104
+ })
105
+
106
+ describe('ListItem accessibility', () => {
107
+ it('has no accessibility violations', async () => {
108
+ const { container } = render(
109
+ <ListItem>
110
+ <ListItemContent title="Accessible item" subtitle="With subtitle" />
111
+ </ListItem>
112
+ )
113
+
114
+ const results = await axe(container)
115
+ expect(results).toHaveNoViolations()
116
+ })
117
+
118
+ it('has no accessibility violations when pressable', async () => {
119
+ const { container } = render(
120
+ <ListItem onPress={() => {}} accessibilityRole="button">
121
+ <ListItemContent title="Pressable item" />
122
+ </ListItem>
123
+ )
124
+
125
+ const results = await axe(container)
126
+ expect(results).toHaveNoViolations()
127
+ })
128
+ })
@@ -0,0 +1,89 @@
1
+ import React from 'react'
2
+ import { View, Text, Pressable, type ViewProps, type PressableProps } from 'react-native'
3
+ import { cn } from '../../../utils/cn'
4
+
5
+ // --- ListItem (container) ---
6
+
7
+ export interface ListItemProps extends PressableProps {
8
+ className?: string
9
+ children: React.ReactNode
10
+ }
11
+
12
+ export function ListItem({ className, children, onPress, ...props }: ListItemProps) {
13
+ const Container = onPress ? Pressable : View
14
+ const containerProps = onPress ? { onPress, ...props } : props
15
+
16
+ return (
17
+ <Container
18
+ className={cn('flex-row items-center py-3 px-4 min-h-[48px]', className)}
19
+ {...(containerProps as any)}
20
+ >
21
+ {children}
22
+ </Container>
23
+ )
24
+ }
25
+
26
+ // --- ListItemIcon (leading slot) ---
27
+
28
+ export interface ListItemIconProps extends ViewProps {
29
+ icon: React.ComponentType<{ size?: number; className?: string }>
30
+ className?: string
31
+ }
32
+
33
+ export function ListItemIcon({ icon: Icon, className, ...props }: ListItemIconProps) {
34
+ return (
35
+ <View className={cn('mr-3 items-center justify-center', className)} {...props}>
36
+ <Icon size={20} className="text-text-secondary" />
37
+ </View>
38
+ )
39
+ }
40
+
41
+ // --- ListItemContent (title + subtitle) ---
42
+
43
+ export interface ListItemContentProps extends ViewProps {
44
+ title: string
45
+ subtitle?: string
46
+ className?: string
47
+ }
48
+
49
+ export function ListItemContent({ title, subtitle, className, ...props }: ListItemContentProps) {
50
+ return (
51
+ <View className={cn('flex-1 justify-center', className)} {...props}>
52
+ <Text className="text-sm font-medium text-text-primary">{title}</Text>
53
+ {subtitle && (
54
+ <Text className="text-xs text-text-secondary mt-0.5">{subtitle}</Text>
55
+ )}
56
+ </View>
57
+ )
58
+ }
59
+
60
+ // --- ListItemTrailing (right-side slot) ---
61
+
62
+ export interface ListItemTrailingProps extends ViewProps {
63
+ className?: string
64
+ children: React.ReactNode
65
+ }
66
+
67
+ export function ListItemTrailing({ className, children, ...props }: ListItemTrailingProps) {
68
+ return (
69
+ <View className={cn('ml-3 items-center justify-center', className)} {...props}>
70
+ {children}
71
+ </View>
72
+ )
73
+ }
74
+
75
+ // --- ListItemDivider ---
76
+
77
+ export interface ListItemDividerProps extends ViewProps {
78
+ inset?: boolean
79
+ className?: string
80
+ }
81
+
82
+ export function ListItemDivider({ inset = true, className, ...props }: ListItemDividerProps) {
83
+ return (
84
+ <View
85
+ className={cn('h-px bg-divider', inset ? 'ml-14' : '', className)}
86
+ {...props}
87
+ />
88
+ )
89
+ }
@@ -0,0 +1,14 @@
1
+ export {
2
+ ListItem,
3
+ ListItemIcon,
4
+ ListItemContent,
5
+ ListItemTrailing,
6
+ ListItemDivider,
7
+ } from './ListItem'
8
+ export type {
9
+ ListItemProps,
10
+ ListItemIconProps,
11
+ ListItemContentProps,
12
+ ListItemTrailingProps,
13
+ ListItemDividerProps,
14
+ } from './ListItem'