@schandlergarcia/sf-web-components 1.9.37 → 1.9.39
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/package.json +4 -1
- package/scripts/postinstall.mjs +116 -65
- package/src/components/library/cards/ActionList.jsx +38 -0
- package/src/components/library/cards/ActivityCard.jsx +56 -0
- package/src/components/library/cards/BaseCard.jsx +109 -0
- package/src/components/library/cards/CalloutCard.jsx +37 -0
- package/src/components/library/cards/ChartCard.jsx +105 -0
- package/src/components/library/cards/FeedPanel.jsx +39 -0
- package/src/components/library/cards/ListCard.jsx +193 -0
- package/src/components/library/cards/MetricCard.jsx +109 -0
- package/src/components/library/cards/MetricsStrip.jsx +78 -0
- package/src/components/library/cards/SectionCard.jsx +83 -0
- package/src/components/library/cards/SemanticMetricCard.jsx +52 -0
- package/src/components/library/cards/SemanticMetricCardWithLoading.jsx +23 -0
- package/src/components/library/cards/SemanticTableCard.jsx +48 -0
- package/src/components/library/cards/SemanticTableCardWithLoading.jsx +22 -0
- package/src/components/library/cards/StatusCard.jsx +220 -0
- package/src/components/library/cards/TableCard.jsx +337 -0
- package/src/components/library/cards/WidgetCard.jsx +90 -0
- package/src/components/library/charts/D3Chart.jsx +109 -0
- package/src/components/library/charts/D3ChartTemplates.jsx +126 -0
- package/src/components/library/charts/GeoMap.jsx +293 -0
- package/src/components/library/chat/ChatBar.jsx +256 -0
- package/src/components/library/chat/ChatInput.jsx +89 -0
- package/src/components/library/chat/ChatMessage.jsx +178 -0
- package/src/components/library/chat/ChatMessageList.jsx +73 -0
- package/src/components/library/chat/ChatPanel.jsx +97 -0
- package/src/components/library/chat/ChatSuggestions.jsx +28 -0
- package/src/components/library/chat/ChatToolCall.jsx +100 -0
- package/src/components/library/chat/ChatTypingIndicator.jsx +23 -0
- package/src/components/library/chat/ChatWelcome.jsx +43 -0
- package/src/components/library/chat/index.jsx +10 -0
- package/src/components/library/chat/useChatState.jsx +130 -0
- package/src/components/library/data/DataModeProvider.jsx +67 -0
- package/src/components/library/data/DataModeToggle.jsx +36 -0
- package/src/components/library/data/chartDataProvider.jsx +61 -0
- package/src/components/library/data/filterUtils.jsx +141 -0
- package/src/components/library/data/useDataSource.jsx +33 -0
- package/src/components/library/data/usePageFilters.jsx +99 -0
- package/src/components/library/filters/FilterBar.jsx +95 -0
- package/src/components/library/filters/SearchFilter.jsx +36 -0
- package/src/components/library/filters/SelectFilter.jsx +55 -0
- package/src/components/library/filters/ToggleFilter.jsx +52 -0
- package/src/components/library/filters/index.jsx +4 -0
- package/src/components/library/forms/FormField.jsx +291 -0
- package/src/components/library/forms/FormModal.jsx +201 -0
- package/src/components/library/forms/FormRenderer.jsx +46 -0
- package/src/components/library/forms/FormSection.jsx +69 -0
- package/src/components/library/forms/index.jsx +5 -0
- package/src/components/library/forms/useFormState.jsx +165 -0
- package/src/components/library/heroui/Accordion.jsx +26 -0
- package/src/components/library/heroui/Alert.jsx +8 -0
- package/src/components/library/heroui/Badge.jsx +8 -0
- package/src/components/library/heroui/Breadcrumbs.jsx +22 -0
- package/src/components/library/heroui/Button.jsx +58 -0
- package/src/components/library/heroui/Card.jsx +8 -0
- package/src/components/library/heroui/Collapsible.jsx +42 -0
- package/src/components/library/heroui/DatePicker.jsx +34 -0
- package/src/components/library/heroui/Dialog.jsx +37 -0
- package/src/components/library/heroui/Drawer.jsx +32 -0
- package/src/components/library/heroui/Dropdown.jsx +28 -0
- package/src/components/library/heroui/Field.jsx +51 -0
- package/src/components/library/heroui/Input.jsx +6 -0
- package/src/components/library/heroui/Kbd.jsx +8 -0
- package/src/components/library/heroui/Meter.jsx +8 -0
- package/src/components/library/heroui/Modal.jsx +32 -0
- package/src/components/library/heroui/Pagination.jsx +8 -0
- package/src/components/library/heroui/Popover.jsx +64 -0
- package/src/components/library/heroui/ProgressBar.jsx +8 -0
- package/src/components/library/heroui/ProgressCircle.jsx +8 -0
- package/src/components/library/heroui/ScrollShadow.jsx +8 -0
- package/src/components/library/heroui/Select.jsx +37 -0
- package/src/components/library/heroui/Separator.jsx +8 -0
- package/src/components/library/heroui/Skeleton.jsx +8 -0
- package/src/components/library/heroui/Tabs.jsx +26 -0
- package/src/components/library/heroui/Toast.jsx +25 -0
- package/src/components/library/heroui/Toggle.jsx +14 -0
- package/src/components/library/heroui/Tooltip.jsx +21 -0
- package/src/components/library/index.jsx +146 -0
- package/src/components/library/layout/PageContainer.jsx +11 -0
- package/src/components/library/skeletons/CardSkeleton.jsx +30 -0
- package/src/components/library/theme/AppThemeProvider.jsx +67 -0
- package/src/components/library/theme/tokens.jsx +72 -0
- package/src/components/library/ui/Alert.jsx +80 -0
- package/src/components/library/ui/Avatar.jsx +44 -0
- package/src/components/library/ui/BreadcrumbExtras.tsx +120 -0
- package/src/components/library/ui/Button.jsx +61 -0
- package/src/components/library/ui/Card.jsx +117 -0
- package/src/components/library/ui/Checkbox.jsx +17 -0
- package/src/components/library/ui/Chip.jsx +38 -0
- package/src/components/library/ui/Collapsible.tsx +31 -0
- package/src/components/library/ui/Container.jsx +56 -0
- package/src/components/library/ui/DatePicker.tsx +34 -0
- package/src/components/library/ui/Dialog.tsx +141 -0
- package/src/components/library/ui/EmptyState.jsx +46 -0
- package/src/components/library/ui/Field.tsx +82 -0
- package/src/components/library/ui/FieldGroup.jsx +17 -0
- package/src/components/library/ui/Input.jsx +21 -0
- package/src/components/library/ui/Label.jsx +22 -0
- package/src/components/library/ui/PaginationExtras.tsx +142 -0
- package/src/components/library/ui/Popover.tsx +39 -0
- package/src/components/library/ui/Select.tsx +113 -0
- package/src/components/library/ui/Spinner.d.ts +10 -0
- package/src/components/library/ui/Spinner.jsx +64 -0
- package/src/components/library/ui/Text.jsx +46 -0
- package/src/components/library/ui/Toggle.jsx +42 -0
- package/src/components/workspace/ComponentRegistry.jsx +297 -0
- package/src/lib/index.ts +1 -0
- package/src/lib/utils.ts +6 -0
|
@@ -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";
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { useState, useMemo, useCallback, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MIN_SUBMIT_MS = 4000;
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extracts all field ids and their default values from a form schema.
|
|
7
|
+
*/
|
|
8
|
+
function buildDefaults(sections) {
|
|
9
|
+
const defaults = {};
|
|
10
|
+
for (const section of sections) {
|
|
11
|
+
for (const field of section.fields ?? []) {
|
|
12
|
+
if (field.type === "checkboxGroup") {
|
|
13
|
+
defaults[field.id] = [];
|
|
14
|
+
} else if (field.type === "toggle" || field.type === "checkbox") {
|
|
15
|
+
defaults[field.id] = false;
|
|
16
|
+
} else {
|
|
17
|
+
defaults[field.id] = "";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return defaults;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Runs validation for all fields.
|
|
26
|
+
* Returns an object of { fieldId: errorMessage } (empty object = valid).
|
|
27
|
+
*/
|
|
28
|
+
function runValidation(values, sections) {
|
|
29
|
+
const errors = {};
|
|
30
|
+
for (const section of sections) {
|
|
31
|
+
for (const field of section.fields ?? []) {
|
|
32
|
+
const val = values[field.id];
|
|
33
|
+
|
|
34
|
+
if (field.required) {
|
|
35
|
+
const empty =
|
|
36
|
+
val === undefined ||
|
|
37
|
+
val === null ||
|
|
38
|
+
val === "" ||
|
|
39
|
+
(Array.isArray(val) && val.length === 0);
|
|
40
|
+
if (empty) {
|
|
41
|
+
errors[field.id] = field.requiredMessage ?? `${field.label ?? field.id} is required`;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (field.validate) {
|
|
47
|
+
const msg = field.validate(val, values);
|
|
48
|
+
if (msg) errors[field.id] = msg;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return errors;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Form state management hook.
|
|
57
|
+
*
|
|
58
|
+
* @param {Object} options
|
|
59
|
+
* @param {Object} options.initialValues — prefill for editing (merged over field defaults)
|
|
60
|
+
* @param {Array} options.sections — form schema sections (used for defaults + validation)
|
|
61
|
+
* @param {Function} options.onSubmit — called with (values) when form is valid
|
|
62
|
+
* @param {number} options.minSubmitMs — minimum time the submit spinner shows (default 4000ms, set 0 to disable)
|
|
63
|
+
*
|
|
64
|
+
* @returns {Object} { values, errors, touched, isDirty, isValid, isSubmitting,
|
|
65
|
+
* setValue, setValues, setTouched, validate, reset, handleSubmit }
|
|
66
|
+
*/
|
|
67
|
+
export default function useFormState({ initialValues = {}, sections = [], onSubmit, minSubmitMs = DEFAULT_MIN_SUBMIT_MS } = {}) {
|
|
68
|
+
const defaults = useMemo(() => buildDefaults(sections), [sections]);
|
|
69
|
+
const merged = useMemo(() => ({ ...defaults, ...initialValues }), [defaults, initialValues]);
|
|
70
|
+
|
|
71
|
+
const [values, setValuesState] = useState(merged);
|
|
72
|
+
const [errors, setErrors] = useState({});
|
|
73
|
+
const [touched, setTouchedState] = useState({});
|
|
74
|
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
75
|
+
const submitRef = useRef(onSubmit);
|
|
76
|
+
submitRef.current = onSubmit;
|
|
77
|
+
|
|
78
|
+
const isDirty = useMemo(() => {
|
|
79
|
+
return Object.keys(merged).some((k) => {
|
|
80
|
+
const a = merged[k];
|
|
81
|
+
const b = values[k];
|
|
82
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
83
|
+
return a.length !== b.length || a.some((v, i) => v !== b[i]);
|
|
84
|
+
}
|
|
85
|
+
return a !== b;
|
|
86
|
+
});
|
|
87
|
+
}, [merged, values]);
|
|
88
|
+
|
|
89
|
+
const setValue = useCallback((id, value) => {
|
|
90
|
+
setValuesState((prev) => ({ ...prev, [id]: value }));
|
|
91
|
+
setErrors((prev) => {
|
|
92
|
+
if (!prev[id]) return prev;
|
|
93
|
+
const next = { ...prev };
|
|
94
|
+
delete next[id];
|
|
95
|
+
return next;
|
|
96
|
+
});
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
const setValues = useCallback((vals) => {
|
|
100
|
+
setValuesState((prev) => ({ ...prev, ...vals }));
|
|
101
|
+
}, []);
|
|
102
|
+
|
|
103
|
+
const setTouched = useCallback((id) => {
|
|
104
|
+
setTouchedState((prev) => (prev[id] ? prev : { ...prev, [id]: true }));
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
const validate = useCallback(() => {
|
|
108
|
+
const errs = runValidation(values, sections);
|
|
109
|
+
setErrors(errs);
|
|
110
|
+
const allTouched = {};
|
|
111
|
+
for (const section of sections) {
|
|
112
|
+
for (const field of section.fields ?? []) {
|
|
113
|
+
allTouched[field.id] = true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
setTouchedState(allTouched);
|
|
117
|
+
return Object.keys(errs).length === 0;
|
|
118
|
+
}, [values, sections]);
|
|
119
|
+
|
|
120
|
+
const reset = useCallback(() => {
|
|
121
|
+
setValuesState(merged);
|
|
122
|
+
setErrors({});
|
|
123
|
+
setTouchedState({});
|
|
124
|
+
}, [merged]);
|
|
125
|
+
|
|
126
|
+
const handleSubmit = useCallback(
|
|
127
|
+
async (e) => {
|
|
128
|
+
e?.preventDefault?.();
|
|
129
|
+
const valid = validate();
|
|
130
|
+
if (!valid) return false;
|
|
131
|
+
|
|
132
|
+
setIsSubmitting(true);
|
|
133
|
+
try {
|
|
134
|
+
const delay = minSubmitMs > 0
|
|
135
|
+
? new Promise((r) => setTimeout(r, minSubmitMs))
|
|
136
|
+
: Promise.resolve();
|
|
137
|
+
await Promise.all([submitRef.current?.(values), delay]);
|
|
138
|
+
return true;
|
|
139
|
+
} catch (err) {
|
|
140
|
+
setErrors((prev) => ({ ...prev, _form: err?.message ?? "Submission failed" }));
|
|
141
|
+
return false;
|
|
142
|
+
} finally {
|
|
143
|
+
setIsSubmitting(false);
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
[validate, values, minSubmitMs]
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const isValid = Object.keys(errors).length === 0;
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
values,
|
|
153
|
+
errors,
|
|
154
|
+
touched,
|
|
155
|
+
isDirty,
|
|
156
|
+
isValid,
|
|
157
|
+
isSubmitting,
|
|
158
|
+
setValue,
|
|
159
|
+
setValues,
|
|
160
|
+
setTouched,
|
|
161
|
+
validate,
|
|
162
|
+
reset,
|
|
163
|
+
handleSubmit,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Accordion } from "@heroui/react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* HeroUI v3 Accordion — compound component.
|
|
6
|
+
*
|
|
7
|
+
* Sub-components via dot notation on the named `Accordion` export:
|
|
8
|
+
* Accordion.Item, Accordion.Heading, Accordion.Trigger,
|
|
9
|
+
* Accordion.Panel, Accordion.Body, Accordion.Indicator
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* import { Accordion } from "@/components/library";
|
|
13
|
+
* <Accordion>
|
|
14
|
+
* <Accordion.Item>
|
|
15
|
+
* <Accordion.Heading>
|
|
16
|
+
* <Accordion.Trigger>Section 1<Accordion.Indicator /></Accordion.Trigger>
|
|
17
|
+
* </Accordion.Heading>
|
|
18
|
+
* <Accordion.Panel><Accordion.Body>Content</Accordion.Body></Accordion.Panel>
|
|
19
|
+
* </Accordion.Item>
|
|
20
|
+
* </Accordion>
|
|
21
|
+
*/
|
|
22
|
+
export default function HeroUIAccordion({ variant = "default", ...props }) {
|
|
23
|
+
return <Accordion variant={variant} {...props} />;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export { Accordion };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Breadcrumbs } from "@heroui/react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* HeroUI v3 Breadcrumbs — compound component.
|
|
6
|
+
*
|
|
7
|
+
* Sub-components via dot notation on the named `Breadcrumbs` export:
|
|
8
|
+
* Breadcrumbs.Item
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* import { Breadcrumbs } from "@/components/library";
|
|
12
|
+
* <Breadcrumbs>
|
|
13
|
+
* <Breadcrumbs.Item href="/">Home</Breadcrumbs.Item>
|
|
14
|
+
* <Breadcrumbs.Item href="/settings">Settings</Breadcrumbs.Item>
|
|
15
|
+
* <Breadcrumbs.Item>Profile</Breadcrumbs.Item>
|
|
16
|
+
* </Breadcrumbs>
|
|
17
|
+
*/
|
|
18
|
+
export default function HeroUIBreadcrumbs(props) {
|
|
19
|
+
return <Breadcrumbs {...props} />;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export { Breadcrumbs };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Button as HeroButton } from "@heroui/react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* HeroUI v3 Button wrapper with shadcn compatibility.
|
|
6
|
+
*
|
|
7
|
+
* Maps shadcn variants to HeroUI:
|
|
8
|
+
* - primary/secondary/destructive -> solid
|
|
9
|
+
* - outline -> bordered
|
|
10
|
+
* - ghost -> light
|
|
11
|
+
*/
|
|
12
|
+
export default function HeroUIButton({
|
|
13
|
+
variant = "primary",
|
|
14
|
+
size = "md",
|
|
15
|
+
fullWidth,
|
|
16
|
+
onClick,
|
|
17
|
+
children,
|
|
18
|
+
className = "",
|
|
19
|
+
...props
|
|
20
|
+
}) {
|
|
21
|
+
// Map shadcn variants to HeroUI variants
|
|
22
|
+
const variantMap = {
|
|
23
|
+
primary: "solid",
|
|
24
|
+
secondary: "solid",
|
|
25
|
+
destructive: "solid",
|
|
26
|
+
outline: "bordered",
|
|
27
|
+
ghost: "light"
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Map shadcn variants to HeroUI colors
|
|
31
|
+
const colorMap = {
|
|
32
|
+
primary: "primary",
|
|
33
|
+
secondary: "default",
|
|
34
|
+
destructive: "danger",
|
|
35
|
+
outline: "default",
|
|
36
|
+
ghost: "default"
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const heroVariant = variantMap[variant] || "solid";
|
|
40
|
+
const heroColor = colorMap[variant] || "primary";
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<HeroButton
|
|
44
|
+
variant={heroVariant}
|
|
45
|
+
color={heroColor}
|
|
46
|
+
size={size}
|
|
47
|
+
fullWidth={fullWidth}
|
|
48
|
+
onPress={onClick}
|
|
49
|
+
className={className}
|
|
50
|
+
{...props}
|
|
51
|
+
>
|
|
52
|
+
{children}
|
|
53
|
+
</HeroButton>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Also export as Button for shadcn compatibility
|
|
58
|
+
export const Button = HeroUIButton;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Accordion } from "@heroui/react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* HeroUI v3 Collapsible — maps to Accordion for shadcn compatibility.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@/components/library";
|
|
9
|
+
* <Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
|
10
|
+
* <CollapsibleTrigger>Toggle</CollapsibleTrigger>
|
|
11
|
+
* <CollapsibleContent>Content</CollapsibleContent>
|
|
12
|
+
* </Collapsible>
|
|
13
|
+
*/
|
|
14
|
+
export default function HeroUICollapsible({ open, onOpenChange, children, ...props }) {
|
|
15
|
+
// Map shadcn props to HeroUI Accordion
|
|
16
|
+
const selectedKeys = open ? ["item"] : [];
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<Accordion
|
|
20
|
+
selectedKeys={selectedKeys}
|
|
21
|
+
onSelectionChange={(keys) => {
|
|
22
|
+
const isOpen = Array.from(keys).includes("item");
|
|
23
|
+
onOpenChange?.(isOpen);
|
|
24
|
+
}}
|
|
25
|
+
{...props}
|
|
26
|
+
>
|
|
27
|
+
<Accordion.Item key="item" aria-label="Collapsible content">
|
|
28
|
+
{children}
|
|
29
|
+
</Accordion.Item>
|
|
30
|
+
</Accordion>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const Collapsible = HeroUICollapsible;
|
|
35
|
+
|
|
36
|
+
export const CollapsibleTrigger = ({ children, ...props }) => (
|
|
37
|
+
<Accordion.Trigger {...props}>{children}</Accordion.Trigger>
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
export const CollapsibleContent = ({ children, ...props }) => (
|
|
41
|
+
<Accordion.Panel {...props}>{children}</Accordion.Panel>
|
|
42
|
+
);
|