@titan-design/react-ui 0.1.0 → 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 (192) hide show
  1. package/package.json +10 -5
  2. package/src/components/custom/DateTime/DateTime.stories.tsx +180 -0
  3. package/src/components/custom/DateTime/DateTime.test.tsx +152 -0
  4. package/src/components/custom/DateTime/DateTime.tsx +235 -0
  5. package/src/components/custom/DateTime/index.ts +2 -0
  6. package/src/components/custom/EmptyState/EmptyState.stories.tsx +99 -0
  7. package/src/components/custom/EmptyState/EmptyState.test.tsx +106 -0
  8. package/src/components/custom/EmptyState/EmptyState.tsx +68 -0
  9. package/src/components/custom/EmptyState/index.ts +2 -0
  10. package/src/components/custom/Metric/Metric.stories.tsx +133 -0
  11. package/src/components/custom/Metric/Metric.test.tsx +120 -0
  12. package/src/components/custom/Metric/Metric.tsx +92 -0
  13. package/src/components/custom/Metric/index.ts +2 -0
  14. package/src/components/custom/Sidebar/Sidebar.stories.tsx +227 -0
  15. package/src/components/custom/Sidebar/Sidebar.test.tsx +185 -0
  16. package/src/components/custom/Sidebar/Sidebar.tsx +257 -0
  17. package/src/components/custom/Sidebar/index.ts +18 -0
  18. package/src/components/custom/Table/Table.stories.tsx +481 -0
  19. package/src/components/custom/Table/Table.test.tsx +531 -0
  20. package/src/components/custom/Table/Table.tsx +696 -0
  21. package/src/components/custom/Table/index.ts +22 -0
  22. package/src/components/custom/Typography/Typography.stories.tsx +153 -0
  23. package/src/components/custom/Typography/Typography.test.tsx +107 -0
  24. package/src/components/custom/Typography/Typography.tsx +218 -0
  25. package/src/components/custom/Typography/index.ts +19 -0
  26. package/src/components/custom/index.ts +8 -0
  27. package/src/components/custom/stepper/Stepper.stories.tsx +245 -0
  28. package/src/components/custom/stepper/Stepper.test.tsx +230 -0
  29. package/src/components/custom/stepper/Stepper.tsx +250 -0
  30. package/src/components/custom/stepper/index.ts +10 -0
  31. package/src/components/index.ts +5 -0
  32. package/src/components/ui/alert/Alert.stories.tsx +154 -0
  33. package/src/components/ui/alert/Alert.test.tsx +202 -0
  34. package/src/components/ui/alert/Alert.tsx +170 -0
  35. package/src/components/ui/alert/index.ts +8 -0
  36. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +299 -0
  37. package/src/components/ui/autocomplete/Autocomplete.test.tsx +247 -0
  38. package/src/components/ui/autocomplete/Autocomplete.tsx +332 -0
  39. package/src/components/ui/autocomplete/index.ts +2 -0
  40. package/src/components/ui/avatar/Avatar.stories.tsx +105 -0
  41. package/src/components/ui/avatar/Avatar.test.tsx +135 -0
  42. package/src/components/ui/avatar/Avatar.tsx +168 -0
  43. package/src/components/ui/avatar/index.ts +2 -0
  44. package/src/components/ui/badge/Badge.stories.tsx +100 -0
  45. package/src/components/ui/badge/Badge.test.tsx +86 -0
  46. package/src/components/ui/badge/Badge.tsx +105 -0
  47. package/src/components/ui/badge/index.ts +2 -0
  48. package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +70 -0
  49. package/src/components/ui/breadcrumbs/Breadcrumbs.test.tsx +156 -0
  50. package/src/components/ui/breadcrumbs/Breadcrumbs.tsx +127 -0
  51. package/src/components/ui/breadcrumbs/index.ts +2 -0
  52. package/src/components/ui/button/Button.stories.tsx +216 -0
  53. package/src/components/ui/button/Button.test.tsx +132 -0
  54. package/src/components/ui/button/Button.tsx +242 -0
  55. package/src/components/ui/button/index.ts +10 -0
  56. package/src/components/ui/card/Card.stories.tsx +443 -0
  57. package/src/components/ui/card/Card.test.tsx +265 -0
  58. package/src/components/ui/card/Card.tsx +380 -0
  59. package/src/components/ui/card/index.ts +20 -0
  60. package/src/components/ui/checkbox/Checkbox.stories.tsx +126 -0
  61. package/src/components/ui/checkbox/Checkbox.test.tsx +152 -0
  62. package/src/components/ui/checkbox/Checkbox.tsx +163 -0
  63. package/src/components/ui/checkbox/index.ts +2 -0
  64. package/src/components/ui/chip/Chip.stories.tsx +112 -0
  65. package/src/components/ui/chip/Chip.test.tsx +119 -0
  66. package/src/components/ui/chip/Chip.tsx +151 -0
  67. package/src/components/ui/chip/index.ts +2 -0
  68. package/src/components/ui/collapse/Collapse.stories.tsx +313 -0
  69. package/src/components/ui/collapse/Collapse.test.tsx +298 -0
  70. package/src/components/ui/collapse/Collapse.tsx +275 -0
  71. package/src/components/ui/collapse/index.ts +18 -0
  72. package/src/components/ui/data-row/DataRow.stories.tsx +78 -0
  73. package/src/components/ui/data-row/DataRow.test.tsx +78 -0
  74. package/src/components/ui/data-row/DataRow.tsx +29 -0
  75. package/src/components/ui/data-row/index.ts +2 -0
  76. package/src/components/ui/divider/Divider.stories.tsx +71 -0
  77. package/src/components/ui/divider/Divider.test.tsx +55 -0
  78. package/src/components/ui/divider/Divider.tsx +37 -0
  79. package/src/components/ui/divider/index.ts +2 -0
  80. package/src/components/ui/drawer/Drawer.stories.tsx +228 -0
  81. package/src/components/ui/drawer/Drawer.test.tsx +142 -0
  82. package/src/components/ui/drawer/Drawer.tsx +228 -0
  83. package/src/components/ui/drawer/index.ts +9 -0
  84. package/src/components/ui/form-field/FormField.stories.tsx +260 -0
  85. package/src/components/ui/form-field/FormField.test.tsx +280 -0
  86. package/src/components/ui/form-field/FormField.tsx +308 -0
  87. package/src/components/ui/form-field/index.ts +2 -0
  88. package/src/components/ui/help-tip/HelpTip.stories.tsx +250 -0
  89. package/src/components/ui/help-tip/HelpTip.test.tsx +198 -0
  90. package/src/components/ui/help-tip/HelpTip.tsx +229 -0
  91. package/src/components/ui/help-tip/index.ts +2 -0
  92. package/src/components/ui/icon-box/IconBox.stories.tsx +139 -0
  93. package/src/components/ui/icon-box/IconBox.test.tsx +120 -0
  94. package/src/components/ui/icon-box/IconBox.tsx +57 -0
  95. package/src/components/ui/icon-box/index.ts +1 -0
  96. package/src/components/ui/index.ts +36 -0
  97. package/src/components/ui/input/Input.stories.tsx +192 -0
  98. package/src/components/ui/input/Input.test.tsx +86 -0
  99. package/src/components/ui/input/Input.tsx +273 -0
  100. package/src/components/ui/input/index.ts +2 -0
  101. package/src/components/ui/link/Link.stories.tsx +78 -0
  102. package/src/components/ui/link/Link.test.tsx +94 -0
  103. package/src/components/ui/link/Link.tsx +86 -0
  104. package/src/components/ui/link/index.ts +2 -0
  105. package/src/components/ui/list-item/ListItem.stories.tsx +146 -0
  106. package/src/components/ui/list-item/ListItem.test.tsx +128 -0
  107. package/src/components/ui/list-item/ListItem.tsx +89 -0
  108. package/src/components/ui/list-item/index.ts +14 -0
  109. package/src/components/ui/menu/Menu.stories.tsx +180 -0
  110. package/src/components/ui/menu/Menu.test.tsx +353 -0
  111. package/src/components/ui/menu/Menu.tsx +222 -0
  112. package/src/components/ui/menu/index.ts +2 -0
  113. package/src/components/ui/modal/Modal.stories.tsx +350 -0
  114. package/src/components/ui/modal/Modal.test.tsx +159 -0
  115. package/src/components/ui/modal/Modal.tsx +254 -0
  116. package/src/components/ui/modal/index.ts +19 -0
  117. package/src/components/ui/popover/Popover.stories.tsx +190 -0
  118. package/src/components/ui/popover/Popover.test.tsx +277 -0
  119. package/src/components/ui/popover/Popover.tsx +162 -0
  120. package/src/components/ui/popover/index.ts +2 -0
  121. package/src/components/ui/progress/Progress.stories.tsx +190 -0
  122. package/src/components/ui/progress/Progress.test.tsx +181 -0
  123. package/src/components/ui/progress/Progress.tsx +320 -0
  124. package/src/components/ui/progress/index.ts +9 -0
  125. package/src/components/ui/radio/Radio.stories.tsx +199 -0
  126. package/src/components/ui/radio/Radio.test.tsx +166 -0
  127. package/src/components/ui/radio/Radio.tsx +200 -0
  128. package/src/components/ui/radio/index.ts +2 -0
  129. package/src/components/ui/section/Section.stories.tsx +111 -0
  130. package/src/components/ui/section/Section.test.tsx +121 -0
  131. package/src/components/ui/section/Section.tsx +52 -0
  132. package/src/components/ui/section/index.ts +6 -0
  133. package/src/components/ui/select/Select.stories.tsx +286 -0
  134. package/src/components/ui/select/Select.test.tsx +180 -0
  135. package/src/components/ui/select/Select.tsx +236 -0
  136. package/src/components/ui/select/index.ts +2 -0
  137. package/src/components/ui/skeleton/Skeleton.stories.tsx +153 -0
  138. package/src/components/ui/skeleton/Skeleton.test.tsx +162 -0
  139. package/src/components/ui/skeleton/Skeleton.tsx +229 -0
  140. package/src/components/ui/skeleton/index.ts +16 -0
  141. package/src/components/ui/spinner/Spinner.stories.tsx +67 -0
  142. package/src/components/ui/spinner/Spinner.test.tsx +67 -0
  143. package/src/components/ui/spinner/Spinner.tsx +72 -0
  144. package/src/components/ui/spinner/index.ts +2 -0
  145. package/src/components/ui/stack/Stack.stories.tsx +127 -0
  146. package/src/components/ui/stack/Stack.test.tsx +184 -0
  147. package/src/components/ui/stack/Stack.tsx +76 -0
  148. package/src/components/ui/stack/index.ts +2 -0
  149. package/src/components/ui/surface/Surface.stories.tsx +91 -0
  150. package/src/components/ui/surface/Surface.test.tsx +63 -0
  151. package/src/components/ui/surface/Surface.tsx +51 -0
  152. package/src/components/ui/surface/index.ts +1 -0
  153. package/src/components/ui/switch/Switch.stories.tsx +111 -0
  154. package/src/components/ui/switch/Switch.test.tsx +112 -0
  155. package/src/components/ui/switch/Switch.tsx +103 -0
  156. package/src/components/ui/switch/index.ts +2 -0
  157. package/src/components/ui/tabs/Tabs.stories.tsx +213 -0
  158. package/src/components/ui/tabs/Tabs.test.tsx +192 -0
  159. package/src/components/ui/tabs/Tabs.tsx +253 -0
  160. package/src/components/ui/tabs/index.ts +16 -0
  161. package/src/components/ui/toast/Toast.stories.tsx +255 -0
  162. package/src/components/ui/toast/Toast.test.tsx +207 -0
  163. package/src/components/ui/toast/Toast.tsx +327 -0
  164. package/src/components/ui/toast/index.ts +8 -0
  165. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +415 -0
  166. package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +224 -0
  167. package/src/components/ui/toolbar-button/ToolbarButton.tsx +307 -0
  168. package/src/components/ui/toolbar-button/index.ts +7 -0
  169. package/src/components/ui/tooltip/Tooltip.stories.tsx +126 -0
  170. package/src/components/ui/tooltip/Tooltip.test.tsx +179 -0
  171. package/src/components/ui/tooltip/Tooltip.tsx +143 -0
  172. package/src/components/ui/tooltip/index.ts +2 -0
  173. package/src/index.ts +10 -0
  174. package/src/test/setup.ts +19 -0
  175. package/src/theme/Colors.stories.tsx +258 -0
  176. package/src/theme/config.ts +193 -0
  177. package/src/theme/elevation.stories.tsx +346 -0
  178. package/src/theme/elevation.test.ts +27 -0
  179. package/src/theme/elevation.ts +567 -0
  180. package/src/theme/index.ts +39 -0
  181. package/src/theme/shadows.stories.tsx +523 -0
  182. package/src/theme/shadows.ts +439 -0
  183. package/src/theme/tokens/index.ts +2 -0
  184. package/src/theme/tokens/primitives.ts +354 -0
  185. package/src/theme/tokens/semantic.ts +381 -0
  186. package/src/types/jest-axe.d.ts +16 -0
  187. package/src/types/nativewind.d.ts +54 -0
  188. package/src/utils/cn.ts +14 -0
  189. package/src/utils/colors.ts +140 -0
  190. package/src/utils/form.ts +188 -0
  191. package/src/utils/index.ts +29 -0
  192. package/src/utils/useTheme.ts +72 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@titan-design/react-ui",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Cross-platform design system built on Gluestack UI",
