@titan-design/react-ui 0.2.6 → 0.3.0

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 (191) hide show
  1. package/dist/bodymap-BhG55xHM.d.mts +318 -0
  2. package/dist/bodymap-BhG55xHM.d.ts +318 -0
  3. package/dist/bodymap.d.mts +3 -0
  4. package/dist/bodymap.d.ts +3 -0
  5. package/dist/bodymap.js +2670 -0
  6. package/dist/bodymap.js.map +1 -0
  7. package/dist/bodymap.mjs +1101 -0
  8. package/dist/bodymap.mjs.map +1 -0
  9. package/dist/{chunk-UXVRPOME.mjs → chunk-7XPB4NAO.mjs} +125 -785
  10. package/dist/chunk-7XPB4NAO.mjs.map +1 -0
  11. package/dist/chunk-P7TSQUN6.mjs +846 -0
  12. package/dist/chunk-P7TSQUN6.mjs.map +1 -0
  13. package/dist/chunk-TOD2WQBG.mjs +1048 -0
  14. package/dist/chunk-TOD2WQBG.mjs.map +1 -0
  15. package/dist/index.d.mts +1131 -4
  16. package/dist/index.d.ts +1131 -4
  17. package/dist/index.js +6396 -225
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +5572 -641
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/theme/index.d.mts +317 -1
  22. package/dist/theme/index.d.ts +317 -1
  23. package/dist/theme/index.js +185 -2
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/index.mjs +2 -1
  26. package/dist/theme/tokens-css.d.mts +20 -0
  27. package/dist/theme/tokens-css.d.ts +20 -0
  28. package/dist/theme/tokens-css.js +598 -0
  29. package/dist/theme/tokens-css.js.map +1 -0
  30. package/dist/theme/tokens-css.mjs +24 -0
  31. package/dist/theme/tokens-css.mjs.map +1 -0
  32. package/package.json +25 -5
  33. package/src/bodymap.ts +37 -0
  34. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  35. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  36. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  37. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  38. package/src/components/custom/Gauge/index.ts +2 -0
  39. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  40. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  41. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  42. package/src/components/custom/Scatter/index.ts +2 -0
  43. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  44. package/src/components/custom/Table/Table.test.tsx +27 -0
  45. package/src/components/custom/Table/Table.tsx +33 -17
  46. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  47. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  48. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  49. package/src/components/custom/Treemap/index.ts +2 -0
  50. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
  51. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
  52. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -0
  53. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  54. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  55. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  56. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  57. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  58. package/src/components/custom/Workout/BodyMap.tsx +347 -0
  59. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  60. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  61. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +477 -0
  62. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  63. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  64. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  65. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  66. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  67. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  68. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  69. package/src/components/custom/Workout/ExerciseCard.test.tsx +343 -0
  70. package/src/components/custom/Workout/ExerciseCard.tsx +442 -0
  71. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
  72. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
  73. package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -0
  74. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  75. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  76. package/src/components/custom/Workout/InputBar.tsx +162 -0
  77. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  78. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  79. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  80. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  81. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  82. package/src/components/custom/Workout/MesoCard.tsx +237 -0
  83. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  84. package/src/components/custom/Workout/MesoProgressBar.test.tsx +153 -0
  85. package/src/components/custom/Workout/MesoProgressBar.tsx +165 -0
  86. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  87. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  88. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  89. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  90. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  91. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  92. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  93. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  94. package/src/components/custom/Workout/PlaceholderStrip.tsx +90 -0
  95. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  96. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  97. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  98. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  99. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  100. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  101. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
  102. package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
  103. package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -0
  104. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  105. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  106. package/src/components/custom/Workout/ReadinessCheck.tsx +265 -0
  107. package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
  108. package/src/components/custom/Workout/RestTimer.tsx +15 -11
  109. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  110. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  111. package/src/components/custom/Workout/SetRow.tsx +247 -0
  112. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  113. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  114. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  115. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  116. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  117. package/src/components/custom/Workout/StatusDot.tsx +160 -0
  118. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  119. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  120. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  121. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  122. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  123. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  124. package/src/components/custom/Workout/TempoDisplay.tsx +209 -0
  125. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
  126. package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
  127. package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
  128. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  129. package/src/components/custom/Workout/VelocityStrip.test.tsx +244 -0
  130. package/src/components/custom/Workout/VelocityStrip.tsx +288 -0
  131. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  132. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  133. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  134. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  135. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  136. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  137. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  138. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  139. package/src/components/custom/Workout/WorkoutCard.tsx +228 -0
  140. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  141. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  142. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  143. package/src/components/custom/Workout/index.ts +152 -0
  144. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  145. package/src/components/custom/index.ts +3 -0
  146. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  147. package/src/components/custom/stepper/Stepper.tsx +2 -2
  148. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  149. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  150. package/src/components/ui/card/Card.tsx +1 -1
  151. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  152. package/src/components/ui/collapse/Collapse.tsx +1 -1
  153. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  154. package/src/components/ui/drawer/Drawer.tsx +3 -3
  155. package/src/components/ui/form-field/FormField.tsx +1 -1
  156. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  157. package/src/components/ui/menu/Menu.tsx +2 -2
  158. package/src/components/ui/pill/Pill.tsx +1 -1
  159. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  160. package/src/components/ui/popover/Popover.tsx +1 -1
  161. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  162. package/src/components/ui/section/Section.stories.tsx +4 -4
  163. package/src/components/ui/select/Select.tsx +1 -1
  164. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  165. package/src/components/ui/tabs/Tabs.tsx +2 -2
  166. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  167. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  168. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  169. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  170. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  171. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  172. package/src/stories/PresetShowcase.stories.tsx +15 -15
  173. package/src/theme/config.completeness.test.ts +77 -0
  174. package/src/theme/config.ts +151 -0
  175. package/src/theme/index.ts +2 -1
  176. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  177. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  178. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  179. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  180. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  181. package/src/theme/manifest/design-freeze.test.ts +155 -0
  182. package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
  183. package/src/theme/manifest/extract-css-properties.test.ts +112 -0
  184. package/src/theme/manifest/index.ts +11 -0
  185. package/src/theme/tokens-css.test.ts +28 -0
  186. package/src/theme/tokens-css.ts +36 -0
  187. package/src/theme/workout-tokens.ts +59 -0
  188. package/src/utils/index.ts +10 -0
  189. package/src/utils/workout-format.test.ts +81 -0
  190. package/src/utils/workout-format.ts +83 -0
  191. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -34,7 +34,7 @@ type Story = StoryObj<typeof Stack>
