@titan-design/react-ui 0.0.2 → 0.1.1

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 (206) hide show
  1. package/dist/{chunk-NA3EES23.mjs → chunk-MGW37MPO.mjs} +26 -4
  2. package/dist/chunk-MGW37MPO.mjs.map +1 -0
  3. package/dist/index.d.mts +115 -2
  4. package/dist/index.d.ts +115 -2
  5. package/dist/index.js +262 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +225 -3
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/theme/index.d.mts +124 -109
  10. package/dist/theme/index.d.ts +124 -109
  11. package/dist/theme/index.js +24 -1
  12. package/dist/theme/index.js.map +1 -1
  13. package/dist/theme/index.mjs +1 -1
  14. package/package.json +12 -5
  15. package/src/components/custom/DateTime/DateTime.stories.tsx +180 -0
  16. package/src/components/custom/DateTime/DateTime.test.tsx +152 -0
  17. package/src/components/custom/DateTime/DateTime.tsx +235 -0
  18. package/src/components/custom/DateTime/index.ts +2 -0
  19. package/src/components/custom/EmptyState/EmptyState.stories.tsx +99 -0
  20. package/src/components/custom/EmptyState/EmptyState.test.tsx +106 -0
  21. package/src/components/custom/EmptyState/EmptyState.tsx +68 -0
  22. package/src/components/custom/EmptyState/index.ts +2 -0
  23. package/src/components/custom/Metric/Metric.stories.tsx +133 -0
  24. package/src/components/custom/Metric/Metric.test.tsx +120 -0
  25. package/src/components/custom/Metric/Metric.tsx +92 -0
  26. package/src/components/custom/Metric/index.ts +2 -0
  27. package/src/components/custom/Sidebar/Sidebar.stories.tsx +227 -0
  28. package/src/components/custom/Sidebar/Sidebar.test.tsx +185 -0
  29. package/src/components/custom/Sidebar/Sidebar.tsx +257 -0
  30. package/src/components/custom/Sidebar/index.ts +18 -0
  31. package/src/components/custom/Table/Table.stories.tsx +481 -0
  32. package/src/components/custom/Table/Table.test.tsx +531 -0
  33. package/src/components/custom/Table/Table.tsx +696 -0
  34. package/src/components/custom/Table/index.ts +22 -0
  35. package/src/components/custom/Typography/Typography.stories.tsx +153 -0
  36. package/src/components/custom/Typography/Typography.test.tsx +107 -0
  37. package/src/components/custom/Typography/Typography.tsx +218 -0
  38. package/src/components/custom/Typography/index.ts +19 -0
  39. package/src/components/custom/index.ts +8 -0
  40. package/src/components/custom/stepper/Stepper.stories.tsx +245 -0
  41. package/src/components/custom/stepper/Stepper.test.tsx +230 -0
  42. package/src/components/custom/stepper/Stepper.tsx +250 -0
  43. package/src/components/custom/stepper/index.ts +10 -0
  44. package/src/components/index.ts +5 -0
  45. package/src/components/ui/alert/Alert.stories.tsx +154 -0
  46. package/src/components/ui/alert/Alert.test.tsx +202 -0
  47. package/src/components/ui/alert/Alert.tsx +170 -0
  48. package/src/components/ui/alert/index.ts +8 -0
  49. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +299 -0
  50. package/src/components/ui/autocomplete/Autocomplete.test.tsx +247 -0
  51. package/src/components/ui/autocomplete/Autocomplete.tsx +332 -0
  52. package/src/components/ui/autocomplete/index.ts +2 -0
  53. package/src/components/ui/avatar/Avatar.stories.tsx +105 -0
  54. package/src/components/ui/avatar/Avatar.test.tsx +135 -0
  55. package/src/components/ui/avatar/Avatar.tsx +168 -0
  56. package/src/components/ui/avatar/index.ts +2 -0
  57. package/src/components/ui/badge/Badge.stories.tsx +100 -0
  58. package/src/components/ui/badge/Badge.test.tsx +86 -0
  59. package/src/components/ui/badge/Badge.tsx +105 -0
  60. package/src/components/ui/badge/index.ts +2 -0
  61. package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +70 -0
  62. package/src/components/ui/breadcrumbs/Breadcrumbs.test.tsx +156 -0
  63. package/src/components/ui/breadcrumbs/Breadcrumbs.tsx +127 -0
  64. package/src/components/ui/breadcrumbs/index.ts +2 -0
  65. package/src/components/ui/button/Button.stories.tsx +216 -0
  66. package/src/components/ui/button/Button.test.tsx +132 -0
  67. package/src/components/ui/button/Button.tsx +242 -0
  68. package/src/components/ui/button/index.ts +10 -0
  69. package/src/components/ui/card/Card.stories.tsx +443 -0
  70. package/src/components/ui/card/Card.test.tsx +265 -0
  71. package/src/components/ui/card/Card.tsx +380 -0
  72. package/src/components/ui/card/index.ts +20 -0
  73. package/src/components/ui/checkbox/Checkbox.stories.tsx +126 -0
  74. package/src/components/ui/checkbox/Checkbox.test.tsx +152 -0
  75. package/src/components/ui/checkbox/Checkbox.tsx +163 -0
  76. package/src/components/ui/checkbox/index.ts +2 -0
  77. package/src/components/ui/chip/Chip.stories.tsx +112 -0
  78. package/src/components/ui/chip/Chip.test.tsx +119 -0
  79. package/src/components/ui/chip/Chip.tsx +151 -0
  80. package/src/components/ui/chip/index.ts +2 -0
  81. package/src/components/ui/collapse/Collapse.stories.tsx +313 -0
  82. package/src/components/ui/collapse/Collapse.test.tsx +298 -0
  83. package/src/components/ui/collapse/Collapse.tsx +275 -0
  84. package/src/components/ui/collapse/index.ts +18 -0
  85. package/src/components/ui/data-row/DataRow.stories.tsx +78 -0
  86. package/src/components/ui/data-row/DataRow.test.tsx +78 -0
  87. package/src/components/ui/data-row/DataRow.tsx +29 -0
  88. package/src/components/ui/data-row/index.ts +2 -0
  89. package/src/components/ui/divider/Divider.stories.tsx +71 -0
  90. package/src/components/ui/divider/Divider.test.tsx +55 -0
  91. package/src/components/ui/divider/Divider.tsx +37 -0
  92. package/src/components/ui/divider/index.ts +2 -0
  93. package/src/components/ui/drawer/Drawer.stories.tsx +228 -0
  94. package/src/components/ui/drawer/Drawer.test.tsx +142 -0
  95. package/src/components/ui/drawer/Drawer.tsx +228 -0
  96. package/src/components/ui/drawer/index.ts +9 -0
  97. package/src/components/ui/form-field/FormField.stories.tsx +260 -0
  98. package/src/components/ui/form-field/FormField.test.tsx +280 -0
  99. package/src/components/ui/form-field/FormField.tsx +308 -0
  100. package/src/components/ui/form-field/index.ts +2 -0
  101. package/src/components/ui/help-tip/HelpTip.stories.tsx +250 -0
  102. package/src/components/ui/help-tip/HelpTip.test.tsx +198 -0
  103. package/src/components/ui/help-tip/HelpTip.tsx +229 -0
  104. package/src/components/ui/help-tip/index.ts +2 -0
  105. package/src/components/ui/icon-box/IconBox.stories.tsx +139 -0
  106. package/src/components/ui/icon-box/IconBox.test.tsx +120 -0
  107. package/src/components/ui/icon-box/IconBox.tsx +57 -0
  108. package/src/components/ui/icon-box/index.ts +1 -0
  109. package/src/components/ui/index.ts +36 -0
  110. package/src/components/ui/input/Input.stories.tsx +192 -0
  111. package/src/components/ui/input/Input.test.tsx +86 -0
  112. package/src/components/ui/input/Input.tsx +273 -0
  113. package/src/components/ui/input/index.ts +2 -0
  114. package/src/components/ui/link/Link.stories.tsx +78 -0
  115. package/src/components/ui/link/Link.test.tsx +94 -0
  116. package/src/components/ui/link/Link.tsx +86 -0
  117. package/src/components/ui/link/index.ts +2 -0
  118. package/src/components/ui/list-item/ListItem.stories.tsx +146 -0
  119. package/src/components/ui/list-item/ListItem.test.tsx +128 -0
  120. package/src/components/ui/list-item/ListItem.tsx +89 -0
  121. package/src/components/ui/list-item/index.ts +14 -0
  122. package/src/components/ui/menu/Menu.stories.tsx +180 -0
  123. package/src/components/ui/menu/Menu.test.tsx +353 -0
  124. package/src/components/ui/menu/Menu.tsx +222 -0
  125. package/src/components/ui/menu/index.ts +2 -0
  126. package/src/components/ui/modal/Modal.stories.tsx +350 -0
  127. package/src/components/ui/modal/Modal.test.tsx +159 -0
  128. package/src/components/ui/modal/Modal.tsx +254 -0
  129. package/src/components/ui/modal/index.ts +19 -0
  130. package/src/components/ui/popover/Popover.stories.tsx +190 -0
  131. package/src/components/ui/popover/Popover.test.tsx +277 -0
  132. package/src/components/ui/popover/Popover.tsx +162 -0
  133. package/src/components/ui/popover/index.ts +2 -0
  134. package/src/components/ui/progress/Progress.stories.tsx +190 -0
  135. package/src/components/ui/progress/Progress.test.tsx +181 -0
  136. package/src/components/ui/progress/Progress.tsx +320 -0
  137. package/src/components/ui/progress/index.ts +9 -0
  138. package/src/components/ui/radio/Radio.stories.tsx +199 -0
  139. package/src/components/ui/radio/Radio.test.tsx +166 -0
  140. package/src/components/ui/radio/Radio.tsx +200 -0
  141. package/src/components/ui/radio/index.ts +2 -0
  142. package/src/components/ui/section/Section.stories.tsx +111 -0
  143. package/src/components/ui/section/Section.test.tsx +121 -0
  144. package/src/components/ui/section/Section.tsx +52 -0
  145. package/src/components/ui/section/index.ts +6 -0
  146. package/src/components/ui/select/Select.stories.tsx +286 -0
  147. package/src/components/ui/select/Select.test.tsx +180 -0
  148. package/src/components/ui/select/Select.tsx +236 -0
  149. package/src/components/ui/select/index.ts +2 -0
  150. package/src/components/ui/skeleton/Skeleton.stories.tsx +153 -0
  151. package/src/components/ui/skeleton/Skeleton.test.tsx +162 -0
  152. package/src/components/ui/skeleton/Skeleton.tsx +229 -0
  153. package/src/components/ui/skeleton/index.ts +16 -0
  154. package/src/components/ui/spinner/Spinner.stories.tsx +67 -0
  155. package/src/components/ui/spinner/Spinner.test.tsx +67 -0
  156. package/src/components/ui/spinner/Spinner.tsx +72 -0
  157. package/src/components/ui/spinner/index.ts +2 -0
  158. package/src/components/ui/stack/Stack.stories.tsx +127 -0
  159. package/src/components/ui/stack/Stack.test.tsx +184 -0
  160. package/src/components/ui/stack/Stack.tsx +76 -0
  161. package/src/components/ui/stack/index.ts +2 -0
  162. package/src/components/ui/surface/Surface.stories.tsx +91 -0
  163. package/src/components/ui/surface/Surface.test.tsx +63 -0
  164. package/src/components/ui/surface/Surface.tsx +51 -0
  165. package/src/components/ui/surface/index.ts +1 -0
  166. package/src/components/ui/switch/Switch.stories.tsx +111 -0
  167. package/src/components/ui/switch/Switch.test.tsx +112 -0
  168. package/src/components/ui/switch/Switch.tsx +103 -0
  169. package/src/components/ui/switch/index.ts +2 -0
  170. package/src/components/ui/tabs/Tabs.stories.tsx +213 -0
  171. package/src/components/ui/tabs/Tabs.test.tsx +192 -0
  172. package/src/components/ui/tabs/Tabs.tsx +253 -0
  173. package/src/components/ui/tabs/index.ts +16 -0
  174. package/src/components/ui/toast/Toast.stories.tsx +255 -0
  175. package/src/components/ui/toast/Toast.test.tsx +207 -0
  176. package/src/components/ui/toast/Toast.tsx +327 -0
  177. package/src/components/ui/toast/index.ts +8 -0
  178. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +415 -0
  179. package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +224 -0
  180. package/src/components/ui/toolbar-button/ToolbarButton.tsx +307 -0
  181. package/src/components/ui/toolbar-button/index.ts +7 -0
  182. package/src/components/ui/tooltip/Tooltip.stories.tsx +126 -0
  183. package/src/components/ui/tooltip/Tooltip.test.tsx +179 -0
  184. package/src/components/ui/tooltip/Tooltip.tsx +143 -0
  185. package/src/components/ui/tooltip/index.ts +2 -0
  186. package/src/index.ts +10 -0
  187. package/src/test/setup.ts +19 -0
  188. package/src/theme/Colors.stories.tsx +258 -0
  189. package/src/theme/config.ts +193 -0
  190. package/src/theme/elevation.stories.tsx +346 -0
  191. package/src/theme/elevation.test.ts +27 -0
  192. package/src/theme/elevation.ts +567 -0
  193. package/src/theme/index.ts +39 -0
  194. package/src/theme/shadows.stories.tsx +523 -0
  195. package/src/theme/shadows.ts +439 -0
  196. package/src/theme/tokens/index.ts +2 -0
  197. package/src/theme/tokens/primitives.ts +354 -0
  198. package/src/theme/tokens/semantic.ts +381 -0
  199. package/src/types/jest-axe.d.ts +16 -0
  200. package/src/types/nativewind.d.ts +54 -0
  201. package/src/utils/cn.ts +14 -0
  202. package/src/utils/colors.ts +140 -0
  203. package/src/utils/form.ts +188 -0
  204. package/src/utils/index.ts +29 -0
  205. package/src/utils/useTheme.ts +72 -0
  206. package/dist/chunk-NA3EES23.mjs.map +0 -1
