@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,99 @@
|
|
|
1
|
+
import { useState, useMemo, useCallback } from "react";
|
|
2
|
+
import { applyFilters, sortByKey } from "./filterUtils";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hook for managing page-level filter and sort state.
|
|
6
|
+
*
|
|
7
|
+
* @param {Object} options
|
|
8
|
+
* @param {Array} options.data — raw data array
|
|
9
|
+
* @param {Array} options.filters — filter definitions [{ id, type, key?, keys?, defaultValue? }]
|
|
10
|
+
* @param {Object} options.defaultSort — { key, direction } or null
|
|
11
|
+
* @returns {Object} { values, setFilter, resetFilters, sort, setSort, filteredData, sortedData, activeFilterCount }
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const { values, setFilter, resetFilters, sortedData } = usePageFilters({
|
|
15
|
+
* data: incidents,
|
|
16
|
+
* filters: [
|
|
17
|
+
* { id: "search", type: "search", keys: ["title", "description"] },
|
|
18
|
+
* { id: "severity", type: "select", key: "severity", defaultValue: "all" },
|
|
19
|
+
* { id: "active", type: "toggle", key: "resolved", matchValue: false },
|
|
20
|
+
* ],
|
|
21
|
+
* defaultSort: { key: "timestamp", direction: "desc" },
|
|
22
|
+
* });
|
|
23
|
+
*/
|
|
24
|
+
export default function usePageFilters({ data = [], filters = [], defaultSort = null } = {}) {
|
|
25
|
+
const initialValues = useMemo(() => {
|
|
26
|
+
const v = {};
|
|
27
|
+
for (const f of filters) {
|
|
28
|
+
if (f.defaultValue !== undefined) {
|
|
29
|
+
v[f.id] = f.defaultValue;
|
|
30
|
+
} else if (f.type === "search") {
|
|
31
|
+
v[f.id] = "";
|
|
32
|
+
} else if (f.type === "select") {
|
|
33
|
+
v[f.id] = "all";
|
|
34
|
+
} else if (f.type === "toggle") {
|
|
35
|
+
v[f.id] = false;
|
|
36
|
+
} else if (f.type === "dateRange") {
|
|
37
|
+
v[f.id] = null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return v;
|
|
41
|
+
}, [filters]);
|
|
42
|
+
|
|
43
|
+
const [values, setValues] = useState(initialValues);
|
|
44
|
+
const [sort, setSortState] = useState(defaultSort);
|
|
45
|
+
|
|
46
|
+
const setFilter = useCallback((id, value) => {
|
|
47
|
+
setValues((prev) => ({ ...prev, [id]: value }));
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const resetFilters = useCallback(() => {
|
|
51
|
+
setValues(initialValues);
|
|
52
|
+
}, [initialValues]);
|
|
53
|
+
|
|
54
|
+
const setSort = useCallback((key, direction) => {
|
|
55
|
+
setSortState(key ? { key, direction: direction ?? "asc" } : null);
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
const toggleSort = useCallback((key) => {
|
|
59
|
+
setSortState((prev) => {
|
|
60
|
+
if (prev?.key !== key) return { key, direction: "asc" };
|
|
61
|
+
if (prev.direction === "asc") return { key, direction: "desc" };
|
|
62
|
+
return null;
|
|
63
|
+
});
|
|
64
|
+
}, []);
|
|
65
|
+
|
|
66
|
+
const filteredData = useMemo(
|
|
67
|
+
() => applyFilters(data, filters, values),
|
|
68
|
+
[data, filters, values]
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const sortedData = useMemo(
|
|
72
|
+
() => (sort ? sortByKey(filteredData, sort.key, sort.direction) : filteredData),
|
|
73
|
+
[filteredData, sort]
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const activeFilterCount = useMemo(() => {
|
|
77
|
+
let count = 0;
|
|
78
|
+
for (const f of filters) {
|
|
79
|
+
const v = values[f.id];
|
|
80
|
+
if (f.type === "search" && v && v.trim()) count++;
|
|
81
|
+
else if (f.type === "select" && v && v !== "all") count++;
|
|
82
|
+
else if (f.type === "toggle" && v) count++;
|
|
83
|
+
else if (f.type === "dateRange" && v) count++;
|
|
84
|
+
}
|
|
85
|
+
return count;
|
|
86
|
+
}, [filters, values]);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
values,
|
|
90
|
+
setFilter,
|
|
91
|
+
resetFilters,
|
|
92
|
+
sort,
|
|
93
|
+
setSort,
|
|
94
|
+
toggleSort,
|
|
95
|
+
filteredData,
|
|
96
|
+
sortedData,
|
|
97
|
+
activeFilterCount,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import SearchFilter from "./SearchFilter";
|
|
3
|
+
import SelectFilter from "./SelectFilter";
|
|
4
|
+
import ToggleFilter from "./ToggleFilter";
|
|
5
|
+
import { FunnelIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Renders a row of filter controls from a definitions array.
|
|
9
|
+
* Pairs with usePageFilters hook for state management.
|
|
10
|
+
*
|
|
11
|
+
* @param {Array} filters — filter definitions [{ id, type, ... }]
|
|
12
|
+
* @param {Object} values — current filter values keyed by filter id
|
|
13
|
+
* @param {Function} onChange — (filterId, value) => void
|
|
14
|
+
* @param {Function} onReset — () => void
|
|
15
|
+
* @param {number} activeCount — number of active filters (for badge)
|
|
16
|
+
* @param {string} layout — "inline" (default) or "stacked"
|
|
17
|
+
*/
|
|
18
|
+
export default function FilterBar({
|
|
19
|
+
filters = [],
|
|
20
|
+
values = {},
|
|
21
|
+
onChange,
|
|
22
|
+
onReset,
|
|
23
|
+
activeCount = 0,
|
|
24
|
+
layout = "inline",
|
|
25
|
+
}) {
|
|
26
|
+
if (!filters.length) return null;
|
|
27
|
+
|
|
28
|
+
const isStacked = layout === "stacked";
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
className={[
|
|
33
|
+
"flex gap-3",
|
|
34
|
+
isStacked
|
|
35
|
+
? "flex-col"
|
|
36
|
+
: "flex-col sm:flex-row sm:flex-wrap sm:items-center",
|
|
37
|
+
].join(" ")}
|
|
38
|
+
>
|
|
39
|
+
{filters.map((filter) => {
|
|
40
|
+
const val = values[filter.id];
|
|
41
|
+
|
|
42
|
+
switch (filter.type) {
|
|
43
|
+
case "search":
|
|
44
|
+
return (
|
|
45
|
+
<SearchFilter
|
|
46
|
+
key={filter.id}
|
|
47
|
+
value={val ?? ""}
|
|
48
|
+
onChange={(v) => onChange?.(filter.id, v)}
|
|
49
|
+
placeholder={filter.placeholder ?? "Search…"}
|
|
50
|
+
className={filter.className ?? (isStacked ? "w-full" : "w-full sm:w-64")}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
case "select":
|
|
55
|
+
return (
|
|
56
|
+
<SelectFilter
|
|
57
|
+
key={filter.id}
|
|
58
|
+
value={val ?? "all"}
|
|
59
|
+
onChange={(v) => onChange?.(filter.id, v)}
|
|
60
|
+
options={filter.options ?? []}
|
|
61
|
+
label={filter.label}
|
|
62
|
+
placeholder={filter.placeholder ?? "All"}
|
|
63
|
+
className={filter.className}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
case "toggle":
|
|
68
|
+
return (
|
|
69
|
+
<ToggleFilter
|
|
70
|
+
key={filter.id}
|
|
71
|
+
value={val ?? false}
|
|
72
|
+
onChange={(v) => onChange?.(filter.id, v)}
|
|
73
|
+
label={filter.label}
|
|
74
|
+
className={filter.className}
|
|
75
|
+
/>
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
default:
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
})}
|
|
82
|
+
|
|
83
|
+
{activeCount > 0 && onReset ? (
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
onClick={onReset}
|
|
87
|
+
className="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-slate-500 transition hover:bg-slate-100 hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200"
|
|
88
|
+
>
|
|
89
|
+
<XMarkIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
|
90
|
+
Clear {activeCount} {activeCount === 1 ? "filter" : "filters"}
|
|
91
|
+
</button>
|
|
92
|
+
) : null}
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
|
3
|
+
|
|
4
|
+
export default function SearchFilter({
|
|
5
|
+
value = "",
|
|
6
|
+
onChange,
|
|
7
|
+
placeholder = "Search…",
|
|
8
|
+
className = "",
|
|
9
|
+
}) {
|
|
10
|
+
return (
|
|
11
|
+
<div className={["relative", className].filter(Boolean).join(" ")}>
|
|
12
|
+
<MagnifyingGlassIcon
|
|
13
|
+
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 dark:text-slate-500"
|
|
14
|
+
aria-hidden="true"
|
|
15
|
+
/>
|
|
16
|
+
<input
|
|
17
|
+
type="text"
|
|
18
|
+
value={value}
|
|
19
|
+
onChange={(e) => onChange?.(e.target.value)}
|
|
20
|
+
placeholder={placeholder}
|
|
21
|
+
className="h-9 w-full rounded-lg border border-slate-200 bg-white pl-9 pr-8 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"
|
|
22
|
+
aria-label={placeholder}
|
|
23
|
+
/>
|
|
24
|
+
{value ? (
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
onClick={() => onChange?.("")}
|
|
28
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-slate-400 hover:text-slate-600 dark:text-slate-500 dark:hover:text-slate-300"
|
|
29
|
+
aria-label="Clear search"
|
|
30
|
+
>
|
|
31
|
+
<XMarkIcon className="h-4 w-4" />
|
|
32
|
+
</button>
|
|
33
|
+
) : null}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ChevronDownIcon } from "@heroicons/react/24/outline";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Dropdown select filter.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} value — current selected value
|
|
8
|
+
* @param {Function} onChange — (value) => void
|
|
9
|
+
* @param {Array} options — [{ value, label }] or ["string", ...]
|
|
10
|
+
* @param {string} label — visible label
|
|
11
|
+
* @param {string} placeholder — placeholder when no value selected
|
|
12
|
+
*/
|
|
13
|
+
export default function SelectFilter({
|
|
14
|
+
value = "all",
|
|
15
|
+
onChange,
|
|
16
|
+
options = [],
|
|
17
|
+
label,
|
|
18
|
+
placeholder,
|
|
19
|
+
className = "",
|
|
20
|
+
}) {
|
|
21
|
+
const normalizedOptions = options.map((opt) =>
|
|
22
|
+
typeof opt === "string" ? { value: opt, label: opt } : opt
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<div className={["relative inline-flex items-center gap-2", className].filter(Boolean).join(" ")}>
|
|
27
|
+
{label ? (
|
|
28
|
+
<span className="shrink-0 text-xs font-medium text-slate-500 dark:text-slate-400">
|
|
29
|
+
{label}
|
|
30
|
+
</span>
|
|
31
|
+
) : null}
|
|
32
|
+
<div className="relative">
|
|
33
|
+
<select
|
|
34
|
+
value={value}
|
|
35
|
+
onChange={(e) => onChange?.(e.target.value)}
|
|
36
|
+
className="h-9 appearance-none rounded-lg border border-slate-200 bg-white py-0 pl-3 pr-8 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"
|
|
37
|
+
aria-label={label ?? placeholder ?? "Filter"}
|
|
38
|
+
>
|
|
39
|
+
{placeholder ? (
|
|
40
|
+
<option value="all">{placeholder}</option>
|
|
41
|
+
) : null}
|
|
42
|
+
{normalizedOptions.map((opt) => (
|
|
43
|
+
<option key={opt.value} value={opt.value}>
|
|
44
|
+
{opt.label}
|
|
45
|
+
</option>
|
|
46
|
+
))}
|
|
47
|
+
</select>
|
|
48
|
+
<ChevronDownIcon
|
|
49
|
+
className="pointer-events-none absolute right-2 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 dark:text-slate-500"
|
|
50
|
+
aria-hidden="true"
|
|
51
|
+
/>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Toggle switch filter.
|
|
5
|
+
*
|
|
6
|
+
* @param {boolean} value — current on/off state
|
|
7
|
+
* @param {Function} onChange — (boolean) => void
|
|
8
|
+
* @param {string} label — visible label
|
|
9
|
+
*/
|
|
10
|
+
export default function ToggleFilter({
|
|
11
|
+
value = false,
|
|
12
|
+
onChange,
|
|
13
|
+
label,
|
|
14
|
+
className = "",
|
|
15
|
+
}) {
|
|
16
|
+
return (
|
|
17
|
+
<label
|
|
18
|
+
className={[
|
|
19
|
+
"inline-flex cursor-pointer items-center gap-2",
|
|
20
|
+
className,
|
|
21
|
+
]
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.join(" ")}
|
|
24
|
+
>
|
|
25
|
+
<button
|
|
26
|
+
type="button"
|
|
27
|
+
role="switch"
|
|
28
|
+
aria-checked={value}
|
|
29
|
+
onClick={() => onChange?.(!value)}
|
|
30
|
+
className={[
|
|
31
|
+
"relative inline-flex h-5 w-9 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",
|
|
32
|
+
value
|
|
33
|
+
? "bg-brand-500"
|
|
34
|
+
: "bg-slate-200 dark:bg-slate-700",
|
|
35
|
+
].join(" ")}
|
|
36
|
+
>
|
|
37
|
+
<span
|
|
38
|
+
aria-hidden="true"
|
|
39
|
+
className={[
|
|
40
|
+
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow ring-0 transition-transform",
|
|
41
|
+
value ? "translate-x-4" : "translate-x-0",
|
|
42
|
+
].join(" ")}
|
|
43
|
+
/>
|
|
44
|
+
</button>
|
|
45
|
+
{label ? (
|
|
46
|
+
<span className="text-sm font-medium text-slate-700 dark:text-slate-200">
|
|
47
|
+
{label}
|
|
48
|
+
</span>
|
|
49
|
+
) : null}
|
|
50
|
+
</label>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
@@ -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
|
+
}
|