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.
- 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/DisplayField.tsx +40 -0
- package/apps/lib/components/FormBuilder/context.ts +71 -0
- package/apps/lib/components/FormBuilder/fields/ChoiceFields.tsx +65 -0
- package/apps/lib/components/FormBuilder/fields/ColorField.tsx +76 -0
- package/apps/lib/components/FormBuilder/fields/CustomField.tsx +16 -0
- package/apps/lib/components/FormBuilder/fields/DateField.tsx +34 -0
- package/apps/lib/components/FormBuilder/fields/FieldArray.tsx +79 -0
- package/apps/lib/components/FormBuilder/fields/FieldShell.tsx +174 -0
- package/apps/lib/components/FormBuilder/fields/FileField.tsx +40 -0
- package/apps/lib/components/FormBuilder/fields/OptionListFields.tsx +139 -0
- package/apps/lib/components/FormBuilder/fields/OtpField.tsx +32 -0
- package/apps/lib/components/FormBuilder/fields/PhoneField.tsx +82 -0
- package/apps/lib/components/FormBuilder/fields/RichTextEditorField.tsx +37 -0
- package/apps/lib/components/FormBuilder/fields/SelectField.tsx +101 -0
- package/apps/lib/components/FormBuilder/fields/SignatureField.tsx +175 -0
- package/apps/lib/components/FormBuilder/fields/SliderField.tsx +72 -0
- package/apps/lib/components/FormBuilder/fields/SwitchBoxField.tsx +42 -0
- package/apps/lib/components/FormBuilder/fields/TableField.tsx +312 -0
- package/apps/lib/components/FormBuilder/fields/TextField.tsx +222 -0
- package/apps/lib/components/FormBuilder/fields/TreeSelectField.tsx +49 -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 +292 -0
- package/apps/lib/components/FormBuilder/header.tsx +105 -0
- package/apps/lib/components/FormBuilder/index.ts +37 -0
- package/apps/lib/components/FormBuilder/numberFormat.ts +16 -0
- package/apps/lib/components/FormBuilder/stepper.tsx +217 -0
- package/apps/lib/components/FormBuilder/submit.tsx +41 -0
- package/apps/lib/components/FormBuilder/types.ts +264 -0
- package/apps/lib/components/FormBuilder/viewFormat.tsx +137 -0
- package/apps/lib/components/FormRenderer/FormDrawer.tsx +121 -0
- package/apps/lib/components/FormRenderer/form-renderer.tsx +113 -0
- package/apps/lib/components/FormRenderer/index.ts +9 -0
- package/apps/lib/components/FormRenderer/types.ts +79 -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/RadioCard.tsx +2 -0
- package/apps/lib/components/SearchableSelect.tsx +9 -3
- 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/registry.json +51 -58
- package/apps/lib/tsconfig.tsbuildinfo +1 -0
- package/apps/lib/utils/color.ts +175 -0
- 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 +270 -0
- package/docs/components/form-renderer.md +228 -0
- package/docs/components/form-summary.md +123 -0
- package/docs/components/image-attachment.md +10 -4
- 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 +408 -0
- package/docs/how-to/guides.md +153 -179
- package/docs/tutorials/building-first-form.md +150 -159
- 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,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
|
+
}
|