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,270 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: FormBuilder
|
|
3
|
+
description: A compound, composition-based form. Author forms as JSX children (FormBuilder.Text, FormBuilder.Select, …) with steps-as-components, a drawer wrapper, edit + read-only view modes, and any react-hook-form resolver.
|
|
4
|
+
component: true
|
|
5
|
+
group: Forms
|
|
6
|
+
keywords:
|
|
7
|
+
[
|
|
8
|
+
form-builder,
|
|
9
|
+
form,
|
|
10
|
+
compound,
|
|
11
|
+
composition,
|
|
12
|
+
react-hook-form,
|
|
13
|
+
resolver,
|
|
14
|
+
stepper,
|
|
15
|
+
drawer,
|
|
16
|
+
fields,
|
|
17
|
+
view-mode,
|
|
18
|
+
]
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
# FormBuilder
|
|
22
|
+
|
|
23
|
+
A **compound, composition-based** form. You author a form as JSX children — each
|
|
24
|
+
field is a `FormBuilder.*` component wired to [react-hook-form](https://react-hook-form.com/)
|
|
25
|
+
for you. Steps are components, the drawer is a wrapper, and the same markup renders
|
|
26
|
+
editable or read-only.
|
|
27
|
+
|
|
28
|
+
```tsx
|
|
29
|
+
<FormBuilder onSubmit={save} resolver={zodResolver(schema)} defaultValues={d}>
|
|
30
|
+
<FormBuilder.Section title="Identity" color="Blue">
|
|
31
|
+
<FormBuilder.Text name="name" label="Name" required />
|
|
32
|
+
<FormBuilder.Currency name="price" label="Price" currencySymbol="$" />
|
|
33
|
+
</FormBuilder.Section>
|
|
34
|
+
<FormBuilder.Submit>Save</FormBuilder.Submit>
|
|
35
|
+
</FormBuilder>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
> Need the same form to render as a page _or_ a drawer, with a title header and an
|
|
39
|
+
> `actions` slot for the Save? Wrap these same children in
|
|
40
|
+
> [FormRenderer](./form-renderer.md).
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
TORCH Glare is a copy-in library: the CLI copies this component's source into your project.
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npx torch-glare@latest init
|
|
48
|
+
npx torch-glare@latest add FormBuilder
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Imports
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
import { FormBuilder } from "@/components/FormBuilder";
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Root props
|
|
58
|
+
|
|
59
|
+
| Prop | Type | Notes |
|
|
60
|
+
| ---------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
|
61
|
+
| `onSubmit` | `(values) => void \| Promise<void>` | Called on a valid submit. |
|
|
62
|
+
| `onInvalid` | `(errors) => void` | Called when validation fails. |
|
|
63
|
+
| `resolver` | `Resolver` | Any react-hook-form resolver, e.g. `zodResolver(schema)`. |
|
|
64
|
+
| `defaultValues` | `DefaultValues` | Initial values (create). |
|
|
65
|
+
| `values` | `T` | Controlled values (edit) — the form re-syncs when this changes. |
|
|
66
|
+
| `mode` | `'edit' \| 'view'` | `'view'` renders every field read-only. |
|
|
67
|
+
| `loading` | `boolean` | Submit shows a spinner; inputs disable. |
|
|
68
|
+
| `fieldDirection` | `'horizontal' \| 'vertical'` | Row layout (auto-vertical inside a drawer). |
|
|
69
|
+
| `resetOnSuccess` | `boolean` | Reset to defaults after a successful submit. |
|
|
70
|
+
| `form` | `UseFormReturn` | A hoisted `useForm` to bind to — pass when a `conclusion` panel must read the same values. |
|
|
71
|
+
| `conclusion` | `ReactNode` | A live panel (e.g. `FormSummary`) rendered **outside** the `<form>` as the grid's right column. |
|
|
72
|
+
| `id` | `string` | Sets the underlying `<form id>`, so a button outside the form can submit it via `form={id}` (e.g. a drawer header's Save). |
|
|
73
|
+
| `className` | `string` | Lands on the form's outermost element — e.g. `"min-w-0 flex-1"` to fill the space beside a `conclusion` panel. |
|
|
74
|
+
|
|
75
|
+
**Adaptive layout.** FormBuilder lays out responsively: a `FormBuilder.Stepper` puts its nav rail in
|
|
76
|
+
a **left** column beside the fields (both inside the form surface, under the title header), and a
|
|
77
|
+
`conclusion` renders as its own panel **outside** the form surface, beside it — the same structure
|
|
78
|
+
`FormDrawer` uses in the drawer tray (6px gutter). So a stepper + conclusion reads as three columns
|
|
79
|
+
(nav · fields · conclusion), a conclusion alone as two, and a plain form as one. The fields column
|
|
80
|
+
caps at 1100px and centers. The conclusion is outside the `<form>` and reads values via its own
|
|
81
|
+
`form` prop.
|
|
82
|
+
|
|
83
|
+
## Field components
|
|
84
|
+
|
|
85
|
+
Each is a JSX child taking at least `name`, plus `label`, `placeholder`,
|
|
86
|
+
`description`, `required`, `disabled`, `hidden`, `fullWidth`.
|
|
87
|
+
|
|
88
|
+
| Component | Input | Value |
|
|
89
|
+
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------ |
|
|
90
|
+
| `FormBuilder.Text` / `.Email` / `.Password` | `InputField` | `string` |
|
|
91
|
+
| `FormBuilder.Number` | numeric `InputField` | `number` |
|
|
92
|
+
| `FormBuilder.Currency` (`currencySymbol`) | `InputField` + symbol | `number` |
|
|
93
|
+
| `FormBuilder.Textarea` | `Textarea` | `string` |
|
|
94
|
+
| `FormBuilder.Slider` (`min,max,step,range`) | `@radix-ui/react-slider` | `number` / `[number,number]` |
|
|
95
|
+
| `FormBuilder.Color` (`presets`, `alpha`) | `ColorPicker` (full palette: SV area, hue, opacity, eyedropper, HEX/RGB/HSL, presets) | hex `string` (`#rrggbb`, or `#rrggbbaa` when opacity < 100%) |
|
|
96
|
+
| `FormBuilder.Phone` (`defaultCountry`) | `SearchableSelect` + `InputField` | `string` |
|
|
97
|
+
| `FormBuilder.Select` / `.SearchableSelect` (`options`, async props) | `SearchableSelect` | `string` |
|
|
98
|
+
| `FormBuilder.MultiSelect` / `.Tags` (`options`) | `BadgeField` | `string[]` |
|
|
99
|
+
| `FormBuilder.RadioList` (`options`, each with optional `description`) | boxed radio list | `string` |
|
|
100
|
+
| `FormBuilder.CheckboxGroup` (`options`, each with optional `description`) | boxed checkbox list | `string[]` |
|
|
101
|
+
| `FormBuilder.RadioCards` (`options` with `description`) | `RadioCard` | `string` |
|
|
102
|
+
| `FormBuilder.Checkbox` (`subLabel`) | `Checkbox` + inline label | `boolean` |
|
|
103
|
+
| `FormBuilder.SwitchBox` (`subLabel`) | `Switch` in a `#f9f9f9` box | `boolean` |
|
|
104
|
+
| `FormBuilder.Otp` (`length`) | `InputOTP` | `string` |
|
|
105
|
+
| `FormBuilder.Date` | `DatePicker` | `Date` |
|
|
106
|
+
| `FormBuilder.DateRange` | `DatePicker` (range) | `{from,to}` |
|
|
107
|
+
| `FormBuilder.DateMultiple` | `DatePicker` (multiple) | `Date[]` |
|
|
108
|
+
| `FormBuilder.DateTime` | `DatePicker` (timePicker) | `Date` |
|
|
109
|
+
| `FormBuilder.TreeSelect` (`nodes`, `getNodeId`, `getNodeLabel`, …) | `SearchableTree` | node id (`string`) |
|
|
110
|
+
| `FormBuilder.File` / `.Image` (`accept`, `multiple`) | `ImageAttachment` | `File \| File[]` |
|
|
111
|
+
| `FormBuilder.RichText` | `TextEditor` (EditorJS) | `OutputData` |
|
|
112
|
+
| `FormBuilder.Signature` (`penColor`) | canvas pad | PNG data-URL `string` |
|
|
113
|
+
| `FormBuilder.FieldArray` (`children` render fn, `defaultItem`) | RHF `useFieldArray` | `object[]` |
|
|
114
|
+
| `FormBuilder.Table` (`columns`, `selectable`, `reorderable`, `defaultItem`) | editable grid in a `SectionBlock` | `object[]` |
|
|
115
|
+
| `FormBuilder.Custom` (`render`, `formatView`) | your control | anything |
|
|
116
|
+
|
|
117
|
+
`FormBuilder.Password` accepts `strengthMeter` (shows a `PasswordLevel` meter). `FormBuilder.FieldArray`
|
|
118
|
+
renders a repeating list — its `children` is a render fn `(rowName, index, remove) => …` and sub-fields
|
|
119
|
+
are named `${rowName}.field`.
|
|
120
|
+
|
|
121
|
+
`FormBuilder.Table` is the **table-shaped** counterpart of `FieldArray` (value `object[]`): an editable
|
|
122
|
+
grid where each row is a record and each column cell is any `FormBuilder.*` field. It renders **inside a
|
|
123
|
+
`SectionBlock`**, so place it as a **top-level child** of the form (not inside a `FormBuilder.Section`).
|
|
124
|
+
Each column pairs a `header` with a `cell(rowName, index)` renderer — name the cell's field
|
|
125
|
+
`${rowName}.<key>`. The section header carries the actions: a **Add New** button (also repeated as the
|
|
126
|
+
bottom footer) and a **Delete Row** button that's disabled until rows are checkbox-selected. Rows support
|
|
127
|
+
**checkbox selection** (+ select-all), **drag-drop reordering**, and — per column, via `sortKey` — a
|
|
128
|
+
sort toggle in the header. Everything hides in `mode="view"`, where cells render read-only. Cells render
|
|
129
|
+
"bare" (control only) with validation errors shown as a tooltip on the control, so a row stays one line
|
|
130
|
+
tall, and each field passes `onTable` so it's borderless and blends into the grid. Practical cell fields
|
|
131
|
+
are the compact ones — `Text`, `Number`, `Currency`, `Select`, `SearchableSelect`, `Date`, `Phone`,
|
|
132
|
+
`Checkbox`, `SwitchBox`; wide fields (`RichText`, `Signature`, `File`) work but aren't suited to a cell.
|
|
133
|
+
|
|
134
|
+
```tsx
|
|
135
|
+
<FormBuilder.Table
|
|
136
|
+
name="items"
|
|
137
|
+
title="Items"
|
|
138
|
+
color="Green"
|
|
139
|
+
addLabel="Add item"
|
|
140
|
+
defaultItem={{ item: "", category: "hardware", qty: 1, price: 0 }}
|
|
141
|
+
columns={[
|
|
142
|
+
{
|
|
143
|
+
header: "Item",
|
|
144
|
+
width: 200,
|
|
145
|
+
cell: (row) => <FormBuilder.Text name={`${row}.item`} required />,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
header: "Category",
|
|
149
|
+
cell: (row) => <FormBuilder.Select name={`${row}.category`} options={CATEGORY} required />,
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
header: "Qty",
|
|
153
|
+
width: 110,
|
|
154
|
+
cell: (row) => <FormBuilder.Number name={`${row}.qty`} required />,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
header: "Price",
|
|
158
|
+
width: 140,
|
|
159
|
+
cell: (row) => <FormBuilder.Currency name={`${row}.price`} currencySymbol="$" required />,
|
|
160
|
+
},
|
|
161
|
+
]}
|
|
162
|
+
/>
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
`FormBuilder.RadioList` (single-select, `string`) and `FormBuilder.CheckboxGroup` (multi-select,
|
|
166
|
+
`string[]`) render their `options` as a boxed, divided list — control on the left, primary
|
|
167
|
+
label, and an optional per-option `description` shown as a secondary label. The whole row is
|
|
168
|
+
clickable. Multi-select is also available as `.MultiSelect` / `.Tags` (a tag-chip picker).
|
|
169
|
+
|
|
170
|
+
`FormBuilder.SwitchBox` (value `boolean`) is a switch wrapped in a `#f9f9f9` field box. It
|
|
171
|
+
renders like any other field — the `label` sits in the normal label column — and the box holds
|
|
172
|
+
an optional inline `subLabel`, a vertical divider, and the switch.
|
|
173
|
+
|
|
174
|
+
`FormBuilder.Section` (props `title`, `color`, `icon`) groups fields in a Glare
|
|
175
|
+
`SectionBlock`. `FormBuilder.Submit` is a loading-aware submit button (hidden in view mode). It
|
|
176
|
+
**auto-associates with the enclosing form** (via context), so it submits even when placed in a
|
|
177
|
+
header / action bar that renders _outside_ the `<form>` — no manual `form={id}` wiring.
|
|
178
|
+
|
|
179
|
+
## Title header + action bar
|
|
180
|
+
|
|
181
|
+
`FormBuilder.Header` renders a **title pill** (Glare `HeaderBar`) on the left and an
|
|
182
|
+
**action bar** (your buttons) on the right, **absolutely positioned** over a
|
|
183
|
+
scrollable form body — the products-services item-edit look. Place it as a direct
|
|
184
|
+
child of `<FormBuilder>`; the root then switches to the scroll-shell layout.
|
|
185
|
+
|
|
186
|
+
```tsx
|
|
187
|
+
<FormBuilder onSubmit={save} resolver={r} defaultValues={d}>
|
|
188
|
+
<FormBuilder.Header title="Item" variant="new">
|
|
189
|
+
<FormBuilder.Submit>Save</FormBuilder.Submit>
|
|
190
|
+
{/* add more actions, e.g. a "Save as draft" Button */}
|
|
191
|
+
</FormBuilder.Header>
|
|
192
|
+
|
|
193
|
+
<FormBuilder.Section title="Identity" color="Blue">
|
|
194
|
+
<FormBuilder.Text name="name" label="Name" required />
|
|
195
|
+
</FormBuilder.Section>
|
|
196
|
+
</FormBuilder>
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
- `title` — plain text (uppercased). `label` — badge text (defaults from `variant`).
|
|
200
|
+
- `variant` — `"new" | "edit" | "detail"` (badge color); defaults from `mode`
|
|
201
|
+
(view → `detail`). `children` are the action buttons; `FormBuilder.Submit` submits
|
|
202
|
+
the form (it auto-associates with it, even though the header sits outside `<form>`)
|
|
203
|
+
and auto-hides in view mode.
|
|
204
|
+
|
|
205
|
+
## Stepper (steps are components)
|
|
206
|
+
|
|
207
|
+
Wrap steps in `FormBuilder.Stepper`; each `FormBuilder.Step` holds a step's fields.
|
|
208
|
+
**Every step's fields are always mounted and registered** — the whole form is live
|
|
209
|
+
regardless of which step is showing; the stepper only toggles _visibility_.
|
|
210
|
+
**Navigation is the step buttons themselves**: click a step to go there. Backward is
|
|
211
|
+
free; clicking forward validates the steps in between and stops at the first one with
|
|
212
|
+
errors (shown with a red indicator). Put the **Submit** in the `FormBuilder.Header` (or the
|
|
213
|
+
`FormRenderer` `actions`) — it submits every step's fields at once, from any step.
|
|
214
|
+
|
|
215
|
+
```tsx
|
|
216
|
+
<FormBuilder onSubmit={save} resolver={r} defaultValues={d}>
|
|
217
|
+
<FormBuilder.Header title="Item" variant="new">
|
|
218
|
+
<FormBuilder.Submit>Save</FormBuilder.Submit>
|
|
219
|
+
</FormBuilder.Header>
|
|
220
|
+
|
|
221
|
+
<FormBuilder.Stepper>
|
|
222
|
+
<FormBuilder.Step title="Basics">
|
|
223
|
+
<FormBuilder.Text name="name" label="Name" required />
|
|
224
|
+
</FormBuilder.Step>
|
|
225
|
+
<FormBuilder.Step title="Details">
|
|
226
|
+
<FormBuilder.Select name="category" label="Category" required options={cats} />
|
|
227
|
+
</FormBuilder.Step>
|
|
228
|
+
</FormBuilder.Stepper>
|
|
229
|
+
</FormBuilder>
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
> **In a drawer?** `FormBuilder` is drawer-unaware — it only displays inputs. To
|
|
233
|
+
> show a form in a drawer, wrap it in `FormDrawer` from
|
|
234
|
+
> [FormRenderer](./form-renderer.md) (pass `fieldDirection="vertical"` to the
|
|
235
|
+
> form), or use `FormRenderer` with `display="drawer"`.
|
|
236
|
+
|
|
237
|
+
## Calculation panel
|
|
238
|
+
|
|
239
|
+
To show computed totals **beside** the form, render a [FormSummary](./form-summary.md)
|
|
240
|
+
next to it (not inside it) and share one hoisted form via the `form` prop:
|
|
241
|
+
|
|
242
|
+
```tsx
|
|
243
|
+
const form = useForm({ resolver: r, defaultValues: d })
|
|
244
|
+
|
|
245
|
+
<div className="flex items-start gap-4">
|
|
246
|
+
<FormBuilder form={form} onSubmit={save} className="min-w-0 flex-1">…fields…</FormBuilder>
|
|
247
|
+
<FormSummary form={form} title="Invoice">…rows…</FormSummary>
|
|
248
|
+
</div>
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`form` makes FormBuilder use an existing react-hook-form instance instead of creating its
|
|
252
|
+
own — that's what lets something outside the form read the same live values.
|
|
253
|
+
|
|
254
|
+
## Edit & view modes
|
|
255
|
+
|
|
256
|
+
- **Edit**: pass `values` (not just `defaultValues`); the form repopulates when the
|
|
257
|
+
data loads. Use a remount `key` so initial-only inputs (date, rich text) re-seed.
|
|
258
|
+
- **View**: `mode="view"` renders the same children read-only (label + formatted value)
|
|
259
|
+
with no submit — one markup drives both edit and detail views.
|
|
260
|
+
|
|
261
|
+
```tsx
|
|
262
|
+
<FormBuilder mode="view" values={entity} onSubmit={() => {}} defaultValues={d}>
|
|
263
|
+
…the same fields…
|
|
264
|
+
</FormBuilder>
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## Validation
|
|
268
|
+
|
|
269
|
+
Resolver-agnostic — pass any react-hook-form `resolver` (`zodResolver`, a plain
|
|
270
|
+
resolver function, etc.). The library never depends on zod.
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: FormRenderer
|
|
3
|
+
description: A thin wrapper around FormBuilder. Author fields as JSX children; FormRenderer owns page-vs-drawer display, the absolute title header, and drawer field layout. You compose the Save and pass it via the actions prop.
|
|
4
|
+
component: true
|
|
5
|
+
group: Forms
|
|
6
|
+
keywords: [form-renderer, form, drawer, header, display, view-mode, react-hook-form]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# FormRenderer
|
|
10
|
+
|
|
11
|
+
A thin wrapper around the compound [FormBuilder](./form-builder.md). You author the
|
|
12
|
+
fields as **JSX children** exactly as you would with `FormBuilder`; `FormRenderer`
|
|
13
|
+
takes care of the surrounding concerns:
|
|
14
|
+
|
|
15
|
+
- **page vs drawer** display (`display="drawer"` hosts the form in a `FormDrawer`),
|
|
16
|
+
- the **absolute title header** + action bar (page display),
|
|
17
|
+
- **vertical field layout** inside a drawer.
|
|
18
|
+
|
|
19
|
+
FormRenderer never manufactures a Submit — **you compose the Save and hand it to `actions`**.
|
|
20
|
+
It renders in the form's header action pill (page) or the drawer header (drawer), and a bare
|
|
21
|
+
`<FormBuilder.Submit>` auto-targets this form (even though the header sits outside the `<form>`).
|
|
22
|
+
|
|
23
|
+
Reach for `FormBuilder` directly when you just want the form. Reach for
|
|
24
|
+
`FormRenderer` when you want the same form to render as a page _or_ a drawer with
|
|
25
|
+
the standard chrome.
|
|
26
|
+
|
|
27
|
+
```tsx
|
|
28
|
+
<FormRenderer
|
|
29
|
+
onSubmit={save}
|
|
30
|
+
resolver={zodResolver(schema)}
|
|
31
|
+
defaultValues={d}
|
|
32
|
+
header={{ title: "Item", variant: "new" }}
|
|
33
|
+
actions={<FormBuilder.Submit>Save</FormBuilder.Submit>}
|
|
34
|
+
>
|
|
35
|
+
<FormBuilder.Section title="Identity" color="Blue">
|
|
36
|
+
<FormBuilder.Text name="name" label="Name" required />
|
|
37
|
+
<FormBuilder.Currency name="price" label="Price" currencySymbol="$" />
|
|
38
|
+
</FormBuilder.Section>
|
|
39
|
+
</FormRenderer>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npx torch-glare@latest init
|
|
46
|
+
npx torch-glare@latest add FormRenderer
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`add` also pulls in `FormBuilder`, which FormRenderer renders through.
|
|
50
|
+
|
|
51
|
+
## Imports
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
import { FormRenderer, FormDrawer } from "@/components/FormRenderer";
|
|
55
|
+
import { FormBuilder } from "@/components/FormBuilder";
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Props
|
|
59
|
+
|
|
60
|
+
| Prop | Type | Notes |
|
|
61
|
+
| -------------------------------------------------------------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
62
|
+
| `children` | `ReactNode` | The form body — `FormBuilder.Section` / field / `FormBuilder.Stepper` JSX. |
|
|
63
|
+
| `onSubmit` / `onInvalid` | fns | Submit / validation-fail callbacks. |
|
|
64
|
+
| `resolver` | `Resolver` | Any react-hook-form resolver, e.g. `zodResolver(schema)`. |
|
|
65
|
+
| `defaultValues` / `values` | `DefaultValues` / `T` | Initial values; `values` re-syncs on change (edit). |
|
|
66
|
+
| `mode` | `'edit' \| 'view'` | `'view'` renders read-only, no Submit. |
|
|
67
|
+
| `loading` / `resetOnSuccess` | `boolean` | Forwarded to `FormBuilder`. |
|
|
68
|
+
| `fieldDirection` | `'horizontal' \| 'vertical'` | Defaults to vertical inside a drawer. |
|
|
69
|
+
| `form` | `UseFormReturn<T>` | A hoisted `useForm` to bind to — pass when a sibling (e.g. a `summary` `FormSummary`) must read the same live values; the caller owns `resolver`/`defaultValues` on it. Omit to let FormRenderer create its own. |
|
|
70
|
+
| `display` | `'page' \| 'drawer'` | `'drawer'` wraps the form in `FormDrawer`. |
|
|
71
|
+
| `header` | `{ title; label?; variant? }` | Absolute title header + action bar (page display); `actions` render in it. |
|
|
72
|
+
| `summary` | `ReactNode` | A live panel (typically `FormSummary`) rendered beside the form (page) or in the drawer tray (drawer). Give the same hoisted `form` so it reads live values. On a page, a `summary` **plus** a `FormBuilder.Stepper` lays out as three columns — stepper nav · fields · summary. |
|
|
73
|
+
| `actions` | `ReactNode` | The form's action bar — rendered in the header action pill (page) or drawer header (drawer). Put the Save here: `actions={<FormBuilder.Submit>Save</FormBuilder.Submit>}`. A bare `FormBuilder.Submit` auto-targets this form. |
|
|
74
|
+
| `id` | `string` | `id` on the underlying `<form>`. Optional — FormRenderer generates and wires one otherwise. |
|
|
75
|
+
| `open` / `onOpenChange` / `title` / `badge` / `onOpenInNewTab` | — | Drawer control (when `display="drawer"`). `title` / `badge` are strings that override `header.title` / `header.label`. |
|
|
76
|
+
|
|
77
|
+
## Drawer & view
|
|
78
|
+
|
|
79
|
+
```tsx
|
|
80
|
+
// drawer — pass the Save via `actions`; it renders in the drawer header
|
|
81
|
+
<FormRenderer
|
|
82
|
+
display="drawer" open={open} onOpenChange={setOpen} title="New item" badge="New"
|
|
83
|
+
onSubmit={save} resolver={r} defaultValues={d}
|
|
84
|
+
actions={<FormBuilder.Submit>Save</FormBuilder.Submit>}
|
|
85
|
+
>
|
|
86
|
+
{fields}
|
|
87
|
+
</FormRenderer>
|
|
88
|
+
|
|
89
|
+
// read-only view — same children, omit `actions` (a view has no Save)
|
|
90
|
+
<FormRenderer mode="view" values={entity} onSubmit={() => {}}>
|
|
91
|
+
{fields}
|
|
92
|
+
</FormRenderer>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Stepper
|
|
96
|
+
|
|
97
|
+
Drop a `FormBuilder.Stepper` in as the child. The Save lives in the header `actions` and
|
|
98
|
+
submits every step's registered fields at once (steps stay mounted, so the whole form is live).
|
|
99
|
+
|
|
100
|
+
```tsx
|
|
101
|
+
<FormRenderer
|
|
102
|
+
onSubmit={save}
|
|
103
|
+
resolver={r}
|
|
104
|
+
defaultValues={d}
|
|
105
|
+
header={{ title: "New item", variant: "new" }}
|
|
106
|
+
actions={<FormBuilder.Submit>Save</FormBuilder.Submit>}
|
|
107
|
+
>
|
|
108
|
+
<FormBuilder.Stepper>
|
|
109
|
+
<FormBuilder.Step title="Basics">
|
|
110
|
+
<FormBuilder.Text name="name" label="Name" required />
|
|
111
|
+
</FormBuilder.Step>
|
|
112
|
+
</FormBuilder.Stepper>
|
|
113
|
+
</FormRenderer>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Summary panel
|
|
117
|
+
|
|
118
|
+
Pass a hoisted `useForm` as `form` and a `FormSummary` as `summary` — FormRenderer binds
|
|
119
|
+
the form to that instance and lays the panel beside the form (page) or in the drawer tray
|
|
120
|
+
(drawer), so it recomputes live as fields change.
|
|
121
|
+
|
|
122
|
+
```tsx
|
|
123
|
+
const form = useForm<Invoice>({ resolver, defaultValues })
|
|
124
|
+
|
|
125
|
+
<FormRenderer
|
|
126
|
+
form={form}
|
|
127
|
+
onSubmit={save}
|
|
128
|
+
header={{ title: 'Invoice', variant: 'new' }}
|
|
129
|
+
actions={<FormBuilder.Submit>Save invoice</FormBuilder.Submit>}
|
|
130
|
+
summary={
|
|
131
|
+
<FormSummary form={form} title="Invoice" subtitle="Summary">
|
|
132
|
+
<FormSummary.Group title="Total">
|
|
133
|
+
<FormSummary.Row label="Overall Total" emphasized compute={overallTotal} />
|
|
134
|
+
</FormSummary.Group>
|
|
135
|
+
</FormSummary>
|
|
136
|
+
}
|
|
137
|
+
>
|
|
138
|
+
{fields}
|
|
139
|
+
</FormRenderer>
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Works with `display="drawer"` too — the summary moves into the drawer's tray.
|
|
143
|
+
|
|
144
|
+
## `FormDrawer` — the drawer wrapper
|
|
145
|
+
|
|
146
|
+
`FormRenderer` uses `FormDrawer` internally for `display: 'drawer'`. It's also exported
|
|
147
|
+
so you can put a hand-composed `FormBuilder` in a drawer directly. Since the header sits
|
|
148
|
+
outside the `<form>`, wire the Save button to the form via `id` / `form={id}`:
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
<FormDrawer
|
|
152
|
+
open={open}
|
|
153
|
+
onOpenChange={setOpen}
|
|
154
|
+
title="New item"
|
|
155
|
+
badge="New"
|
|
156
|
+
actions={
|
|
157
|
+
<Button type="submit" form="item-form" is_loading={saving}>
|
|
158
|
+
Save
|
|
159
|
+
</Button>
|
|
160
|
+
}
|
|
161
|
+
>
|
|
162
|
+
<FormBuilder
|
|
163
|
+
id="item-form"
|
|
164
|
+
onSubmit={save}
|
|
165
|
+
resolver={r}
|
|
166
|
+
defaultValues={d}
|
|
167
|
+
fieldDirection="vertical"
|
|
168
|
+
>
|
|
169
|
+
…fields…
|
|
170
|
+
</FormBuilder>
|
|
171
|
+
</FormDrawer>
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
`FormDrawer` props: `open`, `onOpenChange`, `title`, `badge`, `variant`, `actions`,
|
|
175
|
+
`onOpenInNewTab`, `children`, `summary`. It owns no form state.
|
|
176
|
+
|
|
177
|
+
### The title
|
|
178
|
+
|
|
179
|
+
The drawer renders the **same floating header as the page form** — a [HeaderBar](./header-bar.md)
|
|
180
|
+
title pill on the left, a dark action pill on the right — so a form's title looks identical in
|
|
181
|
+
either display. `title` is the plain (uppercased) text, `badge` the colored emphasis pill, and
|
|
182
|
+
`variant` (`new` / `edit` / `detail`) picks the badge colors; `badge` defaults from it
|
|
183
|
+
(New / Edit / View).
|
|
184
|
+
|
|
185
|
+
```tsx
|
|
186
|
+
<FormDrawer title="Sales Invoice" badge="New" variant="new" … />
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Driving it through `FormRenderer`, the `header` prop feeds both displays — including
|
|
190
|
+
`header.variant`, which the drawer previously ignored:
|
|
191
|
+
|
|
192
|
+
```tsx
|
|
193
|
+
<FormRenderer display="drawer" header={{ title: "Sales Invoice", variant: "edit" }} … />
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`title` and `badge` are **plain strings** (they were `ReactNode` before this shared-header
|
|
197
|
+
change) because they render through `HeaderBar`'s uppercase text treatment.
|
|
198
|
+
|
|
199
|
+
### A summary beside the form
|
|
200
|
+
|
|
201
|
+
`summary` renders a conclusion panel — typically a [FormSummary](./form-summary.md) — _beside_
|
|
202
|
+
the form rather than inside its scrollable body. `FormDrawer` puts the form in a
|
|
203
|
+
[`DrawerPanel`](./drawer.md#drawerpanel) (the light surface) and the summary next to it with a
|
|
204
|
+
6px gutter; the tray paints nothing, so the summary keeps its own dark background and full
|
|
205
|
+
height, scrolling internally when it outruns the drawer.
|
|
206
|
+
|
|
207
|
+
Hoist the form so both read the same instance:
|
|
208
|
+
|
|
209
|
+
```tsx
|
|
210
|
+
const form = useForm({ resolver, defaultValues })
|
|
211
|
+
|
|
212
|
+
<FormDrawer
|
|
213
|
+
open={open} onOpenChange={setOpen} title="New item" badge="New"
|
|
214
|
+
summary={
|
|
215
|
+
<FormSummary form={form} title="Item" subtitle="Summary">
|
|
216
|
+
<FormSummary.Group title="Pricing">…rows…</FormSummary.Group>
|
|
217
|
+
</FormSummary>
|
|
218
|
+
}
|
|
219
|
+
>
|
|
220
|
+
<FormBuilder form={form} id="item-form" onSubmit={save} fieldDirection="vertical">
|
|
221
|
+
…fields…
|
|
222
|
+
</FormBuilder>
|
|
223
|
+
</FormDrawer>
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
> `childrenOutside` is the deprecated former name for `summary`. It still works.
|
|
227
|
+
|
|
228
|
+
`FormRenderer` does **not** forward `summary` — use `FormDrawer` directly when you need one.
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: FormSummary
|
|
3
|
+
description: A read-only calculation panel displayed beside a form. Each row declares a compute(values) that runs against the live form values, so totals recalculate as the user types.
|
|
4
|
+
component: true
|
|
5
|
+
group: Forms
|
|
6
|
+
keywords: [form-summary, summary, totals, calculation, invoice, panel, react-hook-form, useWatch]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# FormSummary
|
|
10
|
+
|
|
11
|
+
A read-only **calculation panel** that sits **beside a form** — the invoice-summary
|
|
12
|
+
look: grouped rows of computed totals, right-aligned, in Glare's dark `SystemStyle`
|
|
13
|
+
fields.
|
|
14
|
+
|
|
15
|
+
Each `FormSummary.Row` declares a **`compute(values)`** that runs against the **live**
|
|
16
|
+
form values, so every total recalculates as the user types.
|
|
17
|
+
|
|
18
|
+
The panel renders **outside** the form, beside it. Both read the same form, so
|
|
19
|
+
**hoist `useForm`** and hand the instance to each:
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
const form = useForm({ resolver: zodResolver(schema), defaultValues })
|
|
23
|
+
|
|
24
|
+
<div className="flex gap-4">
|
|
25
|
+
<FormBuilder form={form} onSubmit={save} className="min-w-0 flex-1">
|
|
26
|
+
<FormBuilder.Section title="Line items">…fields…</FormBuilder.Section>
|
|
27
|
+
</FormBuilder>
|
|
28
|
+
|
|
29
|
+
<FormSummary form={form} title="Invoice" subtitle="Summary">
|
|
30
|
+
<FormSummary.Group title="Total">
|
|
31
|
+
<FormSummary.Row label="Total Discount" compute={totalDiscount} />
|
|
32
|
+
<FormSummary.Row label="Overall Total" emphasized compute={overallTotal} />
|
|
33
|
+
<FormSummary.Row label="Overall Total" currency="IQD" tone="success"
|
|
34
|
+
compute={v => overallTotal(v) * v.iqdRate} decimals={0} />
|
|
35
|
+
<FormSummary.Row label="Overall Total" currency="USD" tone="info" compute={overallTotal} />
|
|
36
|
+
</FormSummary.Group>
|
|
37
|
+
</FormSummary>
|
|
38
|
+
</div>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`FormBuilder` stays purely an input renderer — it knows nothing about the panel, and
|
|
42
|
+
**you** own the side-by-side layout. Passing `form` to `FormBuilder` makes it use that
|
|
43
|
+
instance instead of creating its own.
|
|
44
|
+
|
|
45
|
+
### Height
|
|
46
|
+
|
|
47
|
+
The panel is `h-full` and fills whatever height its parent gives it, scrolling its groups
|
|
48
|
+
(header pinned) once they outrun it. That means **the parent has to have a height** — the
|
|
49
|
+
usual mistake is `items-start` on the flex row, which collapses the panel to its content
|
|
50
|
+
instead. Leave the row at its default `items-stretch`, as above.
|
|
51
|
+
|
|
52
|
+
To make the panel own the viewport rather than grow with the form, bound the row:
|
|
53
|
+
|
|
54
|
+
```tsx
|
|
55
|
+
<div className="flex h-[calc(100vh-2rem)] gap-4">
|
|
56
|
+
<FormBuilder … className="min-w-0 flex-1 overflow-y-auto" />
|
|
57
|
+
<FormSummary … />
|
|
58
|
+
</div>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
> Hoisting the form means a remount `key` no longer resets it. Call `form.reset(defaults)`
|
|
62
|
+
> instead (e.g. when opening a drawer).
|
|
63
|
+
|
|
64
|
+
## Installation
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npx torch-glare@latest init
|
|
68
|
+
npx torch-glare@latest add FormSummary
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Imports
|
|
72
|
+
|
|
73
|
+
```tsx
|
|
74
|
+
import { FormSummary } from '@/components/FormSummary'
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## `FormSummary` props
|
|
78
|
+
|
|
79
|
+
| Prop | Type | Notes |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `title` | `ReactNode` | Panel title, e.g. `"Invoice"`. |
|
|
82
|
+
| `subtitle` | `ReactNode` | Muted text beside the title, e.g. `"Summary"`. |
|
|
83
|
+
| `form` | `UseFormReturn` | The form to read values from — required when rendered outside the `<FormBuilder>`. |
|
|
84
|
+
| `width` | `number \| string` | Panel width; default `228`. |
|
|
85
|
+
| `children` | `ReactNode` | `FormSummary.Group` children. |
|
|
86
|
+
|
|
87
|
+
## `FormSummary.Group` props
|
|
88
|
+
|
|
89
|
+
| Prop | Type | Notes |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| `title` | `ReactNode` | The group heading (Customer / Total / Tax / …). Groups are separated by a divider. |
|
|
92
|
+
| `collapsible` | `boolean` | When titled, the heading is a [ConclusionHeader](./conclusion-header.md) that opens/closes **this group's** rows; default `true`. |
|
|
93
|
+
| `defaultOpen` | `boolean` | Initial open state when collapsible; default `true`. |
|
|
94
|
+
|
|
95
|
+
The main panel title is a plain header — collapsing happens **per group**, not on the whole panel.
|
|
96
|
+
|
|
97
|
+
## `FormSummary.Row` props
|
|
98
|
+
|
|
99
|
+
| Prop | Type | Notes |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| `label` | `ReactNode` | Row label, above the value. |
|
|
102
|
+
| `compute` | `(values) => number \| string` | **The calculation** — receives the live form values. |
|
|
103
|
+
| `value` | `number \| string` | Static value, when there's nothing to compute. |
|
|
104
|
+
| `currency` | `ReactNode` | Currency code, e.g. `"IQD"` — rendered **inside** the field's trailing edge. |
|
|
105
|
+
| `tone` | `'neutral' \| 'success' \| 'info'` | Color of the currency code (green / blue). |
|
|
106
|
+
| `emphasized` | `boolean` | The primary result — lighter, emphasized border. |
|
|
107
|
+
| `decimals` | `number` | Decimal places; default `2` (→ `0.00`). |
|
|
108
|
+
| `action` | `ReactNode` | Trailing slot inside the field, e.g. an `ActionButton`. Shares the slot with `currency`. |
|
|
109
|
+
| `format` | `(value) => string` | Override the default number formatting. |
|
|
110
|
+
|
|
111
|
+
## Computing totals
|
|
112
|
+
|
|
113
|
+
`compute` is a plain function of the form values — keep the maths outside the JSX:
|
|
114
|
+
|
|
115
|
+
```tsx
|
|
116
|
+
const subTotal = v => v.items.reduce((s, i) => s + i.qty * i.price, 0)
|
|
117
|
+
const totalDiscount = v => v.items.reduce((s, i) => s + i.discount, 0)
|
|
118
|
+
const taxable = v => Math.max(0, subTotal(v) - totalDiscount(v))
|
|
119
|
+
const totalTax = v => taxable(v) * (v.taxRate / 100)
|
|
120
|
+
const overallTotal = v => taxable(v) + totalTax(v)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The panel is **read-only** — it contributes nothing to the submitted values.
|
|
@@ -875,10 +875,16 @@ describe('AttachmentImagePreview', () => {
|
|
|
875
875
|
|
|
876
876
|
### Default Styling
|
|
877
877
|
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
-
|
|
881
|
-
|
|
878
|
+
Matches the *Attachment-Field-1.0 / Pic-Container-1.0* design:
|
|
879
|
+
|
|
880
|
+
- **Field box** (`ImageAttachment`): a `#f9f9f9` (`background-presentation-form-field-primary`)
|
|
881
|
+
rounded container holding an optional preview and the drop zone, `gap-[10px]`.
|
|
882
|
+
- **Drop zone**: fills the box (`flex-1`), 65px tall, transparent with a dashed
|
|
883
|
+
`border-presentation-action-borderstyle` border; drag-active tints it gray.
|
|
884
|
+
- **Preview thumbnail** (`ExpandableImage`): a fixed **65×65** square, `object-cover`, rounded.
|
|
885
|
+
- **Placeholder** (no image): ocean fill (`badge-ocean-subtle`) + dashed `blue-purple-300`
|
|
886
|
+
border + paperclip icon; on hover it goes gray (`badge-gray-subtle` / `badge-gray`).
|
|
887
|
+
- **Hover (has image)**: a `black/50` overlay with a fullscreen icon + the `expandLabel`.
|
|
882
888
|
|
|
883
889
|
## Best Practices
|
|
884
890
|
|