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.
Files changed (85) 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/context.ts +68 -0
  8. package/apps/lib/components/FormBuilder/fields/ChoiceFields.tsx +60 -0
  9. package/apps/lib/components/FormBuilder/fields/ColorField.tsx +59 -0
  10. package/apps/lib/components/FormBuilder/fields/CustomField.tsx +11 -0
  11. package/apps/lib/components/FormBuilder/fields/DateField.tsx +32 -0
  12. package/apps/lib/components/FormBuilder/fields/FieldArray.tsx +74 -0
  13. package/apps/lib/components/FormBuilder/fields/FieldShell.tsx +148 -0
  14. package/apps/lib/components/FormBuilder/fields/FileField.tsx +39 -0
  15. package/apps/lib/components/FormBuilder/fields/OptionListFields.tsx +132 -0
  16. package/apps/lib/components/FormBuilder/fields/OtpField.tsx +31 -0
  17. package/apps/lib/components/FormBuilder/fields/PhoneField.tsx +83 -0
  18. package/apps/lib/components/FormBuilder/fields/RichTextEditorField.tsx +31 -0
  19. package/apps/lib/components/FormBuilder/fields/SelectField.tsx +91 -0
  20. package/apps/lib/components/FormBuilder/fields/SignatureField.tsx +157 -0
  21. package/apps/lib/components/FormBuilder/fields/SliderField.tsx +67 -0
  22. package/apps/lib/components/FormBuilder/fields/SwitchBoxField.tsx +41 -0
  23. package/apps/lib/components/FormBuilder/fields/TableField.tsx +308 -0
  24. package/apps/lib/components/FormBuilder/fields/TextField.tsx +213 -0
  25. package/apps/lib/components/FormBuilder/fields/TreeSelectField.tsx +43 -0
  26. package/apps/lib/components/FormBuilder/fields/countries.ts +303 -0
  27. package/apps/lib/components/FormBuilder/fields/index.ts +25 -0
  28. package/apps/lib/components/FormBuilder/form-builder.tsx +279 -0
  29. package/apps/lib/components/FormBuilder/header.tsx +101 -0
  30. package/apps/lib/components/FormBuilder/index.ts +33 -0
  31. package/apps/lib/components/FormBuilder/numberFormat.ts +16 -0
  32. package/apps/lib/components/FormBuilder/stepper.tsx +290 -0
  33. package/apps/lib/components/FormBuilder/submit.tsx +38 -0
  34. package/apps/lib/components/FormBuilder/types.ts +261 -0
  35. package/apps/lib/components/FormRenderer/FormDrawer.tsx +128 -0
  36. package/apps/lib/components/FormRenderer/form-renderer.tsx +111 -0
  37. package/apps/lib/components/FormRenderer/index.ts +4 -0
  38. package/apps/lib/components/FormRenderer/types.ts +77 -0
  39. package/apps/lib/components/FormSummary.tsx +282 -0
  40. package/apps/lib/components/ImageAttachment.tsx +36 -61
  41. package/apps/lib/components/Label.tsx +49 -42
  42. package/apps/lib/components/Popover.tsx +6 -2
  43. package/apps/lib/components/RadioCard.tsx +2 -0
  44. package/apps/lib/components/SearchableSelect.tsx +16 -5
  45. package/apps/lib/components/SectionBlock.tsx +16 -8
  46. package/apps/lib/components/Select.tsx +41 -120
  47. package/apps/lib/components/TextEditor/RichTextField.tsx +46 -0
  48. package/apps/lib/components/{TextEditor.tsx → TextEditor/TextEditor.tsx} +63 -9
  49. package/apps/lib/components/TextEditor/TextEditorToolbar.tsx +429 -0
  50. package/apps/lib/components/TextEditor/editor-tools/AlignmentTune.ts +70 -0
  51. package/apps/lib/components/TextEditor/editor-tools/ColorInlineTool.ts +50 -0
  52. package/apps/lib/components/TextEditor/editor-tools/StrikethroughInlineTool.ts +48 -0
  53. package/apps/lib/components/TextEditor/editor-tools/inlineFormat.ts +98 -0
  54. package/apps/lib/{types → components/TextEditor}/editorjs.d.ts +19 -0
  55. package/apps/lib/components/TextEditor/index.ts +7 -0
  56. package/apps/lib/components/Textarea.tsx +1 -1
  57. package/apps/lib/layouts/FieldSection.tsx +25 -22
  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/dist/src/shared/tailwindInit.d.ts.map +1 -1
  62. package/dist/src/shared/tailwindInit.js +3 -0
  63. package/dist/src/shared/tailwindInit.js.map +1 -1
  64. package/docs/components/card.md +4 -2
  65. package/docs/components/chart-block-tool.md +5 -4
  66. package/docs/components/color-picker.md +101 -0
  67. package/docs/components/conclusion-header.md +80 -0
  68. package/docs/components/drawer.md +153 -102
  69. package/docs/components/form-builder.md +260 -0
  70. package/docs/components/form-renderer.md +234 -0
  71. package/docs/components/form-summary.md +123 -0
  72. package/docs/components/image-attachment.md +10 -4
  73. package/docs/components/searchable-select.md +50 -46
  74. package/docs/components/table-dnd-wrapper.md +5 -3
  75. package/docs/components/text-editor.md +18 -0
  76. package/docs/how-to/form-and-list-recipes.md +26 -19
  77. package/docs/how-to/forms-with-form-builder.md +411 -0
  78. package/docs/how-to/guides.md +153 -179
  79. package/docs/reference/tailwind-plugins.md +11 -1
  80. package/docs/tutorials/building-first-form.md +150 -159
  81. package/docs/tutorials/getting-started.md +9 -0
  82. package/package.json +1 -1
  83. /package/apps/lib/components/{ChartBlockTool.ts → TextEditor/ChartBlockTool.ts} +0 -0
  84. /package/apps/lib/components/{TableDnDWrapper.ts → TextEditor/TableDnDWrapper.ts} +0 -0
  85. /package/apps/lib/{utils → components/TextEditor}/markdownParser.ts +0 -0
