@schandlergarcia/sf-web-components 1.2.5 → 1.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 (165) hide show
  1. package/package.json +2 -1
  2. package/scripts/postinstall.mjs +69 -93
  3. package/src/components/library/.sfdx/tools/sobjects/standardObjects/Account.cls +196 -0
  4. package/src/components/library/.sfdx/tools/sobjects/standardObjects/AccountHistory.cls +25 -0
  5. package/src/components/library/.sfdx/tools/sobjects/standardObjects/Asset.cls +138 -0
  6. package/src/components/library/.sfdx/tools/sobjects/standardObjects/Attachment.cls +35 -0
  7. package/src/components/library/.sfdx/tools/sobjects/standardObjects/Case.cls +111 -0
  8. package/src/components/library/.sfdx/tools/sobjects/standardObjects/Contact.cls +167 -0
  9. package/src/components/library/.sfdx/tools/sobjects/standardObjects/Contract.cls +96 -0
  10. package/src/components/library/.sfdx/tools/sobjects/standardObjects/Domain.cls +29 -0
  11. package/src/components/library/.sfdx/tools/sobjects/standardObjects/Lead.cls +128 -0
  12. package/src/components/library/.sfdx/tools/sobjects/standardObjects/Note.cls +32 -0
  13. package/src/components/library/.sfdx/tools/sobjects/standardObjects/Opportunity.cls +113 -0
  14. package/src/components/library/.sfdx/tools/sobjects/standardObjects/Order.cls +127 -0
  15. package/src/components/library/.sfdx/tools/sobjects/standardObjects/Pricebook2.cls +47 -0
  16. package/src/components/library/.sfdx/tools/sobjects/standardObjects/PricebookEntry.cls +47 -0
  17. package/src/components/library/.sfdx/tools/sobjects/standardObjects/Product2.cls +91 -0
  18. package/src/components/library/.sfdx/tools/sobjects/standardObjects/RecordType.cls +35 -0
  19. package/src/components/library/.sfdx/tools/sobjects/standardObjects/Report.cls +47 -0
  20. package/src/components/library/.sfdx/tools/sobjects/standardObjects/Task.cls +79 -0
  21. package/src/components/library/.sfdx/tools/sobjects/standardObjects/User.cls +2318 -0
  22. package/src/components/library/.sfdx/tools/soqlMetadata/standardObjects/Account.json +2952 -0
  23. package/src/components/library/.sfdx/tools/soqlMetadata/standardObjects/AccountHistory.json +875 -0
  24. package/src/components/library/.sfdx/tools/soqlMetadata/standardObjects/Asset.json +1699 -0
  25. package/src/components/library/.sfdx/tools/soqlMetadata/standardObjects/Attachment.json +362 -0
  26. package/src/components/library/.sfdx/tools/soqlMetadata/standardObjects/Case.json +1371 -0
  27. package/src/components/library/.sfdx/tools/soqlMetadata/standardObjects/Contact.json +2309 -0
  28. package/src/components/library/.sfdx/tools/soqlMetadata/standardObjects/Contract.json +1304 -0
  29. package/src/components/library/.sfdx/tools/soqlMetadata/standardObjects/Domain.json +293 -0
  30. package/src/components/library/.sfdx/tools/soqlMetadata/standardObjects/Lead.json +1977 -0
  31. package/src/components/library/.sfdx/tools/soqlMetadata/standardObjects/Note.json +303 -0
  32. package/src/components/library/.sfdx/tools/soqlMetadata/standardObjects/Opportunity.json +1470 -0
  33. package/src/components/library/.sfdx/tools/soqlMetadata/standardObjects/Order.json +1646 -0
  34. package/src/components/library/.sfdx/tools/soqlMetadata/standardObjects/Pricebook2.json +482 -0
  35. package/src/components/library/.sfdx/tools/soqlMetadata/standardObjects/PricebookEntry.json +433 -0
  36. package/src/components/library/.sfdx/tools/soqlMetadata/standardObjects/Product2.json +1039 -0
  37. package/src/components/library/.sfdx/tools/soqlMetadata/standardObjects/RecordType.json +2576 -0
  38. package/src/components/library/.sfdx/tools/soqlMetadata/standardObjects/Report.json +486 -0
  39. package/src/components/library/.sfdx/tools/soqlMetadata/standardObjects/Task.json +4296 -0
  40. package/src/components/library/.sfdx/tools/soqlMetadata/standardObjects/User.json +30415 -0
  41. package/src/components/library/.sfdx/tools/soqlMetadata/typeNames.json +78 -0
  42. package/src/components/library/.sfdx/typings/lwc/sobjects/Account.d.ts +264 -0
  43. package/src/components/library/.sfdx/typings/lwc/sobjects/AccountHistory.d.ts +44 -0
  44. package/src/components/library/.sfdx/typings/lwc/sobjects/Asset.d.ts +240 -0
  45. package/src/components/library/.sfdx/typings/lwc/sobjects/Attachment.d.ts +76 -0
  46. package/src/components/library/.sfdx/typings/lwc/sobjects/Case.d.ts +172 -0
  47. package/src/components/library/.sfdx/typings/lwc/sobjects/Contact.d.ts +264 -0
  48. package/src/components/library/.sfdx/typings/lwc/sobjects/Contract.d.ts +188 -0
  49. package/src/components/library/.sfdx/typings/lwc/sobjects/Domain.d.ts +52 -0
  50. package/src/components/library/.sfdx/typings/lwc/sobjects/Lead.d.ts +252 -0
  51. package/src/components/library/.sfdx/typings/lwc/sobjects/Note.d.ts +64 -0
  52. package/src/components/library/.sfdx/typings/lwc/sobjects/Opportunity.d.ts +200 -0
  53. package/src/components/library/.sfdx/typings/lwc/sobjects/Order.d.ts +260 -0
  54. package/src/components/library/.sfdx/typings/lwc/sobjects/Pricebook2.d.ts +64 -0
  55. package/src/components/library/.sfdx/typings/lwc/sobjects/PricebookEntry.d.ts +76 -0
  56. package/src/components/library/.sfdx/typings/lwc/sobjects/Product2.d.ts +96 -0
  57. package/src/components/library/.sfdx/typings/lwc/sobjects/RecordType.d.ts +64 -0
  58. package/src/components/library/.sfdx/typings/lwc/sobjects/Report.d.ts +80 -0
  59. package/src/components/library/.sfdx/typings/lwc/sobjects/Task.d.ts +184 -0
  60. package/src/components/library/.sfdx/typings/lwc/sobjects/User.d.ts +752 -0
  61. package/src/components/library/cards/ActionList.jsx +38 -0
  62. package/src/components/library/cards/ActivityCard.jsx +56 -0
  63. package/src/components/library/cards/BaseCard.jsx +109 -0
  64. package/src/components/library/cards/CalloutCard.jsx +37 -0
  65. package/src/components/library/cards/ChartCard.jsx +105 -0
  66. package/src/components/library/cards/FeedPanel.jsx +39 -0
  67. package/src/components/library/cards/ListCard.jsx +193 -0
  68. package/src/components/library/cards/MetricCard.jsx +109 -0
  69. package/src/components/library/cards/MetricsStrip.jsx +78 -0
  70. package/src/components/library/cards/SectionCard.jsx +83 -0
  71. package/src/components/library/cards/SemanticMetricCard.jsx +52 -0
  72. package/src/components/library/cards/SemanticMetricCardWithLoading.jsx +23 -0
  73. package/src/components/library/cards/SemanticTableCard.jsx +48 -0
  74. package/src/components/library/cards/SemanticTableCardWithLoading.jsx +22 -0
  75. package/src/components/library/cards/StatusCard.jsx +220 -0
  76. package/src/components/library/cards/TableCard.jsx +337 -0
  77. package/src/components/library/cards/WidgetCard.jsx +90 -0
  78. package/src/components/library/charts/D3Chart.jsx +109 -0
  79. package/src/components/library/charts/D3ChartTemplates.jsx +126 -0
  80. package/src/components/library/charts/GeoMap.jsx +293 -0
  81. package/src/components/library/chat/ChatBar.jsx +256 -0
  82. package/src/components/library/chat/ChatInput.jsx +89 -0
  83. package/src/components/library/chat/ChatMessage.jsx +178 -0
  84. package/src/components/library/chat/ChatMessageList.jsx +73 -0
  85. package/src/components/library/chat/ChatPanel.jsx +97 -0
  86. package/src/components/library/chat/ChatSuggestions.jsx +28 -0
  87. package/src/components/library/chat/ChatToolCall.jsx +100 -0
  88. package/src/components/library/chat/ChatTypingIndicator.jsx +23 -0
  89. package/src/components/library/chat/ChatWelcome.jsx +43 -0
  90. package/src/components/library/chat/index.jsx +10 -0
  91. package/src/components/library/chat/useChatState.jsx +130 -0
  92. package/src/components/library/data/DataModeProvider.jsx +67 -0
  93. package/src/components/library/data/DataModeToggle.jsx +36 -0
  94. package/src/components/library/data/chartDataProvider.jsx +61 -0
  95. package/src/components/library/data/filterUtils.jsx +141 -0
  96. package/src/components/library/data/useDataSource.jsx +33 -0
  97. package/src/components/library/data/usePageFilters.jsx +99 -0
  98. package/src/components/library/filters/FilterBar.jsx +95 -0
  99. package/src/components/library/filters/SearchFilter.jsx +36 -0
  100. package/src/components/library/filters/SelectFilter.jsx +55 -0
  101. package/src/components/library/filters/ToggleFilter.jsx +52 -0
  102. package/src/components/library/filters/index.jsx +4 -0
  103. package/src/components/library/forms/FormField.jsx +291 -0
  104. package/src/components/library/forms/FormModal.jsx +201 -0
  105. package/src/components/library/forms/FormRenderer.jsx +46 -0
  106. package/src/components/library/forms/FormSection.jsx +69 -0
  107. package/src/components/library/forms/index.jsx +5 -0
  108. package/src/components/library/forms/useFormState.jsx +165 -0
  109. package/src/components/library/heroui/Accordion.jsx +26 -0
  110. package/src/components/library/heroui/Alert.jsx +8 -0
  111. package/src/components/library/heroui/Badge.jsx +8 -0
  112. package/src/components/library/heroui/Breadcrumbs.jsx +22 -0
  113. package/src/components/library/heroui/Button.jsx +58 -0
  114. package/src/components/library/heroui/Card.jsx +8 -0
  115. package/src/components/library/heroui/Collapsible.jsx +42 -0
  116. package/src/components/library/heroui/DatePicker.jsx +34 -0
  117. package/src/components/library/heroui/Dialog.jsx +37 -0
  118. package/src/components/library/heroui/Drawer.jsx +32 -0
  119. package/src/components/library/heroui/Dropdown.jsx +28 -0
  120. package/src/components/library/heroui/Field.jsx +51 -0
  121. package/src/components/library/heroui/Input.jsx +6 -0
  122. package/src/components/library/heroui/Kbd.jsx +8 -0
  123. package/src/components/library/heroui/Meter.jsx +8 -0
  124. package/src/components/library/heroui/Modal.jsx +32 -0
  125. package/src/components/library/heroui/Pagination.jsx +8 -0
  126. package/src/components/library/heroui/Popover.jsx +64 -0
  127. package/src/components/library/heroui/ProgressBar.jsx +8 -0
  128. package/src/components/library/heroui/ProgressCircle.jsx +8 -0
  129. package/src/components/library/heroui/ScrollShadow.jsx +8 -0
  130. package/src/components/library/heroui/Select.jsx +37 -0
  131. package/src/components/library/heroui/Separator.jsx +8 -0
  132. package/src/components/library/heroui/Skeleton.jsx +8 -0
  133. package/src/components/library/heroui/Tabs.jsx +26 -0
  134. package/src/components/library/heroui/Toast.jsx +25 -0
  135. package/src/components/library/heroui/Toggle.jsx +14 -0
  136. package/src/components/library/heroui/Tooltip.jsx +21 -0
  137. package/src/components/library/index.jsx +149 -0
  138. package/src/components/library/layout/PageContainer.jsx +11 -0
  139. package/src/components/library/skeletons/CardSkeleton.jsx +30 -0
  140. package/src/components/library/theme/AppThemeProvider.jsx +67 -0
  141. package/src/components/library/theme/tokens.jsx +72 -0
  142. package/src/components/library/ui/Alert.jsx +80 -0
  143. package/src/components/library/ui/Avatar.jsx +44 -0
  144. package/src/components/library/ui/BreadcrumbExtras.tsx +119 -0
  145. package/src/components/library/ui/Card.jsx +117 -0
  146. package/src/components/library/ui/Checkbox.jsx +17 -0
  147. package/src/components/library/ui/Chip.jsx +38 -0
  148. package/src/components/library/ui/Collapsible.tsx +31 -0
  149. package/src/components/library/ui/Container.jsx +56 -0
  150. package/src/components/library/ui/DatePicker.tsx +34 -0
  151. package/src/components/library/ui/Dialog.tsx +141 -0
  152. package/src/components/library/ui/EmptyState.jsx +46 -0
  153. package/src/components/library/ui/Field.tsx +82 -0
  154. package/src/components/library/ui/FieldGroup.jsx +17 -0
  155. package/src/components/library/ui/Label.jsx +22 -0
  156. package/src/components/library/ui/PaginationExtras.tsx +143 -0
  157. package/src/components/library/ui/Popover.tsx +39 -0
  158. package/src/components/library/ui/Select.tsx +113 -0
  159. package/src/components/library/ui/Spinner.jsx +64 -0
  160. package/src/components/library/ui/Text.jsx +46 -0
  161. package/src/components/library/ui/UIButton.jsx +61 -0
  162. package/src/components/library/ui/UIInput.jsx +21 -0
  163. package/src/components/workspace/ComponentRegistry.jsx +297 -0
  164. package/src/templates/pages/Home.tsx.template +5 -5
  165. package/src/templates/pages/NotFound.tsx.template +2 -2
