@titan-design/react-ui 0.2.5 → 0.2.7

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 (158) hide show
  1. package/dist/chunk-3VLOBS6O.mjs +846 -0
  2. package/dist/chunk-3VLOBS6O.mjs.map +1 -0
  3. package/dist/{chunk-UXVRPOME.mjs → chunk-5SSKGS6E.mjs} +3 -783
  4. package/dist/chunk-5SSKGS6E.mjs.map +1 -0
  5. package/dist/index.d.mts +1005 -4
  6. package/dist/index.d.ts +1005 -4
  7. package/dist/index.js +6043 -213
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +5901 -177
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/theme/index.d.mts +57 -1
  12. package/dist/theme/index.d.ts +57 -1
  13. package/dist/theme/index.js +63 -0
  14. package/dist/theme/index.js.map +1 -1
  15. package/dist/theme/index.mjs +2 -1
  16. package/dist/theme/tokens-css.d.mts +18 -0
  17. package/dist/theme/tokens-css.d.ts +18 -0
  18. package/dist/theme/tokens-css.js +289 -0
  19. package/dist/theme/tokens-css.js.map +1 -0
  20. package/dist/theme/tokens-css.mjs +24 -0
  21. package/dist/theme/tokens-css.mjs.map +1 -0
  22. package/docs/WEB_SETUP.md +217 -0
  23. package/package.json +17 -5
  24. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  25. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  26. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  27. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  28. package/src/components/custom/Gauge/index.ts +2 -0
  29. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  30. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  31. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  32. package/src/components/custom/Scatter/index.ts +2 -0
  33. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  34. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  35. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  36. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  37. package/src/components/custom/Treemap/index.ts +2 -0
  38. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  39. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  40. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  41. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  42. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  43. package/src/components/custom/Workout/BodyMap.tsx +348 -0
  44. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  45. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  46. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +478 -0
  47. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  48. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  49. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  50. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  51. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  52. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  53. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  54. package/src/components/custom/Workout/ExerciseCard.test.tsx +319 -0
  55. package/src/components/custom/Workout/ExerciseCard.tsx +429 -0
  56. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  57. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  58. package/src/components/custom/Workout/InputBar.tsx +163 -0
  59. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  60. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  61. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  62. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  63. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  64. package/src/components/custom/Workout/MesoCard.tsx +235 -0
  65. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  66. package/src/components/custom/Workout/MesoProgressBar.test.tsx +141 -0
  67. package/src/components/custom/Workout/MesoProgressBar.tsx +161 -0
  68. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  69. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  70. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  71. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  72. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  73. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  74. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  75. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  76. package/src/components/custom/Workout/PlaceholderStrip.tsx +91 -0
  77. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  78. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  79. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  80. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  81. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  82. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  83. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  84. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  85. package/src/components/custom/Workout/ReadinessCheck.tsx +266 -0
  86. package/src/components/custom/Workout/RestTimer.tsx +8 -6
  87. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  88. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  89. package/src/components/custom/Workout/SetRow.tsx +253 -0
  90. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  91. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  92. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  93. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  94. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  95. package/src/components/custom/Workout/StatusDot.tsx +159 -0
  96. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  97. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  98. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  99. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  100. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  101. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  102. package/src/components/custom/Workout/TempoDisplay.tsx +207 -0
  103. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  104. package/src/components/custom/Workout/VelocityStrip.test.tsx +234 -0
  105. package/src/components/custom/Workout/VelocityStrip.tsx +287 -0
  106. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  107. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  108. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  109. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  110. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  111. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  112. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  113. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  114. package/src/components/custom/Workout/WorkoutCard.tsx +226 -0
  115. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  116. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  117. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  118. package/src/components/custom/Workout/index.ts +113 -0
  119. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  120. package/src/components/custom/index.ts +3 -0
  121. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  122. package/src/components/custom/stepper/Stepper.tsx +2 -2
  123. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  124. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  125. package/src/components/ui/card/Card.tsx +1 -1
  126. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  127. package/src/components/ui/collapse/Collapse.tsx +1 -1
  128. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  129. package/src/components/ui/drawer/Drawer.tsx +3 -3
  130. package/src/components/ui/form-field/FormField.tsx +1 -1
  131. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  132. package/src/components/ui/menu/Menu.tsx +2 -2
  133. package/src/components/ui/pill/Pill.tsx +1 -1
  134. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  135. package/src/components/ui/popover/Popover.tsx +1 -1
  136. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  137. package/src/components/ui/section/Section.stories.tsx +4 -4
  138. package/src/components/ui/select/Select.tsx +1 -1
  139. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  140. package/src/components/ui/tabs/Tabs.tsx +2 -2
  141. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  142. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  143. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  144. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  145. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  146. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  147. package/src/stories/PresetShowcase.stories.tsx +15 -15
  148. package/src/theme/index.ts +2 -1
  149. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  150. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  151. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  152. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  153. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  154. package/src/theme/manifest/index.ts +11 -0
  155. package/src/theme/tokens-css.test.ts +28 -0
  156. package/src/theme/tokens-css.ts +34 -0
  157. package/src/theme/workout-tokens.ts +61 -0
  158. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -0,0 +1,188 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import React from 'react'
