@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,92 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { View, Text, Pressable, type ViewProps } from 'react-native'
3
+
4
+ // Aliases for spec compatibility: under=behind, maintenance=ontrack, productive=target
5
+ export type VolumeStatus = 'untrained' | 'behind' | 'ontrack' | 'target' | 'over'
6
+
7
+ export interface MuscleGroupChipProps extends ViewProps {
8
+ name: string
9
+ volumeStatus?: VolumeStatus
10
+ onPress?: () => void
11
+ className?: string
12
+ }
13
+
14
+ const dotColorMap: Record<VolumeStatus, string> = {
15
+ untrained: '#2C2C2C',
16
+ behind: '#406D87',
17
+ ontrack: '#14B8A6',
18
+ target: '#FF7900',
19
+ over: '#D14343',
20
+ }
21
+
22
+ export function MuscleGroupChip({
23
+ name,
24
+ volumeStatus,
25
+ onPress,
26
+ className,
27
+ ...props
28
+ }: MuscleGroupChipProps) {
29
+ const dotColor = volumeStatus
30
+ ? dotColorMap[volumeStatus]
31
+ : '#2C2C2C'
32
+
33
+ const statusLabel = volumeStatus ?? 'no status'
34
+
35
+ const content = (
36
+ <View
37
+ className={className}
38
+ style={{
39
+ flexDirection: 'row',
40
+ alignItems: 'center',
41
+ borderRadius: 9999,
42
+ paddingHorizontal: 9,
43
+ paddingVertical: 3,
44
+ backgroundColor: 'var(--color-surface-raised)',
45
+ borderWidth: 1,
46
+ borderColor: 'var(--color-border-default)',
47
+ }}
48
+ accessibilityLabel={onPress ? undefined : `${name}, volume status: ${statusLabel}`}
49
+ testID="muscle-group-chip"
50
+ {...props}
51
+ >
52
+ <View
53
+ style={{
54
+ width: 6,
55
+ height: 6,
56
+ borderRadius: 9999,
57
+ marginRight: 5,
58
+ flexShrink: 0,
59
+ backgroundColor: dotColor,
60
+ }}
61
+ accessibilityElementsHidden
62
+ testID="muscle-group-chip-dot"
63
+ />
64
+ <Text
65
+ style={{
66
+ fontFamily: 'Inter, sans-serif',
67
+ fontWeight: '500',
68
+ fontSize: 11,
69
+ color: 'var(--color-text-secondary)',
70
+ }}
71
+ accessibilityElementsHidden
72
+ >
73
+ {name}
74
+ </Text>
75
+ </View>
76
+ )
77
+
78
+ if (onPress) {
79
+ return (
80
+ <Pressable
81
+ onPress={onPress}
82
+ accessibilityRole="button"
83
+ accessibilityLabel={`${name}, volume status: ${statusLabel}`}
84
+ testID="muscle-group-chip-pressable"
85
+ >
86
+ {content}
87
+ </Pressable>
88
+ )
89
+ }
90
+
91
+ return content
92
+ }
@@ -0,0 +1,94 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { PlaceholderStrip } from './PlaceholderStrip'
4
+
5
+ const meta: Meta<typeof PlaceholderStrip> = {
6
+ title: 'Custom/Workout/PlaceholderStrip',
7
+ component: PlaceholderStrip,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ width: {
11
+ control: 'text',
12
+ description: 'Fixed width or flex:1 (default)',
13
+ },
14
+ mode: {
15
+ control: 'select',
16
+ options: ['single', 'segmented'],
17
+ description: 'Render as single dashed strip or segmented mini-strip',
18
+ },
19
+ segments: {
20
+ control: 'number',
21
+ description: 'Number of segments in segmented mode',
22
+ },
23
+ },
24
+ }
25
+
26
+ export default meta
27
+ type Story = StoryObj<typeof PlaceholderStrip>
28
+
29
+ export const Default: Story = {
30
+ args: {},
31
+ decorators: [
32
+ (Story) => (
33
+ <View style={{ width: 300 }}>
34
+ <Story />
35
+ </View>
36
+ ),
37
+ ],
38
+ }
39
+
40
+ export const FixedWidth: Story = {
41
+ args: {
42
+ width: 200,
43
+ },
44
+ }
45
+
46
+ export const Segmented: Story = {
47
+ args: {
48
+ mode: 'segmented',
49
+ segments: 5,
50
+ },
51
+ decorators: [
52
+ (Story) => (
53
+ <View style={{ width: 300 }}>
54
+ <Story />
55
+ </View>
56
+ ),
57
+ ],
58
+ }
59
+
60
+ export const SegmentedThree: Story = {
61
+ args: {
62
+ mode: 'segmented',
63
+ segments: 3,
64
+ },
65
+ decorators: [
66
+ (Story) => (
67
+ <View style={{ width: 200 }}>
68
+ <Story />
69
+ </View>
70
+ ),
71
+ ],
72
+ }
73
+
74
+ export const MultipleStrips: Story = {
75
+ render: () => (
76
+ <View style={{ flexDirection: 'row', gap: 4, width: 300 }}>
77
+ <View style={{ flex: 1, height: 3, backgroundColor: '#2ed573', borderRadius: 1 }} />
78
+ <View style={{ flex: 1, height: 3, backgroundColor: '#ffd43b', borderRadius: 1 }} />
79
+ <View style={{ flex: 1, height: 3, backgroundColor: '#ffa502', borderRadius: 1 }} />
80
+ <PlaceholderStrip />
81
+ <PlaceholderStrip />
82
+ </View>
83
+ ),
84
+ }
85
+
86
+ export const MixedModes: Story = {
87
+ render: () => (
88
+ <View style={{ gap: 12, width: 300 }}>
89
+ <PlaceholderStrip mode="single" />
90
+ <PlaceholderStrip mode="segmented" segments={5} />
91
+ <PlaceholderStrip mode="segmented" segments={8} />
92
+ </View>
93
+ ),
94
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { PlaceholderStrip } from './PlaceholderStrip'
5
+
6
+ describe('PlaceholderStrip', () => {
7
+ it('renders a strip element', () => {
8
+ render(<PlaceholderStrip />)
9
+ expect(screen.getByTestId('placeholder-strip')).toBeInTheDocument()
10
+ })
11
+
12
+ it('sets accessibility label', () => {
13
+ render(<PlaceholderStrip />)
14
+ expect(
15
+ screen.getByLabelText('Planned set, not yet completed'),
16
+ ).toBeInTheDocument()
17
+ })
18
+
19
+ it('applies fixed width when provided', () => {
20
+ render(<PlaceholderStrip width={200} />)
21
+ const strip = screen.getByTestId('placeholder-strip')
22
+ expect(strip).toBeInTheDocument()
23
+ })
24
+
25
+ it('renders multiple strips', () => {
26
+ render(
27
+ <>
28
+ <PlaceholderStrip />
29
+ <PlaceholderStrip />
30
+ <PlaceholderStrip />
31
+ </>,
32
+ )
33
+ const strips = screen.getAllByTestId('placeholder-strip')
34
+ expect(strips).toHaveLength(3)
35
+ })
36
+
37
+ it('defaults to single mode', () => {
38
+ render(<PlaceholderStrip />)
39
+ expect(screen.queryAllByTestId('placeholder-segment')).toHaveLength(0)
40
+ })
41
+
42
+ describe('segmented mode', () => {
43
+ it('renders segments when mode is segmented', () => {
44
+ render(<PlaceholderStrip mode="segmented" segments={5} />)
45
+ const segments = screen.getAllByTestId('placeholder-segment')
46
+ expect(segments).toHaveLength(5)
47
+ })
48
+
49
+ it('defaults to 3 segments when segments prop is omitted', () => {
50
+ render(<PlaceholderStrip mode="segmented" />)
51
+ const segments = screen.getAllByTestId('placeholder-segment')
52
+ expect(segments).toHaveLength(3)
53
+ })
54
+
55
+ it('still has the strip container testID', () => {
56
+ render(<PlaceholderStrip mode="segmented" segments={4} />)
57
+ expect(screen.getByTestId('placeholder-strip')).toBeInTheDocument()
58
+ })
59
+
60
+ it('sets accessibility label in segmented mode', () => {
61
+ render(<PlaceholderStrip mode="segmented" segments={3} />)
62
+ expect(
63
+ screen.getByLabelText('Planned set, not yet completed'),
64
+ ).toBeInTheDocument()
65
+ })
66
+ })
67
+
68
+ describe('accessibility', () => {
69
+ it('has no accessibility violations in single mode', async () => {
70
+ const { container } = render(<PlaceholderStrip />)
71
+ const results = await axe(container)
72
+ expect(results).toHaveNoViolations()
73
+ })
74
+
75
+ it('has no accessibility violations in segmented mode', async () => {
76
+ const { container } = render(
77
+ <PlaceholderStrip mode="segmented" segments={4} />,
78
+ )
79
+ const results = await axe(container)
80
+ expect(results).toHaveNoViolations()
81
+ })
82
+ })
83
+ })
@@ -0,0 +1,91 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { View, type ViewProps } from 'react-native'
3
+ import { cn } from '../../../utils/cn'
4
+ import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
5
+
6
+ export interface PlaceholderStripProps extends ViewProps {
7
+ width?: number | string
8
+ /** Render as single solid strip or segmented mini-strip */
9
+ mode?: 'single' | 'segmented'
10
+ /** Number of segments when mode is 'segmented' */
11
+ segments?: number
12
+ className?: string
13
+ }
14
+
15
+ function SingleStrip({
16
+ width,
17
+ className,
18
+ ...props
19
+ }: Omit<PlaceholderStripProps, 'mode' | 'segments'>) {
20
+ return (
21
+ <View
22
+ className={cn(className)}
23
+ style={[
24
+ {
25
+ height: 3,
26
+ backgroundColor: 'transparent',
27
+ borderWidth: 1,
28
+ borderStyle: 'dashed',
29
+ borderColor: WORKOUT_TOKENS.border.strong,
30
+ borderRadius: 1,
31
+ opacity: 0.5,
32
+ },
33
+ width != null ? { width: width as number } : { flex: 1 },
34
+ ]}
35
+ accessibilityRole="image"
36
+ accessibilityLabel="Planned set, not yet completed"
37
+ testID="placeholder-strip"
38
+ {...props}
39
+ />
40
+ )
41
+ }
42
+
43
+ function SegmentedStrip({
44
+ segments = 3,
45
+ width,
46
+ className,
47
+ ...props
48
+ }: Omit<PlaceholderStripProps, 'mode'>) {
49
+ return (
50
+ <View
51
+ className={cn('flex-row', className)}
52
+ style={[
53
+ { height: 3, gap: 2, opacity: 0.5 },
54
+ width != null ? { width: width as number } : { flex: 1 },
55
+ ]}
56
+ accessibilityRole="image"
57
+ accessibilityLabel="Planned set, not yet completed"
58
+ testID="placeholder-strip"
59
+ {...props}
60
+ >
61
+ {Array.from({ length: segments }, (_, i) => (
62
+ <View
63
+ key={i}
64
+ style={{
65
+ flex: 1,
66
+ height: 3,
67
+ backgroundColor: 'transparent',
68
+ borderWidth: 1,
69
+ borderStyle: 'dashed',
70
+ borderColor: WORKOUT_TOKENS.border.strong,
71
+ borderRadius: 1,
72
+ minWidth: 4,
73
+ }}
74
+ accessibilityElementsHidden
75
+ testID="placeholder-segment"
76
+ />
77
+ ))}
78
+ </View>
79
+ )
80
+ }
81
+
82
+ export function PlaceholderStrip({
83
+ mode = 'single',
84
+ segments,
85
+ ...props
86
+ }: PlaceholderStripProps) {
87
+ if (mode === 'segmented') {
88
+ return <SegmentedStrip segments={segments} {...props} />
89
+ }
90
+ return <SingleStrip {...props} />
91
+ }
@@ -0,0 +1,105 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { PrBadge } from './PrBadge'
4
+
5
+ const meta: Meta<typeof PrBadge> = {
6
+ title: 'Custom/Workout/PrBadge',
7
+ component: PrBadge,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ type: {
11
+ control: 'select',
12
+ options: ['e1rm', 'weight', 'reps', 'volume', 'velocity'],
13
+ description: 'PR type determines auto-generated label',
14
+ },
15
+ label: {
16
+ control: 'text',
17
+ description: 'Custom label (overrides auto-generated)',
18
+ },
19
+ compact: {
20
+ control: 'boolean',
21
+ description: 'Show only star icon, no text',
22
+ },
23
+ animate: {
24
+ control: 'boolean',
25
+ description: 'Trigger pop animation on mount',
26
+ },
27
+ },
28
+ }
29
+
30
+ export default meta
31
+ type Story = StoryObj<typeof PrBadge>
32
+
33
+ export const Animated: Story = {
34
+ args: {
35
+ animate: true,
36
+ type: 'e1rm',
37
+ },
38
+ }
39
+
40
+ export const Static: Story = {
41
+ args: {
42
+ animate: false,
43
+ type: 'e1rm',
44
+ },
45
+ }
46
+
47
+ export const WeightPr: Story = {
48
+ args: {
49
+ type: 'weight',
50
+ animate: false,
51
+ },
52
+ }
53
+
54
+ export const RepsPr: Story = {
55
+ args: {
56
+ type: 'reps',
57
+ animate: false,
58
+ },
59
+ }
60
+
61
+ export const VelocityPr: Story = {
62
+ args: {
63
+ type: 'velocity',
64
+ animate: false,
65
+ },
66
+ }
67
+
68
+ export const CustomLabel: Story = {
69
+ args: {
70
+ label: 'PR 5RM',
71
+ animate: false,
72
+ },
73
+ }
74
+
75
+ export const Compact: Story = {
76
+ args: {
77
+ compact: true,
78
+ animate: false,
79
+ },
80
+ }
81
+
82
+ export const CompactAnimated: Story = {
83
+ args: {
84
+ compact: true,
85
+ animate: true,
86
+ },
87
+ }
88
+
89
+ export const AllTypes: Story = {
90
+ render: () => (
91
+ <View style={{ gap: 8 }}>
92
+ <View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
93
+ <PrBadge type="e1rm" animate={false} />
94
+ <PrBadge type="weight" animate={false} />
95
+ <PrBadge type="reps" animate={false} />
96
+ <PrBadge type="volume" animate={false} />
97
+ <PrBadge type="velocity" animate={false} />
98
+ </View>
99
+ <View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
100
+ <PrBadge compact animate={false} />
101
+ <PrBadge label="PR 5RM" animate={false} />
102
+ </View>
103
+ </View>
104
+ ),
105
+ }
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { PrBadge } from './PrBadge'
5
+
6
+ describe('PrBadge', () => {
7
+ it('renders star and default PR label', () => {
8
+ render(<PrBadge animate={false} />)
9
+ expect(screen.getByText(/\u2605 PR e1RM/)).toBeInTheDocument()
10
+ })
11
+
12
+ it('renders with animation wrapper when animate is true', () => {
13
+ render(<PrBadge animate />)
14
+ expect(screen.getByTestId('pr-badge-animated')).toBeInTheDocument()
15
+ })
16
+
17
+ it('renders without animation wrapper when animate is false', () => {
18
+ render(<PrBadge animate={false} />)
19
+ expect(screen.queryByTestId('pr-badge-animated')).not.toBeInTheDocument()
20
+ })
21
+
22
+ it('defaults to animated', () => {
23
+ render(<PrBadge />)
24
+ expect(screen.getByTestId('pr-badge-animated')).toBeInTheDocument()
25
+ })
26
+
27
+ it('sets accessibility label with resolved label', () => {
28
+ render(<PrBadge animate={false} />)
29
+ expect(
30
+ screen.getByLabelText('Personal record: PR e1RM'),
31
+ ).toBeInTheDocument()
32
+ })
33
+
34
+ describe('type prop', () => {
35
+ it('generates label from e1rm type', () => {
36
+ render(<PrBadge type="e1rm" animate={false} />)
37
+ expect(screen.getByText(/PR e1RM/)).toBeInTheDocument()
38
+ })
39
+
40
+ it('generates label from weight type', () => {
41
+ render(<PrBadge type="weight" animate={false} />)
42
+ expect(screen.getByText(/PR Weight/)).toBeInTheDocument()
43
+ })
44
+
45
+ it('generates label from reps type', () => {
46
+ render(<PrBadge type="reps" animate={false} />)
47
+ expect(screen.getByText(/PR Reps/)).toBeInTheDocument()
48
+ })
49
+
50
+ it('generates label from volume type', () => {
51
+ render(<PrBadge type="volume" animate={false} />)
52
+ expect(screen.getByText(/PR Volume/)).toBeInTheDocument()
53
+ })
54
+
55
+ it('generates label from velocity type', () => {
56
+ render(<PrBadge type="velocity" animate={false} />)
57
+ expect(screen.getByText(/PR Velocity/)).toBeInTheDocument()
58
+ })
59
+ })
60
+
61
+ describe('custom label', () => {
62
+ it('uses custom label over auto-generated', () => {
63
+ render(<PrBadge label="PR 5RM" animate={false} />)
64
+ expect(screen.getByText(/PR 5RM/)).toBeInTheDocument()
65
+ })
66
+
67
+ it('sets accessibility label with custom label', () => {
68
+ render(<PrBadge label="PR 5RM" animate={false} />)
69
+ expect(
70
+ screen.getByLabelText('Personal record: PR 5RM'),
71
+ ).toBeInTheDocument()
72
+ })
73
+ })
74
+
75
+ describe('compact mode', () => {
76
+ it('renders only the star icon when compact', () => {
77
+ render(<PrBadge compact animate={false} />)
78
+ const star = screen.getByTestId('pr-badge-star')
79
+ expect(star).toBeInTheDocument()
80
+ // The compact star is now a Lucide SVG icon, not text
81
+ expect(star.querySelector('svg')).toBeInTheDocument()
82
+ expect(star).not.toHaveTextContent('\u2605')
83
+ })
84
+
85
+ it('does not show label text in compact mode', () => {
86
+ render(<PrBadge compact animate={false} />)
87
+ expect(screen.queryByText(/PR e1RM/)).not.toBeInTheDocument()
88
+ })
89
+
90
+ it('has correct accessibility label in compact mode', () => {
91
+ render(<PrBadge compact animate={false} />)
92
+ expect(
93
+ screen.getByLabelText('Personal record: PR e1RM'),
94
+ ).toBeInTheDocument()
95
+ })
96
+ })
97
+
98
+ describe('accessibility', () => {
99
+ it('has no accessibility violations', async () => {
100
+ const { container } = render(<PrBadge animate={false} />)
101
+ const results = await axe(container)
102
+ expect(results).toHaveNoViolations()
103
+ })
104
+
105
+ it('has no accessibility violations in compact mode', async () => {
106
+ const { container } = render(<PrBadge compact animate={false} />)
107
+ const results = await axe(container)
108
+ expect(results).toHaveNoViolations()
109
+ })
110
+ })
111
+ })
@@ -0,0 +1,103 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { useEffect, useState } from 'react'
3
+ import { View, Text, Animated, Easing, type ViewProps } from 'react-native'
4
+ import { Star } from 'lucide-react'
5
+ import { BaseBadge } from './BaseBadge'
6
+
7
+ export type PRType = 'e1rm' | 'weight' | 'reps' | 'volume' | 'velocity'
8
+
9
+ export interface PrBadgeProps extends ViewProps {
10
+ /** PR type determines auto-generated label */
11
+ type?: PRType
12
+ /** Display label (e.g., "PR e1RM", "PR 5RM"). Auto-generated from type if omitted. */
13
+ label?: string
14
+ /** Show only the star icon, no text label */
15
+ compact?: boolean
16
+ /** Trigger pop animation on mount */
17
+ animate?: boolean
18
+ className?: string
19
+ }
20
+
21
+ const typeLabels: Record<PRType, string> = {
22
+ e1rm: 'PR e1RM',
23
+ weight: 'PR Weight',
24
+ reps: 'PR Reps',
25
+ volume: 'PR Volume',
26
+ velocity: 'PR Velocity',
27
+ }
28
+
29
+ export function PrBadge({
30
+ type = 'e1rm',
31
+ label: labelProp,
32
+ compact = false,
33
+ animate = true,
34
+ className,
35
+ ...props
36
+ }: PrBadgeProps) {
37
+ const resolvedLabel = labelProp ?? typeLabels[type]
38
+ const [scale] = useState(() => new Animated.Value(animate ? 0.8 : 1))
39
+ const [opacity] = useState(() => new Animated.Value(animate ? 0 : 1))
40
+
41
+ useEffect(() => {
42
+ if (!animate) return
43
+
44
+ // HTML demo uses a 3-keyframe sequence; bezier approximation is close enough for native
45
+ Animated.parallel([
46
+ Animated.timing(scale, {
47
+ toValue: 1,
48
+ duration: 400,
49
+ easing: Easing.bezier(0.34, 1.56, 0.64, 1),
50
+ useNativeDriver: true,
51
+ }),
52
+ Animated.timing(opacity, {
53
+ toValue: 1,
54
+ duration: 400,
55
+ easing: Easing.bezier(0.34, 1.56, 0.64, 1),
56
+ useNativeDriver: true,
57
+ }),
58
+ ]).start()
59
+ }, [animate, scale, opacity])
60
+
61
+ const badge = compact ? (
62
+ <View
63
+ className={className}
64
+ accessibilityRole="image"
65
+ accessibilityLabel={`Personal record: ${resolvedLabel}`}
66
+ testID="pr-badge-star"
67
+ {...props}
68
+ >
69
+ <Star size={14} color="#FF7900" fill="#FF7900" strokeWidth={2} />
70
+ </View>
71
+ ) : (
72
+ <BaseBadge
73
+ variant="pr"
74
+ className={className}
75
+ accessibilityLabel={`Personal record: ${resolvedLabel}`}
76
+ {...props}
77
+ >
78
+ <Text
79
+ style={{
80
+ fontSize: 10,
81
+ color: '#FF7900',
82
+ fontWeight: '700',
83
+ fontFamily: 'Inter, sans-serif',
84
+ }}
85
+ >
86
+ {'\u2605'} {resolvedLabel}
87
+ </Text>
88
+ </BaseBadge>
89
+ )
90
+
91
+ if (animate) {
92
+ return (
93
+ <Animated.View
94
+ style={{ transform: [{ scale }], opacity }}
95
+ testID="pr-badge-animated"
96
+ >
97
+ {badge}
98
+ </Animated.View>
99
+ )
100
+ }
101
+
102
+ return badge
103
+ }