5
5
  "author": "Henry Jewkes",
6
6
  "license": "MIT",
@@ -13,21 +13,26 @@
13
13
  "bugs": {
14
14
  "url": "https://github.com/HJewkes/titan-design/issues"
15
15
  },
16
+ "source": "./src/index.ts",
17
+ "react-native": "./src/index.ts",
16
18
  "main": "./dist/index.js",
17
19
  "module": "./dist/index.mjs",
18
20
  "types": "./dist/index.d.ts",
19
21
  "exports": {
20
22
  ".": {
23
+ "react-native": "./src/index.ts",
21
24
  "types": "./dist/index.d.ts",
22
25
  "import": "./dist/index.mjs",
23
26
  "require": "./dist/index.js"
24
27
  },
25
28
  "./theme": {
29
+ "react-native": "./src/theme/index.ts",
26
30
  "types": "./dist/theme/index.d.ts",
27
31
  "import": "./dist/theme/index.mjs",
28
32
  "require": "./dist/theme/index.js"
29
33
  },
30
- "./theme/global.css": "./src/theme/global.css"
34
+ "./theme/global.css": "./src/theme/global.css",
35
+ "./tailwind.config.js": "./tailwind.config.js"
31
36
  },
32
37
  "engines": {
33
38
  "node": ">=20.0.0"
@@ -47,8 +52,8 @@
47
52
  "peerDependencies": {
48
53
  "lucide-react": ">=0.400.0",
49
54
  "lucide-react-native": ">=0.400.0",
50
- "react": "^18.0.0",
51
- "react-dom": "^18.0.0",
55
+ "react": "^18.0.0 || ^19.0.0",
56
+ "react-dom": "^18.0.0 || ^19.0.0",
52
57
  "react-native": ">=0.72.0",
53
58
  "react-native-web": ">=0.19.0"
54
59
  },
@@ -106,7 +111,7 @@
106
111
  },