3
+ import { View, Text, type ViewProps } from 'react-native'
4
+ import { cn } from '../../../utils/cn'
5
+
6
+ const BRAND_PRIMARY = '#FF7900'
7
+
8
+ export interface SparklineProps extends ViewProps {
9
+ data: number[]
10
+ width?: number
11
+ height?: number
12
+ color?: string
13
+ showDots?: boolean
14
+ referenceLines?: Array<{
15
+ value: number
16
+ color: string
17
+ dashed?: boolean
18
+ label?: string
19
+ }>
20
+ highlightLast?: boolean
21
+ className?: string
22
+ }
23
+
24
+ function normalizeData(
25
+ data: number[],
26
+ height: number,
27
+ ): number[] {
28
+ if (data.length === 0) return []
29
+ const min = Math.min(...data)
30
+ const max = Math.max(...data)
31
+ const range = max - min || 1
32
+ return data.map((v) => height - ((v - min) / range) * height)
33
+ }
34
+
35
+ function normalizeValue(
36
+ value: number,
37
+ data: number[],
38
+ height: number,
39
+ ): number {
40
+ const min = Math.min(...data)
41
+ const max = Math.max(...data)
42
+ const range = max - min || 1
43
+ return height - ((value - min) / range) * height
44
+ }
45
+
46
+ export function Sparkline({
47
+ data,
48
+ width = 80,
49
+ height = 30,
50
+ color,
51
+ showDots = false,
52
+ referenceLines,
53
+ highlightLast = false,
54
+ className,
55
+ ...props
56
+ }: SparklineProps) {
57
+ if (data.length === 0) {
58
+ return (
59
+ <View
60
+ style={{ width, height }}
61
+ className={className}
62
+ testID="sparkline-empty"
63
+ accessibilityLabel="Sparkline chart, no data"
64
+ {...props}
65
+ />
66
+ )
67
+ }
68
+
69
+ const resolvedColor = color ?? BRAND_PRIMARY
70
+ const normalized = normalizeData(data, height)
71
+ const stepX = data.length > 1 ? width / (data.length - 1) : 0
72
+
73
+ const dotSize = 3
74
+ const highlightSize = 6
75
+
76
+ return (
77
+ <View
78
+ style={{ width, height, position: 'relative' }}
79
+ className={cn(className)}
80
+ accessibilityRole="image"
81
+ accessibilityLabel={`Sparkline chart with ${data.length} data points`}
82
+ testID="sparkline"
83
+ {...props}
84
+ >
85
+ {referenceLines?.map((line, i) => {
86
+ const y = normalizeValue(line.value, data, height)
87
+ return (
88
+ <View
89
+ key={`ref-${i}`}
90
+ style={[
91
+ {
92
+ position: 'absolute',
93
+ top: y,
94
+ left: 0,
95
+ right: 0,
96
+ opacity: 0.6,
97
+ },
98
+ line.dashed
99
+ ? {
100
+ height: 0,
101
+ borderStyle: 'dashed',
102
+ borderTopWidth: 1,
103
+ borderTopColor: line.color,
104
+ }
105
+ : {
106
+ height: 1,
107
+ backgroundColor: line.color,
108
+ },
109
+ ]}
110
+ accessibilityElementsHidden
111
+ testID={`sparkline-reference-${i}`}
112
+ >
113
+ {line.label && (
114
+ <Text
115
+ style={{
116
+ position: 'absolute',
117
+ right: 0,
118
+ top: -10,
119
+ fontSize: 7,
120
+ color: line.color,
121
+ opacity: 1,
122
+ }}
123
+ testID={`sparkline-reference-label-${i}`}
124
+ >
125
+ {line.label}
126
+ </Text>
127
+ )}
128
+ </View>
129
+ )
130
+ })}
131
+
132
+ {/* Line segments connecting data points */}
133
+ {normalized.map((y, i) => {
134
+ if (i === 0) return null
135
+ const prevY = normalized[i - 1]
136
+ const x1 = (i - 1) * stepX
137
+ const x2 = i * stepX
138
+ const dx = x2 - x1
139
+ const dy = y - prevY
140
+ const length = Math.sqrt(dx * dx + dy * dy)
141
+ const angle = Math.atan2(dy, dx) * (180 / Math.PI)
142
+ return (
143
+ <View
144
+ key={`line-${i}`}
145
+ style={{
146
+ position: 'absolute',
147
+ left: x1,
148
+ top: prevY,
149
+ width: length,
150
+ height: 1.5,
151
+ backgroundColor: resolvedColor,
152
+ transformOrigin: '0 0',
153
+ transform: [{ rotate: `${angle}deg` }],
154
+ }}
155
+ accessibilityElementsHidden
156
+ testID={`sparkline-segment-${i}`}
157
+ />
158
+ )
159
+ })}
160
+
161
+ {/* Data point dots */}
162
+ {(showDots || highlightLast) &&
163
+ normalized.map((y, i) => {
164
+ const isLast = i === data.length - 1
165
+ const shouldShow = showDots || (highlightLast && isLast)
166
+ if (!shouldShow) return null
167
+
168
+ const size = highlightLast && isLast ? highlightSize : dotSize
169
+ return (
170
+ <View
171
+ key={`dot-${i}`}
172
+ style={{
173
+ position: 'absolute',
174
+ left: i * stepX - size / 2,
175
+ top: y - size / 2,
176
+ width: size,
177
+ height: size,
178
+ borderRadius: size / 2,
179
+ backgroundColor: resolvedColor,
180
+ }}
181
+ accessibilityElementsHidden
182
+ testID={`sparkline-dot-${i}`}
183
+ />
184
+ )
185
+ })}
186
+ </View>
187
+ )
188
+ }
@@ -0,0 +1,150 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { StatusDot } from './StatusDot'
4
+
5
+ const meta: Meta<typeof StatusDot> = {
6
+ title: 'Custom/Workout/StatusDot',
7
+ component: StatusDot,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ variant: {
11
+ control: 'select',
12
+ options: ['success', 'warning', 'error', 'neutral', 'on-track', 'deviation', 'future'],
13
+ description: 'Status variant',
14
+ },
15
+ icon: {
16
+ control: 'select',
17
+ options: [undefined, 'check', 'exclamation', 'dash'],
18
+ description: 'Icon character inside the dot',
19
+ },
20
+ size: {
21
+ control: 'select',
22
+ options: ['sm', 'md'],
23
+ description: 'Dot size',
24
+ },
25
+ glow: {
26
+ control: 'boolean',
27
+ description: 'Subtle glow effect in variant color',
28
+ },
29
+ label: {
30
+ control: 'text',
31
+ description: 'Optional label text next to dot',
32
+ },
33
+ },
34
+ }
35
+
36
+ export default meta
37
+ type Story = StoryObj<typeof StatusDot>
38
+
39
+ export const Success: Story = {
40
+ args: {
41
+ variant: 'success',
42
+ size: 'md',
43
+ icon: 'check',
44
+ },
45
+ }
46
+
47
+ export const Warning: Story = {
48
+ args: {
49
+ variant: 'warning',
50
+ size: 'md',
51
+ icon: 'exclamation',
52
+ },
53
+ }
54
+
55
+ export const Error: Story = {
56
+ args: {
57
+ variant: 'error',
58
+ size: 'md',
59
+ icon: 'dash',
60
+ },
61
+ }
62
+
63
+ export const Neutral: Story = {
64
+ args: {
65
+ variant: 'neutral',
66
+ size: 'sm',
67
+ },
68
+ }
69
+
70
+ export const OnTrack: Story = {
71
+ args: {
72
+ variant: 'on-track',
73
+ size: 'md',
74
+ icon: 'check',
75
+ },
76
+ }
77
+
78
+ export const Deviation: Story = {
79
+ args: {
80
+ variant: 'deviation',
81
+ size: 'md',
82
+ icon: 'exclamation',
83
+ },
84
+ }
85
+
86
+ export const Future: Story = {
87
+ args: {
88
+ variant: 'future',
89
+ size: 'md',
90
+ },
91
+ }
92
+
93
+ export const WithGlow: Story = {
94
+ args: {
95
+ variant: 'success',
96
+ size: 'md',
97
+ icon: 'check',
98
+ glow: true,
99
+ },
100
+ }
101
+
102
+ export const WithLabel: Story = {
103
+ args: {
104
+ variant: 'success',
105
+ size: 'md',
106
+ icon: 'check',
107
+ label: 'On track',
108
+ },
109
+ }
110
+
111
+ export const SmallWithLabel: Story = {
112
+ args: {
113
+ variant: 'warning',
114
+ size: 'sm',
115
+ label: 'Needs attention',
116
+ },
117
+ }
118
+
119
+ export const AllVariants: Story = {
120
+ render: () => (
121
+ <View style={{ gap: 12 }}>
122
+ <View style={{ flexDirection: 'row', gap: 16, alignItems: 'center' }}>
123
+ <StatusDot variant="success" size="sm" />
124
+ <StatusDot variant="warning" size="sm" />
125
+ <StatusDot variant="error" size="sm" />
126
+ <StatusDot variant="neutral" size="sm" />
127
+ <StatusDot variant="on-track" size="sm" />
128
+ <StatusDot variant="deviation" size="sm" />
129
+ <StatusDot variant="future" size="sm" />
130
+ </View>
131
+ <View style={{ flexDirection: 'row', gap: 16, alignItems: 'center' }}>
132
+ <StatusDot variant="success" size="md" icon="check" />
133
+ <StatusDot variant="warning" size="md" icon="exclamation" />
134
+ <StatusDot variant="error" size="md" icon="dash" />
135
+ <StatusDot variant="neutral" size="md" />
136
+ <StatusDot variant="on-track" size="md" icon="check" />
137
+ <StatusDot variant="deviation" size="md" icon="exclamation" />
138
+ <StatusDot variant="future" size="md" />
139
+ </View>
140
+ <View style={{ gap: 8 }}>
141
+ <StatusDot variant="success" size="md" icon="check" label="On track" />
142
+ <StatusDot variant="on-track" size="md" icon="check" label="On track (ring)" glow />
143
+ <StatusDot variant="deviation" size="md" icon="exclamation" label="Deviation" glow />
144
+ <StatusDot variant="future" size="sm" label="Planned" />
145
+ <StatusDot variant="error" size="md" icon="dash" label="Failed" />
146
+ <StatusDot variant="neutral" size="sm" label="Planned" />
147
+ </View>
148
+ </View>
149
+ ),
150
+ }
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { StatusDot } from './StatusDot'
5
+
6
+ describe('StatusDot', () => {
7
+ it('renders a dot', () => {
8
+ render(<StatusDot variant="success" />)
9
+ expect(screen.getByTestId('status-dot')).toBeInTheDocument()
10
+ })
11
+
12
+ it('shows icon at md size', () => {
13
+ render(<StatusDot variant="success" size="md" icon="check" />)
14
+ expect(screen.getByText('\u2713')).toBeInTheDocument()
15
+ })
16
+
17
+ it('does not show icon at sm size', () => {
18
+ render(<StatusDot variant="success" size="sm" icon="check" />)
19
+ expect(screen.queryByText('\u2713')).not.toBeInTheDocument()
20
+ })
21
+
22
+ it('renders exclamation icon', () => {
23
+ render(<StatusDot variant="warning" size="md" icon="exclamation" />)
24
+ expect(screen.getByText('!')).toBeInTheDocument()
25
+ })
26
+
27
+ it('renders dash icon', () => {
28
+ render(<StatusDot variant="error" size="md" icon="dash" />)
29
+ expect(screen.getByText('\u2014')).toBeInTheDocument()
30
+ })
31
+
32
+ it('renders label text when provided', () => {
33
+ render(<StatusDot variant="success" label="On track" />)
34
+ expect(screen.getByText('On track')).toBeInTheDocument()
35
+ })
36
+
37
+ it('does not render label when omitted', () => {
38
+ render(<StatusDot variant="success" />)
39
+ expect(screen.queryByText('On track')).not.toBeInTheDocument()
40
+ })
41
+
42
+ it('defaults to sm size', () => {
43
+ render(<StatusDot variant="neutral" />)
44
+ expect(screen.getByTestId('status-dot')).toBeInTheDocument()
45
+ })
46
+
47
+ it('sets accessibility label with variant', () => {
48
+ render(<StatusDot variant="warning" />)
49
+ expect(screen.getByLabelText('warning status')).toBeInTheDocument()
50
+ })
51
+
52
+ describe('solid variants', () => {
53
+ it('renders all solid variant types without error', () => {
54
+ const { rerender } = render(<StatusDot variant="success" />)
55
+ expect(screen.getByTestId('status-dot')).toBeInTheDocument()
56
+
57
+ rerender(<StatusDot variant="warning" />)
58
+ expect(screen.getByTestId('status-dot')).toBeInTheDocument()
59
+
60
+ rerender(<StatusDot variant="error" />)
61
+ expect(screen.getByTestId('status-dot')).toBeInTheDocument()
62
+
63
+ rerender(<StatusDot variant="neutral" />)
64
+ expect(screen.getByTestId('status-dot')).toBeInTheDocument()
65
+ })
66
+ })
67
+
68
+ describe('ring variants', () => {
69
+ it('renders on-track variant', () => {
70
+ render(<StatusDot variant="on-track" />)
71
+ expect(screen.getByTestId('status-dot')).toBeInTheDocument()
72
+ expect(screen.getByLabelText('on-track status')).toBeInTheDocument()
73
+ })
74
+
75
+ it('renders deviation variant', () => {
76
+ render(<StatusDot variant="deviation" />)
77
+ expect(screen.getByTestId('status-dot')).toBeInTheDocument()
78
+ expect(screen.getByLabelText('deviation status')).toBeInTheDocument()
79
+ })
80
+
81
+ it('renders future variant', () => {
82
+ render(<StatusDot variant="future" />)
83
+ expect(screen.getByTestId('status-dot')).toBeInTheDocument()
84
+ expect(screen.getByLabelText('future status')).toBeInTheDocument()
85
+ })
86
+
87
+ it('renders the future variant with a 1px dashed border', () => {
88
+ render(<StatusDot variant="future" />)
89
+ const dot = screen.getByTestId('status-dot')
90
+ expect(dot.style.borderTopStyle).toBe('dashed')
91
+ expect(dot.style.borderTopWidth).toBe('1px')
92
+ })
93
+ })
94
+
95
+ describe('md size', () => {
96
+ it('renders at md size without error', () => {
97
+ render(<StatusDot variant="success" size="md" />)
98
+ expect(screen.getByTestId('status-dot')).toBeInTheDocument()
99
+ })
100
+ })
101
+
102
+ describe('glow', () => {
103
+ it('applies a box-shadow when glow is set on a solid variant', () => {
104
+ render(<StatusDot variant="success" glow />)
105
+ const dot = screen.getByTestId('status-dot')
106
+ expect(dot.style.boxShadow).toContain('rgba(20,184,166,0.4)')
107
+ })
108
+
109
+ it('applies a box-shadow when glow is set on a ring variant', () => {
110
+ render(<StatusDot variant="on-track" glow />)
111
+ const dot = screen.getByTestId('status-dot')
112
+ expect(dot.style.boxShadow).toContain('rgba(20,184,166,0.4)')
113
+ })
114
+
115
+ it('applies a gray box-shadow when glow is set on the future variant', () => {
116
+ render(<StatusDot variant="future" glow />)
117
+ const dot = screen.getByTestId('status-dot')
118
+ expect(dot.style.boxShadow).toContain('rgba(107,114,128,0.4)')
119
+ })
120
+
121
+ it('does not apply a box-shadow when glow is not set', () => {
122
+ render(<StatusDot variant="on-track" />)
123
+ const dot = screen.getByTestId('status-dot')
124
+ expect(dot.style.boxShadow).toBe('')
125
+ })
126
+ })
127
+
128
+ describe('accessibility', () => {
129
+ it('has no accessibility violations', async () => {
130
+ const { container } = render(
131
+ <StatusDot variant="success" size="md" icon="check" label="On track" />,
132
+ )
133
+ const results = await axe(container)
134
+ expect(results).toHaveNoViolations()
135
+ })
136
+
137
+ it('has no accessibility violations for ring variants', async () => {
138
+ const { container } = render(
139
+ <StatusDot variant="on-track" size="md" icon="check" label="On track" />,
140
+ )
141
+ const results = await axe(container)
142
+ expect(results).toHaveNoViolations()
143
+ })
144
+
145
+ it('has no accessibility violations without label', async () => {
146
+ const { container } = render(<StatusDot variant="warning" size="md" />)
147
+ const results = await axe(container)
148
+ expect(results).toHaveNoViolations()
149
+ })
150
+
151
+ it('has no accessibility violations for all solid variants', async () => {
152
+ for (const variant of ['success', 'warning', 'error', 'neutral'] as const) {
153
+ const { container, unmount } = render(<StatusDot variant={variant} />)
154
+ const results = await axe(container)
155
+ expect(results).toHaveNoViolations()
156
+ unmount()
157
+ }
158
+ })
159
+
160
+ it('sets combined accessibility label when label prop is provided', () => {
161
+ render(<StatusDot variant="success" label="On track" />)
162
+ expect(screen.getByLabelText('success status: On track')).toBeInTheDocument()
163
+ })
164
+ })
165
+ })
@@ -0,0 +1,159 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { View, Text, type ViewProps } from 'react-native'
3
+
4
+ export type StatusDotVariant =
5
+ | 'success'
6
+ | 'warning'
7
+ | 'error'
8
+ | 'neutral'
9
+ | 'on-track'
10
+ | 'deviation'
11
+ | 'future'
12
+
13
+ export interface StatusDotProps extends ViewProps {
14
+ variant: StatusDotVariant
15
+ icon?: 'check' | 'exclamation' | 'dash'
16
+ size?: 'sm' | 'md'
17
+ /** Adds a subtle glow effect in the variant color */
18
+ glow?: boolean
19
+ label?: string
20
+ className?: string
21
+ }
22
+
23
+ const solidVariantColors: Record<string, string> = {
24
+ success: '#14B8A6',
25
+ warning: '#FFB020',
26
+ error: '#D14343',
27
+ neutral: '#6B7280',
28
+ }
29
+
30
+ const ringVariantStyles: Record<string, Record<string, unknown>> = {
31
+ 'on-track': {
32
+ backgroundColor: 'rgba(20,184,166,0.15)',
33
+ borderWidth: 1,
34
+ borderColor: 'rgba(20,184,166,0.3)',
35
+ },
36
+ deviation: {
37
+ backgroundColor: 'rgba(245,158,11,0.15)',
38
+ borderWidth: 1,
39
+ borderColor: 'rgba(245,158,11,0.25)',
40
+ },
41
+ future: {
42
+ backgroundColor: 'rgba(107,114,128,0.1)',
43
+ borderWidth: 1,
44
+ borderStyle: 'dashed',
45
+ borderColor: 'rgba(107,114,128,0.2)',
46
+ },
47
+ }
48
+
49
+ const glowStyles: Record<StatusDotVariant, Record<string, unknown>> = {
50
+ success: { boxShadow: '0 0 4px rgba(20,184,166,0.4)' },
51
+ warning: { boxShadow: '0 0 4px rgba(245,158,11,0.4)' },
52
+ error: { boxShadow: '0 0 4px rgba(239,68,68,0.4)' },
53
+ neutral: { boxShadow: '0 0 4px rgba(107,114,128,0.4)' },
54
+ 'on-track': { boxShadow: '0 0 4px rgba(20,184,166,0.4)' },
55
+ deviation: { boxShadow: '0 0 4px rgba(245,158,11,0.4)' },
56
+ future: { boxShadow: '0 0 4px rgba(107,114,128,0.4)' },
57
+ }
58
+
59
+ const iconChars: Record<string, string> = {
60
+ check: '\u2713',
61
+ exclamation: '!',
62
+ dash: '\u2014',
63
+ }
64
+
65
+ /** Color-matched icon colors: dark on solid bg, light on transparent bg */
66
+ const iconColors: Record<StatusDotVariant, string> = {
67
+ success: '#0A5C52',
68
+ warning: '#6B4000',
69
+ error: '#5C1A1A',
70
+ neutral: '#D1D5DB',
71
+ 'on-track': '#14B8A6',
72
+ deviation: '#FFB020',
73
+ future: '#6B7280',
74
+ }
75
+
76
+ const sizeDimensions = {
77
+ sm: { size: 8, showIcon: false },
78
+ md: { size: 18, showIcon: true },
79
+ }
80
+
81
+ function isSolidVariant(variant: StatusDotVariant): boolean {
82
+ return variant in solidVariantColors
83
+ }
84
+
85
+ export function StatusDot({
86
+ variant,
87
+ icon,
88
+ size = 'sm',
89
+ glow,
90
+ label,
91
+ className,
92
+ ...props
93
+ }: StatusDotProps) {
94
+ const config = sizeDimensions[size]
95
+ const solid = isSolidVariant(variant)
96
+ const ring = ringVariantStyles[variant]
97
+
98
+ const glowStyle = glow ? glowStyles[variant] : null
99
+
100
+ const dot = (
101
+ <View
102
+ style={[
103
+ {
104
+ width: config.size,
105
+ height: config.size,
106
+ borderRadius: 9999,
107
+ alignItems: 'center',
108
+ justifyContent: 'center',
109
+ flexShrink: 0,
110
+ },
111
+ solid ? { backgroundColor: solidVariantColors[variant] } : undefined,
112
+ ring ? (ring as Record<string, unknown>) : undefined,
113
+ glowStyle ? (glowStyle as Record<string, unknown>) : undefined,
114
+ ]}
115
+ accessibilityRole="image"
116
+ accessibilityLabel={`${variant} status`}
117
+ testID="status-dot"
118
+ >
119
+ {config.showIcon && icon && (
120
+ <Text
121
+ style={{
122
+ fontSize: 11,
123
+ lineHeight: 11,
124
+ fontWeight: '900',
125
+ color: iconColors[variant],
126
+ }}
127
+ accessibilityElementsHidden
128
+ >
129
+ {iconChars[icon]}
130
+ </Text>
131
+ )}
132
+ </View>
133
+ )
134
+
135
+ if (label) {
136
+ return (
137
+ <View
138
+ className={className}
139
+ style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}
140
+ accessibilityLabel={`${variant} status: ${label}`}
141
+ {...props}
142
+ >
143
+ <View accessibilityElementsHidden>{dot}</View>
144
+ <Text
145
+ style={{ fontSize: 12, lineHeight: 16, color: '#9CA3AF' }}
146
+ accessibilityElementsHidden
147
+ >
148
+ {label}
149
+ </Text>
150
+ </View>
151
+ )
152
+ }
153
+
154
+ return (
155
+ <View className={className} {...props}>
156
+ {dot}
157
+ </View>
158
+ )
159
+ }