@@ -0,0 +1,273 @@
1
+ import React, { forwardRef, useState } from 'react'
2
+ import { TextInput, View, Text, Pressable, type TextInputProps } from 'react-native'
3
+ import { cn } from '../../../utils/cn'
4
+
5
+ export type InputSize = 'sm' | 'md' | 'lg'
6
+ export type InputVariant = 'outline' | 'filled' | 'underline'
7
+
8
+ export interface InputProps extends Omit<TextInputProps, 'editable'> {
9
+ /** Input size */
10
+ size?: InputSize
11
+ /** Visual variant */
12
+ variant?: InputVariant
13
+ /** Whether the input is disabled */
14
+ isDisabled?: boolean
15
+ /** Whether the input has an error */
16
+ isInvalid?: boolean
17
+ /** Whether the input is read-only */
18
+ isReadOnly?: boolean
19
+ /** Whether the input is required */
20
+ isRequired?: boolean
21
+ /** Whether this is a multiline textarea */
22
+ multiline?: boolean
23
+ /** Number of visible lines for multiline */
24
+ numberOfLines?: number
25
+ /** Label text */
26
+ label?: string
27
+ /** Helper text shown below input */
28
+ helperText?: string
29
+ /** Error message (shown when isInvalid is true) */
30
+ errorMessage?: string
31
+ /** Left element/icon */
32
+ leftElement?: React.ReactNode
33
+ /** Right element/icon */
34
+ rightElement?: React.ReactNode
35
+ /** Additional className for the container */
36
+ className?: string
37
+ /** Additional className for the input */
38
+ inputClassName?: string
39
+ }
40
+
41
+ const sizeStyles: Record<InputSize, string> = {
42
+ sm: 'h-8 px-3 text-sm',
43
+ md: 'h-10 px-4 text-base',
44
+ lg: 'h-12 px-4 text-lg',
45
+ }
46
+
47
+ const multilineSizeStyles: Record<InputSize, string> = {
48
+ sm: 'min-h-[80px] px-3 py-2 text-sm',
49
+ md: 'min-h-[100px] px-4 py-3 text-base',
50
+ lg: 'min-h-[120px] px-4 py-3 text-lg',
51
+ }
52
+
53
+ const variantStyles: Record<InputVariant, { base: string; focus: string; hover: string; error: string }> = {
54
+ outline: {
55
+ base: 'border border-border-input rounded-md bg-transparent',
56
+ hover: 'web:hover:border-border-input-hover',
57
+ focus: 'border-border-input-focus',
58
+ error: 'border-border-input-error',
59
+ },
60
+ filled: {
61
+ base: 'border border-transparent rounded-md bg-surface-input',
62
+ hover: 'web:hover:border-border-input-hover',
63
+ focus: 'border-border-input-focus bg-transparent',
64
+ error: 'border-border-input-error',
65
+ },
66
+ underline: {
67
+ base: 'border-b border-border-input rounded-none bg-transparent',
68
+ hover: 'web:hover:border-border-input-hover',
69
+ focus: 'border-border-input-focus',
70
+ error: 'border-border-input-error',
71
+ },
72
+ }
73
+
74
+ /**
75
+ * Input component for text entry.
76
+ *
77
+ * @example
78
+ * <Input
79
+ * label="Email"
80
+ * placeholder="Enter your email"
81
+ * helperText="We'll never share your email"
82
+ * />
83
+ *
84
+ * // With error state
85
+ * <Input
86
+ * label="Password"
87
+ * isInvalid
88
+ * errorMessage="Password is required"
89
+ * />
90
+ */
91
+ export const Input = forwardRef<TextInput, InputProps>(function Input(
92
+ {
93
+ size = 'md',
94
+ variant = 'outline',
95
+ isDisabled = false,
96
+ isInvalid = false,
97
+ isReadOnly = false,
98
+ isRequired = false,
99
+ multiline = false,
100
+ numberOfLines = 4,
101
+ label,
102
+ helperText,
103
+ errorMessage,
104
+ leftElement,
105
+ rightElement,
106
+ className,
107
+ inputClassName,
108
+ onFocus,
109
+ onBlur,
110
+ ...props
111
+ },
112
+ ref
113
+ ) {
114
+ const [isFocused, setIsFocused] = useState(false)
115
+
116
+ const handleFocus = (e: any) => {
117
+ setIsFocused(true)
118
+ onFocus?.(e)
119
+ }
120
+
121
+ const handleBlur = (e: any) => {
122
+ setIsFocused(false)
123
+ onBlur?.(e)
124
+ }
125
+
126
+ const showError = isInvalid && errorMessage
127
+ const showHelper = !showError && helperText
128
+
129
+ return (
130
+ <View className={cn('w-full', className)}>
131
+ {label && (
132
+ <Text className="mb-1.5 text-sm font-medium text-text-primary">
133
+ {label}
134
+ {isRequired && <Text className="text-status-error ml-0.5">*</Text>}
135
+ </Text>
136
+ )}
137
+
138
+ <View
139
+ className={cn(
140
+ 'flex-row transition-colors duration-150',
141
+ multiline ? 'items-start' : 'items-center',
142
+ variantStyles[variant].base,
143
+ !isDisabled && !isInvalid && variantStyles[variant].hover,
144
+ isFocused && variantStyles[variant].focus,
145
+ isInvalid && variantStyles[variant].error,
146
+ isDisabled && 'opacity-40 cursor-not-allowed'
147
+ )}
148
+ >
149
+ {leftElement && (
150
+ <View className={cn('pl-3 pr-1', multiline && 'pt-3')}>
151
+ {leftElement}
152
+ </View>
153
+ )}
154
+
155
+ <TextInput
156
+ ref={ref}
157
+ editable={!isDisabled && !isReadOnly}
158
+ accessibilityLabel={label}
159
+ accessibilityState={{
160
+ disabled: isDisabled,
161
+ }}
162
+ multiline={multiline}
163
+ numberOfLines={multiline ? numberOfLines : 1}
164
+ textAlignVertical={multiline ? 'top' : 'center'}
165
+ placeholderTextColor="var(--color-text-tertiary)"
166
+ onFocus={handleFocus}
167
+ onBlur={handleBlur}
168
+ className={cn(
169
+ 'flex-1 text-text-primary',
170
+ multiline ? multilineSizeStyles[size] : sizeStyles[size],
171
+ leftElement && 'pl-1',
172
+ rightElement && 'pr-1',
173
+ inputClassName
174
+ )}
175
+ {...props}
176
+ />
177
+
178
+ {rightElement && (
179
+ <View className={cn('pl-1 pr-3', multiline && 'pt-3')}>
180
+ {rightElement}
181
+ </View>
182
+ )}
183
+ </View>
184
+
185
+ {showHelper && (
186
+ <Text className="mt-1.5 text-xs text-text-secondary">
187
+ {helperText}
188
+ </Text>
189
+ )}
190
+
191
+ {showError && (
192
+ <Text className="mt-1.5 text-xs text-status-error">
193
+ {errorMessage}
194
+ </Text>
195
+ )}
196
+ </View>
197
+ )
198
+ })
199
+
200
+ export interface InputGroupProps {
201
+ children: React.ReactNode
202
+ className?: string
203
+ }
204
+
205
+ /**
206
+ * Container for grouping related inputs.
207
+ */
208
+ export function InputGroup({ children, className }: InputGroupProps) {
209
+ return (
210
+ <View className={cn('flex-col gap-4', className)}>
211
+ {children}
212
+ </View>
213
+ )
214
+ }
215
+
216
+ export interface PasswordInputProps extends Omit<InputProps, 'secureTextEntry' | 'rightElement'> {
217
+ /** Custom show password icon */
218
+ showIcon?: React.ReactNode
219
+ /** Custom hide password icon */
220
+ hideIcon?: React.ReactNode
221
+ }
222
+
223
+ /**
224
+ * Password input with visibility toggle.
225
+ *
226
+ * @example
227
+ * <PasswordInput
228
+ * label="Password"
229
+ * placeholder="Enter your password"
230
+ * />
231
+ */
232
+ export const PasswordInput = forwardRef<TextInput, PasswordInputProps>(function PasswordInput(
233
+ { showIcon, hideIcon, ...props },
234
+ ref
235
+ ) {
236
+ const [isVisible, setIsVisible] = useState(false)
237
+
238
+ const toggleVisibility = () => setIsVisible(!isVisible)
239
+
240
+ // Default eye icons (simple SVG-like views)
241
+ const defaultShowIcon = (
242
+ <View className="w-5 h-5 items-center justify-center">
243
+ <View className="w-3 h-3 rounded-full border-2 border-text-secondary" />
244
+ <View className="absolute w-1.5 h-1.5 rounded-full bg-text-secondary" />
245
+ </View>
246
+ )
247
+
248
+ const defaultHideIcon = (
249
+ <View className="w-5 h-5 items-center justify-center">
250
+ <View className="w-3 h-3 rounded-full border-2 border-text-secondary" />
251
+ <View className="absolute w-1.5 h-1.5 rounded-full bg-text-secondary" />
252
+ <View className="absolute w-5 h-0.5 bg-text-secondary rotate-45" />
253
+ </View>
254
+ )
255
+
256
+ return (
257
+ <Input
258
+ ref={ref}
259
+ secureTextEntry={!isVisible}
260
+ rightElement={
261
+ <Pressable
262
+ onPress={toggleVisibility}
263
+ accessibilityRole="button"
264
+ accessibilityLabel={isVisible ? 'Hide password' : 'Show password'}
265
+ className="p-1 rounded active:opacity-70"
266
+ >
267
+ {isVisible ? (hideIcon || defaultHideIcon) : (showIcon || defaultShowIcon)}
268
+ </Pressable>
269
+ }
270
+ {...props}
271
+ />
272
+ )
273
+ })
@@ -0,0 +1,2 @@
1
+ export { Input, InputGroup, PasswordInput } from './Input'
2
+ export type { InputProps, InputGroupProps, PasswordInputProps, InputSize, InputVariant } from './Input'
@@ -0,0 +1,78 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { Link } from './Link'
4
+
5
+ const meta: Meta<typeof Link> = {
6
+ title: 'Components/Link',
7
+ component: Link,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ color: {
11
+ control: 'select',
12
+ options: ['default', 'primary', 'secondary', 'inherit'],
13
+ description: 'Link color',
14
+ },
15
+ underline: {
16
+ control: 'select',
17
+ options: ['always', 'hover', 'none'],
18
+ description: 'Underline behavior',
19
+ },
20
+ isExternal: {
21
+ control: 'boolean',
22
+ description: 'Whether the link opens externally',
23
+ },
24
+ isDisabled: {
25
+ control: 'boolean',
26
+ description: 'Whether the link is disabled',
27
+ },
28
+ },
29
+ }
30
+
31
+ export default meta
32
+ type Story = StoryObj<typeof Link>
33
+
34
+ export const Default: Story = {
35
+ args: {
36
+ children: 'Click here',
37
+ color: 'default',
38
+ underline: 'hover',
39
+ onPress: () => {},
40
+ },
41
+ }
42
+
43
+ export const AllColors: Story = {
44
+ render: () => (
45
+ <View style={{ flexDirection: 'row', gap: 16 }}>
46
+ <Link color="default" onPress={() => {}}>Default</Link>
47
+ <Link color="primary" onPress={() => {}}>Primary</Link>
48
+ <Link color="secondary" onPress={() => {}}>Secondary</Link>
49
+ </View>
50
+ ),
51
+ }
52
+
53
+ export const UnderlineStyles: Story = {
54
+ render: () => (
55
+ <View style={{ flexDirection: 'row', gap: 16 }}>
56
+ <Link underline="always" onPress={() => {}}>Always</Link>
57
+ <Link underline="hover" onPress={() => {}}>Hover</Link>
58
+ <Link underline="none" onPress={() => {}}>None</Link>
59
+ </View>
60
+ ),
61
+ }
62
+
63
+ export const External: Story = {
64
+ args: {
65
+ children: 'Visit Example',
66
+ href: 'https://example.com',
67
+ isExternal: true,
68
+ onPress: () => {},
69
+ },
70
+ }
71
+
72
+ export const Disabled: Story = {
73
+ args: {
74
+ children: 'Disabled Link',
75
+ isDisabled: true,
76
+ onPress: () => {},
77
+ },
78
+ }
@@ -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
+ }