torch-glare 2.4.1 → 2.4.2

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 (79) hide show
  1. package/apps/lib/components/Button.tsx +1 -1
  2. package/apps/lib/components/Card.tsx +47 -20
  3. package/apps/lib/components/ColorPicker.tsx +441 -0
  4. package/apps/lib/components/ConclusionHeader.tsx +148 -0
  5. package/apps/lib/components/DatePicker.tsx +2 -0
  6. package/apps/lib/components/Drawer.tsx +66 -24
  7. package/apps/lib/components/FormBuilder/DisplayField.tsx +40 -0
  8. package/apps/lib/components/FormBuilder/context.ts +71 -0
  9. package/apps/lib/components/FormBuilder/fields/ChoiceFields.tsx +65 -0
  10. package/apps/lib/components/FormBuilder/fields/ColorField.tsx +76 -0
  11. package/apps/lib/components/FormBuilder/fields/CustomField.tsx +16 -0
  12. package/apps/lib/components/FormBuilder/fields/DateField.tsx +34 -0
  13. package/apps/lib/components/FormBuilder/fields/FieldArray.tsx +79 -0
  14. package/apps/lib/components/FormBuilder/fields/FieldShell.tsx +174 -0
  15. package/apps/lib/components/FormBuilder/fields/FileField.tsx +40 -0
  16. package/apps/lib/components/FormBuilder/fields/OptionListFields.tsx +139 -0
  17. package/apps/lib/components/FormBuilder/fields/OtpField.tsx +32 -0
  18. package/apps/lib/components/FormBuilder/fields/PhoneField.tsx +82 -0
  19. package/apps/lib/components/FormBuilder/fields/RichTextEditorField.tsx +37 -0
  20. package/apps/lib/components/FormBuilder/fields/SelectField.tsx +101 -0
  21. package/apps/lib/components/FormBuilder/fields/SignatureField.tsx +175 -0
  22. package/apps/lib/components/FormBuilder/fields/SliderField.tsx +72 -0
  23. package/apps/lib/components/FormBuilder/fields/SwitchBoxField.tsx +42 -0
  24. package/apps/lib/components/FormBuilder/fields/TableField.tsx +312 -0
  25. package/apps/lib/components/FormBuilder/fields/TextField.tsx +222 -0
  26. package/apps/lib/components/FormBuilder/fields/TreeSelectField.tsx +49 -0
  27. package/apps/lib/components/FormBuilder/fields/countries.ts +303 -0
  28. package/apps/lib/components/FormBuilder/fields/index.ts +25 -0
  29. package/apps/lib/components/FormBuilder/form-builder.tsx +292 -0
  30. package/apps/lib/components/FormBuilder/header.tsx +105 -0
  31. package/apps/lib/components/FormBuilder/index.ts +37 -0
  32. package/apps/lib/components/FormBuilder/numberFormat.ts +16 -0
  33. package/apps/lib/components/FormBuilder/stepper.tsx +217 -0
  34. package/apps/lib/components/FormBuilder/submit.tsx +41 -0
  35. package/apps/lib/components/FormBuilder/types.ts +264 -0
  36. package/apps/lib/components/FormBuilder/viewFormat.tsx +137 -0
  37. package/apps/lib/components/FormRenderer/FormDrawer.tsx +121 -0
  38. package/apps/lib/components/FormRenderer/form-renderer.tsx +113 -0
  39. package/apps/lib/components/FormRenderer/index.ts +9 -0
  40. package/apps/lib/components/FormRenderer/types.ts +79 -0
  41. package/apps/lib/components/FormSummary.tsx +282 -0
  42. package/apps/lib/components/ImageAttachment.tsx +36 -61
  43. package/apps/lib/components/Label.tsx +49 -42
  44. package/apps/lib/components/RadioCard.tsx +2 -0
  45. package/apps/lib/components/SearchableSelect.tsx +9 -3
  46. package/apps/lib/components/SectionBlock.tsx +16 -8
  47. package/apps/lib/components/Select.tsx +41 -120
  48. package/apps/lib/components/TextEditor/RichTextField.tsx +46 -0
  49. package/apps/lib/components/{TextEditor.tsx → TextEditor/TextEditor.tsx} +63 -9
  50. package/apps/lib/components/TextEditor/TextEditorToolbar.tsx +429 -0
  51. package/apps/lib/components/TextEditor/editor-tools/AlignmentTune.ts +70 -0
  52. package/apps/lib/components/TextEditor/editor-tools/ColorInlineTool.ts +50 -0
  53. package/apps/lib/components/TextEditor/editor-tools/StrikethroughInlineTool.ts +48 -0
  54. package/apps/lib/components/TextEditor/editor-tools/inlineFormat.ts +98 -0
  55. package/apps/lib/{types → components/TextEditor}/editorjs.d.ts +19 -0
  56. package/apps/lib/components/TextEditor/index.ts +7 -0
  57. package/apps/lib/components/Textarea.tsx +1 -1
  58. package/apps/lib/registry.json +51 -58
  59. package/apps/lib/tsconfig.tsbuildinfo +1 -0
  60. package/apps/lib/utils/color.ts +175 -0
  61. package/docs/components/card.md +4 -2
  62. package/docs/components/chart-block-tool.md +5 -4
  63. package/docs/components/color-picker.md +101 -0
  64. package/docs/components/conclusion-header.md +80 -0
  65. package/docs/components/drawer.md +153 -102
  66. package/docs/components/form-builder.md +270 -0
  67. package/docs/components/form-renderer.md +228 -0
  68. package/docs/components/form-summary.md +123 -0
  69. package/docs/components/image-attachment.md +10 -4
  70. package/docs/components/table-dnd-wrapper.md +5 -3
  71. package/docs/components/text-editor.md +18 -0
  72. package/docs/how-to/form-and-list-recipes.md +26 -19
  73. package/docs/how-to/forms-with-form-builder.md +408 -0
  74. package/docs/how-to/guides.md +153 -179
  75. package/docs/tutorials/building-first-form.md +150 -159
  76. package/package.json +1 -1
  77. /package/apps/lib/components/{ChartBlockTool.ts → TextEditor/ChartBlockTool.ts} +0 -0
  78. /package/apps/lib/components/{TableDnDWrapper.ts → TextEditor/TableDnDWrapper.ts} +0 -0
  79. /package/apps/lib/{utils → components/TextEditor}/markdownParser.ts +0 -0
