torch-glare 2.4.1 → 2.4.3
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/apps/lib/components/Button.tsx +1 -1
- package/apps/lib/components/Card.tsx +47 -20
- package/apps/lib/components/ColorPicker.tsx +441 -0
- package/apps/lib/components/ConclusionHeader.tsx +148 -0
- package/apps/lib/components/DatePicker.tsx +2 -0
- package/apps/lib/components/Drawer.tsx +66 -24
- package/apps/lib/components/FormBuilder/context.ts +68 -0
- package/apps/lib/components/FormBuilder/fields/ChoiceFields.tsx +60 -0
- package/apps/lib/components/FormBuilder/fields/ColorField.tsx +59 -0
- package/apps/lib/components/FormBuilder/fields/CustomField.tsx +11 -0
- package/apps/lib/components/FormBuilder/fields/DateField.tsx +32 -0
- package/apps/lib/components/FormBuilder/fields/FieldArray.tsx +74 -0
- package/apps/lib/components/FormBuilder/fields/FieldShell.tsx +148 -0
- package/apps/lib/components/FormBuilder/fields/FileField.tsx +39 -0
- package/apps/lib/components/FormBuilder/fields/OptionListFields.tsx +132 -0
- package/apps/lib/components/FormBuilder/fields/OtpField.tsx +31 -0
- package/apps/lib/components/FormBuilder/fields/PhoneField.tsx +83 -0
- package/apps/lib/components/FormBuilder/fields/RichTextEditorField.tsx +31 -0
- package/apps/lib/components/FormBuilder/fields/SelectField.tsx +91 -0
- package/apps/lib/components/FormBuilder/fields/SignatureField.tsx +157 -0
- package/apps/lib/components/FormBuilder/fields/SliderField.tsx +67 -0
- package/apps/lib/components/FormBuilder/fields/SwitchBoxField.tsx +41 -0
- package/apps/lib/components/FormBuilder/fields/TableField.tsx +308 -0
- package/apps/lib/components/FormBuilder/fields/TextField.tsx +213 -0
- package/apps/lib/components/FormBuilder/fields/TreeSelectField.tsx +43 -0
- package/apps/lib/components/FormBuilder/fields/countries.ts +303 -0
- package/apps/lib/components/FormBuilder/fields/index.ts +25 -0
- package/apps/lib/components/FormBuilder/form-builder.tsx +279 -0
- package/apps/lib/components/FormBuilder/header.tsx +101 -0
- package/apps/lib/components/FormBuilder/index.ts +33 -0
- package/apps/lib/components/FormBuilder/numberFormat.ts +16 -0
- package/apps/lib/components/FormBuilder/stepper.tsx +290 -0
- package/apps/lib/components/FormBuilder/submit.tsx +38 -0
- package/apps/lib/components/FormBuilder/types.ts +261 -0
- package/apps/lib/components/FormRenderer/FormDrawer.tsx +128 -0
- package/apps/lib/components/FormRenderer/form-renderer.tsx +111 -0
- package/apps/lib/components/FormRenderer/index.ts +4 -0
- package/apps/lib/components/FormRenderer/types.ts +77 -0
- package/apps/lib/components/FormSummary.tsx +282 -0
- package/apps/lib/components/ImageAttachment.tsx +36 -61
- package/apps/lib/components/Label.tsx +49 -42
- package/apps/lib/components/Popover.tsx +6 -2
- package/apps/lib/components/RadioCard.tsx +2 -0
- package/apps/lib/components/SearchableSelect.tsx +16 -5
- package/apps/lib/components/SectionBlock.tsx +16 -8
- package/apps/lib/components/Select.tsx +41 -120
- package/apps/lib/components/TextEditor/RichTextField.tsx +46 -0
- package/apps/lib/components/{TextEditor.tsx → TextEditor/TextEditor.tsx} +63 -9
- package/apps/lib/components/TextEditor/TextEditorToolbar.tsx +429 -0
- package/apps/lib/components/TextEditor/editor-tools/AlignmentTune.ts +70 -0
- package/apps/lib/components/TextEditor/editor-tools/ColorInlineTool.ts +50 -0
- package/apps/lib/components/TextEditor/editor-tools/StrikethroughInlineTool.ts +48 -0
- package/apps/lib/components/TextEditor/editor-tools/inlineFormat.ts +98 -0
- package/apps/lib/{types → components/TextEditor}/editorjs.d.ts +19 -0
- package/apps/lib/components/TextEditor/index.ts +7 -0
- package/apps/lib/components/Textarea.tsx +1 -1
- package/apps/lib/layouts/FieldSection.tsx +25 -22
- package/apps/lib/registry.json +51 -58
- package/apps/lib/tsconfig.tsbuildinfo +1 -0
- package/apps/lib/utils/color.ts +175 -0
- package/dist/src/shared/tailwindInit.d.ts.map +1 -1
- package/dist/src/shared/tailwindInit.js +3 -0
- package/dist/src/shared/tailwindInit.js.map +1 -1
- package/docs/components/card.md +4 -2
- package/docs/components/chart-block-tool.md +5 -4
- package/docs/components/color-picker.md +101 -0
- package/docs/components/conclusion-header.md +80 -0
- package/docs/components/drawer.md +153 -102
- package/docs/components/form-builder.md +260 -0
- package/docs/components/form-renderer.md +234 -0
- package/docs/components/form-summary.md +123 -0
- package/docs/components/image-attachment.md +10 -4
- package/docs/components/searchable-select.md +50 -46
- package/docs/components/table-dnd-wrapper.md +5 -3
- package/docs/components/text-editor.md +18 -0
- package/docs/how-to/form-and-list-recipes.md +26 -19
- package/docs/how-to/forms-with-form-builder.md +411 -0
- package/docs/how-to/guides.md +153 -179
- package/docs/reference/tailwind-plugins.md +11 -1
- package/docs/tutorials/building-first-form.md +150 -159
- package/docs/tutorials/getting-started.md +9 -0
- package/package.json +1 -1
- /package/apps/lib/components/{ChartBlockTool.ts → TextEditor/ChartBlockTool.ts} +0 -0
- /package/apps/lib/components/{TableDnDWrapper.ts → TextEditor/TableDnDWrapper.ts} +0 -0
- /package/apps/lib/{utils → components/TextEditor}/markdownParser.ts +0 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type ReactNode, useState } from "react";
|
|
4
|
+
import { useFormContext, useFieldArray, type FieldValues, type ArrayPath } from "react-hook-form";
|
|
5
|
+
import { CSS } from "@dnd-kit/utilities";
|
|
6
|
+
import {
|
|
7
|
+
DndContext,
|
|
8
|
+
closestCenter,
|
|
9
|
+
KeyboardSensor,
|
|
10
|
+
PointerSensor,
|
|
11
|
+
useSensor,
|
|
12
|
+
useSensors,
|
|
13
|
+
type DragEndEvent,
|
|
14
|
+
} from "@dnd-kit/core";
|
|
15
|
+
import {
|
|
16
|
+
SortableContext,
|
|
17
|
+
sortableKeyboardCoordinates,
|
|
18
|
+
useSortable,
|
|
19
|
+
verticalListSortingStrategy,
|
|
20
|
+
} from "@dnd-kit/sortable";
|
|
21
|
+
|
|
22
|
+
import { cn } from "../../../utils/cn";
|
|
23
|
+
import { SectionBlock } from "../../SectionBlock";
|
|
24
|
+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../Table";
|
|
25
|
+
import { Checkbox } from "../../Checkbox";
|
|
26
|
+
import { Button } from "../../Button";
|
|
27
|
+
import { CellContext, useLoading } from "../context";
|
|
28
|
+
import type { TableColumn, TableFieldProps } from "../types";
|
|
29
|
+
|
|
30
|
+
const ALIGN: Record<NonNullable<TableColumn["align"]>, string> = {
|
|
31
|
+
start: "justify-start text-start",
|
|
32
|
+
center: "justify-center text-center",
|
|
33
|
+
end: "justify-end text-end",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* `FormBuilder.Table` — an editable data-grid field built on the FieldArray pattern.
|
|
38
|
+
* Rows are records; each column cell is any `FormBuilder.*` field, rendered "bare" via
|
|
39
|
+
* `CellContext`. Rows can be checkbox-selected, drag-reordered, added and removed.
|
|
40
|
+
*/
|
|
41
|
+
export function TableField(props: TableFieldProps) {
|
|
42
|
+
const { name, columns, selectable = true, reorderable = true } = props;
|
|
43
|
+
const form = useFormContext<FieldValues>();
|
|
44
|
+
const loading = useLoading();
|
|
45
|
+
|
|
46
|
+
const { fields, append, remove, move, replace } = useFieldArray({
|
|
47
|
+
control: form.control,
|
|
48
|
+
name: name as ArrayPath<FieldValues>,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
52
|
+
const [sort, setSort] = useState<{ key: string; dir: "asc" | "desc" } | null>(null);
|
|
53
|
+
|
|
54
|
+
const sensors = useSensors(
|
|
55
|
+
useSensor(PointerSensor),
|
|
56
|
+
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (props.hidden) return null;
|
|
60
|
+
|
|
61
|
+
const showHandle = reorderable;
|
|
62
|
+
const showSelect = selectable;
|
|
63
|
+
|
|
64
|
+
const toggleRow = (id: string) =>
|
|
65
|
+
setSelected((prev) => {
|
|
66
|
+
const next = new Set(prev);
|
|
67
|
+
if (next.has(id)) next.delete(id);
|
|
68
|
+
else next.add(id);
|
|
69
|
+
return next;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const allSelected = fields.length > 0 && fields.every((f) => selected.has(f.id));
|
|
73
|
+
const someSelected = fields.some((f) => selected.has(f.id));
|
|
74
|
+
const toggleAll = () => setSelected(allSelected ? new Set() : new Set(fields.map((f) => f.id)));
|
|
75
|
+
|
|
76
|
+
const deleteSelected = () => {
|
|
77
|
+
if (loading) return;
|
|
78
|
+
const indices = fields.map((f, i) => (selected.has(f.id) ? i : -1)).filter((i) => i >= 0);
|
|
79
|
+
remove(indices);
|
|
80
|
+
setSelected(new Set());
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const onDragEnd = (event: DragEndEvent) => {
|
|
84
|
+
const { active, over } = event;
|
|
85
|
+
if (!over || active.id === over.id) return;
|
|
86
|
+
const oldIndex = fields.findIndex((f) => f.id === active.id);
|
|
87
|
+
const newIndex = fields.findIndex((f) => f.id === over.id);
|
|
88
|
+
if (oldIndex >= 0 && newIndex >= 0) move(oldIndex, newIndex);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
// Sort the rows by a column's key (asc/desc toggle). `replace` re-keys the field ids,
|
|
92
|
+
// so selection (keyed by id) is cleared afterwards.
|
|
93
|
+
const applySort = (key: string) => {
|
|
94
|
+
if (loading) return;
|
|
95
|
+
const dir: "asc" | "desc" = sort?.key === key && sort.dir === "asc" ? "desc" : "asc";
|
|
96
|
+
const rows = (form.getValues(name) as Record<string, unknown>[] | undefined) ?? [];
|
|
97
|
+
const sorted = [...rows].sort((a, b) => {
|
|
98
|
+
const av = a?.[key];
|
|
99
|
+
const bv = b?.[key];
|
|
100
|
+
if (av == null) return bv == null ? 0 : 1;
|
|
101
|
+
if (bv == null) return -1;
|
|
102
|
+
const cmp =
|
|
103
|
+
typeof av === "number" && typeof bv === "number"
|
|
104
|
+
? av - bv
|
|
105
|
+
: String(av).localeCompare(String(bv));
|
|
106
|
+
return dir === "asc" ? cmp : -cmp;
|
|
107
|
+
});
|
|
108
|
+
replace(sorted);
|
|
109
|
+
setSort({ key, dir });
|
|
110
|
+
setSelected(new Set());
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const totalCols = columns.length + (showHandle ? 1 : 0) + (showSelect ? 1 : 0);
|
|
114
|
+
|
|
115
|
+
const rowCells = (rowName: string, index: number) => (
|
|
116
|
+
<>
|
|
117
|
+
{showSelect && (
|
|
118
|
+
<TableCell isDummy>
|
|
119
|
+
<Checkbox
|
|
120
|
+
size="S"
|
|
121
|
+
checked={selected.has(fields[index].id)}
|
|
122
|
+
onCheckedChange={() => toggleRow(fields[index].id)}
|
|
123
|
+
/>
|
|
124
|
+
</TableCell>
|
|
125
|
+
)}
|
|
126
|
+
{columns.map((col, ci) => (
|
|
127
|
+
<TableCell
|
|
128
|
+
key={ci}
|
|
129
|
+
style={col.width ? { width: col.width } : undefined}
|
|
130
|
+
childrenClassName={cn(
|
|
131
|
+
"min-w-0 w-full [&:has(input)]:bg-transparent",
|
|
132
|
+
col.align && ALIGN[col.align],
|
|
133
|
+
)}
|
|
134
|
+
>
|
|
135
|
+
<CellContext.Provider value={true}>{col.cell(rowName, index)}</CellContext.Provider>
|
|
136
|
+
</TableCell>
|
|
137
|
+
))}
|
|
138
|
+
</>
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const table = (
|
|
142
|
+
<Table>
|
|
143
|
+
<TableHeader>
|
|
144
|
+
<TableRow>
|
|
145
|
+
{showHandle && <TableHead isDummy />}
|
|
146
|
+
{showSelect && (
|
|
147
|
+
<TableHead isDummy>
|
|
148
|
+
<Checkbox
|
|
149
|
+
size="S"
|
|
150
|
+
checked={allSelected ? true : someSelected ? "indeterminate" : false}
|
|
151
|
+
onCheckedChange={toggleAll}
|
|
152
|
+
/>
|
|
153
|
+
</TableHead>
|
|
154
|
+
)}
|
|
155
|
+
{columns.map((col, ci) => (
|
|
156
|
+
<TableHead
|
|
157
|
+
key={ci}
|
|
158
|
+
sortType={col.sortKey && sort?.key === col.sortKey ? sort.dir : undefined}
|
|
159
|
+
onSort={col.sortKey ? () => applySort(col.sortKey as string) : undefined}
|
|
160
|
+
>
|
|
161
|
+
{col.header}
|
|
162
|
+
</TableHead>
|
|
163
|
+
))}
|
|
164
|
+
</TableRow>
|
|
165
|
+
</TableHeader>
|
|
166
|
+
|
|
167
|
+
<TableBody>
|
|
168
|
+
{showHandle ? (
|
|
169
|
+
<SortableContext items={fields.map((f) => f.id)} strategy={verticalListSortingStrategy}>
|
|
170
|
+
{fields.map((f, index) => (
|
|
171
|
+
<SortableRow key={f.id} id={f.id} state={selected.has(f.id) ? "selected" : undefined}>
|
|
172
|
+
{rowCells(`${name}.${index}`, index)}
|
|
173
|
+
</SortableRow>
|
|
174
|
+
))}
|
|
175
|
+
</SortableContext>
|
|
176
|
+
) : (
|
|
177
|
+
fields.map((f, index) => (
|
|
178
|
+
<TableRow key={f.id} state={selected.has(f.id) ? "selected" : undefined}>
|
|
179
|
+
{rowCells(`${name}.${index}`, index)}
|
|
180
|
+
</TableRow>
|
|
181
|
+
))
|
|
182
|
+
)}
|
|
183
|
+
|
|
184
|
+
{fields.length === 0 && (
|
|
185
|
+
<TableRow>
|
|
186
|
+
<TableCell colSpan={totalCols}>
|
|
187
|
+
<span className="typography-body-small-regular text-content-presentation-global-secondary">
|
|
188
|
+
No rows yet.
|
|
189
|
+
</span>
|
|
190
|
+
</TableCell>
|
|
191
|
+
</TableRow>
|
|
192
|
+
)}
|
|
193
|
+
|
|
194
|
+
<TableRow className="h-[40px]">
|
|
195
|
+
<TableCell
|
|
196
|
+
colSpan={totalCols}
|
|
197
|
+
className="border-t-2 border-transparent hover:border-border-presentation-table-action-hover hover:bg-background-presentation-table-acton-hover"
|
|
198
|
+
>
|
|
199
|
+
<button
|
|
200
|
+
type="button"
|
|
201
|
+
disabled={loading}
|
|
202
|
+
onClick={() => append((props.defaultItem ?? {}) as never)}
|
|
203
|
+
className="flex w-full items-center justify-start gap-2 typography-body-medium-semibold text-content-presentation-action-light-primary [&_i]:text-[20px]"
|
|
204
|
+
>
|
|
205
|
+
<i className="ri-add-line" />
|
|
206
|
+
{props.addLabel ?? "Add New"}
|
|
207
|
+
</button>
|
|
208
|
+
</TableCell>
|
|
209
|
+
</TableRow>
|
|
210
|
+
</TableBody>
|
|
211
|
+
</Table>
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// Header actions on the right of the SectionBlock title row: a "Delete Row" that's disabled
|
|
215
|
+
// until rows are selected, and a primary "Add New".
|
|
216
|
+
const headerActions = (
|
|
217
|
+
<>
|
|
218
|
+
{selectable && (
|
|
219
|
+
<Button
|
|
220
|
+
type="button"
|
|
221
|
+
size="S"
|
|
222
|
+
variant="BorderStyle"
|
|
223
|
+
disabled={loading || selected.size === 0}
|
|
224
|
+
onClick={deleteSelected}
|
|
225
|
+
>
|
|
226
|
+
<i className="ri-delete-bin-line" />
|
|
227
|
+
Delete Row
|
|
228
|
+
</Button>
|
|
229
|
+
)}
|
|
230
|
+
<Button
|
|
231
|
+
type="button"
|
|
232
|
+
size="S"
|
|
233
|
+
variant="BluColStyle"
|
|
234
|
+
disabled={loading}
|
|
235
|
+
onClick={() => append((props.defaultItem ?? {}) as never)}
|
|
236
|
+
>
|
|
237
|
+
<i className="ri-add-line" />
|
|
238
|
+
{props.addLabel ?? "Add New"}
|
|
239
|
+
</Button>
|
|
240
|
+
</>
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<SectionBlock
|
|
245
|
+
title={props.title}
|
|
246
|
+
color={props.color}
|
|
247
|
+
icon={props.icon}
|
|
248
|
+
action={headerActions}
|
|
249
|
+
bodyClassName="px-[16px]"
|
|
250
|
+
// The bottom "Add New" footer is the last element, so drop SectionBlock's default
|
|
251
|
+
// bottom padding to keep the table flush with the card edge (matches the Figma).
|
|
252
|
+
containerClassName="pb-0"
|
|
253
|
+
>
|
|
254
|
+
<div className="flex w-full flex-col gap-2">
|
|
255
|
+
{props.description && (
|
|
256
|
+
<p className="typography-body-small-regular text-content-presentation-global-secondary">
|
|
257
|
+
{props.description}
|
|
258
|
+
</p>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
<div className="w-full overflow-x-auto">
|
|
262
|
+
{showHandle ? (
|
|
263
|
+
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
|
264
|
+
{table}
|
|
265
|
+
</DndContext>
|
|
266
|
+
) : (
|
|
267
|
+
table
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</SectionBlock>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A draggable table row — the drag handle lives in the leading `isDummy` cell. */
|
|
276
|
+
function SortableRow({
|
|
277
|
+
id,
|
|
278
|
+
state,
|
|
279
|
+
children,
|
|
280
|
+
}: {
|
|
281
|
+
id: string;
|
|
282
|
+
state?: "selected";
|
|
283
|
+
children: ReactNode;
|
|
284
|
+
}) {
|
|
285
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
286
|
+
id,
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const style = {
|
|
290
|
+
transform: CSS.Transform.toString(transform),
|
|
291
|
+
transition,
|
|
292
|
+
opacity: isDragging ? 0.5 : 1,
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<TableRow ref={setNodeRef} style={style} state={state}>
|
|
297
|
+
<TableCell
|
|
298
|
+
{...attributes}
|
|
299
|
+
{...listeners}
|
|
300
|
+
className="cursor-grab active:cursor-grabbing"
|
|
301
|
+
isDummy
|
|
302
|
+
>
|
|
303
|
+
<i className="ri-draggable text-[18px] text-content-presentation-global-secondary" />
|
|
304
|
+
</TableCell>
|
|
305
|
+
{children}
|
|
306
|
+
</TableRow>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ReactNode, useEffect, useLayoutEffect, useRef, useState, type ChangeEvent } from "react";
|
|
4
|
+
import type { ControllerRenderProps, FieldValues } from "react-hook-form";
|
|
5
|
+
|
|
6
|
+
import { InputField } from "../../InputField";
|
|
7
|
+
import { Textarea } from "../../Textarea";
|
|
8
|
+
import { PasswordLevel } from "../../PasswordLevel";
|
|
9
|
+
import { useLoading, useCell } from "../context";
|
|
10
|
+
import { formatNumber } from "../numberFormat";
|
|
11
|
+
import type { BaseFieldProps, CurrencyFieldProps, PasswordFieldProps } from "../types";
|
|
12
|
+
import { FieldShell } from "./FieldShell";
|
|
13
|
+
|
|
14
|
+
// Run before paint on the client; fall back to useEffect on the server (no SSR warning).
|
|
15
|
+
const useIsoLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A number input that shows a **live** thousands-separated value (e.g. "1,299")
|
|
19
|
+
* while storing the raw `number` in the form. Commas are inserted as you type and
|
|
20
|
+
* the caret is preserved; decimals (and a trailing ".") are kept intact.
|
|
21
|
+
*/
|
|
22
|
+
function NumberInput({
|
|
23
|
+
field,
|
|
24
|
+
icon,
|
|
25
|
+
placeholder,
|
|
26
|
+
disabled,
|
|
27
|
+
onTable,
|
|
28
|
+
}: {
|
|
29
|
+
field: ControllerRenderProps<FieldValues, string>;
|
|
30
|
+
icon?: ReactNode;
|
|
31
|
+
placeholder?: string;
|
|
32
|
+
disabled?: boolean;
|
|
33
|
+
onTable?: boolean;
|
|
34
|
+
}) {
|
|
35
|
+
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
36
|
+
const caretRef = useRef<number | null>(null);
|
|
37
|
+
const [text, setText] = useState<string>(() => formatNumber(field.value));
|
|
38
|
+
|
|
39
|
+
// Reflect external value changes (e.g. edit data loading) while not editing.
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (document.activeElement === inputRef.current) return;
|
|
42
|
+
setText(formatNumber(field.value));
|
|
43
|
+
}, [field.value]);
|
|
44
|
+
|
|
45
|
+
// Restore the caret after a live reformat (commas shift positions).
|
|
46
|
+
useIsoLayoutEffect(() => {
|
|
47
|
+
if (caretRef.current != null && inputRef.current) {
|
|
48
|
+
inputRef.current.setSelectionRange(caretRef.current, caretRef.current);
|
|
49
|
+
caretRef.current = null;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const setRefs = (el: HTMLInputElement | null) => {
|
|
54
|
+
inputRef.current = el;
|
|
55
|
+
if (typeof field.ref === "function") field.ref(el);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
|
59
|
+
const raw = e.target.value;
|
|
60
|
+
const caret = e.target.selectionStart ?? raw.length;
|
|
61
|
+
const digitsBeforeCaret = raw.slice(0, caret).replace(/\D/g, "").length;
|
|
62
|
+
|
|
63
|
+
// Keep digits and a single decimal point.
|
|
64
|
+
let cleaned = raw.replace(/[^\d.]/g, "");
|
|
65
|
+
const dot = cleaned.indexOf(".");
|
|
66
|
+
if (dot !== -1) {
|
|
67
|
+
cleaned = cleaned.slice(0, dot + 1) + cleaned.slice(dot + 1).replace(/\./g, "");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let formatted = "";
|
|
71
|
+
if (cleaned !== "") {
|
|
72
|
+
const [intPart, decPart] = cleaned.split(".");
|
|
73
|
+
const intFmt = intPart === "" ? "" : Number(intPart).toLocaleString("en-US");
|
|
74
|
+
formatted = decPart !== undefined ? `${intFmt}.${decPart}` : intFmt;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Caret goes after the same count of digits, skipping the inserted commas.
|
|
78
|
+
let pos = 0;
|
|
79
|
+
let seen = 0;
|
|
80
|
+
while (pos < formatted.length && seen < digitsBeforeCaret) {
|
|
81
|
+
if (/\d/.test(formatted[pos])) seen++;
|
|
82
|
+
pos++;
|
|
83
|
+
}
|
|
84
|
+
caretRef.current = pos;
|
|
85
|
+
|
|
86
|
+
setText(formatted);
|
|
87
|
+
field.onChange(cleaned === "" || cleaned === "." ? undefined : Number(cleaned));
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<InputField
|
|
92
|
+
name={field.name}
|
|
93
|
+
ref={setRefs}
|
|
94
|
+
type="text"
|
|
95
|
+
inputMode="decimal"
|
|
96
|
+
icon={icon}
|
|
97
|
+
value={text}
|
|
98
|
+
placeholder={placeholder}
|
|
99
|
+
disabled={disabled}
|
|
100
|
+
onTable={onTable}
|
|
101
|
+
className="w-full"
|
|
102
|
+
onChange={handleChange}
|
|
103
|
+
onBlur={() => field.onBlur()}
|
|
104
|
+
/>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function TextLike({ props, type }: { props: BaseFieldProps; type: "text" | "email" | "password" }) {
|
|
109
|
+
const loading = useLoading();
|
|
110
|
+
const cell = useCell();
|
|
111
|
+
return (
|
|
112
|
+
<FieldShell {...props}>
|
|
113
|
+
{(field) => (
|
|
114
|
+
<InputField
|
|
115
|
+
{...field}
|
|
116
|
+
type={type}
|
|
117
|
+
value={field.value ?? ""}
|
|
118
|
+
placeholder={props.placeholder}
|
|
119
|
+
disabled={props.disabled || loading}
|
|
120
|
+
onTable={cell}
|
|
121
|
+
className="w-full"
|
|
122
|
+
/>
|
|
123
|
+
)}
|
|
124
|
+
</FieldShell>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function TextField(props: BaseFieldProps) {
|
|
129
|
+
return <TextLike props={props} type="text" />;
|
|
130
|
+
}
|
|
131
|
+
export function EmailField(props: BaseFieldProps) {
|
|
132
|
+
return <TextLike props={props} type="email" />;
|
|
133
|
+
}
|
|
134
|
+
export function PasswordField(props: PasswordFieldProps) {
|
|
135
|
+
const loading = useLoading();
|
|
136
|
+
const cell = useCell();
|
|
137
|
+
return (
|
|
138
|
+
<FieldShell {...props}>
|
|
139
|
+
{(field) => (
|
|
140
|
+
<div className="flex w-full flex-col gap-2">
|
|
141
|
+
<InputField
|
|
142
|
+
{...field}
|
|
143
|
+
type="password"
|
|
144
|
+
value={field.value ?? ""}
|
|
145
|
+
placeholder={props.placeholder}
|
|
146
|
+
disabled={props.disabled || loading}
|
|
147
|
+
onTable={cell}
|
|
148
|
+
className="w-full"
|
|
149
|
+
/>
|
|
150
|
+
{props.strengthMeter && <PasswordLevel value={field.value ?? ""} />}
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
</FieldShell>
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function NumberField(props: BaseFieldProps) {
|
|
158
|
+
const loading = useLoading();
|
|
159
|
+
const cell = useCell();
|
|
160
|
+
return (
|
|
161
|
+
<FieldShell {...props}>
|
|
162
|
+
{(field) => (
|
|
163
|
+
<NumberInput
|
|
164
|
+
field={field}
|
|
165
|
+
placeholder={props.placeholder}
|
|
166
|
+
disabled={props.disabled || loading}
|
|
167
|
+
onTable={cell}
|
|
168
|
+
/>
|
|
169
|
+
)}
|
|
170
|
+
</FieldShell>
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function CurrencyField(props: CurrencyFieldProps) {
|
|
175
|
+
const loading = useLoading();
|
|
176
|
+
const cell = useCell();
|
|
177
|
+
return (
|
|
178
|
+
<FieldShell {...props}>
|
|
179
|
+
{(field) => (
|
|
180
|
+
<NumberInput
|
|
181
|
+
field={field}
|
|
182
|
+
icon={
|
|
183
|
+
props.currencySymbol ? (
|
|
184
|
+
<span className="typography-body-small-medium">{props.currencySymbol}</span>
|
|
185
|
+
) : undefined
|
|
186
|
+
}
|
|
187
|
+
placeholder={props.placeholder}
|
|
188
|
+
disabled={props.disabled || loading}
|
|
189
|
+
onTable={cell}
|
|
190
|
+
/>
|
|
191
|
+
)}
|
|
192
|
+
</FieldShell>
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function TextareaField(props: BaseFieldProps & { rows?: number }) {
|
|
197
|
+
const loading = useLoading();
|
|
198
|
+
return (
|
|
199
|
+
<FieldShell {...props}>
|
|
200
|
+
{(field, fieldState) => (
|
|
201
|
+
<Textarea
|
|
202
|
+
{...field}
|
|
203
|
+
value={field.value ?? ""}
|
|
204
|
+
rows={props.rows}
|
|
205
|
+
placeholder={props.placeholder}
|
|
206
|
+
disabled={props.disabled || loading}
|
|
207
|
+
state={fieldState.error ? "negative" : undefined}
|
|
208
|
+
className="w-full"
|
|
209
|
+
/>
|
|
210
|
+
)}
|
|
211
|
+
</FieldShell>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { SearchableTree } from "../../SearchableTree";
|
|
4
|
+
import type { TreeSelectFieldProps } from "../types";
|
|
5
|
+
import { FieldShell } from "./FieldShell";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `FormBuilder.TreeSelect` — hierarchical single-select (Glare `SearchableTree`).
|
|
9
|
+
* Stores the selected node's id; seeds the picker + view label by finding the
|
|
10
|
+
* node back from that id.
|
|
11
|
+
*/
|
|
12
|
+
export function TreeSelectField<T>(props: TreeSelectFieldProps<T>) {
|
|
13
|
+
const findById = (id: unknown): T | null => {
|
|
14
|
+
const target = String(id ?? "");
|
|
15
|
+
if (!target) return null;
|
|
16
|
+
// Works for both nested (getNodeChildren) and flat node lists.
|
|
17
|
+
const stack: T[] = [...props.nodes];
|
|
18
|
+
while (stack.length) {
|
|
19
|
+
const node = stack.pop() as T;
|
|
20
|
+
if (props.getNodeId(node) === target) return node;
|
|
21
|
+
const kids = props.getNodeChildren?.(node);
|
|
22
|
+
if (kids && kids.length) stack.push(...kids);
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<FieldShell {...props}>
|
|
29
|
+
{(field) => (
|
|
30
|
+
<SearchableTree
|
|
31
|
+
nodes={props.nodes}
|
|
32
|
+
getNodeId={props.getNodeId}
|
|
33
|
+
getNodeLabel={props.getNodeLabel}
|
|
34
|
+
getNodeChildren={props.getNodeChildren}
|
|
35
|
+
parentIdKey={props.parentIdKey as (keyof T & string) | undefined}
|
|
36
|
+
selectableFolders={props.selectableFolders}
|
|
37
|
+
value={findById(field.value)}
|
|
38
|
+
onSelect={(node) => field.onChange(props.getNodeId(node))}
|
|
39
|
+
/>
|
|
40
|
+
)}
|
|
41
|
+
</FieldShell>
|
|
42
|
+
);
|
|
43
|
+
}
|