@@ -0,0 +1,132 @@
1
+ "use client";
2
+
3
+ import { ReactNode } from "react";
4
+
5
+ import { cn } from "../../../utils/cn";
6
+ import { Label } from "../../Label";
7
+ import { RadioGroup, Radio } from "../../Radio";
8
+ import { Checkbox } from "../../Checkbox";
9
+ import { useLoading } from "../context";
10
+ import type { OptionItem, OptionsFieldProps } from "../types";
11
+ import { FieldShell } from "./FieldShell";
12
+
13
+ /**
14
+ * The `#f9f9f9` rounded box that holds the option rows, with a full-width divider
15
+ * between each (design nodes 8999:170512 / 8999:170546).
16
+ */
17
+ function OptionBox({ children }: { children: ReactNode }) {
18
+ return (
19
+ <div
20
+ className={cn(
21
+ "flex w-full flex-col rounded-[8px] px-[10px]",
22
+ "bg-background-presentation-form-field-primary",
23
+ "divide-y divide-border-presentation-global-primary",
24
+ )}
25
+ >
26
+ {children}
27
+ </div>
28
+ );
29
+ }
30
+
31
+ /**
32
+ * One option row: control on the **left**, then the primary label and an optional
33
+ * secondary label. Uses the `Label` component so the row is a real `<label>` — the
34
+ * whole row is clickable and toggles the control (same mechanism as `LabeledCheckBox`
35
+ * / `LabeledRadio`). `w-full` makes the entire row width tappable; `reverseChildren`
36
+ * puts the control first (left) and `justify-end` keeps the content left-packed.
37
+ */
38
+ function OptionRow({
39
+ control,
40
+ label,
41
+ description,
42
+ }: {
43
+ control: ReactNode;
44
+ label?: string;
45
+ description?: string;
46
+ }) {
47
+ return (
48
+ <Label
49
+ label={label}
50
+ secondaryLabel={description}
51
+ labelDirections="horizontal"
52
+ childrenDirections="horizontal"
53
+ reverseChildren
54
+ className="w-full cursor-pointer justify-end gap-[6px] py-[8px]"
55
+ >
56
+ {control}
57
+ </Label>
58
+ );
59
+ }
60
+
61
+ /**
62
+ * `FormBuilder.RadioList` — single-select options (`string`) as a boxed, divided list
63
+ * (design nodes 8999:170512). Uses the Radix `RadioGroup` for roving-focus / arrow-key nav.
64
+ */
65
+ export function RadioListField(props: OptionsFieldProps) {
66
+ const loading = useLoading();
67
+ return (
68
+ <FieldShell {...props}>
69
+ {(field) => (
70
+ <RadioGroup value={field.value ?? ""} onValueChange={field.onChange} className="w-full">
71
+ <OptionBox>
72
+ {props.options.map((opt: OptionItem) => (
73
+ <OptionRow
74
+ key={opt.value}
75
+ label={opt.label}
76
+ description={opt.description}
77
+ control={
78
+ <Radio
79
+ id={`${props.name}-${opt.value}`}
80
+ value={opt.value}
81
+ size="S"
82
+ disabled={props.disabled || loading}
83
+ />
84
+ }
85
+ />
86
+ ))}
87
+ </OptionBox>
88
+ </RadioGroup>
89
+ )}
90
+ </FieldShell>
91
+ );
92
+ }
93
+
94
+ /**
95
+ * `FormBuilder.CheckboxGroup` — multi-select options as a boxed, divided list (design
96
+ * node 8999:170546). Value is a `string[]` of the selected option values.
97
+ */
98
+ export function CheckboxGroupField(props: OptionsFieldProps) {
99
+ const loading = useLoading();
100
+ return (
101
+ <FieldShell {...props}>
102
+ {(field) => {
103
+ const selected: string[] = Array.isArray(field.value) ? field.value : [];
104
+ const toggle = (value: string, checked: boolean) => {
105
+ field.onChange(checked ? [...selected, value] : selected.filter((v) => v !== value));
106
+ };
107
+ return (
108
+ <div role="group" aria-label={typeof props.label === "string" ? props.label : undefined}>
109
+ <OptionBox>
110
+ {props.options.map((opt: OptionItem) => (
111
+ <OptionRow
112
+ key={opt.value}
113
+ label={opt.label}
114
+ description={opt.description}
115
+ control={
116
+ <Checkbox
117
+ id={`${props.name}-${opt.value}`}
118
+ size="S"
119
+ checked={selected.includes(opt.value)}
120
+ onCheckedChange={(checked) => toggle(opt.value, checked === true)}
121
+ disabled={props.disabled || loading}
122
+ />
123
+ }
124
+ />
125
+ ))}
126
+ </OptionBox>
127
+ </div>
128
+ );
129
+ }}
130
+ </FieldShell>
131
+ );
132
+ }
@@ -0,0 +1,31 @@
1
+ "use client";
2
+
3
+ import { InputOTP, InputOTPGroup, InputOTPSlot } from "../../InputOTP";
4
+ import { useLoading } from "../context";
5
+ import type { OtpFieldProps } from "../types";
6
+ import { FieldShell } from "./FieldShell";
7
+
8
+ /** `FormBuilder.Otp` — a code / PIN input (Glare `InputOTP`). Value is a string. */
9
+ export function OtpField(props: OtpFieldProps) {
10
+ const loading = useLoading();
11
+ const length = props.length ?? 6;
12
+
13
+ return (
14
+ <FieldShell {...props}>
15
+ {(field) => (
16
+ <InputOTP
17
+ maxLength={length}
18
+ value={field.value ?? ""}
19
+ onChange={(v: string) => field.onChange(v)}
20
+ disabled={props.disabled || loading}
21
+ >
22
+ <InputOTPGroup>
23
+ {Array.from({ length }, (_, i) => (
24
+ <InputOTPSlot key={i} index={i} />
25
+ ))}
26
+ </InputOTPGroup>
27
+ </InputOTP>
28
+ )}
29
+ </FieldShell>
30
+ );
31
+ }
@@ -0,0 +1,83 @@
1
+ "use client";
2
+
3
+ import { SearchableSelect } from "../../SearchableSelect";
4
+ import { InputField } from "../../InputField";
5
+ import { cn } from "../../../utils/cn";
6
+ import { useLoading, useCell } from "../context";
7
+ import type { PhoneFieldProps } from "../types";
8
+ import { FieldShell } from "./FieldShell";
9
+ import { COUNTRIES, countryByCode, countryByDial, flagEmoji } from "./countries";
10
+
11
+ // Dial codes longest-first, so "+1" doesn't shadow "+1…"-style codes when prefix-matching.
12
+ const DIALS = [...new Set(COUNTRIES.map((c) => c.dial))].sort((a, b) => b.length - a.length);
13
+
14
+ // Country picker rows. Keyed by the unique ISO code; the row shows flag + dial, but `searchText`
15
+ // makes filtering match the country NAME (typing a dial code won't match).
16
+ const COUNTRY_OPTIONS = COUNTRIES.map((c) => ({
17
+ value: c.code,
18
+ label: `${flagEmoji(c.code)} ${c.dial}`,
19
+ searchText: c.name,
20
+ }));
21
+
22
+ function splitPhone(value: unknown, defaultDial: string): { dial: string; number: string } {
23
+ const s = typeof value === "string" ? value.trim() : "";
24
+ if (!s) return { dial: defaultDial, number: "" };
25
+ const idx = s.indexOf(" ");
26
+ if (s.startsWith("+") && idx > 0) {
27
+ return { dial: s.slice(0, idx), number: s.slice(idx + 1).trim() };
28
+ }
29
+ if (s.startsWith("+")) {
30
+ const dial = DIALS.find((d) => s.startsWith(d));
31
+ if (dial) return { dial, number: s.slice(dial.length).trim() };
32
+ }
33
+ return { dial: defaultDial, number: s };
34
+ }
35
+
36
+ /**
37
+ * `FormBuilder.Phone` — country dial-code (every country) + number. The picker is a
38
+ * `SearchableSelect` keyed by the unique ISO code (dial codes repeat) — type to filter by
39
+ * country name or dial. The value is stored as a `"+<dial> <number>"` string.
40
+ */
41
+ export function PhoneField(props: PhoneFieldProps) {
42
+ const loading = useLoading();
43
+ const cell = useCell();
44
+ const defaultDial = props.defaultCountry ?? "+964"; // Iraq
45
+
46
+ return (
47
+ <FieldShell {...props}>
48
+ {(field) => {
49
+ const { dial, number } = splitPhone(field.value, defaultDial);
50
+ const country = countryByDial(dial);
51
+ const disabled = props.disabled || loading;
52
+ const commit = (d: string, n: string) => field.onChange(n ? `${d} ${n}` : d);
53
+ return (
54
+ <div className="flex w-full min-w-0 items-center gap-2">
55
+ {/* Searchable country picker — filter by name or dial; rows carry the full country name. */}
56
+ <SearchableSelect
57
+ options={COUNTRY_OPTIONS}
58
+ value={country?.code ?? null}
59
+ onValueChange={(code) => commit(countryByCode(code)?.dial ?? dial, number)}
60
+ placeholder="+964"
61
+ size="M"
62
+ onTable={cell}
63
+ inputClassName="w-[100px] min-w-[0px]"
64
+ className={cn("w-[113px] shrink-0", disabled && "pointer-events-none opacity-60")}
65
+ />
66
+ <div className="min-w-0 flex-1">
67
+ <InputField
68
+ type="tel"
69
+ inputMode="tel"
70
+ value={number}
71
+ onChange={(e) => commit(dial, e.target.value.replace(/[^\d\s-]/g, ""))}
72
+ placeholder={props.placeholder ?? "Phone number"}
73
+ disabled={disabled}
74
+ onTable={cell}
75
+ className="w-full"
76
+ />
77
+ </div>
78
+ </div>
79
+ );
80
+ }}
81
+ </FieldShell>
82
+ );
83
+ }
@@ -0,0 +1,31 @@
1
+ "use client";
2
+
3
+ import type { OutputData } from "@editorjs/editorjs";
4
+
5
+ // Import the lazy wrapper from its file directly, NOT the folder barrel: the barrel
6
+ // statically re-exports `TextEditor` (which loads EditorJS at module init) and would drag
7
+ // it into the SSR bundle, crashing server rendering. This path stays SSR-safe (lazy inside).
8
+ import { RichTextField as RichTextEditor } from "../../TextEditor/RichTextField";
9
+ import type { BaseFieldProps } from "../types";
10
+ import { FieldShell } from "./FieldShell";
11
+
12
+ /**
13
+ * `FormBuilder.RichText` — EditorJS content (value = `OutputData`).
14
+ *
15
+ * Forced to a **vertical, full-width** layout (label on top, editor below spanning the whole
16
+ * section): the editor + its toolbar need the full width, not the cramped right column.
17
+ */
18
+ export function RichTextField(props: BaseFieldProps) {
19
+ return (
20
+ <FieldShell {...props} direction="vertical" fullWidth>
21
+ {(field) => (
22
+ <RichTextEditor
23
+ data={field.value as OutputData | undefined}
24
+ onChange={field.onChange}
25
+ readOnly={props.disabled}
26
+ placeholder={props.placeholder}
27
+ />
28
+ )}
29
+ </FieldShell>
30
+ );
31
+ }
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ import { SearchableSelect } from "../../SearchableSelect";
4
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../Select";
5
+ import { BadgeField } from "../../BadgeField";
6
+ import type { Tag } from "../../../hooks/useTagSelection";
7
+ import { useLoading, useCell } from "../context";
8
+ import type { SelectFieldProps, SearchableSelectFieldProps, OptionsFieldProps } from "../types";
9
+ import { FieldShell } from "./FieldShell";
10
+
11
+ /** `FormBuilder.Select` — the plain `Select` dropdown. */
12
+ export function SelectField(props: SelectFieldProps) {
13
+ const loading = useLoading();
14
+ const cell = useCell();
15
+
16
+ return (
17
+ <FieldShell {...props}>
18
+ {(field) => (
19
+ <Select
20
+ value={typeof field.value === "string" ? field.value : ""}
21
+ onValueChange={field.onChange}
22
+ disabled={props.disabled || loading}
23
+ >
24
+ <SelectTrigger size="XL" onTable={cell} className="w-full">
25
+ <SelectValue placeholder={props.placeholder ?? "Select…"} />
26
+ </SelectTrigger>
27
+ <SelectContent>
28
+ {props.options.map((opt) => (
29
+ <SelectItem key={opt.value} value={opt.value}>
30
+ {opt.label}
31
+ </SelectItem>
32
+ ))}
33
+ </SelectContent>
34
+ </Select>
35
+ )}
36
+ </FieldShell>
37
+ );
38
+ }
39
+
40
+ /**
41
+ * `FormBuilder.SearchableSelect` — the `SearchableSelect` combobox: a search box with
42
+ * client-side filtering, or server-side search + infinite scroll via the async props.
43
+ */
44
+ export function SearchableSelectField(props: SearchableSelectFieldProps) {
45
+ const cell = useCell();
46
+ return (
47
+ <FieldShell {...props}>
48
+ {(field) => (
49
+ <SearchableSelect
50
+ options={props.options}
51
+ value={field.value ?? null}
52
+ onValueChange={(value) => field.onChange(value)}
53
+ placeholder={props.placeholder ?? "Select…"}
54
+ filterClientSide={props.filterClientSide ?? true}
55
+ onSearchChange={props.onSearchChange}
56
+ onLoadMore={props.onLoadMore}
57
+ hasMore={props.hasMore}
58
+ loading={props.loading}
59
+ onTable={cell}
60
+ className="w-full"
61
+ />
62
+ )}
63
+ </FieldShell>
64
+ );
65
+ }
66
+
67
+ /** `FormBuilder.MultiSelect` / `.Tags` — BadgeField, value is `string[]`. */
68
+ export function MultiSelectField(props: OptionsFieldProps) {
69
+ const cell = useCell();
70
+ return (
71
+ <FieldShell {...props}>
72
+ {(field) => {
73
+ const selected = new Set<string>(Array.isArray(field.value) ? field.value : []);
74
+ const tags: Tag[] = props.options.map((opt) => ({
75
+ id: opt.value,
76
+ name: opt.label,
77
+ value: opt.value,
78
+ isSelected: selected.has(opt.value),
79
+ }));
80
+ return (
81
+ <BadgeField
82
+ tags={tags}
83
+ onValueChange={(picked) => field.onChange(picked.map((t) => t.value ?? t.id))}
84
+ onTable={cell}
85
+ className="w-full"
86
+ />
87
+ );
88
+ }}
89
+ </FieldShell>
90
+ );
91
+ }
@@ -0,0 +1,157 @@
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 {...props} direction="vertical" fullWidth>
26
+ {(field) => (
27
+ <SignaturePad
28
+ value={typeof field.value === "string" ? field.value : ""}
29
+ onChange={field.onChange}
30
+ disabled={props.disabled || loading}
31
+ penColor={penColor}
32
+ />
33
+ )}
34
+ </FieldShell>
35
+ );
36
+ }
37
+
38
+ interface SignaturePadProps {
39
+ value: string;
40
+ onChange: (value: string) => void;
41
+ disabled?: boolean;
42
+ penColor: string;
43
+ }
44
+
45
+ function SignaturePad({ value, onChange, disabled, penColor }: SignaturePadProps) {
46
+ const canvasRef = useRef<HTMLCanvasElement>(null);
47
+ const drawing = useRef(false);
48
+ const last = useRef<{ x: number; y: number } | null>(null);
49
+ // Track whether the canvas currently holds strokes, so Clear can no-op cleanly.
50
+ const dirty = useRef(false);
51
+
52
+ // Size the backing store to the element box (accounting for DPR) and seed it
53
+ // from the current value. Runs once on mount; a remount `key` re-seeds on edit.
54
+ useEffect(() => {
55
+ const canvas = canvasRef.current;
56
+ if (!canvas) return;
57
+ const ctx = canvas.getContext("2d");
58
+ if (!ctx) return;
59
+
60
+ const rect = canvas.getBoundingClientRect();
61
+ const dpr = window.devicePixelRatio || 1;
62
+ canvas.width = Math.max(1, Math.round(rect.width * dpr));
63
+ canvas.height = Math.max(1, Math.round(rect.height * dpr));
64
+ ctx.scale(dpr, dpr);
65
+ ctx.lineWidth = 2;
66
+ ctx.lineCap = "round";
67
+ ctx.lineJoin = "round";
68
+ ctx.strokeStyle = penColor;
69
+
70
+ if (value) {
71
+ const img = new Image();
72
+ img.onload = () => ctx.drawImage(img, 0, 0, rect.width, rect.height);
73
+ img.src = value;
74
+ dirty.current = true;
75
+ }
76
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- initial-only seed (like date/richtext)
77
+ }, []);
78
+
79
+ const pointToCanvas = (e: React.PointerEvent<HTMLCanvasElement>) => {
80
+ const rect = e.currentTarget.getBoundingClientRect();
81
+ return { x: e.clientX - rect.left, y: e.clientY - rect.top };
82
+ };
83
+
84
+ const handleDown = (e: React.PointerEvent<HTMLCanvasElement>) => {
85
+ if (disabled) return;
86
+ e.currentTarget.setPointerCapture(e.pointerId);
87
+ drawing.current = true;
88
+ last.current = pointToCanvas(e);
89
+ };
90
+
91
+ const handleMove = (e: React.PointerEvent<HTMLCanvasElement>) => {
92
+ if (!drawing.current || disabled) return;
93
+ const ctx = canvasRef.current?.getContext("2d");
94
+ if (!ctx || !last.current) return;
95
+ const p = pointToCanvas(e);
96
+ ctx.beginPath();
97
+ ctx.moveTo(last.current.x, last.current.y);
98
+ ctx.lineTo(p.x, p.y);
99
+ ctx.stroke();
100
+ last.current = p;
101
+ dirty.current = true;
102
+ };
103
+
104
+ const handleUp = () => {
105
+ if (!drawing.current) return;
106
+ drawing.current = false;
107
+ last.current = null;
108
+ if (disabled) return;
109
+ const canvas = canvasRef.current;
110
+ if (canvas && dirty.current) onChange(canvas.toDataURL("image/png"));
111
+ };
112
+
113
+ const clear = () => {
114
+ const canvas = canvasRef.current;
115
+ const ctx = canvas?.getContext("2d");
116
+ if (canvas && ctx) {
117
+ const rect = canvas.getBoundingClientRect();
118
+ ctx.clearRect(0, 0, rect.width, rect.height);
119
+ }
120
+ dirty.current = false;
121
+ onChange("");
122
+ };
123
+
124
+ return (
125
+ <div
126
+ className={cn(
127
+ GroupStyles({ size: "M", variant: "PresentationStyle" }),
128
+ "relative h-[440px] w-full overflow-hidden p-0",
129
+ disabled && "opacity-60",
130
+ )}
131
+ >
132
+ <canvas
133
+ ref={canvasRef}
134
+ onPointerDown={handleDown}
135
+ onPointerMove={handleMove}
136
+ onPointerUp={handleUp}
137
+ onPointerLeave={handleUp}
138
+ className={cn(
139
+ "h-full w-full touch-none bg-white",
140
+ disabled ? "cursor-not-allowed" : "cursor-crosshair",
141
+ )}
142
+ />
143
+ {!disabled && (
144
+ <Button
145
+ type="button"
146
+ size="S"
147
+ variant="BorderStyle"
148
+ onClick={clear}
149
+ className="absolute right-2 top-2"
150
+ >
151
+ <i className="ri-eraser-line" />
152
+ Clear
153
+ </Button>
154
+ )}
155
+ </div>
156
+ );
157
+ }
@@ -0,0 +1,67 @@
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 {...props}>
33
+ {(field) => {
34
+ const raw = field.value;
35
+ const values: number[] = props.range
36
+ ? Array.isArray(raw)
37
+ ? (raw as number[])
38
+ : [min, max]
39
+ : [typeof raw === "number" ? raw : min];
40
+ const disabled = props.disabled || loading;
41
+ return (
42
+ <div className="flex w-full items-center gap-[10px] rounded-[8px] bg-background-presentation-form-field-primary px-[10px] py-[6px]">
43
+ <Slider.Root
44
+ className="relative flex h-[12px] w-full grow touch-none select-none items-center"
45
+ min={min}
46
+ max={max}
47
+ step={step}
48
+ value={values}
49
+ onValueChange={(v) => field.onChange(props.range ? v : v[0])}
50
+ disabled={disabled}
51
+ >
52
+ <Slider.Track className="relative h-[6px] grow rounded-[10px] bg-background-presentation-body-scroller-default">
53
+ <Slider.Range className="absolute h-full rounded-[10px] bg-background-presentation-body-scroller-hover" />
54
+ </Slider.Track>
55
+ {values.map((_, i) => (
56
+ <Slider.Thumb key={i} className={THUMB} />
57
+ ))}
58
+ </Slider.Root>
59
+ <span className="min-w-[40px] shrink-0 whitespace-nowrap text-right tabular-nums typography-body-medium-regular text-content-presentation-global-primary">
60
+ {props.range ? `${values[0]}–${fmt(values[1])}` : fmt(values[0])}
61
+ </span>
62
+ </div>
63
+ );
64
+ }}
65
+ </FieldShell>
66
+ );
67
+ }
@@ -0,0 +1,41 @@
1
+ "use client";
2
+
3
+ import { Switch } from "../../Switch";
4
+ import { useLoading } from "../context";
5
+ import type { SwitchBoxFieldProps } from "../types";
6
+ import { FieldShell } from "./FieldShell";
7
+
8
+ /**
9
+ * `FormBuilder.SwitchBox` — a switch inside a `#f9f9f9` field box (design node
10
+ * 8999:170563). Value is a `boolean`.
11
+ *
12
+ * Renders like any other field: the field `label` sits in the normal `FieldShell` label
13
+ * column. The control is the box, which holds an optional inline `subLabel` (the design's
14
+ * "Sub-Field-Header"), a vertical divider, and the switch on the right.
15
+ */
16
+ export function SwitchBoxField({ subLabel, ...props }: SwitchBoxFieldProps) {
17
+ const loading = useLoading();
18
+ const hasSubLabel = subLabel != null && subLabel !== "";
19
+ return (
20
+ <FieldShell {...props}>
21
+ {(field) => (
22
+ <div className="flex w-full items-center justify-end gap-[10px] rounded-[8px] bg-background-presentation-form-field-primary px-[10px] py-[6px]">
23
+ {hasSubLabel && (
24
+ <>
25
+ <span className="min-w-0 flex-1 typography-headers-small-regular text-content-presentation-global-primary">
26
+ {subLabel}
27
+ </span>
28
+ <div className="h-[20px] w-px shrink-0 bg-border-presentation-global-primary" />
29
+ </>
30
+ )}
31
+ <Switch
32
+ id={props.name}
33
+ checked={!!field.value}
34
+ onCheckedChange={field.onChange}
35
+ disabled={props.disabled || loading}
36
+ />
37
+ </div>
38
+ )}
39
+ </FieldShell>
40
+ );
41
+ }