@@ -0,0 +1,291 @@
1
+ import React from "react";
2
+ import { ChevronDownIcon } from "@heroicons/react/24/outline";
3
+
4
+ const INPUT_BASE =
5
+ "h-10 w-full rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-50 dark:placeholder:text-slate-500 dark:focus:ring-offset-slate-950";
6
+
7
+ const INPUT_ERROR =
8
+ "border-red-300 focus:ring-red-500 dark:border-red-700 dark:focus:ring-red-500";
9
+
10
+ function cx(...classes) {
11
+ return classes.filter(Boolean).join(" ");
12
+ }
13
+
14
+ function FieldLabel({ label, required, htmlFor }) {
15
+ if (!label) return null;
16
+ return (
17
+ <label htmlFor={htmlFor} className="block text-sm font-medium text-slate-700 dark:text-slate-200">
18
+ {label}
19
+ {required ? <span className="ml-0.5 text-red-500">*</span> : null}
20
+ </label>
21
+ );
22
+ }
23
+
24
+ function FieldError({ error }) {
25
+ if (!error) return null;
26
+ return <p className="text-xs text-red-600 dark:text-red-400">{error}</p>;
27
+ }
28
+
29
+ function FieldDescription({ description }) {
30
+ if (!description) return null;
31
+ return <p className="text-xs text-slate-500 dark:text-slate-400">{description}</p>;
32
+ }
33
+
34
+ // ─── Individual field renderers ───
35
+
36
+ function TextField({ field, value, onChange, onBlur, error }) {
37
+ const inputType = field.inputType ?? field.type;
38
+ const type = { text: "text", email: "email", url: "url", number: "number", date: "date" }[inputType] ?? "text";
39
+
40
+ return (
41
+ <input
42
+ id={field.id}
43
+ name={field.id}
44
+ type={type}
45
+ value={value ?? ""}
46
+ onChange={(e) => onChange(field.type === "number" ? e.target.value : e.target.value)}
47
+ onBlur={onBlur}
48
+ placeholder={field.placeholder}
49
+ disabled={field.disabled}
50
+ readOnly={field.readOnly}
51
+ min={field.min}
52
+ max={field.max}
53
+ step={field.step}
54
+ className={cx(INPUT_BASE, error && INPUT_ERROR)}
55
+ />
56
+ );
57
+ }
58
+
59
+ function TextareaField({ field, value, onChange, onBlur, error }) {
60
+ return (
61
+ <textarea
62
+ id={field.id}
63
+ name={field.id}
64
+ value={value ?? ""}
65
+ onChange={(e) => onChange(e.target.value)}
66
+ onBlur={onBlur}
67
+ placeholder={field.placeholder}
68
+ disabled={field.disabled}
69
+ readOnly={field.readOnly}
70
+ rows={field.rows ?? 3}
71
+ className={cx(
72
+ "w-full rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm placeholder:text-slate-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-50 dark:placeholder:text-slate-500 dark:focus:ring-offset-slate-950",
73
+ error && INPUT_ERROR
74
+ )}
75
+ />
76
+ );
77
+ }
78
+
79
+ function SelectField({ field, value, onChange, onBlur, error }) {
80
+ const options = (field.options ?? []).map((opt) =>
81
+ typeof opt === "string" ? { value: opt, label: opt } : opt
82
+ );
83
+
84
+ return (
85
+ <div className="relative">
86
+ <select
87
+ id={field.id}
88
+ name={field.id}
89
+ value={value ?? ""}
90
+ onChange={(e) => onChange(e.target.value)}
91
+ onBlur={onBlur}
92
+ disabled={field.disabled}
93
+ className={cx(
94
+ "h-10 w-full appearance-none rounded-lg border border-slate-200 bg-white py-0 pl-3 pr-9 text-sm font-medium text-slate-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-200 dark:focus:ring-offset-slate-950",
95
+ error && INPUT_ERROR
96
+ )}
97
+ >
98
+ {field.placeholder ? (
99
+ <option value="">{field.placeholder}</option>
100
+ ) : null}
101
+ {options.map((opt) => (
102
+ <option key={opt.value} value={opt.value}>
103
+ {opt.label}
104
+ </option>
105
+ ))}
106
+ </select>
107
+ <ChevronDownIcon
108
+ className="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 dark:text-slate-500"
109
+ aria-hidden="true"
110
+ />
111
+ </div>
112
+ );
113
+ }
114
+
115
+ function RadioField({ field, value, onChange }) {
116
+ const options = (field.options ?? []).map((opt) =>
117
+ typeof opt === "string" ? { value: opt, label: opt } : opt
118
+ );
119
+ const layout = field.layout ?? (options.length <= 4 ? "horizontal" : "vertical");
120
+
121
+ return (
122
+ <div
123
+ className={cx(
124
+ "flex gap-3",
125
+ layout === "vertical" ? "flex-col" : "flex-row flex-wrap"
126
+ )}
127
+ role="radiogroup"
128
+ aria-labelledby={`${field.id}-label`}
129
+ >
130
+ {options.map((opt) => (
131
+ <label
132
+ key={opt.value}
133
+ className="inline-flex cursor-pointer items-center gap-2 text-sm text-slate-700 dark:text-slate-200"
134
+ >
135
+ <input
136
+ type="radio"
137
+ name={field.id}
138
+ value={opt.value}
139
+ checked={value === opt.value}
140
+ onChange={() => onChange(opt.value)}
141
+ disabled={field.disabled || opt.disabled}
142
+ className="h-4 w-4 border-slate-300 text-brand-600 focus:ring-brand-500 dark:border-slate-600 dark:bg-slate-800"
143
+ />
144
+ {opt.label}
145
+ {opt.description ? (
146
+ <span className="text-xs text-slate-400 dark:text-slate-500">{opt.description}</span>
147
+ ) : null}
148
+ </label>
149
+ ))}
150
+ </div>
151
+ );
152
+ }
153
+
154
+ function CheckboxField({ field, value, onChange }) {
155
+ return (
156
+ <label className="inline-flex cursor-pointer items-center gap-2.5 text-sm text-slate-700 dark:text-slate-200">
157
+ <input
158
+ type="checkbox"
159
+ id={field.id}
160
+ name={field.id}
161
+ checked={Boolean(value)}
162
+ onChange={(e) => onChange(e.target.checked)}
163
+ disabled={field.disabled}
164
+ className="h-4 w-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500 dark:border-slate-600 dark:bg-slate-800"
165
+ />
166
+ {field.checkboxLabel ?? field.label}
167
+ </label>
168
+ );
169
+ }
170
+
171
+ function CheckboxGroupField({ field, value, onChange }) {
172
+ const selected = Array.isArray(value) ? value : [];
173
+ const options = (field.options ?? []).map((opt) =>
174
+ typeof opt === "string" ? { value: opt, label: opt } : opt
175
+ );
176
+ const layout = field.layout ?? (options.length <= 4 ? "horizontal" : "vertical");
177
+
178
+ function toggleValue(optValue) {
179
+ const next = selected.includes(optValue)
180
+ ? selected.filter((v) => v !== optValue)
181
+ : [...selected, optValue];
182
+ onChange(next);
183
+ }
184
+
185
+ return (
186
+ <div
187
+ className={cx(
188
+ "flex gap-3",
189
+ layout === "vertical" ? "flex-col" : "flex-row flex-wrap"
190
+ )}
191
+ >
192
+ {options.map((opt) => (
193
+ <label
194
+ key={opt.value}
195
+ className="inline-flex cursor-pointer items-center gap-2 text-sm text-slate-700 dark:text-slate-200"
196
+ >
197
+ <input
198
+ type="checkbox"
199
+ checked={selected.includes(opt.value)}
200
+ onChange={() => toggleValue(opt.value)}
201
+ disabled={field.disabled || opt.disabled}
202
+ className="h-4 w-4 rounded border-slate-300 text-brand-600 focus:ring-brand-500 dark:border-slate-600 dark:bg-slate-800"
203
+ />
204
+ {opt.label}
205
+ </label>
206
+ ))}
207
+ </div>
208
+ );
209
+ }
210
+
211
+ function ToggleField({ field, value, onChange }) {
212
+ const checked = Boolean(value);
213
+
214
+ return (
215
+ <div className="flex items-center gap-3">
216
+ <button
217
+ type="button"
218
+ role="switch"
219
+ aria-checked={checked}
220
+ onClick={() => onChange(!checked)}
221
+ disabled={field.disabled}
222
+ className={cx(
223
+ "relative inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-2 dark:focus:ring-offset-slate-950",
224
+ checked ? "bg-brand-500" : "bg-slate-200 dark:bg-slate-700",
225
+ field.disabled && "cursor-not-allowed opacity-60"
226
+ )}
227
+ >
228
+ <span
229
+ aria-hidden="true"
230
+ className={cx(
231
+ "pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow ring-0 transition-transform",
232
+ checked ? "translate-x-5" : "translate-x-0"
233
+ )}
234
+ />
235
+ </button>
236
+ {field.toggleLabel ? (
237
+ <span className="text-sm text-slate-700 dark:text-slate-200">{field.toggleLabel}</span>
238
+ ) : null}
239
+ </div>
240
+ );
241
+ }
242
+
243
+ // ─── Main FormField ───
244
+
245
+ const FIELD_RENDERERS = {
246
+ text: TextField,
247
+ email: TextField,
248
+ url: TextField,
249
+ number: TextField,
250
+ date: TextField,
251
+ textarea: TextareaField,
252
+ select: SelectField,
253
+ radio: RadioField,
254
+ checkbox: CheckboxField,
255
+ checkboxGroup: CheckboxGroupField,
256
+ toggle: ToggleField,
257
+ };
258
+
259
+ /**
260
+ * Renders a single form field with label, description, error message,
261
+ * and the appropriate input type.
262
+ */
263
+ export default function FormField({ field, value, error, touched, onChange, onBlur }) {
264
+ const Renderer = FIELD_RENDERERS[field.type];
265
+ if (!Renderer) return null;
266
+
267
+ const showError = error && touched;
268
+ const noLabel = field.type === "checkbox";
269
+
270
+ return (
271
+ <div className="flex flex-col gap-1.5">
272
+ {!noLabel ? (
273
+ <FieldLabel label={field.label} required={field.required} htmlFor={field.id} />
274
+ ) : null}
275
+ {field.description && field.type !== "toggle" ? (
276
+ <FieldDescription description={field.description} />
277
+ ) : null}
278
+ <Renderer
279
+ field={field}
280
+ value={value}
281
+ onChange={onChange}
282
+ onBlur={onBlur}
283
+ error={showError}
284
+ />
285
+ {field.type === "toggle" && field.description ? (
286
+ <FieldDescription description={field.description} />
287
+ ) : null}
288
+ {showError ? <FieldError error={error} /> : null}
289
+ </div>
290
+ );
291
+ }
@@ -0,0 +1,201 @@
1
+ import React, { useEffect, useCallback } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { motion, AnimatePresence } from "framer-motion";
4
+ import { XMarkIcon } from "@heroicons/react/24/outline";
5
+ import Spinner from "../ui/Spinner";
6
+ import FormRenderer from "./FormRenderer";
7
+ import useFormState from "./useFormState";
8
+
9
+ const OVERLAY_VARIANTS = {
10
+ hidden: { opacity: 0 },
11
+ visible: { opacity: 1 },
12
+ };
13
+
14
+ const PANEL_VARIANTS = {
15
+ hidden: { opacity: 0, y: 24, scale: 0.97 },
16
+ visible: { opacity: 1, y: 0, scale: 1, transition: { type: "spring", damping: 25, stiffness: 350 } },
17
+ exit: { opacity: 0, y: 16, scale: 0.97, transition: { duration: 0.15 } },
18
+ };
19
+
20
+ /**
21
+ * Size → max-width mapping.
22
+ */
23
+ const SIZE_CLASSES = {
24
+ sm: "max-w-md",
25
+ md: "max-w-xl",
26
+ lg: "max-w-2xl",
27
+ xl: "max-w-4xl",
28
+ };
29
+
30
+ /**
31
+ * Modal dialog for creating or editing records.
32
+ * Wraps FormRenderer + useFormState in an animated overlay.
33
+ *
34
+ * @param {boolean} isOpen — whether the modal is visible
35
+ * @param {Function} onClose — close handler
36
+ * @param {string} title — modal title (e.g. "Edit Service", "New Incident")
37
+ * @param {string} subtitle — optional subtitle
38
+ * @param {Array} sections — form schema sections
39
+ * @param {Object} initialValues — prefill for editing (empty = create mode)
40
+ * @param {Function} onSubmit — async (values) => void, called on valid submit
41
+ * @param {string} submitLabel — submit button text (default: "Save")
42
+ * @param {string} cancelLabel — cancel button text (default: "Cancel")
43
+ * @param {string} size — "sm" | "md" | "lg" | "xl" (default: "lg")
44
+ * @param {boolean} destructive — if true, submit button is red (for delete confirmations)
45
+ */
46
+ export default function FormModal({
47
+ isOpen = false,
48
+ onClose,
49
+ title,
50
+ subtitle,
51
+ sections = [],
52
+ initialValues = {},
53
+ onSubmit,
54
+ submitLabel = "Save",
55
+ cancelLabel = "Cancel",
56
+ size = "lg",
57
+ destructive = false,
58
+ minSubmitMs,
59
+ }) {
60
+ const form = useFormState({
61
+ initialValues,
62
+ sections,
63
+ minSubmitMs,
64
+ onSubmit: async (vals) => {
65
+ await onSubmit?.(vals);
66
+ onClose?.();
67
+ },
68
+ });
69
+
70
+ useEffect(() => {
71
+ if (isOpen) form.reset();
72
+ // eslint-disable-next-line react-hooks/exhaustive-deps
73
+ }, [isOpen]);
74
+
75
+ const onKeyDown = useCallback(
76
+ (e) => {
77
+ if (e.key === "Escape") onClose?.();
78
+ },
79
+ [onClose]
80
+ );
81
+
82
+ useEffect(() => {
83
+ if (!isOpen) return;
84
+ document.addEventListener("keydown", onKeyDown);
85
+ document.body.style.overflow = "hidden";
86
+ return () => {
87
+ document.removeEventListener("keydown", onKeyDown);
88
+ document.body.style.overflow = "";
89
+ };
90
+ }, [isOpen, onKeyDown]);
91
+
92
+ if (typeof document === "undefined") return null;
93
+
94
+ return createPortal(
95
+ <AnimatePresence>
96
+ {isOpen ? (
97
+ <div className="fixed inset-0 z-50 flex items-start justify-center px-4 pt-[10vh] sm:pt-[12vh]">
98
+ {/* Backdrop */}
99
+ <motion.div
100
+ key="overlay"
101
+ className="fixed inset-0 bg-black/40 backdrop-blur-sm"
102
+ variants={OVERLAY_VARIANTS}
103
+ initial="hidden"
104
+ animate="visible"
105
+ exit="hidden"
106
+ transition={{ duration: 0.2 }}
107
+ onClick={onClose}
108
+ aria-hidden="true"
109
+ />
110
+
111
+ {/* Panel */}
112
+ <motion.div
113
+ key="panel"
114
+ role="dialog"
115
+ aria-modal="true"
116
+ aria-label={title}
117
+ variants={PANEL_VARIANTS}
118
+ initial="hidden"
119
+ animate="visible"
120
+ exit="exit"
121
+ className={[
122
+ "relative z-10 flex w-full flex-col rounded-xl border border-slate-200 bg-white shadow-xl dark:border-slate-800 dark:bg-slate-900",
123
+ SIZE_CLASSES[size] ?? SIZE_CLASSES.lg,
124
+ "max-h-[80vh]",
125
+ ].join(" ")}
126
+ >
127
+ {/* Header */}
128
+ <div className="flex items-start justify-between border-b border-slate-100 px-6 py-4 dark:border-slate-800">
129
+ <div>
130
+ <h2 className="text-base font-semibold text-slate-900 dark:text-slate-50">
131
+ {title}
132
+ </h2>
133
+ {subtitle ? (
134
+ <p className="mt-0.5 text-sm text-slate-500 dark:text-slate-400">{subtitle}</p>
135
+ ) : null}
136
+ </div>
137
+ <button
138
+ type="button"
139
+ onClick={onClose}
140
+ className="rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600 dark:text-slate-500 dark:hover:bg-slate-800 dark:hover:text-slate-300"
141
+ aria-label="Close"
142
+ >
143
+ <XMarkIcon className="h-5 w-5" />
144
+ </button>
145
+ </div>
146
+
147
+ {/* Body — scrollable */}
148
+ <div className="flex-1 overflow-y-auto px-6 py-5">
149
+ <FormRenderer
150
+ sections={sections}
151
+ values={form.values}
152
+ errors={form.errors}
153
+ touched={form.touched}
154
+ onFieldChange={form.setValue}
155
+ onFieldBlur={form.setTouched}
156
+ formError={form.errors._form}
157
+ />
158
+ </div>
159
+
160
+ {/* Footer */}
161
+ <div className="flex items-center justify-between border-t border-slate-100 px-6 py-4 dark:border-slate-800">
162
+ <div className="text-xs text-slate-400 dark:text-slate-500">
163
+ {form.isDirty ? "Unsaved changes" : "\u00A0"}
164
+ </div>
165
+ <div className="flex items-center gap-3">
166
+ <button
167
+ type="button"
168
+ onClick={onClose}
169
+ className="inline-flex h-9 items-center rounded-lg border border-slate-200 bg-white px-4 text-sm font-medium text-slate-700 shadow-sm transition hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
170
+ >
171
+ {cancelLabel}
172
+ </button>
173
+ <button
174
+ type="button"
175
+ onClick={form.handleSubmit}
176
+ disabled={form.isSubmitting}
177
+ className={[
178
+ "inline-flex h-9 items-center gap-2 rounded-lg border border-transparent px-4 text-sm font-medium text-white shadow-sm transition",
179
+ "focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 dark:focus-visible:ring-offset-slate-900",
180
+ "disabled:cursor-not-allowed disabled:opacity-60",
181
+ destructive
182
+ ? "bg-red-600 hover:bg-red-500 focus-visible:ring-red-500 dark:bg-red-600 dark:hover:bg-red-500"
183
+ : "bg-brand-600 hover:bg-brand-500 focus-visible:ring-brand-500 dark:bg-brand-500 dark:hover:bg-brand-400",
184
+ ].join(" ")}
185
+ >
186
+ {form.isSubmitting ? (
187
+ <>
188
+ <Spinner size="sm" tone="white" label="Submitting" />
189
+ Saving…
190
+ </>
191
+ ) : submitLabel}
192
+ </button>
193
+ </div>
194
+ </div>
195
+ </motion.div>
196
+ </div>
197
+ ) : null}
198
+ </AnimatePresence>,
199
+ document.body
200
+ );
201
+ }
@@ -0,0 +1,46 @@
1
+ import React from "react";
2
+ import FormSection from "./FormSection";
3
+
4
+ /**
5
+ * Renders a complete form from a schema definition.
6
+ * Pairs with useFormState for state management.
7
+ *
8
+ * @param {Array} sections — form schema sections
9
+ * @param {Object} values — current form values
10
+ * @param {Object} errors — current validation errors
11
+ * @param {Object} touched — which fields have been touched
12
+ * @param {Function} onFieldChange — (fieldId, value) => void
13
+ * @param {Function} onFieldBlur — (fieldId) => void
14
+ * @param {string} formError — top-level form error (e.g. submission failure)
15
+ */
16
+ export default function FormRenderer({
17
+ sections = [],
18
+ values = {},
19
+ errors = {},
20
+ touched = {},
21
+ onFieldChange,
22
+ onFieldBlur,
23
+ formError,
24
+ }) {
25
+ return (
26
+ <div className="space-y-8">
27
+ {formError ? (
28
+ <div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300">
29
+ {formError}
30
+ </div>
31
+ ) : null}
32
+
33
+ {sections.map((section, idx) => (
34
+ <FormSection
35
+ key={section.id ?? idx}
36
+ section={section}
37
+ values={values}
38
+ errors={errors}
39
+ touched={touched}
40
+ onFieldChange={onFieldChange}
41
+ onFieldBlur={onFieldBlur}
42
+ />
43
+ ))}
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,69 @@
1
+ import React from "react";
2
+ import FormField from "./FormField";
3
+
4
+ /**
5
+ * Fields that naturally span full width by default.
6
+ */
7
+ const FULL_WIDTH_TYPES = new Set([
8
+ "textarea",
9
+ "radio",
10
+ "checkboxGroup",
11
+ ]);
12
+
13
+ /**
14
+ * A titled section of a form with a 2-column grid layout.
15
+ * Fields default to 1-column (half width) unless the type is naturally
16
+ * full-width or the field specifies `colSpan: 2`.
17
+ */
18
+ export default function FormSection({
19
+ section,
20
+ values,
21
+ errors,
22
+ touched,
23
+ onFieldChange,
24
+ onFieldBlur,
25
+ }) {
26
+ const fields = section.fields ?? [];
27
+ if (!fields.length) return null;
28
+
29
+ return (
30
+ <fieldset className="space-y-4">
31
+ {section.title ? (
32
+ <div className="border-b border-slate-100 pb-3 dark:border-slate-800">
33
+ <legend className="text-sm font-semibold text-slate-900 dark:text-slate-50">
34
+ {section.title}
35
+ </legend>
36
+ {section.description ? (
37
+ <p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
38
+ {section.description}
39
+ </p>
40
+ ) : null}
41
+ </div>
42
+ ) : null}
43
+
44
+ <div className="grid grid-cols-1 gap-x-4 gap-y-5 sm:grid-cols-2">
45
+ {fields.map((field) => {
46
+ const span =
47
+ field.colSpan ??
48
+ (FULL_WIDTH_TYPES.has(field.type) ? 2 : 1);
49
+
50
+ return (
51
+ <div
52
+ key={field.id}
53
+ className={span === 2 ? "sm:col-span-2" : undefined}
54
+ >
55
+ <FormField
56
+ field={field}
57
+ value={values[field.id]}
58
+ error={errors[field.id]}
59
+ touched={touched[field.id]}
60
+ onChange={(val) => onFieldChange(field.id, val)}
61
+ onBlur={() => onFieldBlur(field.id)}
62
+ />
63
+ </div>
64
+ );
65
+ })}
66
+ </div>
67
+ </fieldset>
68
+ );
69
+ }
@@ -0,0 +1,5 @@
1
+ export { default as FormModal } from "./FormModal";
2
+ export { default as FormRenderer } from "./FormRenderer";
3
+ export { default as FormSection } from "./FormSection";
4
+ export { default as FormField } from "./FormField";
5
+ export { default as useFormState } from "./useFormState";