@lotics/ui 11.4.0 → 11.6.0
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/AGENTS.md +41 -1100
- package/docs/ai_patterns.md +374 -0
- package/docs/catalog.md +920 -0
- package/docs/composition.md +439 -0
- package/docs/data_entry.md +398 -0
- package/docs/templates.md +357 -0
- package/examples/tpl_item_list.tsx +2 -6
- package/package.json +3 -2
- package/src/table.test.ts +90 -0
- package/src/table.tsx +200 -43
- package/src/table_fit.ts +88 -0
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
# Data entry — which pattern for which job
|
|
2
|
+
|
|
3
|
+
How data gets captured and edited in a `@lotics/ui` screen: inline edit, forms, find-or-create,
|
|
4
|
+
line items, handoffs, phased records, billing, tags, dispositions, attachments, and stage gates.
|
|
5
|
+
This is the most common thing to get right — read it before building any editing surface, and
|
|
6
|
+
match the JOB to the pattern below. Exact prop APIs are the shipped sources — `../src/<name>.tsx`
|
|
7
|
+
(never guess a prop; open the file). The component inventory lives in [the catalog](./catalog.md),
|
|
8
|
+
layout/color laws in [the composition grammar](./composition.md), and full worked-example screens
|
|
9
|
+
in [the templates](./templates.md) (`examples/tpl_*.tsx`).
|
|
10
|
+
|
|
11
|
+
## The decision table
|
|
12
|
+
|
|
13
|
+
| You're capturing… | Reach for | Why |
|
|
14
|
+
|---|---|---|
|
|
15
|
+
| an EXISTING record's fields | [**Inline edit**](#inline-edit--the-preferred-way-to-edit-an-existing-record) (`Inline*`) | edit in place, no form mode |
|
|
16
|
+
| a brand-NEW record | **create-then-refine** (`tpl_record`: "New" is ONE CLICK → a fresh Draft; everything refines in place on the record surface) | nobody fills 5 sections in one sitting; the surface is the editor |
|
|
17
|
+
| a RELATED record (pick or make) | [**find-or-create**](#find-or-create--the-combobox-family-is-the-control) (`Combobox allowCustom`) | one control covers both |
|
|
18
|
+
| REPEATING rows you build & revise | [**line items**](#line-items--create--preview--edit-a-composition-not-a-primitive) (create→preview→edit) | add / edit / remove, live totals |
|
|
19
|
+
| CHARGES that bill onto documents | [**billing**](#billing--the-invoice-document-is-the-unit) (`tpl_record` Billing section) | the invoice document is the unit |
|
|
20
|
+
| a record's FEE/charge SUMMARY | [**`Ledger`**](#fee-summary--ledger) (worked example: `tpl_item_list` drawer) | grouped money lines, one emphasized total — no bars/charts |
|
|
21
|
+
| a multi-value TAG field | [**`Select multi`**](#tag--multi-value-field--select-multi) (`renderSelected` → `Chip`) | chips composed, not a separate control |
|
|
22
|
+
| ONE choice from a small visible set | **`ChipGroup` pills** (or `RadioPicker`) | required single-select, one tap, every option visible |
|
|
23
|
+
| a STATUS with terminal outcomes | [**disposition**](#disposition--lifecycle-status-is-asymmetric-by-phase) (open → resolve → revise) | guides the decision |
|
|
24
|
+
| FILES | [**attachment field**](#attachments--a-full-add--preview--delete-field) (dropzone + grid + gallery) | add / preview / delete |
|
|
25
|
+
| a state TRANSITION mid-flow | [**stage gate**](#stage-gates--tiered-by-weight) (popover / dialog by weight) | right-sized friction |
|
|
26
|
+
|
|
27
|
+
## Inline edit — the preferred way to edit an existing record
|
|
28
|
+
|
|
29
|
+
When the whole record is editable (a detail/record screen, dense settings), don't wrap it in a
|
|
30
|
+
form mode or a preview↔edit card — make each VALUE inline-editable: it reads as a value on a quiet
|
|
31
|
+
chip, hover reveals the input-family border (no extra grey wash, no pencil icon that shifts
|
|
32
|
+
layout), click swaps the input in **at the same height** (zero reflow, the whole point), and it
|
|
33
|
+
commits on blur (Enter saves, Escape reverts) or via `controls="buttons"` (✓ primary / ✕).
|
|
34
|
+
|
|
35
|
+
One per type:
|
|
36
|
+
|
|
37
|
+
- **`InlineTextInput`** — plain text; `struck` renders the value struck-through (a done task title).
|
|
38
|
+
- **`InlineNumberInput`** — `format` formats the RESTING value (currency, units); edit mode is a
|
|
39
|
+
bare number input with `min`/`max`.
|
|
40
|
+
- **`InlineSelect`** — plain options OR `renderOptionContent`; floats an `OptionList` in a popover
|
|
41
|
+
so the row never grows; the RESTING value renders like its option (`renderOptionContent` by
|
|
42
|
+
default; `renderSelected` overrides) — a colored `OptionBadge`, not just a label.
|
|
43
|
+
- **`InlineMemberSelect`** — a `MemberChip` at rest → member picker; the inline twin of
|
|
44
|
+
`MemberSelect`; worked example: `tpl_record`'s "Sales owner" fact.
|
|
45
|
+
- **`InlineDatePicker`** — `format="datetime"` for always-on time; `optionalTime` to let the user
|
|
46
|
+
ADD/REMOVE a time — the value's own shape, date vs datetime, is the source of truth
|
|
47
|
+
(`optionalTime` is ignored when `format="datetime"`).
|
|
48
|
+
- **`InlineTimePicker`** — a time-only value.
|
|
49
|
+
- **`InlineTagSelect`** — the MULTI member — a tag SET in the inline vocabulary: selected tags
|
|
50
|
+
render as badges inside the standard chip, clicking floats a multi `OptionList` (checkbox rows),
|
|
51
|
+
CLOSING commits the new set in one `onSave` — never a borderless `Select` posing as an inline
|
|
52
|
+
field.
|
|
53
|
+
|
|
54
|
+
All are built on **`useInlineEdit`** + **`InlineEditView`** (custom inputs join the family via
|
|
55
|
+
those). `onSave` is async: the saving spinner sits INSIDE the control at its right edge (never a
|
|
56
|
+
sibling — that reflows); an error shows inline without losing the edit.
|
|
57
|
+
|
|
58
|
+
To let a value be UNSET (a diff-write CLEAR — unassign, remove a due date, drop a select), pass
|
|
59
|
+
**`onClear`** to `InlineSelect` / `InlineMemberSelect` / `InlineDatePicker`. It surfaces through
|
|
60
|
+
each popover's OWN clear affordance — `InlineSelect` renders a compact left-aligned "Clear"
|
|
61
|
+
`Button` below the `OptionList` (only while a value is set); `InlineDatePicker` reuses the
|
|
62
|
+
calendar's own footer "Clear" button. Never a bolted-on sibling row (it can't reach the option
|
|
63
|
+
list's active-highlight and doubles the footer hairline), and never a persistent ✕ on the resting
|
|
64
|
+
cell (noise on a dense board, and a pressable nested in the trigger is invalid DOM). `onSave`'s
|
|
65
|
+
`next` stays non-null — a caller opts in per field; the clear fires `onClear`, which writes `null`
|
|
66
|
+
(the app workflow must ACCEPT null on that input — a `select`/`date`/`member` field clears on
|
|
67
|
+
null). `InlineTagSelect` (multi) needs no `onClear` — an empty set is already a valid `onSave`.
|
|
68
|
+
|
|
69
|
+
### The row stack — `DetailTable` + `DetailRow`
|
|
70
|
+
|
|
71
|
+
A STACK of rows lives in a `DetailTable` (label · value · trailing laid out like a TABLE:
|
|
72
|
+
`labelWidth` / `trailingWidth` / `minHeight` set ONCE on the parent, plus the 6px row gap the
|
|
73
|
+
zinc-50 chips need) holding `DetailRow`s — set `trailingWidth` when ANY row carries a trailing
|
|
74
|
+
action/badge, so EVERY row reserves the column and one row's `Copy` button never makes its editor
|
|
75
|
+
narrower than its neighbours'.
|
|
76
|
+
|
|
77
|
+
### The editability affordance
|
|
78
|
+
|
|
79
|
+
An editor AT REST sits on a zinc-50 chip — THE editability affordance: users see what's editable
|
|
80
|
+
without hovering. `background="transparent"` opts a field out of the chip — for DENSE,
|
|
81
|
+
uniformly-editable surfaces (a task list/board where EVERY cell edits: the chip repeated
|
|
82
|
+
everywhere is noise and distinguishes nothing; hover/focus still reveal the input). Keep the chip
|
|
83
|
+
wherever editable and static values MIX. A `disabled` editor rests FLAT automatically — the chip
|
|
84
|
+
is the editability promise, and an inert field must not make it.
|
|
85
|
+
|
|
86
|
+
A READ-ONLY value in the same column — a computed total, a system ID, a synced/locked field — is
|
|
87
|
+
**`InlineStatic`**: it copies the editor box metrics exactly (height, padding, 1px transparent
|
|
88
|
+
border) but stays FLAT and non-interactive, so editable (chip) vs read-only (flat) is legible at a
|
|
89
|
+
glance and the static value never reads as a disabled input.
|
|
90
|
+
|
|
91
|
+
To hang a right-side action/badge off a row, use `DetailRow`'s `trailing` slot (NOT for units —
|
|
92
|
+
"kg"/"$" belong IN the value via `InlineNumberInput format`) — see the "Details" `DetailTable` of
|
|
93
|
+
`tpl_record`, which also reads top→bottom as a full record surface (header → fields →
|
|
94
|
+
`DangerZone`). Not every field is a same-height swap — a tag field, a status, or an attachment
|
|
95
|
+
grid edit in place too (below).
|
|
96
|
+
|
|
97
|
+
## Fieldset form — fields lay out on a RESPONSIVE two-column grid
|
|
98
|
+
|
|
99
|
+
Never hard-code columns, never 3-up. A fieldset is `flexDirection:row, flexWrap:wrap,
|
|
100
|
+
columnGap:16`; each `FormField` declares an intrinsic width via `style` — `half` (`flexGrow:1,
|
|
101
|
+
flexBasis:240`) or `full` (`flexGrow:1, flexBasis:"100%"`) — so two halves sit side-by-side on a
|
|
102
|
+
wide card and stack on a narrow one with zero media queries. Pair short, related fields as halves
|
|
103
|
+
(phone/email, qty/price); give long or singular values the full row (legal name, address, notes).
|
|
104
|
+
Past two columns the label→field link breaks — MANY inputs means GROUPING into labeled fieldsets
|
|
105
|
+
(each its own 2-up grid, `Divider` between), never a third column. `FormDatePicker`/`FormPicker`
|
|
106
|
+
wrap their OWN `FormField` (and don't put a `style` on it) — for a grid cell use a bare
|
|
107
|
+
`FormField style={half}` wrapping `DatePicker`/`Picker`.
|
|
108
|
+
|
|
109
|
+
## Find-or-create — the `Combobox` family IS the control
|
|
110
|
+
|
|
111
|
+
`Combobox` is COMPOUND: a root holds the DATA + behaviour (the shared `useOptionList` engine —
|
|
112
|
+
`options`, `value`, `onValueChange`, `onSearchChange`, `allowCustom`, the per-row
|
|
113
|
+
`getOptionDescription`/`renderOptionContent` so they're typed against the option `data`), and the
|
|
114
|
+
parts render the CHROME, composed as children — never a render-prop pile:
|
|
115
|
+
|
|
116
|
+
```tsx
|
|
117
|
+
<Combobox options={hits} value={sel} onSearchChange={setQ} onValueChange={pick} allowCustom
|
|
118
|
+
customOptionLabel={(q) => `Create "${q}"`} getOptionDescription={(o) => o.data?.code}>
|
|
119
|
+
<ComboboxInput icon="search" clearable onClear={deselect} placeholder="Find or create…" />
|
|
120
|
+
<ComboboxContent recentsLabel="Recent">
|
|
121
|
+
<ComboboxEmpty>No match — type a name to create one</ComboboxEmpty>
|
|
122
|
+
<ComboboxFooter>{validity}</ComboboxFooter>
|
|
123
|
+
</ComboboxContent>
|
|
124
|
+
</Combobox>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
It's a SELECT by default (`ComboboxInput` with NO `icon` → a trailing chevron); opt INTO the
|
|
128
|
+
search-box look with `ComboboxInput icon="search"` only when typing-to-search is primary (a
|
|
129
|
+
large/remote set). For a select that the user can RESET, pass `clearable` + `onClear` (deselect):
|
|
130
|
+
the chevron shows while the field is empty and a clear ✕ replaces it once there's a value/query;
|
|
131
|
+
pressing ✕ clears the text and reopens the full-browse list, so `onClear` lands back on the whole
|
|
132
|
+
set (on focus an empty field already browses everything — `recentOptions ?? all`).
|
|
133
|
+
|
|
134
|
+
`allowCustom` appends a "Create …" row (`customOptionLabel`) when the query matches no option,
|
|
135
|
+
emitting the typed text as the value — so a value not in the known set means CREATE. Wire that
|
|
136
|
+
branch to a create overlay (an anchored popover for a 1–2 field gate, a modal `Dialog` for 3+)
|
|
137
|
+
that builds the new record and attaches it; existing matches attach directly. A record-CREATION
|
|
138
|
+
dialog stays a MINIMAL gate and really creates — the row lands and its own workspace opens for
|
|
139
|
+
refinement (an EMPTY checklist, commons as ghosts). NEVER a creation wizard: create-then-refine
|
|
140
|
+
puts complexity on the record surface, not in front of it.
|
|
141
|
+
|
|
142
|
+
The create row sits BELOW matches by default (the keyboard highlight lands on the first MATCH, so
|
|
143
|
+
Enter on a partial picks it, never a duplicate); pass `customOptionPlacement="top"` to pin it
|
|
144
|
+
above. Use `reflectSelection={false}` and render the attached record below as a card with a
|
|
145
|
+
Change action.
|
|
146
|
+
|
|
147
|
+
For input-level status that must stay visible while results scroll (validity feedback on the typed
|
|
148
|
+
value, a result count, a secondary "create" affordance), drop a `ComboboxFooter` into the content —
|
|
149
|
+
a pinned row below the listbox, OUTSIDE keyboard option-nav; call `useCombobox().close()` from a
|
|
150
|
+
footer action that hands off elsewhere (so the popover dismisses cleanly instead of floating over
|
|
151
|
+
what you navigated to). A `ComboboxEmpty` child gives the no-match state richer content than the
|
|
152
|
+
`ComboboxContent emptyText` string.
|
|
153
|
+
|
|
154
|
+
## Line items — create → preview → edit (a composition, not a primitive)
|
|
155
|
+
|
|
156
|
+
For a list of records you build then revise (invoice rows, repair lines, config entries), each
|
|
157
|
+
item is a `Card` toggling between a read-only PREVIEW (a stack of `DetailRow`s) and an EDIT form
|
|
158
|
+
(the inputs), via a local `editing` flag + Edit/Save/Cancel in the `CardFooter`. A freshly-added
|
|
159
|
+
item opens in edit; Save collapses it to the preview; Edit reopens it. **Edit is cancelable** —
|
|
160
|
+
snapshot the item on Edit so Cancel reverts, or DISCARDS a freshly-added one. Duplicate sits left;
|
|
161
|
+
the destructive **Delete is `danger-secondary`**; Cancel + the Save/Edit toggle sit right. Every
|
|
162
|
+
screen composes its own (~15 lines) so the preview rows + form fit the data.
|
|
163
|
+
|
|
164
|
+
## Handoff — a stage transition, never a message
|
|
165
|
+
|
|
166
|
+
When work crosses departments (sales → operations → accounting), the HANDOFF is the RECORD
|
|
167
|
+
changing desks — never an inbox, a notification, or a copied task. Two types:
|
|
168
|
+
|
|
169
|
+
**(A) Same entity** → a STAGE field on the shared record: sections carry OWNER dot tags, and the
|
|
170
|
+
handoff is MANAGED AS TASKS — each desk's checklist on the record (the task-row vocabulary:
|
|
171
|
+
sibling `CheckCircle` + struck transparent `InlineTextInput` + per-task `InlineMemberSelect`
|
|
172
|
+
assignees + the `CaptureRow` add-affordance — never a decorative progress strip). A NEW record
|
|
173
|
+
starts with an EMPTY checklist — tasks truly vary. The commons split in two: MANDATORY tasks are
|
|
174
|
+
seeded by the app (a workflow on create, per record type) — no human types them; common-but-
|
|
175
|
+
OPTIONAL tasks appear as `SuggestionChip`s under the list — a PILL, never a row, so a suggestion
|
|
176
|
+
can't be mistaken for a task (tap = materialize, ✕ = dismiss for this record; already-present
|
|
177
|
+
labels filter out; suggestions never count in done/total and pause while a filter narrows the
|
|
178
|
+
view) — the same suggestion grammar as the billing Standard pill. Real rows carry a ⋯ `menu` (an
|
|
179
|
+
`ActionMenu`; Delete lives BEHIND it, danger-styled and last — never a bare ✕ a stray tap can
|
|
180
|
+
hit), completing the checklist's CRUD.
|
|
181
|
+
|
|
182
|
+
Open tasks INFORM the handoff, they NEVER block it: the CTA stays enabled, the count warns, and
|
|
183
|
+
open tasks carry over. The handoff CTA opens a DIALOG for the receiving desk (assignee
|
|
184
|
+
`MemberSelect` + an optional note; the open-task warning inside); confirming closes the drawer —
|
|
185
|
+
the record left this register.
|
|
186
|
+
|
|
187
|
+
Tasks PEEK from the register: the done/total column is a pressable compact-`ProgressBar` trigger
|
|
188
|
+
whose popover holds the checklist on FIXED columns — every row ring · struck `InlineTextInput`
|
|
189
|
+
title (flex) · quick-reassign `InlineMemberSelect` (140) — no expandable rows (tags/files depth is
|
|
190
|
+
the Task list template's lesson, not the peek's); the popover body is `PopoverContent`'s own
|
|
191
|
+
ScrollView (`disableBodyScroll` is ONLY for children that manage their own scroll, like
|
|
192
|
+
`OptionList`). The DRAWER carries a real Tasks SECTION in the Record template's shape (heading +
|
|
193
|
+
the compact meter + the same checklist) — one shared task state per record feeds the column, the
|
|
194
|
+
peek, the section, and the handoff dialog; a section owned by a later desk sits visible but GATED
|
|
195
|
+
("Billing opens when the record reaches Accounting"). Registers scope by stage — the receiving
|
|
196
|
+
department's register IS its inbox. The sender's sections stay editable after handoff (the gate
|
|
197
|
+
transfers RESPONSIBILITY, not access — locking is app IAM, not template grammar). Worked example:
|
|
198
|
+
`tpl_record`.
|
|
199
|
+
|
|
200
|
+
**(B) Different entity** (a deal → a shipment) → SPAWN-AND-LINK: the upstream record's terminal
|
|
201
|
+
gate CREATES the downstream record on the shared spine, linked both ways (a linked-record row
|
|
202
|
+
each side); usually TWO apps — the upstream closes its own lifecycle, the downstream starts fresh
|
|
203
|
+
in its department's app. Never merge the two lifecycles into one record.
|
|
204
|
+
|
|
205
|
+
## Sequential phases on one record + the outline rail
|
|
206
|
+
|
|
207
|
+
When a record's work happens in ORDERED PHASES (check-in → check-out; receive → dispatch), the
|
|
208
|
+
phases are NOT tabs or a segmented control — hiding the other phase loses the context the current
|
|
209
|
+
one needs and buries why its gate is blocked. Both phase sections sit on ONE page in
|
|
210
|
+
chronological order; each phase owns ITS OWN fee rows + phase total (the invoice-band idea one
|
|
211
|
+
level up — money never reads as one pot); each closes with a SEQUENTIAL confirm gate that names
|
|
212
|
+
its blockers ("Confirm check-in first."). The header status chip carries the phase.
|
|
213
|
+
|
|
214
|
+
A LONG record surface pairs with a LEFT OUTLINE RAIL — `MenuButton` items + `useSectionNav`
|
|
215
|
+
(scroll-spy: jump to a section, the highlight follows the scroll); on narrow containers the rail
|
|
216
|
+
becomes a PINNED bar naming the current section that opens a full-page section-picker `Modal`. A
|
|
217
|
+
per-phase dot on a rail item carries its confirmed state. A direction that is a TYPE (one record
|
|
218
|
+
per event) is instead a discriminator chosen ONCE at creation — a `SegmentedControl` in the
|
|
219
|
+
create step, never a toggle on the record.
|
|
220
|
+
|
|
221
|
+
## Billing — the invoice DOCUMENT is the unit
|
|
222
|
+
|
|
223
|
+
When charges get grouped into issuable documents (an e-invoice, a bill) and then collected, don't
|
|
224
|
+
split the screen into "enter fees here, issue there" — that smears one job across two places.
|
|
225
|
+
Make **each invoice a FLAT hairline-set band that holds its own editable charge lines** (amount
|
|
226
|
+
input + payment method), its **live total**, its **status badge** (nothing-to-bill · draft ·
|
|
227
|
+
issued + ref), and its **issue action** in its closing row — no card chrome; data-capture
|
|
228
|
+
templates are flat (see [the composition grammar](./composition.md)). A charge never lives apart
|
|
229
|
+
from the document it bills on. Issuing is gated **inline, never a dead end**: when a prerequisite
|
|
230
|
+
is missing (a bill-to tax ID, a method on a charged line) the issue button disables with one muted
|
|
231
|
+
line saying what's needed; the payment-method picker turns required the instant a line carries an
|
|
232
|
+
amount. Issuing a real e-invoice is irreversible → confirm in a `Dialog` (stage gate). A
|
|
233
|
+
grand-total **receipt** validates first — surface the EXACT missing methods (`Alert.alert` listing
|
|
234
|
+
each) rather than a vague "incomplete." A refundable **deposit** is its own card and its own
|
|
235
|
+
receipt — never folded into the total due. Composition over `Card` + `NumberInput` + `Picker` +
|
|
236
|
+
`Badge`; no new primitive. Worked example: `tpl_record`'s Billing section.
|
|
237
|
+
|
|
238
|
+
## Fee summary — `Ledger`
|
|
239
|
+
|
|
240
|
+
For a record's fee/charge SUMMARY (read-heavy, one payment action), reach for the `Ledger`
|
|
241
|
+
compound: **`LedgerGroup`** (label + group sum) → **`LedgerRow`** (label · `meta` · ONE right
|
|
242
|
+
tabular money column; `peek` makes the row a door to its PARTICULARS in an anchored popover —
|
|
243
|
+
references live INSIDE the peek, since a row is never a button holding another button (`reference`
|
|
244
|
+
is ignored while `peek` is set); `reference` = a trailing link on a non-peek row — an issued
|
|
245
|
+
invoice, a receipt no.) → **`LedgerTotal`** (divider-set emphasized close, `zeroLabel` for the
|
|
246
|
+
settled state, `tone` for a danger balance). The root `Ledger` takes `formatValue` so every row
|
|
247
|
+
formats money one way. Pair it with a Record-payment POPOVER that appends a receipt. No
|
|
248
|
+
bars/charts. Worked example: the `tpl_item_list` drawer.
|
|
249
|
+
|
|
250
|
+
## Tag / multi-value field — `Select multi`
|
|
251
|
+
|
|
252
|
+
A tag field's resting state should be a tidy CHIP BOX — and that's just a multi `Select` whose
|
|
253
|
+
`renderSelected` returns a removable `<Chip onDismiss={remove}>`. There's NO separate component
|
|
254
|
+
and no `display` mode: `renderSelected(item, { remove })` composes the anchor — render a `Chip`
|
|
255
|
+
with `remove` for the ✕, or a plain `OptionBadge`/`MemberChip`/custom pill that ignores it;
|
|
256
|
+
`renderOptionContent` renders the menu rows. `searchable` adds the filter field, `allowCustom` the
|
|
257
|
+
create row. Borderless for a grid cell? Pass a `style`, never a `variant`. `Combobox` is the
|
|
258
|
+
SINGLE-value sibling — for a search that emits one pick at a time and renders the selection
|
|
259
|
+
elsewhere, use `reflectSelection={false}` (see the [templates](./templates.md)).
|
|
260
|
+
|
|
261
|
+
## Disposition — lifecycle status is ASYMMETRIC by phase
|
|
262
|
+
|
|
263
|
+
When a status is an OPEN default plus terminal OUTCOMES the user decides (Lead → Closed/Lost,
|
|
264
|
+
Draft → Confirmed/Cancelled, Open → Approved/Rejected), a 3-way segmented/dropdown is wrong — it
|
|
265
|
+
treats a lifecycle as flat peers and guides neither the decision nor the revision. Split by
|
|
266
|
+
phase: while OPEN, surface the outcomes as ACTIONS with valence (`Mark closed` = `primary`,
|
|
267
|
+
`Mark lost` = `danger-secondary`; the open state a quiet dot `Badge`). Coloring the buttons is
|
|
268
|
+
right here — a SINGLE record-level decision is not the approvals-queue "wall of loud buttons."
|
|
269
|
+
Once RESOLVED, show the colored STATE (`Badge variant="dot"` — emerald positive / red negative /
|
|
270
|
+
blue open) with a quiet, REVERSIBLE **Change**: a popover STATE SWITCHER (each state a colored
|
|
271
|
+
dot + label, current marked + disabled), not a generic text menu. A composition (Badge + Button +
|
|
272
|
+
Popover/MenuButton).
|
|
273
|
+
|
|
274
|
+
## Attachments — a full add / preview / DELETE field
|
|
275
|
+
|
|
276
|
+
### Default: `FilesEditor`
|
|
277
|
+
|
|
278
|
+
**`<FilesEditor files onAdd onRemove>`** bundles the upload-aware grid + a toolbar below it
|
|
279
|
+
(Upload · Select · Download all) + a batch SELECT mode + the full-screen gallery +
|
|
280
|
+
Alert-confirmed remove; the host only owns `files` and wires `onAdd` (picked → its upload) /
|
|
281
|
+
`onRemove`. The destructive per-tile ✕ shows only in SELECT mode (the default view is a clean
|
|
282
|
+
preview — no stray-tap deletes); pass `selectTileRemove={false}` to drop that ✕ entirely so
|
|
283
|
+
select mode deletes ONLY via Select → Menu → Delete (the batch gating flow — right for a
|
|
284
|
+
height-bounded cell editor). The built-in gallery is Download + inline preview (no "open in new
|
|
285
|
+
tab" — it's redundant once everything previews inline). In a height-bounded container (a
|
|
286
|
+
popover/drawer) pass `gridMaxHeight` so the grid SCROLLS and the toolbar pins below it; omit it
|
|
287
|
+
in free-flow layouts (a form field) where the grid grows.
|
|
288
|
+
|
|
289
|
+
The rest of the surface: `uploads` passes the live add-queue through to the grid (with
|
|
290
|
+
`onUploadRemove`/`onRetry`/`onRetryAll`); `onUpload` overrides the Upload action's built-in web
|
|
291
|
+
picker (e.g. a native document picker); `readOnly` (or omitting `onRemove`) makes it a view-only
|
|
292
|
+
download+preview surface; omitting `onAdd` hides Upload. Select mode gains a Share action via
|
|
293
|
+
`onShareSelected(files)` and a "Download as ZIP" item via `onDownloadZipSelected(files)` (shown at
|
|
294
|
+
2+ selected) — both host-provided, hidden when omitted. `onDownload` overrides the default
|
|
295
|
+
per-file `downloadFileFromUrl`; `accept` filters the picker; `itemSize`/`minItemWidth`/`columns`
|
|
296
|
+
pass through to the grid; `credentials` covers auth-gated preview URLs; `labels` /
|
|
297
|
+
`galleryLabels` localize the toolbar and the gallery chrome.
|
|
298
|
+
|
|
299
|
+
Reach for the lower-level pieces below only when you need custom chrome.
|
|
300
|
+
|
|
301
|
+
### The pieces — `FileDropzone`, `FileGrid`, `FileGalleryModal`, `FileRow`
|
|
302
|
+
|
|
303
|
+
Capture with `<FileDropzone onFiles accept label hint dropLabel height>` (drag-over lights the
|
|
304
|
+
accent; click falls back to a picker); display what landed with **`<FileGrid files uploads>`**
|
|
305
|
+
ABOVE the dropzone (existing files are the content; the dropzone sinks to the bottom as the "add
|
|
306
|
+
more" affordance — only the empty state leads with it).
|
|
307
|
+
|
|
308
|
+
`FileGrid` is the upload-aware grid: `files` are the saved/completed `DisplayFile`s, `uploads` is
|
|
309
|
+
the LIVE add-queue (`FileUpload[]`) — it interleaves both and renders each in-flight tile itself —
|
|
310
|
+
a LABELED status overlay (uploading spinner · "Retrying" · "Paused" · "Upload failed" + a retry
|
|
311
|
+
button · "Can't upload" for a dead/empty file) — so you never hand-map an upload to a
|
|
312
|
+
`FileThumbnail`. Localize the labels with `labels.upload` (an `UploadStatusLabels`) and
|
|
313
|
+
`labels.retryAll`. **Limitation:** the upload-status labels are NOT wired to
|
|
314
|
+
`LoticsLocaleProvider` — they default to English; pass `labels` per instance to localize.
|
|
315
|
+
|
|
316
|
+
Make it CRUDable by wiring its callbacks: `onFilePress` → set a `number|null` index that drives
|
|
317
|
+
`<FileGalleryModal files activeIndex onIndexChange>` — a FULL-SCREEN viewer with a toolbar
|
|
318
|
+
(filename · counter · actions — download, optional `onOpenExternal`/`onRemove` · close-✕),
|
|
319
|
+
prev/next, ESC, and rotate. The toolbar is RESPONSIVE: on a phone (`useScreenSize().small`) the
|
|
320
|
+
actions collapse into a `⋯` `ActionMenu` so the close-✕ never overflows off-screen (its popover
|
|
321
|
+
portals inside the modal's own `PortalHost` — a raw `<Modal>` lacks one, which is why an inline
|
|
322
|
+
popover would render behind the overlay). The 90° rotate controls for images are NOT in the
|
|
323
|
+
toolbar — they FLOAT as a pill overlaid on the image (every screen size).
|
|
324
|
+
`onDisplayRemove`/`onUploadRemove` → drop the file / cancel the upload (the grid renders a ✕ on
|
|
325
|
+
each tile automatically); `onRetry` / `onRetryAll` for failed uploads.
|
|
326
|
+
|
|
327
|
+
In a sandboxed custom-code app the gallery's "open in new tab" can't pop a window — pass
|
|
328
|
+
`onOpenExternal` wired to the SDK's `openExternal` (omit it elsewhere and the action hides).
|
|
329
|
+
PDF/Word/Excel/CSV all render INLINE here (pdf.js / `@lotics/docx` / `@lotics/xlsx`, lazy-loaded) —
|
|
330
|
+
no native `<iframe>` viewer, so they work inside a cross-origin app iframe. Keep
|
|
331
|
+
label+grid+dropzone on a `gap` (a bare `CardBody` has none).
|
|
332
|
+
|
|
333
|
+
By default `FileGrid` tiles FILL the container width — a uniform size (≥ `minItemWidth`, default
|
|
334
|
+
96) so full rows span the width and reflow as it changes; a short last row keeps that size and
|
|
335
|
+
left-aligns (no stretched tiles, no empty cells). Pass `columns` for a fixed column count, or
|
|
336
|
+
`itemSize` for exact fixed-size tiles. For a dense strip in a FIXED-width container that must NOT
|
|
337
|
+
wrap, pass `singleRow` — it fits as many tiles as the width allows and collapses the rest into a
|
|
338
|
+
clickable "+N" overflow tile (`onOverflowPress(hiddenCount)`, which the host wires — e.g. open the
|
|
339
|
+
gallery at the first hidden file). This is the responsive overflow pattern (avatar stack /
|
|
340
|
+
file-manager); prefer it over a hardcoded `maxVisible`, which can't adapt to width.
|
|
341
|
+
|
|
342
|
+
Under the hood it composes `FileThumbnail` (the completed tile — right surface per MIME: image
|
|
343
|
+
thumbnail · a doc tile with the `FileBadge` centered + a single-line filename · media card; also
|
|
344
|
+
takes `isTemplate` to overlay a TMPL marker) and `UploadingThumbnail` (the in-flight tile); reach
|
|
345
|
+
for either directly only when hand-rolling a NON-grid layout. **When the filename must be
|
|
346
|
+
readable, use `FileRow`** — a horizontal LINE (`FileBadge` or a `placeholder` + the FULL name + a
|
|
347
|
+
meta line + a composable `trailing` slot). `onPress` makes the whole row a pressable door (open
|
|
348
|
+
the file); `trailing` (a status `Badge`, an action, a remove ✕) stays an independently-pressable
|
|
349
|
+
sibling. For attachment lists / message files / document checklists where a square tile truncates
|
|
350
|
+
the name.
|
|
351
|
+
|
|
352
|
+
Never hand-build a drop well, a file grid, the upload tiles, or a preview modal. In a custom-code
|
|
353
|
+
APP, don't hand-roll the upload+preview state either — `@lotics/app-sdk` `useAttachments()` gives
|
|
354
|
+
the chat's instant-preview lifecycle (local object-URL now, `file_id` on complete); map each
|
|
355
|
+
`AttachedFile` to a `FileUpload` (ready → `{ status: "complete", id, file: {…} }`, else
|
|
356
|
+
`{ status, id, filename, mimeType: mime_type, previewUrl: preview_url }`) and feed `FileGrid`'s
|
|
357
|
+
`uploads` — `onUploadRemove={remove}`.
|
|
358
|
+
|
|
359
|
+
### Gated file CRUD — a PATTERN, composed locally, not a sealed kit component
|
|
360
|
+
|
|
361
|
+
When a file field must GUARD deletion (operator-facing, accidental delete is a real risk), DON'T
|
|
362
|
+
use `FileGrid` (its per-tile ✕ is exactly the stray-tap risk you're guarding against) — compose it
|
|
363
|
+
in the app from the kit pieces; don't reach for a one-size widget (the action set + layout vary
|
|
364
|
+
per app — each host composes its own). The recipe: `FileDropzone` (add) + `FileThumbnailGrid
|
|
365
|
+
files selectedIds onFilePress` (pass `selectedIds` ONLY in select mode) + `FileGalleryModal`
|
|
366
|
+
(view + rotate + persist) + a `Dialog` confirm, all driven by **`useSelectionMode()`** — the
|
|
367
|
+
reusable LOGIC. The gating rule: **NO per-thumbnail ✕** — delete is **Select → ⋯ menu → Delete →
|
|
368
|
+
confirm**, so a stray tap never removes a file. Three reusable primitives back it (the logic is
|
|
369
|
+
shared; the action bar + layout + copy stay local):
|
|
370
|
+
|
|
371
|
+
- **`useSelectionMode()`** (`@lotics/ui/use_selection_mode`) — `{ active, selected, enter, exit,
|
|
372
|
+
toggle, toggleAll }`, an agnostic multi-select state machine (string ids; pairs with the grid's
|
|
373
|
+
`selectedIds`).
|
|
374
|
+
- **`shareOrDownloadFiles(files, { title, credentials })`** (`@lotics/ui/share_or_download`) —
|
|
375
|
+
`navigator.share({ files })` (mobile → save to gallery / send to an app), else individual
|
|
376
|
+
`downloadFileFromUrl` — **never a ZIP**. It shares the BYTES (fetches each URL → `File`), so URL
|
|
377
|
+
expiry afterward is moot; the share path needs the host iframe to grant `allow="web-share"`
|
|
378
|
+
(else it falls back to download). Best for FAST urls (presigned storage). **For SLOW urls (an
|
|
379
|
+
auth-gated proxy with a server round-trip), split it:** `prepareShareFiles(files,
|
|
380
|
+
{credentials})` fetches the `File[]` BEFORE the gesture (on menu-open), then `shareFiles(File[])`
|
|
381
|
+
runs `navigator.share` synchronously on the tap — **iOS Safari rejects `share()` if a slow fetch
|
|
382
|
+
burns the tap's transient activation**, which silently lands in the download fallback.
|
|
383
|
+
`prepareShareFiles` throws on a fetch failure (log it, don't swallow) and returns `null` when
|
|
384
|
+
Web Share is unavailable (download instead); `shareFiles` returns
|
|
385
|
+
`"shared" | "cancelled" | "unsupported"`.
|
|
386
|
+
- **`rotateImageToBlob(url, degrees)`** (`@lotics/ui/rotate_image`) — canvas-bake a 90° rotation
|
|
387
|
+
into a NEW blob for re-upload (`useImageRotation` is view-only; this is how a rotation is
|
|
388
|
+
persisted). Pair with `FileGalleryModal`'s `onPersistRotation`/`persisting` (the ✓ shown on a
|
|
389
|
+
rotated image).
|
|
390
|
+
|
|
391
|
+
## Stage gates — tiered by weight
|
|
392
|
+
|
|
393
|
+
A transition that needs NO input is one click. 1–3 quick fields → a POPOVER FORM anchored to its
|
|
394
|
+
action button (title + one-line stake + `FormField`s; confirm in `PopoverFooter`). A
|
|
395
|
+
destructive/exception path (anything touching money or locks) → a `Dialog` (consequence in prose,
|
|
396
|
+
danger confirm + cancel in `DialogFooter`) or an `Alert.alert(title, message, [{cancel},
|
|
397
|
+
{destructive}])` confirm. Never an inline expanding panel for a gate — it shifts layout and loses
|
|
398
|
+
the "this is a gate" framing.
|