107
112
  "files": [
108
113
  "dist",
109
- "src/theme/global.css",
114
+ "src",
110
115
  "tailwind.config.js"
111
116
  ],
112
117
  "sideEffects": false,
@@ -0,0 +1,180 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View, Text } from 'react-native'
3
+ import { DateTime } from './DateTime'
4
+
5
+ const meta: Meta<typeof DateTime> = {
6
+ title: 'Custom/DateTime',
7
+ component: DateTime,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ format: {
11
+ control: 'select',
12
+ options: ['date', 'time', 'datetime', 'relative', 'short', 'medium', 'long', 'full'],
13
+ },
14
+ color: {
15
+ control: 'select',
16
+ options: ['primary', 'secondary', 'tertiary', 'inherit'],
17
+ },
18
+ isUTC: {
19
+ control: 'boolean',
20
+ },
21
+ },
22
+ }
23
+
24
+ export default meta
25
+ type Story = StoryObj<typeof DateTime>
26
+
27
+ const now = Date.now()
28
+ const yesterday = now - 24 * 60 * 60 * 1000
29
+ const lastWeek = now - 7 * 24 * 60 * 60 * 1000
30
+ const nextWeek = now + 7 * 24 * 60 * 60 * 1000
31
+
32
+ export const Default: Story = {
33
+ args: {
34
+ value: now,
35
+ format: 'datetime',
36
+ },
37
+ }
38
+
39
+ export const AllFormats: Story = {
40
+ render: () => {
41
+ const date = new Date('2024-06-15T14:30:00')
42
+ return (
43
+ <View className="gap-3">
44
+ <Row label="date" value={date} format="date" />
45
+ <Row label="time" value={date} format="time" />
46
+ <Row label="datetime" value={date} format="datetime" />
47
+ <Row label="short" value={date} format="short" />
48
+ <Row label="medium" value={date} format="medium" />
49
+ <Row label="long" value={date} format="long" />
50
+ <Row label="full" value={date} format="full" />
51
+ </View>
52
+ )
53
+ },
54
+ }
55
+
56
+ function Row({ label, value, format }: { label: string; value: any; format: any }) {
57
+ return (
58
+ <View className="flex-row items-center">
59
+ <Text className="w-24 text-text-secondary text-sm">{label}:</Text>
60
+ <DateTime value={value} format={format} className="text-text-primary" />
61
+ </View>
62
+ )
63
+ }
64
+
65
+ export const RelativeTime: Story = {
66
+ render: () => {
67
+ const now = Date.now()
68
+ const dates = [
69
+ { label: '30 seconds ago', value: now - 30 * 1000 },
70
+ { label: '5 minutes ago', value: now - 5 * 60 * 1000 },
71
+ { label: '2 hours ago', value: now - 2 * 60 * 60 * 1000 },
72
+ { label: 'Yesterday', value: now - 24 * 60 * 60 * 1000 },
73
+ { label: 'Last week', value: now - 7 * 24 * 60 * 60 * 1000 },
74
+ { label: 'Last month', value: now - 30 * 24 * 60 * 60 * 1000 },
75
+ { label: 'Last year', value: now - 365 * 24 * 60 * 60 * 1000 },
76
+ { label: 'In 2 hours', value: now + 2 * 60 * 60 * 1000 },
77
+ { label: 'Tomorrow', value: now + 24 * 60 * 60 * 1000 },
78
+ { label: 'Next week', value: now + 7 * 24 * 60 * 60 * 1000 },
79
+ ]
80
+
81
+ return (
82
+ <View className="gap-2">
83
+ {dates.map(({ label, value }) => (
84
+ <View key={label} className="flex-row items-center">
85
+ <Text className="w-32 text-text-secondary text-sm">{label}:</Text>
86
+ <DateTime value={value} format="relative" className="text-text-primary" />
87
+ </View>
88
+ ))}
89
+ </View>
90
+ )
91
+ },
92
+ }
93
+
94
+ export const Colors: Story = {
95
+ render: () => (
96
+ <View className="gap-2">
97
+ <DateTime value={now} color="primary" />
98
+ <DateTime value={now} color="secondary" />
99
+ <DateTime value={now} color="tertiary" />
100
+ </View>
101
+ ),
102
+ }
103
+
104
+ export const UTCTime: Story = {
105
+ render: () => (
106
+ <View className="gap-3">
107
+ <View>
108
+ <Text className="text-text-secondary text-sm mb-1">Local Time:</Text>
109
+ <DateTime value={now} format="datetime" className="text-text-primary" />
110
+ </View>
111
+ <View>
112
+ <Text className="text-text-secondary text-sm mb-1">UTC Time:</Text>
113
+ <DateTime value={now} format="datetime" isUTC className="text-text-primary" />
114
+ </View>
115
+ </View>
116
+ ),
117
+ }
118
+
119
+ export const NullValue: Story = {
120
+ render: () => (
121
+ <View className="gap-2">
122
+ <View className="flex-row items-center">
123
+ <Text className="w-32 text-text-secondary text-sm">Default fallback:</Text>
124
+ <DateTime value={null} className="text-text-primary" />
125
+ </View>
126
+ <View className="flex-row items-center">
127
+ <Text className="w-32 text-text-secondary text-sm">Custom fallback:</Text>
128
+ <DateTime value={undefined} fallback="Not set" className="text-text-primary" />
129
+ </View>
130
+ </View>
131
+ ),
132
+ }
133
+
134
+ export const DifferentInputTypes: Story = {
135
+ render: () => {
136
+ const timestamp = 1718450400000 // June 15, 2024
137
+ const dateObject = new Date(timestamp)
138
+ const isoString = dateObject.toISOString()
139
+
140
+ return (
141
+ <View className="gap-3">
142
+ <View>
143
+ <Text className="text-text-secondary text-sm mb-1">Timestamp (number):</Text>
144
+ <DateTime value={timestamp} format="medium" className="text-text-primary" />
145
+ </View>
146
+ <View>
147
+ <Text className="text-text-secondary text-sm mb-1">Date object:</Text>
148
+ <DateTime value={dateObject} format="medium" className="text-text-primary" />
149
+ </View>
150
+ <View>
151
+ <Text className="text-text-secondary text-sm mb-1">ISO string:</Text>
152
+ <DateTime value={isoString} format="medium" className="text-text-primary" />
153
+ </View>
154
+ </View>
155
+ )
156
+ },
157
+ }
158
+
159
+ export const UsageExample: Story = {
160
+ render: () => {
161
+ const items = [
162
+ { title: 'Project kickoff meeting', date: now - 3 * 24 * 60 * 60 * 1000 },
163
+ { title: 'Design review', date: now - 1 * 24 * 60 * 60 * 1000 },
164
+ { title: 'Sprint planning', date: now + 2 * 24 * 60 * 60 * 1000 },
165
+ { title: 'Release deadline', date: now + 7 * 24 * 60 * 60 * 1000 },
166
+ ]
167
+
168
+ return (
169
+ <View className="gap-2 p-4 bg-surface-elevated rounded-lg max-w-md">
170
+ <Text className="text-lg font-semibold text-text-primary mb-2">Upcoming Events</Text>
171
+ {items.map((item, index) => (
172
+ <View key={index} className="flex-row justify-between items-center py-2 border-b border-border-default">
173
+ <Text className="text-text-primary">{item.title}</Text>
174
+ <DateTime value={item.date} format="relative" color="secondary" className="text-sm" />
175
+ </View>
176
+ ))}
177
+ </View>
178
+ )
179
+ },
180
+ }
@@ -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 { DateTime, formatDateTime } from './DateTime'
5
+
6
+ describe('DateTime', () => {
7
+ const testDate = new Date('2024-06-15T14:30:00Z')
8
+ const testTimestamp = testDate.getTime()
9
+ const testISOString = '2024-06-15T14:30:00Z'
10
+
11
+ it('renders with Date object', () => {
12
+ render(<DateTime value={testDate} format="date" isUTC />)
13
+ // Should render a formatted date string
14
+ expect(screen.getByText(/2024/)).toBeInTheDocument()
15
+ })
16
+
17
+ it('renders with timestamp', () => {
18
+ render(<DateTime value={testTimestamp} format="date" isUTC />)
19
+ expect(screen.getByText(/2024/)).toBeInTheDocument()
20
+ })
21
+
22
+ it('renders with ISO string', () => {
23
+ render(<DateTime value={testISOString} format="date" isUTC />)
24
+ expect(screen.getByText(/2024/)).toBeInTheDocument()
25
+ })
26
+
27
+ it('renders fallback when value is null', () => {
28
+ render(<DateTime value={null} />)
29
+ expect(screen.getByText('-')).toBeInTheDocument()
30
+ })
31
+
32
+ it('renders fallback when value is undefined', () => {
33
+ render(<DateTime value={undefined} />)
34
+ expect(screen.getByText('-')).toBeInTheDocument()
35
+ })
36
+
37
+ it('renders custom fallback', () => {
38
+ render(<DateTime value={null} fallback="N/A" />)
39
+ expect(screen.getByText('N/A')).toBeInTheDocument()
40
+ })
41
+
42
+ it('renders Invalid Date for bad input', () => {
43
+ render(<DateTime value="not-a-date" />)
44
+ expect(screen.getByText('Invalid Date')).toBeInTheDocument()
45
+ })
46
+
47
+ describe('formats', () => {
48
+ it('renders date format', () => {
49
+ render(<DateTime value={testDate} format="date" isUTC />)
50
+ expect(screen.getByText(/06/)).toBeInTheDocument()
51
+ })
52
+
53
+ it('renders time format', () => {
54
+ render(<DateTime value={testDate} format="time" isUTC />)
55
+ // Should contain time portion
56
+ const textContent = screen.getByText(/\d/).textContent
57
+ expect(textContent).toBeDefined()
58
+ })
59
+
60
+ it('renders datetime format', () => {
61
+ render(<DateTime value={testDate} format="datetime" isUTC />)
62
+ expect(screen.getByText(/2024/)).toBeInTheDocument()
63
+ })
64
+
65
+ it('renders short format', () => {
66
+ render(<DateTime value={testDate} format="short" isUTC />)
67
+ expect(screen.getByText(/Jun/)).toBeInTheDocument()
68
+ })
69
+
70
+ it('renders medium format', () => {
71
+ render(<DateTime value={testDate} format="medium" isUTC />)
72
+ expect(screen.getByText(/Jun/)).toBeInTheDocument()
73
+ expect(screen.getByText(/2024/)).toBeInTheDocument()
74
+ })
75
+
76
+ it('renders long format', () => {
77
+ render(<DateTime value={testDate} format="long" isUTC />)
78
+ expect(screen.getByText(/June/)).toBeInTheDocument()
79
+ })
80
+
81
+ it('renders full format', () => {
82
+ render(<DateTime value={testDate} format="full" isUTC />)
83
+ expect(screen.getByText(/June/)).toBeInTheDocument()
84
+ expect(screen.getByText(/2024/)).toBeInTheDocument()
85
+ })
86
+
87
+ it('renders relative format', () => {
88
+ // Use a date far in the past to get a stable relative output
89
+ const pastDate = new Date('2020-01-01T00:00:00Z')
90
+ render(<DateTime value={pastDate} format="relative" />)
91
+ // Should contain "ago" or "year" type relative text
92
+ const el = screen.getByText(/year|ago/)
93
+ expect(el).toBeInTheDocument()
94
+ })
95
+ })
96
+
97
+ describe('color variants', () => {
98
+ const colors = ['primary', 'secondary', 'tertiary', 'inherit'] as const
99
+ colors.forEach((color) => {
100
+ it(`renders with color ${color}`, () => {
101
+ render(<DateTime value={testDate} format="short" color={color} isUTC />)
102
+ expect(screen.getByText(/Jun/)).toBeInTheDocument()
103
+ })
104
+ })
105
+ })
106
+
107
+ describe('UTC mode', () => {
108
+ it('renders in UTC when isUTC is true', () => {
109
+ render(<DateTime value={testDate} format="date" isUTC />)
110
+ expect(screen.getByText(/2024/)).toBeInTheDocument()
111
+ })
112
+ })
113
+
114
+ describe('formatDateTime utility', () => {
115
+ it('formats a date value', () => {
116
+ const result = formatDateTime(testDate, 'short', true)
117
+ expect(result).toContain('Jun')
118
+ })
119
+
120
+ it('returns fallback for null', () => {
121
+ expect(formatDateTime(null)).toBe('-')
122
+ })
123
+
124
+ it('returns fallback for undefined', () => {
125
+ expect(formatDateTime(undefined)).toBe('-')
126
+ })
127
+
128
+ it('returns custom fallback', () => {
129
+ expect(formatDateTime(null, 'date', false, 'N/A')).toBe('N/A')
130
+ })
131
+
132
+ it('returns Invalid Date for bad input', () => {
133
+ expect(formatDateTime('not-a-date')).toBe('Invalid Date')
134
+ })
135
+ })
136
+
137
+ describe('accessibility', () => {
138
+ it('has no accessibility violations', async () => {
139
+ const { container } = render(
140
+ <DateTime value={testDate} format="medium" isUTC />
141
+ )
142
+ const results = await axe(container)
143
+ expect(results).toHaveNoViolations()
144
+ })
145
+
146
+ it('has no accessibility violations with fallback', async () => {
147
+ const { container } = render(<DateTime value={null} fallback="No date" />)
148
+ const results = await axe(container)
149
+ expect(results).toHaveNoViolations()
150
+ })
151
+ })
152
+ })
@@ -0,0 +1,235 @@
1
+ import React from 'react'
2
+ import { Text, type TextProps } from 'react-native'
3
+ import { cn } from '../../../utils/cn'
4
+
5
+ export type DateTimeFormat =
6
+ | 'date' // 2024-01-15
7
+ | 'time' // 14:30
8
+ | 'datetime' // 2024-01-15 14:30
9
+ | 'relative' // 2 hours ago
10
+ | 'short' // Jan 15
11
+ | 'medium' // Jan 15, 2024
12
+ | 'long' // January 15, 2024
13
+ | 'full' // Monday, January 15, 2024
14
+
15
+ export interface DateTimeProps extends TextProps {
16
+ /** Date value (timestamp in ms, Date object, or ISO string) */
17
+ value: number | Date | string | null | undefined
18
+ /** Display format */
19
+ format?: DateTimeFormat
20
+ /** Custom format string (overrides format) */
21
+ customFormat?: string
22
+ /** Whether to show in UTC */
23
+ isUTC?: boolean
24
+ /** Fallback text when value is null/undefined */
25
+ fallback?: string
26
+ /** Text color */
27
+ color?: 'primary' | 'secondary' | 'tertiary' | 'inherit'
28
+ /** Additional className */
29
+ className?: string
30
+ }
31
+
32
+ const colorStyles = {
33
+ primary: 'text-text-primary',
34
+ secondary: 'text-text-secondary',
35
+ tertiary: 'text-text-tertiary',
36
+ inherit: '',
37
+ }
38
+
39
+ /**
40
+ * Formats a date value to a readable string.
41
+ */
42
+ function formatDate(
43
+ value: number | Date | string,
44
+ format: DateTimeFormat,
45
+ isUTC: boolean
46
+ ): string {
47
+ const date = value instanceof Date ? value : new Date(value)
48
+
49
+ if (isNaN(date.getTime())) {
50
+ return 'Invalid Date'
51
+ }
52
+
53
+ // For relative time
54
+ if (format === 'relative') {
55
+ return getRelativeTime(date)
56
+ }
57
+
58
+ // Use Intl.DateTimeFormat for locale-aware formatting
59
+ const options: Intl.DateTimeFormatOptions = {}
60
+
61
+ switch (format) {
62
+ case 'date':
63
+ options.year = 'numeric'
64
+ options.month = '2-digit'
65
+ options.day = '2-digit'
66
+ break
67
+ case 'time':
68
+ options.hour = '2-digit'
69
+ options.minute = '2-digit'
70
+ break
71
+ case 'datetime':
72
+ options.year = 'numeric'
73
+ options.month = '2-digit'
74
+ options.day = '2-digit'
75
+ options.hour = '2-digit'
76
+ options.minute = '2-digit'
77
+ break
78
+ case 'short':
79
+ options.month = 'short'
80
+ options.day = 'numeric'
81
+ break
82
+ case 'medium':
83
+ options.month = 'short'
84
+ options.day = 'numeric'
85
+ options.year = 'numeric'
86
+ break
87
+ case 'long':
88
+ options.month = 'long'
89
+ options.day = 'numeric'
90
+ options.year = 'numeric'
91
+ break
92
+ case 'full':
93
+ options.weekday = 'long'
94
+ options.month = 'long'
95
+ options.day = 'numeric'
96
+ options.year = 'numeric'
97
+ break
98
+ }
99
+
100
+ if (isUTC) {
101
+ options.timeZone = 'UTC'
102
+ }
103
+
104
+ return new Intl.DateTimeFormat(undefined, options).format(date)
105
+ }
106
+
107
+ /**
108
+ * Gets relative time string (e.g., "2 hours ago", "in 3 days")
109
+ */
110
+ function getRelativeTime(date: Date): string {
111
+ const now = new Date()
112
+ const diffMs = date.getTime() - now.getTime()
113
+ const diffSecs = Math.round(diffMs / 1000)
114
+ const diffMins = Math.round(diffSecs / 60)
115
+ const diffHours = Math.round(diffMins / 60)
116
+ const diffDays = Math.round(diffHours / 24)
117
+ const diffWeeks = Math.round(diffDays / 7)
118
+ const diffMonths = Math.round(diffDays / 30)
119
+ const diffYears = Math.round(diffDays / 365)
120
+
121
+ // Use Intl.RelativeTimeFormat if available
122
+ if (typeof Intl !== 'undefined' && Intl.RelativeTimeFormat) {
123
+ const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
124
+
125
+ if (Math.abs(diffSecs) < 60) {
126
+ return rtf.format(diffSecs, 'second')
127
+ }
128
+ if (Math.abs(diffMins) < 60) {
129
+ return rtf.format(diffMins, 'minute')
130
+ }
131
+ if (Math.abs(diffHours) < 24) {
132
+ return rtf.format(diffHours, 'hour')
133
+ }
134
+ if (Math.abs(diffDays) < 7) {
135
+ return rtf.format(diffDays, 'day')
136
+ }
137
+ if (Math.abs(diffWeeks) < 4) {
138
+ return rtf.format(diffWeeks, 'week')
139
+ }
140
+ if (Math.abs(diffMonths) < 12) {
141
+ return rtf.format(diffMonths, 'month')
142
+ }
143
+ return rtf.format(diffYears, 'year')
144
+ }
145
+
146
+ // Fallback for environments without Intl.RelativeTimeFormat
147
+ const absSeconds = Math.abs(diffSecs)
148
+ const isFuture = diffMs > 0
149
+ const prefix = isFuture ? 'in ' : ''
150
+ const suffix = isFuture ? '' : ' ago'
151
+
152
+ if (absSeconds < 60) {
153
+ return 'just now'
154
+ }
155
+ if (Math.abs(diffMins) < 60) {
156
+ const mins = Math.abs(diffMins)
157
+ return `${prefix}${mins} minute${mins === 1 ? '' : 's'}${suffix}`
158
+ }
159
+ if (Math.abs(diffHours) < 24) {
160
+ const hours = Math.abs(diffHours)
161
+ return `${prefix}${hours} hour${hours === 1 ? '' : 's'}${suffix}`
162
+ }
163
+ if (Math.abs(diffDays) < 7) {
164
+ const days = Math.abs(diffDays)
165
+ return `${prefix}${days} day${days === 1 ? '' : 's'}${suffix}`
166
+ }
167
+ if (Math.abs(diffWeeks) < 4) {
168
+ const weeks = Math.abs(diffWeeks)
169
+ return `${prefix}${weeks} week${weeks === 1 ? '' : 's'}${suffix}`
170
+ }
171
+ if (Math.abs(diffMonths) < 12) {
172
+ const months = Math.abs(diffMonths)
173
+ return `${prefix}${months} month${months === 1 ? '' : 's'}${suffix}`
174
+ }
175
+ const years = Math.abs(diffYears)
176
+ return `${prefix}${years} year${years === 1 ? '' : 's'}${suffix}`
177
+ }
178
+
179
+ /**
180
+ * DateTime component for displaying formatted dates and times.
181
+ *
182
+ * @example
183
+ * // Basic usage
184
+ * <DateTime value={Date.now()} />
185
+ *
186
+ * @example
187
+ * // Different formats
188
+ * <DateTime value={date} format="relative" />
189
+ * <DateTime value={date} format="long" />
190
+ *
191
+ * @example
192
+ * // UTC time
193
+ * <DateTime value={timestamp} format="datetime" isUTC />
194
+ */
195
+ export function DateTime({
196
+ value,
197
+ format = 'datetime',
198
+ customFormat,
199
+ isUTC = false,
200
+ fallback = '-',
201
+ color = 'inherit',
202
+ className,
203
+ ...props
204
+ }: DateTimeProps) {
205
+ if (value === null || value === undefined) {
206
+ return (
207
+ <Text className={cn(colorStyles[color], className)} {...props}>
208
+ {fallback}
209
+ </Text>
210
+ )
211
+ }
212
+
213
+ const formattedDate = formatDate(value, format, isUTC)
214
+
215
+ return (
216
+ <Text className={cn(colorStyles[color], className)} {...props}>
217
+ {formattedDate}
218
+ </Text>
219
+ )
220
+ }
221
+
222
+ /**
223
+ * Utility function to format dates outside of React components.
224
+ */
225
+ export function formatDateTime(
226
+ value: number | Date | string | null | undefined,
227
+ format: DateTimeFormat = 'datetime',
228
+ isUTC: boolean = false,
229
+ fallback: string = '-'
230
+ ): string {
231
+ if (value === null || value === undefined) {
232
+ return fallback
233
+ }
234
+ return formatDate(value, format, isUTC)
235
+ }
@@ -0,0 +1,2 @@
1
+ export { DateTime, formatDateTime } from './DateTime'
2
+ export type { DateTimeProps, DateTimeFormat } from './DateTime'