@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.
- package/dist/bodymap-BhG55xHM.d.mts +318 -0
- package/dist/bodymap-BhG55xHM.d.ts +318 -0
- package/dist/bodymap.d.mts +3 -0
- package/dist/bodymap.d.ts +3 -0
- package/dist/bodymap.js +2670 -0
- package/dist/bodymap.js.map +1 -0
- package/dist/bodymap.mjs +1101 -0
- package/dist/bodymap.mjs.map +1 -0
- package/dist/{chunk-UXVRPOME.mjs → chunk-7XPB4NAO.mjs} +125 -785
- package/dist/chunk-7XPB4NAO.mjs.map +1 -0
- package/dist/chunk-P7TSQUN6.mjs +846 -0
- package/dist/chunk-P7TSQUN6.mjs.map +1 -0
- package/dist/chunk-TOD2WQBG.mjs +1048 -0
- package/dist/chunk-TOD2WQBG.mjs.map +1 -0
- package/dist/index.d.mts +1131 -4
- package/dist/index.d.ts +1131 -4
- package/dist/index.js +6396 -225
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5572 -641
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +317 -1
- package/dist/theme/index.d.ts +317 -1
- package/dist/theme/index.js +185 -2
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2 -1
- package/dist/theme/tokens-css.d.mts +20 -0
- package/dist/theme/tokens-css.d.ts +20 -0
- package/dist/theme/tokens-css.js +598 -0
- package/dist/theme/tokens-css.js.map +1 -0
- package/dist/theme/tokens-css.mjs +24 -0
- package/dist/theme/tokens-css.mjs.map +1 -0
- package/package.json +25 -5
- package/src/bodymap.ts +37 -0
- package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
- package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
- package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
- package/src/components/custom/Gauge/Gauge.tsx +167 -0
- package/src/components/custom/Gauge/index.ts +2 -0
- package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
- package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
- package/src/components/custom/Scatter/Scatter.tsx +260 -0
- package/src/components/custom/Scatter/index.ts +2 -0
- package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
- package/src/components/custom/Table/Table.test.tsx +27 -0
- package/src/components/custom/Table/Table.tsx +33 -17
- package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
- package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
- package/src/components/custom/Treemap/Treemap.tsx +192 -0
- package/src/components/custom/Treemap/index.ts +2 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -0
- package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
- package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
- package/src/components/custom/Workout/BaseBadge.tsx +113 -0
- package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
- package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
- package/src/components/custom/Workout/BodyMap.tsx +347 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +477 -0
- package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
- package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
- package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
- package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
- package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
- package/src/components/custom/Workout/DeviationBar.tsx +78 -0
- package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +343 -0
- package/src/components/custom/Workout/ExerciseCard.tsx +442 -0
- package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
- package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -0
- package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
- package/src/components/custom/Workout/InputBar.test.tsx +116 -0
- package/src/components/custom/Workout/InputBar.tsx +162 -0
- package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
- package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
- package/src/components/custom/Workout/IntensityBar.tsx +166 -0
- package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
- package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
- package/src/components/custom/Workout/MesoCard.tsx +237 -0
- package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +153 -0
- package/src/components/custom/Workout/MesoProgressBar.tsx +165 -0
- package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
- package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
- package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
- package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
- package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
- package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
- package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
- package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
- package/src/components/custom/Workout/PlaceholderStrip.tsx +90 -0
- package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
- package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
- package/src/components/custom/Workout/PrBadge.tsx +103 -0
- package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
- package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
- package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
- package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
- package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -0
- package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
- package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +265 -0
- package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
- package/src/components/custom/Workout/RestTimer.tsx +15 -11
- package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
- package/src/components/custom/Workout/SetRow.test.tsx +222 -0
- package/src/components/custom/Workout/SetRow.tsx +247 -0
- package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
- package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
- package/src/components/custom/Workout/Sparkline.tsx +188 -0
- package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
- package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
- package/src/components/custom/Workout/StatusDot.tsx +160 -0
- package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
- package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
- package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
- package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
- package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
- package/src/components/custom/Workout/TempoDisplay.tsx +209 -0
- package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
- package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
- package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +244 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +288 -0
- package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
- package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
- package/src/components/custom/Workout/WeekRow.tsx +140 -0
- package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
- package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
- package/src/components/custom/Workout/WeightBadge.tsx +113 -0
- package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
- package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
- package/src/components/custom/Workout/WorkoutCard.tsx +228 -0
- package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
- package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
- package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
- package/src/components/custom/Workout/index.ts +152 -0
- package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
- package/src/components/custom/index.ts +3 -0
- package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
- package/src/components/custom/stepper/Stepper.tsx +2 -2
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
- package/src/components/ui/card/Card.tsx +1 -1
- package/src/components/ui/checkbox/Checkbox.tsx +1 -1
- package/src/components/ui/collapse/Collapse.tsx +1 -1
- package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
- package/src/components/ui/drawer/Drawer.tsx +3 -3
- package/src/components/ui/form-field/FormField.tsx +1 -1
- package/src/components/ui/help-tip/HelpTip.tsx +1 -1
- package/src/components/ui/menu/Menu.tsx +2 -2
- package/src/components/ui/pill/Pill.tsx +1 -1
- package/src/components/ui/popover/Popover.stories.tsx +2 -2
- package/src/components/ui/popover/Popover.tsx +1 -1
- package/src/components/ui/radio/Radio.stories.tsx +1 -1
- package/src/components/ui/section/Section.stories.tsx +4 -4
- package/src/components/ui/select/Select.tsx +1 -1
- package/src/components/ui/stack/Stack.stories.tsx +4 -4
- package/src/components/ui/tabs/Tabs.tsx +2 -2
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
- package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
- package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
- package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
- package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
- package/src/stories/PresetShowcase.stories.tsx +15 -15
- package/src/theme/config.completeness.test.ts +77 -0
- package/src/theme/config.ts +151 -0
- package/src/theme/index.ts +2 -1
- package/src/theme/manifest/css-property-manifest.example.json +79 -0
- package/src/theme/manifest/css-property-manifest.schema.json +93 -0
- package/src/theme/manifest/css-property-manifest.test.ts +81 -0
- package/src/theme/manifest/css-property-manifest.types.ts +49 -0
- package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
- package/src/theme/manifest/design-freeze.test.ts +155 -0
- package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
- package/src/theme/manifest/extract-css-properties.test.ts +112 -0
- package/src/theme/manifest/index.ts +11 -0
- package/src/theme/tokens-css.test.ts +28 -0
- package/src/theme/tokens-css.ts +36 -0
- package/src/theme/workout-tokens.ts +59 -0
- package/src/utils/index.ts +10 -0
- package/src/utils/workout-format.test.ts +81 -0
- package/src/utils/workout-format.ts +83 -0
- 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
|
|
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="center"</Text>
|
|
87
87
|
<HStack gap={2} align="center">
|
|
88
|
-
<View className="rounded bg-primary
|
|
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
|
|
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
|
|
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
|
|
104
|
-
: 'border-r border-border
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
386
|
-
<ColorSwatch label="surface-elevated" className="bg-surface-elevated border border-border
|
|
387
|
-
<ColorSwatch label="surface-raised" className="bg-surface-raised border border-border
|
|
388
|
-
<ColorSwatch label="surface-overlay" className="bg-surface-overlay border border-border
|
|
389
|
-
<ColorSwatch label="surface-input" className="bg-surface-input border border-border
|
|
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
|
|
396
|
-
<ColorSwatch label="background-default" className="bg-background-default border border-border
|
|
397
|
-
<ColorSwatch label="background-subtle" className="bg-background-subtle border border-border
|
|
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
|
|
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
|
+
})
|