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,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
|
+
}
|