34
34
 
35
35
  function Box({ children }: { children: string }) {
36
36
  return (
37
- <View className="rounded bg-primary-500 px-4 py-2">
37
+ <View className="rounded bg-brand-primary px-4 py-2">
38
38
  <Text className="text-sm text-white">{children}</Text>
39
39
  </View>
40
40
  )
@@ -85,13 +85,13 @@ export const WithAlignment: Story = {
85
85
 
86
86
  <Text className="text-sm font-medium text-neutral-400">align=&quot;center&quot;</Text>
87
87
  <HStack gap={2} align="center">
88
- <View className="rounded bg-primary-500 px-4 py-1">
88
+ <View className="rounded bg-brand-primary px-4 py-1">
89
89
  <Text className="text-sm text-white">Short</Text>
90
90
  </View>
91
- <View className="rounded bg-primary-500 px-4 py-4">
91
+ <View className="rounded bg-brand-primary px-4 py-4">
92
92
  <Text className="text-sm text-white">Tall</Text>
93
93
  </View>
94
- <View className="rounded bg-primary-500 px-4 py-2">
94
+ <View className="rounded bg-brand-primary px-4 py-2">
95
95
  <Text className="text-sm text-white">Medium</Text>
96
96
  </View>
97
97
  </HStack>
@@ -100,8 +100,8 @@ export function TabList({ children, className }: TabListProps) {
100
100
 
101
101
  const variantStyles = {
102
102
  line: orientation === 'horizontal'
103
- ? 'border-b border-border-default'
104
- : 'border-r border-border-default',
103
+ ? 'border-b border-border'
104
+ : 'border-r border-border',
105
105
  enclosed: 'bg-surface-raised rounded-lg p-1',
106
106
  'soft-rounded': 'bg-surface-raised rounded-full p-1',
107
107
  }
@@ -319,7 +319,7 @@ export const CompactMode: Story = {
319
319
  onPress={() => setIsCompact(!isCompact)}
320
320
  />
321
321
  </View>
322
- <View className="h-px bg-border-default" />
322
+ <View className="h-px bg-border" />
323
323
  <ToolbarButtonGroup gap="sm">
324
324
  <ToolbarButton
325
325
  label="Filter"
@@ -231,7 +231,7 @@ export function ToolbarButton({
231
231
  <View
232
232
  className={cn(
233
233
  'absolute z-50 top-full left-0 mt-1',
234
- 'rounded-lg shadow-lg border border-border-default',
234
+ 'rounded-lg shadow-lg border border-border',
235
235
  'min-w-[150px] overflow-hidden',
236
236
  `bg-[${MENU_BG}]`
237
237
  )}
@@ -0,0 +1,171 @@
1
+ import { View, Text } from 'react-native'
2
+ import {
3
+ Controller,
4
+ type Control,
5
+ type FieldPath,
6
+ type FieldValues,
7
+ type RegisterOptions,
8
+ } from 'react-hook-form'
9
+ import { FormField } from '../../components/ui/form-field'
10
+ import { Input } from '../../components/ui/input'
11
+ import { Select, type SelectOption } from '../../components/ui/select'
12
+ import { Checkbox } from '../../components/ui/checkbox'
13
+ import { RadioGroup, Radio } from '../../components/ui/radio'
14
+
15
+ /**
16
+ * Reusable react-hook-form adapters for the styled form primitives.
17
+ *
18
+ * Each adapter follows the canonical RHF `Controller` render-prop idiom: the
19
+ * library owns the value/validation state and hands it to the already-styled
20
+ * controlled component through `field` + `fieldState`. None of the components'
21
+ * public APIs are touched — this is pure wiring a consumer would write.
22
+ */
23
+ interface BaseControlledProps<T extends FieldValues> {
24
+ /** The `control` object returned by `useForm`. */
25
+ control: Control<T>
26
+ /** The field name — type-checked against the form's value shape. */
27
+ name: FieldPath<T>
28
+ /** RHF validation rules (required, pattern, validate, ...). */
29
+ rules?: Omit<
30
+ RegisterOptions<T, FieldPath<T>>,
31
+ 'valueAsNumber' | 'valueAsDate' | 'setValueAs' | 'disabled'
32
+ >
33
+ /** Visible field label. */
34
+ label?: string
35
+ /** Marks the field as required in the UI. */
36
+ isRequired?: boolean
37
+ }
38
+
39
+ export interface ControlledInputProps<T extends FieldValues> extends BaseControlledProps<T> {
40
+ placeholder?: string
41
+ }
42
+
43
+ /** `FormField` + `Input` driven by RHF. Surfaces validation via `errorMessage`. */
44
+ export function ControlledInput<T extends FieldValues>({
45
+ control,
46
+ name,
47
+ rules,
48
+ label,
49
+ isRequired,
50
+ placeholder,
51
+ }: ControlledInputProps<T>) {
52
+ return (
53
+ <Controller
54
+ control={control}
55
+ name={name}
56
+ rules={rules}
57
+ render={({ field, fieldState }) => (
58
+ <FormField label={label} isRequired={isRequired} errorMessage={fieldState.error?.message}>
59
+ <Input
60
+ value={(field.value as string | undefined) ?? ''}
61
+ onChangeText={field.onChange}
62
+ onBlur={field.onBlur}
63
+ placeholder={placeholder}
64
+ />
65
+ </FormField>
66
+ )}
67
+ />
68
+ )
69
+ }
70
+
71
+ export interface ControlledSelectProps<T extends FieldValues> extends BaseControlledProps<T> {
72
+ options: SelectOption[]
73
+ placeholder?: string
74
+ }
75
+
76
+ /** `FormField` + `Select` (single-select) driven by RHF. */
77
+ export function ControlledSelect<T extends FieldValues>({
78
+ control,
79
+ name,
80
+ rules,
81
+ label,
82
+ isRequired,
83
+ options,
84
+ placeholder,
85
+ }: ControlledSelectProps<T>) {
86
+ return (
87
+ <Controller
88
+ control={control}
89
+ name={name}
90
+ rules={rules}
91
+ render={({ field, fieldState }) => (
92
+ <FormField label={label} isRequired={isRequired} errorMessage={fieldState.error?.message}>
93
+ <Select
94
+ value={(field.value as string | null | undefined) ?? null}
95
+ onChange={field.onChange}
96
+ options={options}
97
+ placeholder={placeholder}
98
+ />
99
+ </FormField>
100
+ )}
101
+ />
102
+ )
103
+ }
104
+
105
+ /** `Checkbox` (boolean) driven by RHF, with an inline error message. */
106
+ export function ControlledCheckbox<T extends FieldValues>({
107
+ control,
108
+ name,
109
+ rules,
110
+ label,
111
+ }: BaseControlledProps<T>) {
112
+ return (
113
+ <Controller
114
+ control={control}
115
+ name={name}
116
+ rules={rules}
117
+ render={({ field, fieldState }) => (
118
+ <View className="gap-1">
119
+ <Checkbox
120
+ label={label}
121
+ isChecked={Boolean(field.value)}
122
+ onCheckedChange={field.onChange}
123
+ isInvalid={!!fieldState.error}
124
+ />
125
+ {fieldState.error && (
126
+ <Text className="text-xs text-status-error">{fieldState.error.message}</Text>
127
+ )}
128
+ </View>
129
+ )}
130
+ />
131
+ )
132
+ }
133
+
134
+ export interface ControlledRadioGroupProps<T extends FieldValues> extends BaseControlledProps<T> {
135
+ options: SelectOption[]
136
+ }
137
+
138
+ /** `RadioGroup` + `Radio` options driven by RHF, with an inline error message. */
139
+ export function ControlledRadioGroup<T extends FieldValues>({
140
+ control,
141
+ name,
142
+ rules,
143
+ label,
144
+ options,
145
+ }: ControlledRadioGroupProps<T>) {
146
+ return (
147
+ <Controller
148
+ control={control}
149
+ name={name}
150
+ rules={rules}
151
+ render={({ field, fieldState }) => (
152
+ <View className="gap-1">
153
+ <RadioGroup
154
+ label={label}
155
+ value={(field.value as string | null | undefined) ?? null}
156
+ onChange={field.onChange}
157
+ >
158
+ {options.map((option) => (
159
+ <Radio key={option.value} value={option.value}>
160
+ {option.label}
161
+ </Radio>
162
+ ))}
163
+ </RadioGroup>
164
+ {fieldState.error && (
165
+ <Text className="text-xs text-status-error">{fieldState.error.message}</Text>
166
+ )}
167
+ </View>
168
+ )}
169
+ />
170
+ )
171
+ }
@@ -0,0 +1,51 @@
1
+ import React from 'react'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+ import { View, Text } from 'react-native'
4
+ import { RhfContactForm, type ContactFormValues } from './RhfContactForm'
5
+
6
+ /**
7
+ * Examples showing how a consumer drives the styled form primitives
8
+ * (`FormField`, `Input`, `Select`, `Checkbox`, `Radio`) with
9
+ * [react-hook-form](https://react-hook-form.com/) using the canonical
10
+ * `Controller` render-prop idiom. See `ControlledFields.tsx` for the adapters.
11
+ */
12
+ const meta: Meta<typeof RhfContactForm> = {
13
+ title: 'Examples/React Hook Form',
14
+ component: RhfContactForm,
15
+ tags: ['autodocs'],
16
+ parameters: {
17
+ docs: {
18
+ description: {
19
+ component:
20
+ 'react-hook-form owns value + validation state and hands it to the ' +
21
+ 'already-styled controlled components via `Controller`. No component ' +
22
+ 'API is modified — this is pure integration wiring.',
23
+ },
24
+ },
25
+ },
26
+ }
27
+
28
+ export default meta
29
+ type Story = StoryObj<typeof RhfContactForm>
30
+
31
+ /** A complete form: type, select, pick a plan, accept terms, then submit. */
32
+ export const ContactForm: Story = {
33
+ render: function Render() {
34
+ const [submitted, setSubmitted] = React.useState<ContactFormValues | null>(null)
35
+ return (
36
+ <View style={{ gap: 16 }}>
37
+ <RhfContactForm onSubmit={setSubmitted} />
38
+ {submitted && (
39
+ <Text className="text-sm text-text-secondary">
40
+ Submitted: {JSON.stringify(submitted)}
41
+ </Text>
42
+ )}
43
+ </View>
44
+ )
45
+ },
46
+ }
47
+
48
+ /** Submitting empty surfaces per-field validation errors from RHF rules. */
49
+ export const ValidationErrors: Story = {
50
+ render: () => <RhfContactForm />,
51
+ }
@@ -0,0 +1,61 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react'
3
+ import { RhfContactForm } from './RhfContactForm'
4
+
5
+ describe('react-hook-form integration', () => {
6
+ it('submits the collected values from every controlled component', async () => {
7
+ // Arrange
8
+ const onSubmit = vi.fn()
9
+ render(<RhfContactForm onSubmit={onSubmit} />)
10
+
11
+ // Act
12
+ fireEvent.change(screen.getByPlaceholderText('Ada Lovelace'), {
13
+ target: { value: 'Grace Hopper' },
14
+ })
15
+ fireEvent.click(screen.getByText('Select a role...'))
16
+ fireEvent.click(screen.getByText('Designer'))
17
+ fireEvent.click(screen.getByText('Pro'))
18
+ fireEvent.click(screen.getByRole('checkbox'))
19
+ fireEvent.click(screen.getByText('Submit'))
20
+
21
+ // Assert
22
+ await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1))
23
+ expect(onSubmit.mock.calls[0][0]).toEqual({
24
+ fullName: 'Grace Hopper',
25
+ role: 'designer',
26
+ plan: 'pro',
27
+ acceptedTerms: true,
28
+ })
29
+ })
30
+
31
+ it('surfaces validation errors and blocks submit when required fields are empty', async () => {
32
+ // Arrange
33
+ const onSubmit = vi.fn()
34
+ render(<RhfContactForm onSubmit={onSubmit} />)
35
+
36
+ // Act
37
+ fireEvent.click(screen.getByText('Submit'))
38
+
39
+ // Assert
40
+ expect(await screen.findByText('Full name is required')).toBeInTheDocument()
41
+ expect(screen.getByText('Please choose a role')).toBeInTheDocument()
42
+ expect(screen.getByText('Please pick a plan')).toBeInTheDocument()
43
+ expect(screen.getByText('You must accept the terms')).toBeInTheDocument()
44
+ expect(onSubmit).not.toHaveBeenCalled()
45
+ })
46
+
47
+ it('clears a field error once the value becomes valid', async () => {
48
+ // Arrange
49
+ render(<RhfContactForm />)
50
+ fireEvent.click(screen.getByText('Submit'))
51
+ expect(await screen.findByText('Full name is required')).toBeInTheDocument()
52
+
53
+ // Act
54
+ fireEvent.change(screen.getByPlaceholderText('Ada Lovelace'), {
55
+ target: { value: 'Katherine Johnson' },
56
+ })
57
+
58
+ // Assert
59
+ await waitFor(() => expect(screen.queryByText('Full name is required')).not.toBeInTheDocument())
60
+ })
61
+ })
@@ -0,0 +1,99 @@
1
+ import { View } from 'react-native'
2
+ import { useForm, type SubmitHandler } from 'react-hook-form'
3
+ import { Button, ButtonText } from '../../components/ui/button'
4
+ import { type SelectOption } from '../../components/ui/select'
5
+ import {
6
+ ControlledInput,
7
+ ControlledSelect,
8
+ ControlledCheckbox,
9
+ ControlledRadioGroup,
10
+ } from './ControlledFields'
11
+
12
+ export interface ContactFormValues {
13
+ fullName: string
14
+ role: string
15
+ plan: string
16
+ acceptedTerms: boolean
17
+ }
18
+
19
+ const roleOptions: SelectOption[] = [
20
+ { value: 'engineer', label: 'Engineer' },
21
+ { value: 'designer', label: 'Designer' },
22
+ { value: 'manager', label: 'Manager' },
23
+ ]
24
+
25
+ const planOptions: SelectOption[] = [
26
+ { value: 'free', label: 'Free' },
27
+ { value: 'pro', label: 'Pro' },
28
+ ]
29
+
30
+ const defaultValues: ContactFormValues = {
31
+ fullName: '',
32
+ role: '',
33
+ plan: '',
34
+ acceptedTerms: false,
35
+ }
36
+
37
+ export interface RhfContactFormProps {
38
+ /** Called with validated form values on a successful submit. */
39
+ onSubmit?: SubmitHandler<ContactFormValues>
40
+ }
41
+
42
+ /**
43
+ * End-to-end example wiring the styled form primitives to react-hook-form.
44
+ *
45
+ * Demonstrates the `Controller` idiom across `FormField`, `Input`, `Select`,
46
+ * `Checkbox`, and `Radio` — a single `useForm` instance owns state, validation,
47
+ * and submission. No component API is modified.
48
+ */
49
+ export function RhfContactForm({ onSubmit }: RhfContactFormProps) {
50
+ const { control, handleSubmit } = useForm<ContactFormValues>({
51
+ defaultValues,
52
+ })
53
+
54
+ const handleValid: SubmitHandler<ContactFormValues> = (values) => {
55
+ onSubmit?.(values)
56
+ }
57
+
58
+ return (
59
+ <View className="gap-4" style={{ maxWidth: 360 }}>
60
+ <ControlledInput
61
+ control={control}
62
+ name="fullName"
63
+ label="Full name"
64
+ isRequired
65
+ placeholder="Ada Lovelace"
66
+ rules={{ required: 'Full name is required' }}
67
+ />
68
+
69
+ <ControlledSelect
70
+ control={control}
71
+ name="role"
72
+ label="Role"
73
+ isRequired
74
+ options={roleOptions}
75
+ placeholder="Select a role..."
76
+ rules={{ required: 'Please choose a role' }}
77
+ />
78
+
79
+ <ControlledRadioGroup
80
+ control={control}
81
+ name="plan"
82
+ label="Plan"
83
+ options={planOptions}
84
+ rules={{ required: 'Please pick a plan' }}
85
+ />
86
+
87
+ <ControlledCheckbox
88
+ control={control}
89
+ name="acceptedTerms"
90
+ label="I accept the terms and conditions"
91
+ rules={{ required: 'You must accept the terms' }}
92
+ />
93
+
94
+ <Button onPress={handleSubmit(handleValid)}>
95
+ <ButtonText>Submit</ButtonText>
96
+ </Button>
97
+ </View>
98
+ )
99
+ }
@@ -24,7 +24,7 @@ function PresetSwitcher({
24
24
  onSwitch: (name: string) => void
25
25
  }) {
26
26
  return (
27
- <View className="flex-row items-center gap-3 p-4 bg-surface-elevated rounded-lg border border-border-default">
27
+ <View className="flex-row items-center gap-3 p-4 bg-surface-elevated rounded-lg border border-border">
28
28
  <Text className="text-text-secondary text-sm font-medium">Active Preset:</Text>
29
29
  {Object.keys(presets).map((name) => (
30
30
  <Pressable
@@ -33,7 +33,7 @@ function PresetSwitcher({
33
33
  className={`px-4 py-2 rounded-md border ${
34
34
  activePreset === name
35
35
  ? 'bg-brand-primary border-brand-primary'
36
- : 'bg-surface-base border-border-default web:hover:bg-interactive-hover'
36
+ : 'bg-surface-base border-border web:hover:bg-interactive-hover'
37
37
  }`}
38
38
  >
39
39
  <Text
@@ -272,7 +272,7 @@ function TypographyShowcase() {
272
272
  </Section>
273
273
 
274
274
  <Section title="Monospace Font (font-mono)">
275
- <View className="bg-surface-elevated rounded-lg p-4 border border-border-default">
275
+ <View className="bg-surface-elevated rounded-lg p-4 border border-border">
276
276
  <Text className="text-sm text-text-primary font-mono">
277
277
  {'const theme = applyThemePreset(audiobookPreset)'}
278
278
  </Text>
@@ -368,13 +368,13 @@ function ColorPaletteShowcase() {
368
368
 
369
369
  <Section title="Text Colors">
370
370
  <View className="flex-row gap-3 flex-wrap">
371
- <View className="rounded-lg p-3 min-w-[120px] min-h-[60px] justify-end bg-surface-elevated border border-border-default">
371
+ <View className="rounded-lg p-3 min-w-[120px] min-h-[60px] justify-end bg-surface-elevated border border-border">
372
372
  <Text className="text-xs font-mono font-medium text-text-primary">text-primary</Text>
373
373
  </View>
374
- <View className="rounded-lg p-3 min-w-[120px] min-h-[60px] justify-end bg-surface-elevated border border-border-default">
374
+ <View className="rounded-lg p-3 min-w-[120px] min-h-[60px] justify-end bg-surface-elevated border border-border">
375
375
  <Text className="text-xs font-mono font-medium text-text-secondary">text-secondary</Text>
376
376
  </View>
377
- <View className="rounded-lg p-3 min-w-[120px] min-h-[60px] justify-end bg-surface-elevated border border-border-default">
377
+ <View className="rounded-lg p-3 min-w-[120px] min-h-[60px] justify-end bg-surface-elevated border border-border">
378
378
  <Text className="text-xs font-mono font-medium text-text-tertiary">text-tertiary</Text>
379
379
  </View>
380
380
  </View>
@@ -382,25 +382,25 @@ function ColorPaletteShowcase() {
382
382
 
383
383
  <Section title="Surface Colors">
384
384
  <View className="flex-row gap-3 flex-wrap">
385
- <ColorSwatch label="surface-base" className="bg-surface-base border border-border-default" textClass="text-text-primary" />
386
- <ColorSwatch label="surface-elevated" className="bg-surface-elevated border border-border-default" textClass="text-text-primary" />
387
- <ColorSwatch label="surface-raised" className="bg-surface-raised border border-border-default" textClass="text-text-primary" />
388
- <ColorSwatch label="surface-overlay" className="bg-surface-overlay border border-border-default" textClass="text-text-primary" />
389
- <ColorSwatch label="surface-input" className="bg-surface-input border border-border-default" textClass="text-text-primary" />
385
+ <ColorSwatch label="surface-base" className="bg-surface-base border border-border" textClass="text-text-primary" />
386
+ <ColorSwatch label="surface-elevated" className="bg-surface-elevated border border-border" textClass="text-text-primary" />
387
+ <ColorSwatch label="surface-raised" className="bg-surface-raised border border-border" textClass="text-text-primary" />
388
+ <ColorSwatch label="surface-overlay" className="bg-surface-overlay border border-border" textClass="text-text-primary" />
389
+ <ColorSwatch label="surface-input" className="bg-surface-input border border-border" textClass="text-text-primary" />
390
390
  </View>
391
391
  </Section>
392
392
 
393
393
  <Section title="Background Colors">
394
394
  <View className="flex-row gap-3 flex-wrap">
395
- <ColorSwatch label="background-base" className="bg-background-base border border-border-default" textClass="text-text-primary" />
396
- <ColorSwatch label="background-default" className="bg-background-default border border-border-default" textClass="text-text-primary" />
397
- <ColorSwatch label="background-subtle" className="bg-background-subtle border border-border-default" textClass="text-text-primary" />
395
+ <ColorSwatch label="background-base" className="bg-background-base border border-border" textClass="text-text-primary" />
396
+ <ColorSwatch label="background-default" className="bg-background-default border border-border" textClass="text-text-primary" />
397
+ <ColorSwatch label="background-subtle" className="bg-background-subtle border border-border" textClass="text-text-primary" />
398
398
  </View>
399
399
  </Section>
400
400
 
401
401
  <Section title="Border Colors">
402
402
  <View className="flex-row gap-3 flex-wrap">
403
- <View className="rounded-lg p-3 min-w-[120px] min-h-[60px] justify-end bg-surface-base border-2 border-border-default">
403
+ <View className="rounded-lg p-3 min-w-[120px] min-h-[60px] justify-end bg-surface-base border-2 border-border">
404
404
  <Text className="text-xs font-mono font-medium text-text-primary">border-default</Text>
405
405
  </View>
406
406
  <View className="rounded-lg p-3 min-w-[120px] min-h-[60px] justify-end bg-surface-base border-2 border-border-subtle">
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { darkThemeCSSVars, lightThemeCSSVars } from './config'
6
+
7
+ /**
8
+ * Drift guard for the token codegen source (TD-05.08).
9
+ *
10
+ * `config.ts` (darkThemeCSSVars / lightThemeCSSVars) is the source that
11
+ * `tokens-css.ts` renders into `dist/tokens.css`. It must stay a complete,
12
+ * value-accurate mirror of the `:root` / `.light` custom properties in
13
+ * `global.css`; otherwise the generated stylesheet silently drifts from the
14
+ * design system (the exact gap this ticket closed). This test fails if
15
+ * global.css gains a `:root` var that config.ts does not cover, or if any
16
+ * shared var's value diverges.
17
+ */
18
+
19
+ const themeDir = path.dirname(fileURLToPath(import.meta.url))
20
+ const css = readFileSync(path.join(themeDir, 'global.css'), 'utf8')
21
+
22
+ /**
23
+ * global.css :root vars intentionally excluded from the codegen source.
24
+ * Empty today — every :root var is mirrored. Add a var here (with a reason)
25
+ * only if it is genuinely prototype-irrelevant, rather than silently skipping.
26
+ */
27
+ const CODEGEN_ALLOWLIST = new Set<string>([])
28
+
29
+ function parseVarBlock(header: RegExp): Map<string, string> {
30
+ const match = css.match(header)
31
+ if (!match) throw new Error(`could not locate CSS block for ${header}`)
32
+ const vars = new Map<string, string>()
33
+ for (const line of match[1].split('\n')) {
34
+ const declaration = line.match(/^\s*(--[a-z0-9-]+):\s*(.+?);\s*$/i)
35
+ if (declaration) vars.set(declaration[1], declaration[2].trim())
36
+ }
37
+ return vars
38
+ }
39
+
40
+ const globalDark = parseVarBlock(/:root\s*\{([\s\S]*?)\n\s*\}/)
41
+ const globalLight = parseVarBlock(/\.light,\s*\n?\s*:root\.light\s*\{([\s\S]*?)\n\s*\}/)
42
+
43
+ const cases = [
44
+ ['dark', globalDark, darkThemeCSSVars as Record<string, string>],
45
+ ['light', globalLight, lightThemeCSSVars as Record<string, string>],
46
+ ] as const
47
+
48
+ describe('token codegen completeness (config.ts vs global.css)', () => {
49
+ it('parses both global.css theme blocks', () => {
50
+ expect(globalDark.size).toBeGreaterThan(90)
51
+ expect(globalLight.size).toBe(globalDark.size)
52
+ })
53
+
54
+ for (const [mode, globalVars, cfg] of cases) {
55
+ it(`${mode}: config covers every global.css :root var with the exact value`, () => {
56
+ const missing: string[] = []
57
+ const mismatched: string[] = []
58
+ for (const [name, value] of globalVars) {
59
+ if (CODEGEN_ALLOWLIST.has(name)) continue
60
+ if (!(name in cfg)) {
61
+ missing.push(name)
62
+ } else if (cfg[name] !== value) {
63
+ mismatched.push(`${name}: global='${value}' config='${cfg[name]}'`)
64
+ }
65
+ }
66
+ expect(missing, 'global.css vars missing from config.ts codegen source').toEqual([])
67
+ expect(mismatched, 'value drift between config.ts and global.css').toEqual([])
68
+ })
69
+
70
+ it(`${mode}: config emits no var absent from global.css`, () => {
71
+ const extra = Object.keys(cfg).filter(
72
+ (name) => !globalVars.has(name) && !CODEGEN_ALLOWLIST.has(name),
73
+ )
74
+ expect(extra, 'config.ts vars not present in global.css :root').toEqual([])
75
+ })
76
+ }
77
+ })