@@ -0,0 +1,175 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+
5
+ import { Button } from "../../Button";
6
+ import { GroupStyles } from "../../Input";
7
+ import { cn } from "../../../utils/cn";
8
+ import { useLoading } from "../context";
9
+ import type { SignatureFieldProps } from "../types";
10
+ import { FieldShell } from "./FieldShell";
11
+
12
+ /**
13
+ * `FormBuilder.Signature` — a freehand signature pad (native `<canvas>` + pointer
14
+ * events, no dependency). Value is a PNG data-URL string (`""` when empty), so it
15
+ * registers/validates/submits/views like any other field via `FieldShell`.
16
+ *
17
+ * Forced to a **vertical, full-width** layout (label on top, pad below spanning the whole
18
+ * section) — like `FormBuilder.RichText`: the canvas needs the full width, not the right column.
19
+ */
20
+ export function SignatureField(props: SignatureFieldProps) {
21
+ const loading = useLoading();
22
+ const penColor = props.penColor ?? "#111827";
23
+
24
+ return (
25
+ <FieldShell
26
+ {...props}
27
+ direction="vertical"
28
+ fullWidth
29
+ view={(v) =>
30
+ typeof v === "string" && v
31
+ ? {
32
+ valueNode: (
33
+ // eslint-disable-next-line @next/next/no-img-element -- data-URL preview, no optimization applies
34
+ <img
35
+ src={v}
36
+ alt="Signature"
37
+ className="max-h-[120px] rounded-[8px] border border-border-presentation-action-primary bg-white"
38
+ />
39
+ ),
40
+ }
41
+ : { value: "" }
42
+ }
43
+ >
44
+ {(field) => (
45
+ <SignaturePad
46
+ value={typeof field.value === "string" ? field.value : ""}
47
+ onChange={field.onChange}
48
+ disabled={props.disabled || loading}
49
+ penColor={penColor}
50
+ />
51
+ )}
52
+ </FieldShell>
53
+ );
54
+ }
55
+
56
+ interface SignaturePadProps {
57
+ value: string;
58
+ onChange: (value: string) => void;
59
+ disabled?: boolean;
60
+ penColor: string;
61
+ }
62
+
63
+ function SignaturePad({ value, onChange, disabled, penColor }: SignaturePadProps) {
64
+ const canvasRef = useRef<HTMLCanvasElement>(null);
65
+ const drawing = useRef(false);
66
+ const last = useRef<{ x: number; y: number } | null>(null);
67
+ // Track whether the canvas currently holds strokes, so Clear can no-op cleanly.
68
+ const dirty = useRef(false);
69
+
70
+ // Size the backing store to the element box (accounting for DPR) and seed it
71
+ // from the current value. Runs once on mount; a remount `key` re-seeds on edit.
72
+ useEffect(() => {
73
+ const canvas = canvasRef.current;
74
+ if (!canvas) return;
75
+ const ctx = canvas.getContext("2d");
76
+ if (!ctx) return;
77
+
78
+ const rect = canvas.getBoundingClientRect();
79
+ const dpr = window.devicePixelRatio || 1;
80
+ canvas.width = Math.max(1, Math.round(rect.width * dpr));
81
+ canvas.height = Math.max(1, Math.round(rect.height * dpr));
82
+ ctx.scale(dpr, dpr);
83
+ ctx.lineWidth = 2;
84
+ ctx.lineCap = "round";
85
+ ctx.lineJoin = "round";
86
+ ctx.strokeStyle = penColor;
87
+
88
+ if (value) {
89
+ const img = new Image();
90
+ img.onload = () => ctx.drawImage(img, 0, 0, rect.width, rect.height);
91
+ img.src = value;
92
+ dirty.current = true;
93
+ }
94
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- initial-only seed (like date/richtext)
95
+ }, []);
96
+
97
+ const pointToCanvas = (e: React.PointerEvent<HTMLCanvasElement>) => {
98
+ const rect = e.currentTarget.getBoundingClientRect();
99
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
100
+ };
101
+
102
+ const handleDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
103
+ if (disabled) return;
104
+ e.currentTarget.setPointerCapture(e.pointerId);
105
+ drawing.current = true;
106
+ last.current = pointToCanvas(e);
107
+ };
108
+
109
+ const handleMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
110
+ if (!drawing.current || disabled) return;
111
+ const ctx = canvasRef.current?.getContext("2d");
112
+ if (!ctx || !last.current) return;
113
+ const p = pointToCanvas(e);
114
+ ctx.beginPath();
115
+ ctx.moveTo(last.current.x, last.current.y);
116
+ ctx.lineTo(p.x, p.y);
117
+ ctx.stroke();
118
+ last.current = p;
119
+ dirty.current = true;
120
+ };
121
+
122
+ const handleUp = () => {
123
+ if (!drawing.current) return;
124
+ drawing.current = false;
125
+ last.current = null;
126
+ if (disabled) return;
127
+ const canvas = canvasRef.current;
128
+ if (canvas && dirty.current) onChange(canvas.toDataURL("image/png"));
129
+ };
130
+
131
+ const clear = () => {
132
+ const canvas = canvasRef.current;
133
+ const ctx = canvas?.getContext("2d");
134
+ if (canvas && ctx) {
135
+ const rect = canvas.getBoundingClientRect();
136
+ ctx.clearRect(0, 0, rect.width, rect.height);
137
+ }
138
+ dirty.current = false;
139
+ onChange("");
140
+ };
141
+
142
+ return (
143
+ <div
144
+ className={cn(
145
+ GroupStyles({ size: "M", variant: "PresentationStyle" }),
146
+ "relative h-[440px] w-full overflow-hidden p-0",
147
+ disabled && "opacity-60",
148
+ )}
149
+ >
150
+ <canvas
151
+ ref={canvasRef}
152
+ onPointerDown={handleDown}
153
+ onPointerMove={handleMove}
154
+ onPointerUp={handleUp}
155
+ onPointerLeave={handleUp}
156
+ className={cn(
157
+ "h-full w-full touch-none bg-white",
158
+ disabled ? "cursor-not-allowed" : "cursor-crosshair",
159
+ )}
160
+ />
161
+ {!disabled && (
162
+ <Button
163
+ type="button"
164
+ size="S"
165
+ variant="BorderStyle"
166
+ onClick={clear}
167
+ className="absolute right-2 top-2"
168
+ >
169
+ <i className="ri-eraser-line" />
170
+ Clear
171
+ </Button>
172
+ )}
173
+ </div>
174
+ );
175
+ }
@@ -0,0 +1,72 @@
1
+ "use client";
2
+
3
+ import * as Slider from "@radix-ui/react-slider";
4
+
5
+ import { cn } from "../../../utils/cn";
6
+ import { useLoading } from "../context";
7
+ import type { SliderFieldProps } from "../types";
8
+ import { FieldShell } from "./FieldShell";
9
+
10
+ // The white knob + soft shadow, matching the design (same knob token + shadow as `Switch`).
11
+ const THUMB = cn(
12
+ "block size-[12px] rounded-full bg-background-presentation-switcher-knob",
13
+ "shadow-[0px_0px_0px_1px_rgba(0,0,0,0.04),0px_3px_8px_0px_rgba(0,0,0,0.15),0px_3px_1px_0px_rgba(0,0,0,0.06)]",
14
+ "outline-none transition-transform hover:scale-110",
15
+ "focus-visible:ring-2 focus-visible:ring-background-presentation-body-scroller-hover",
16
+ );
17
+
18
+ /**
19
+ * `FormBuilder.Slider` — numeric slider (single value or a `[min,max]` range).
20
+ * A `@radix-ui/react-slider` styled to the Figma range field: a `#f9f9f9` box holding a
21
+ * 6px track, a blue fill, a white knob, and the live value readout on the right.
22
+ */
23
+ export function SliderField(props: SliderFieldProps) {
24
+ const loading = useLoading();
25
+ const min = props.min ?? 0;
26
+ const max = props.max ?? 100;
27
+ const step = props.step ?? 1;
28
+ const suffix = props.suffix ?? "";
29
+ const fmt = (n: number) => `${n}${suffix}`;
30
+
31
+ return (
32
+ <FieldShell
33
+ {...props}
34
+ view={(v) => ({
35
+ value: Array.isArray(v) ? `${fmt(v[0])} – ${fmt(v[1])}` : v == null ? "" : fmt(v as number),
36
+ })}
37
+ >
38
+ {(field) => {
39
+ const raw = field.value;
40
+ const values: number[] = props.range
41
+ ? Array.isArray(raw)
42
+ ? (raw as number[])
43
+ : [min, max]
44
+ : [typeof raw === "number" ? raw : min];
45
+ const disabled = props.disabled || loading;
46
+ return (
47
+ <div className="flex w-full items-center gap-[10px] rounded-[8px] bg-background-presentation-form-field-primary px-[10px] py-[6px]">
48
+ <Slider.Root
49
+ className="relative flex h-[12px] w-full grow touch-none select-none items-center"
50
+ min={min}
51
+ max={max}
52
+ step={step}
53
+ value={values}
54
+ onValueChange={(v) => field.onChange(props.range ? v : v[0])}
55
+ disabled={disabled}
56
+ >
57
+ <Slider.Track className="relative h-[6px] grow rounded-[10px] bg-background-presentation-body-scroller-default">
58
+ <Slider.Range className="absolute h-full rounded-[10px] bg-background-presentation-body-scroller-hover" />
59
+ </Slider.Track>
60
+ {values.map((_, i) => (
61
+ <Slider.Thumb key={i} className={THUMB} />
62
+ ))}
63
+ </Slider.Root>
64
+ <span className="min-w-[40px] shrink-0 whitespace-nowrap text-right tabular-nums typography-body-medium-regular text-content-presentation-global-primary">
65
+ {props.range ? `${values[0]}–${fmt(values[1])}` : fmt(values[0])}
66
+ </span>
67
+ </div>
68
+ );
69
+ }}
70
+ </FieldShell>
71
+ );
72
+ }
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ import { Switch } from "../../Switch";
4
+ import { useLoading } from "../context";
5
+ import { formatFieldView } from "../viewFormat";
6
+ import type { SwitchBoxFieldProps } from "../types";
7
+ import { FieldShell } from "./FieldShell";
8
+
9
+ /**
10
+ * `FormBuilder.SwitchBox` — a switch inside a `#f9f9f9` field box (design node
11
+ * 8999:170563). Value is a `boolean`.
12
+ *
13
+ * Renders like any other field: the field `label` sits in the normal `FieldShell` label
14
+ * column. The control is the box, which holds an optional inline `subLabel` (the design's
15
+ * "Sub-Field-Header"), a vertical divider, and the switch on the right.
16
+ */
17
+ export function SwitchBoxField({ subLabel, ...props }: SwitchBoxFieldProps) {
18
+ const loading = useLoading();
19
+ const hasSubLabel = subLabel != null && subLabel !== "";
20
+ return (
21
+ <FieldShell {...props} view={(v) => formatFieldView({ kind: "boolean", value: v })}>
22
+ {(field) => (
23
+ <div className="flex w-full items-center justify-end gap-[10px] rounded-[8px] bg-background-presentation-form-field-primary px-[10px] py-[6px]">
24
+ {hasSubLabel && (
25
+ <>
26
+ <span className="min-w-0 flex-1 typography-headers-small-regular text-content-presentation-global-primary">
27
+ {subLabel}
28
+ </span>
29
+ <div className="h-[20px] w-px shrink-0 bg-border-presentation-global-primary" />
30
+ </>
31
+ )}
32
+ <Switch
33
+ id={props.name}
34
+ checked={!!field.value}
35
+ onCheckedChange={field.onChange}
36
+ disabled={props.disabled || loading}
37
+ />
38
+ </div>
39
+ )}
40
+ </FieldShell>
41
+ );
42
+ }
@@ -0,0 +1,312 @@
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, useMode } 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 mode = useMode();
45
+ const loading = useLoading();
46
+ const isView = mode === "view";
47
+
48
+ const { fields, append, remove, move, replace } = useFieldArray({
49
+ control: form.control,
50
+ name: name as ArrayPath<FieldValues>,
51
+ });
52
+
53
+ const [selected, setSelected] = useState<Set<string>>(new Set());
54
+ const [sort, setSort] = useState<{ key: string; dir: "asc" | "desc" } | null>(null);
55
+
56
+ const sensors = useSensors(
57
+ useSensor(PointerSensor),
58
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
59
+ );
60
+
61
+ if (props.hidden) return null;
62
+
63
+ const showHandle = reorderable && !isView;
64
+ const showSelect = selectable && !isView;
65
+
66
+ const toggleRow = (id: string) =>
67
+ setSelected((prev) => {
68
+ const next = new Set(prev);
69
+ if (next.has(id)) next.delete(id);
70
+ else next.add(id);
71
+ return next;
72
+ });
73
+
74
+ const allSelected = fields.length > 0 && fields.every((f) => selected.has(f.id));
75
+ const someSelected = fields.some((f) => selected.has(f.id));
76
+ const toggleAll = () => setSelected(allSelected ? new Set() : new Set(fields.map((f) => f.id)));
77
+
78
+ const deleteSelected = () => {
79
+ if (loading) return;
80
+ const indices = fields.map((f, i) => (selected.has(f.id) ? i : -1)).filter((i) => i >= 0);
81
+ remove(indices);
82
+ setSelected(new Set());
83
+ };
84
+
85
+ const onDragEnd = (event: DragEndEvent) => {
86
+ const { active, over } = event;
87
+ if (!over || active.id === over.id) return;
88
+ const oldIndex = fields.findIndex((f) => f.id === active.id);
89
+ const newIndex = fields.findIndex((f) => f.id === over.id);
90
+ if (oldIndex >= 0 && newIndex >= 0) move(oldIndex, newIndex);
91
+ };
92
+
93
+ // Sort the rows by a column's key (asc/desc toggle). `replace` re-keys the field ids,
94
+ // so selection (keyed by id) is cleared afterwards.
95
+ const applySort = (key: string) => {
96
+ if (loading) return;
97
+ const dir: "asc" | "desc" = sort?.key === key && sort.dir === "asc" ? "desc" : "asc";
98
+ const rows = (form.getValues(name) as Record<string, unknown>[] | undefined) ?? [];
99
+ const sorted = [...rows].sort((a, b) => {
100
+ const av = a?.[key];
101
+ const bv = b?.[key];
102
+ if (av == null) return bv == null ? 0 : 1;
103
+ if (bv == null) return -1;
104
+ const cmp =
105
+ typeof av === "number" && typeof bv === "number"
106
+ ? av - bv
107
+ : String(av).localeCompare(String(bv));
108
+ return dir === "asc" ? cmp : -cmp;
109
+ });
110
+ replace(sorted);
111
+ setSort({ key, dir });
112
+ setSelected(new Set());
113
+ };
114
+
115
+ const totalCols = columns.length + (showHandle ? 1 : 0) + (showSelect ? 1 : 0);
116
+
117
+ const rowCells = (rowName: string, index: number) => (
118
+ <>
119
+ {showSelect && (
120
+ <TableCell isDummy>
121
+ <Checkbox
122
+ size="S"
123
+ checked={selected.has(fields[index].id)}
124
+ onCheckedChange={() => toggleRow(fields[index].id)}
125
+ />
126
+ </TableCell>
127
+ )}
128
+ {columns.map((col, ci) => (
129
+ <TableCell
130
+ key={ci}
131
+ style={col.width ? { width: col.width } : undefined}
132
+ childrenClassName={cn(
133
+ "min-w-0 w-full [&:has(input)]:bg-transparent",
134
+ col.align && ALIGN[col.align],
135
+ )}
136
+ >
137
+ <CellContext.Provider value={true}>{col.cell(rowName, index)}</CellContext.Provider>
138
+ </TableCell>
139
+ ))}
140
+ </>
141
+ );
142
+
143
+ const table = (
144
+ <Table>
145
+ <TableHeader>
146
+ <TableRow>
147
+ {showHandle && <TableHead isDummy />}
148
+ {showSelect && (
149
+ <TableHead isDummy>
150
+ <Checkbox
151
+ size="S"
152
+ checked={allSelected ? true : someSelected ? "indeterminate" : false}
153
+ onCheckedChange={toggleAll}
154
+ />
155
+ </TableHead>
156
+ )}
157
+ {columns.map((col, ci) => (
158
+ <TableHead
159
+ key={ci}
160
+ sortType={col.sortKey && sort?.key === col.sortKey ? sort.dir : undefined}
161
+ onSort={col.sortKey ? () => applySort(col.sortKey as string) : undefined}
162
+ >
163
+ {col.header}
164
+ </TableHead>
165
+ ))}
166
+ </TableRow>
167
+ </TableHeader>
168
+
169
+ <TableBody>
170
+ {showHandle ? (
171
+ <SortableContext items={fields.map((f) => f.id)} strategy={verticalListSortingStrategy}>
172
+ {fields.map((f, index) => (
173
+ <SortableRow key={f.id} id={f.id} state={selected.has(f.id) ? "selected" : undefined}>
174
+ {rowCells(`${name}.${index}`, index)}
175
+ </SortableRow>
176
+ ))}
177
+ </SortableContext>
178
+ ) : (
179
+ fields.map((f, index) => (
180
+ <TableRow key={f.id} state={selected.has(f.id) ? "selected" : undefined}>
181
+ {rowCells(`${name}.${index}`, index)}
182
+ </TableRow>
183
+ ))
184
+ )}
185
+
186
+ {fields.length === 0 && (
187
+ <TableRow>
188
+ <TableCell colSpan={totalCols}>
189
+ <span className="typography-body-small-regular text-content-presentation-global-secondary">
190
+ No rows yet.
191
+ </span>
192
+ </TableCell>
193
+ </TableRow>
194
+ )}
195
+
196
+ {!isView && (
197
+ <TableRow className="h-[40px]">
198
+ <TableCell
199
+ colSpan={totalCols}
200
+ className="border-t-2 border-transparent hover:border-border-presentation-table-action-hover hover:bg-background-presentation-table-acton-hover"
201
+ >
202
+ <button
203
+ type="button"
204
+ disabled={loading}
205
+ onClick={() => append((props.defaultItem ?? {}) as never)}
206
+ className="flex w-full items-center justify-start gap-2 typography-body-medium-semibold text-content-presentation-action-light-primary [&_i]:text-[20px]"
207
+ >
208
+ <i className="ri-add-line" />
209
+ {props.addLabel ?? "Add New"}
210
+ </button>
211
+ </TableCell>
212
+ </TableRow>
213
+ )}
214
+ </TableBody>
215
+ </Table>
216
+ );
217
+
218
+ // Header actions on the right of the SectionBlock title row (edit mode only): a "Delete Row"
219
+ // that's disabled until rows are selected, and a primary "Add New".
220
+ const headerActions = !isView ? (
221
+ <>
222
+ {selectable && (
223
+ <Button
224
+ type="button"
225
+ size="S"
226
+ variant="BorderStyle"
227
+ disabled={loading || selected.size === 0}
228
+ onClick={deleteSelected}
229
+ >
230
+ <i className="ri-delete-bin-line" />
231
+ Delete Row
232
+ </Button>
233
+ )}
234
+ <Button
235
+ type="button"
236
+ size="S"
237
+ variant="BluColStyle"
238
+ disabled={loading}
239
+ onClick={() => append((props.defaultItem ?? {}) as never)}
240
+ >
241
+ <i className="ri-add-line" />
242
+ {props.addLabel ?? "Add New"}
243
+ </Button>
244
+ </>
245
+ ) : undefined;
246
+
247
+ return (
248
+ <SectionBlock
249
+ title={props.title}
250
+ color={props.color}
251
+ icon={props.icon}
252
+ action={headerActions}
253
+ bodyClassName="px-[16px]"
254
+ // The bottom "Add New" footer is the last element, so drop SectionBlock's default
255
+ // bottom padding to keep the table flush with the card edge (matches the Figma).
256
+ containerClassName="pb-0"
257
+ >
258
+ <div className="flex w-full flex-col gap-2">
259
+ {props.description && (
260
+ <p className="typography-body-small-regular text-content-presentation-global-secondary">
261
+ {props.description}
262
+ </p>
263
+ )}
264
+
265
+ <div className="w-full overflow-x-auto">
266
+ {showHandle ? (
267
+ <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
268
+ {table}
269
+ </DndContext>
270
+ ) : (
271
+ table
272
+ )}
273
+ </div>
274
+ </div>
275
+ </SectionBlock>
276
+ );
277
+ }
278
+
279
+ /** A draggable table row — the drag handle lives in the leading `isDummy` cell. */
280
+ function SortableRow({
281
+ id,
282
+ state,
283
+ children,
284
+ }: {
285
+ id: string;
286
+ state?: "selected";
287
+ children: ReactNode;
288
+ }) {
289
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
290
+ id,
291
+ });
292
+
293
+ const style = {
294
+ transform: CSS.Transform.toString(transform),
295
+ transition,
296
+ opacity: isDragging ? 0.5 : 1,
297
+ };
298
+
299
+ return (
300
+ <TableRow ref={setNodeRef} style={style} state={state}>
301
+ <TableCell
302
+ {...attributes}
303
+ {...listeners}
304
+ className="cursor-grab active:cursor-grabbing"
305
+ isDummy
306
+ >
307
+ <i className="ri-draggable text-[18px] text-content-presentation-global-secondary" />
308
+ </TableCell>
309
+ {children}
310
+ </TableRow>
311
+ );
312
+ }