@lotics/ui 16.0.0 → 16.2.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 +1 -1
- package/docs/catalog.md +47 -8
- package/docs/composition.md +3 -1
- package/docs/data_entry.md +41 -2
- package/docs/templates.md +10 -2
- package/examples/tpl_item_list.tsx +3 -3
- package/examples/tpl_record.tsx +71 -17
- package/package.json +4 -2
- package/src/button.tsx +10 -11
- package/src/checklist.tsx +275 -17
- package/src/date_picker.tsx +21 -0
- package/src/detail_row.tsx +14 -4
- package/src/form_date_picker.tsx +3 -1
- package/src/form_field.tsx +4 -1
- package/src/icon.tsx +2 -0
- package/src/icon_button.tsx +8 -9
- package/src/inline_edit.tsx +41 -26
- package/src/locale.tsx +26 -2
- package/src/pending_commits.test.ts +68 -0
- package/src/pending_commits.ts +45 -0
- package/src/use_gated_press.ts +41 -0
package/AGENTS.md
CHANGED
|
@@ -15,7 +15,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
15
15
|
| Doc | Read it for |
|
|
16
16
|
|---|---|
|
|
17
17
|
| [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` for a custom-code app's `vite.config.ts`). Read before building any screen; reuse first. |
|
|
18
|
-
| [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (incl. the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates. |
|
|
18
|
+
| [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (incl. the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
|
|
19
19
|
| [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split (modify → review-before-apply; create → save-direct + the `ResultHeader` receipt), findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md). |
|
|
20
20
|
| [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude, banded cards, register vs inset rows, master-detail `Drawer`, view controls, color discipline, typography, whitespace. |
|
|
21
21
|
| [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget). |
|
package/docs/catalog.md
CHANGED
|
@@ -571,7 +571,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
571
571
|
select.
|
|
572
572
|
- **`form_date_picker`** — `FormDatePicker`: `FormField` wrapping a `DatePicker` — one
|
|
573
573
|
labeled date field. (Omits `style`; for a grid cell use a bare `FormField style={half}`
|
|
574
|
-
around `DatePicker`.)
|
|
574
|
+
around `DatePicker`.) Defaults the picker's `accessibilityLabel` to its own visible label
|
|
575
|
+
— the FormField label text is not otherwise associated with the segment inputs. A bare
|
|
576
|
+
`DatePicker` outside a FormField must be given `accessibilityLabel` explicitly; range
|
|
577
|
+
formats suffix each half with the locale's start/end-date names.
|
|
575
578
|
- **`switch_button`** — `SwitchButton`: the full-ROW toggle — a `PressableHighlight` row
|
|
576
579
|
(optional icon + medium title left, `Switch` pinned right) where the whole row IS the
|
|
577
580
|
switch (`accessibilityRole="switch"`, the inner Switch read-only). The settings-panel/menu
|
|
@@ -605,13 +608,19 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
605
608
|
### Inline editing & record surfaces
|
|
606
609
|
|
|
607
610
|
- **`inline_edit`** — `useInlineEdit` + `InlineEditView` / `InlineEditFrame` +
|
|
608
|
-
`
|
|
611
|
+
`useInlineEditFocusRestore` (returns focus to the RESTING control when a KEYBOARD close drops
|
|
612
|
+
it to `<body>` — what an editor with its own resting shape, e.g. the checklist `note` line,
|
|
613
|
+
reaches for instead of the frame) + `INLINE_CONTROL_HEIGHT` (40) +
|
|
614
|
+
`inlineValueTextStyle`: the engine custom inline editors
|
|
609
615
|
join through — a view ⇄ edit toggle, a draft buffer, async `onSave` with the spinner
|
|
610
616
|
INSIDE the control and inline error, commit on blur (Enter saves, Escape reverts) or
|
|
611
617
|
`controls="buttons"`; KEYBOARD focus on the closed view opens edit mode with the input
|
|
612
618
|
focused (type → Tab → type — see the data-entry keyboard contract), pointer focus never
|
|
613
619
|
does; **`variant: "form" | "cell"`** is THE axis that separates a form field from a data-grid
|
|
614
|
-
cell (see below).
|
|
620
|
+
cell (see below). Every commit registers in **`pending_commits`**, which
|
|
621
|
+
`Button`/`IconButton` wait on via **`use_gated_press`** so an action pressed in the same
|
|
622
|
+
gesture as the blur cannot read the record before the edit lands (data_entry.md § Inline
|
|
623
|
+
edit) — automatic, nothing to pass.
|
|
615
624
|
- **`inline_text_input`**, **`inline_number_input`** (`format` for currency/units),
|
|
616
625
|
**`inline_select`**, **`inline_member_select`**, **`inline_date_picker`**
|
|
617
626
|
(`format="datetime"`, `optionalTime`; keyboard focus opens the TYPED segmented `DateField`
|
|
@@ -654,7 +663,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
654
663
|
label+value row for drawer/peek detail; in FORM mode (`labelWidth` set) the value column
|
|
655
664
|
FILLS the row so a stack of inline editors all span the same width + none jumps wider on
|
|
656
665
|
edit; optional `trailing` slot renders a right-side action/badge after the value (units
|
|
657
|
-
belong IN the value via `InlineNumberInput format`). The
|
|
666
|
+
belong IN the value via `InlineNumberInput format`). The LABEL WRAPS inside its column and
|
|
667
|
+
is never clipped — a fixed `labelWidth` would otherwise ellipsize every long field name
|
|
668
|
+
("Registered business address"), and a name the reader can't finish is worse than a taller
|
|
669
|
+
row; a wrapped label's FIRST line stays level with the value's first control line while a
|
|
670
|
+
one-line label still centers on it (both modes, no prop). The FIELD-ANNOTATION vocabulary (same
|
|
658
671
|
names + meanings as `FormField`), always EXPLICIT — a row never hides guidance behind an ⓘ:
|
|
659
672
|
**`description`** = a fact / persistent guidance (muted), under the VALUE (stacked mode
|
|
660
673
|
mirrors the form order label · description · control); **`warning`** = a consequence to weigh
|
|
@@ -738,8 +751,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
738
751
|
filled check when done, distinct from the square checkbox; the task/to-do/checklist
|
|
739
752
|
toggle.
|
|
740
753
|
- **`checklist`** — `Checklist` + `ChecklistRow` — the record-scoped checklist COMPOUND: it
|
|
741
|
-
owns GEOMETRY only (row minHeight 32, gap 12, control/title alignment, the `meta`/
|
|
742
|
-
`expansion` indent, ONE `trailingWidth` so assignee cells column-align) while content
|
|
754
|
+
owns GEOMETRY only (row minHeight 32, gap 12, control/title alignment, the `note`/`meta`/
|
|
755
|
+
`subtasks`/`expansion` indent, ONE `trailingWidth` so assignee cells column-align) while content
|
|
743
756
|
stays composed — `control` takes the `CheckCircle` (omit onChange = read-only ring; a
|
|
744
757
|
PICKER list puts a `CheckboxInput` here and sets `controlWidth={24}` so the indent
|
|
745
758
|
tracks), children the struck transparent `InlineTextInput` or plain `Text`, `trailing` an
|
|
@@ -748,8 +761,34 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
748
761
|
danger-styled and last, never a bare ✕ (omit on read-only rows). NARROW surfaces (a
|
|
749
762
|
drawer/peek checklist) put the editors on the `meta` line instead of `trailing` — the
|
|
750
763
|
second line indents to the title's text edge so the TITLE keeps the full width; wide
|
|
751
|
-
surfaces use `trailing`; never both. `
|
|
752
|
-
|
|
764
|
+
surfaces use `trailing`; never both. **`note` is the row's own free-text line** —
|
|
765
|
+
`{value, onSave, addLabel?, editLabel?, placeholder?}` (`ChecklistRowNote`): the note
|
|
766
|
+
renders as a muted line directly under the title (above `meta`, on its text edge) and is
|
|
767
|
+
ALWAYS visible — never behind `expansion` — presses to edit in place, and saving EMPTY
|
|
768
|
+
removes it (`onSave("")`, whitespace trimmed first — clearing IS the delete, so a note
|
|
769
|
+
needs no ⋯ entry). A row with no note shows a small `sticky-note` add button instead,
|
|
770
|
+
which opens the focused empty editor. It composes with `trailing` AND `meta` (a second
|
|
771
|
+
LINE, not a replacement), the save may be async (spinner + inline error, editor stays
|
|
772
|
+
open), and chrome comes from the `checklist` locale slice (`addNote`, `editNote(note)` —
|
|
773
|
+
it wraps the text so the line announces with it — `notePlaceholder`). Never hand-roll a
|
|
774
|
+
muted sub-line + editor on a checklist row. **`subtasks` are the row's collapsible CHILD
|
|
775
|
+
STEPS** — `{items, expanded?, onToggleExpanded?, countLabel?}` (`ChecklistRowSubtasks`),
|
|
776
|
+
each item a `ChecklistSubtask` `{key, label, checked, onToggle}` and nothing more (no
|
|
777
|
+
note, no assignee, no ⋯: a step needing those is a TASK — give it a row). The parent grows
|
|
778
|
+
a chevron expander on the TRAILING side of the title (never the leading edge — that's
|
|
779
|
+
`control`, and a chevron there would knock the title off the text edge on every row
|
|
780
|
+
WITHOUT steps) carrying the count of UNCHECKED steps beside it; expanded, the children
|
|
781
|
+
render as compact `CheckboxInput` + label rows on the title's text edge, denser than the
|
|
782
|
+
parent (24 vs 32) and struck+muted when checked. `items: []` renders NOTHING, not even the
|
|
783
|
+
expander, so a row can pass the prop unconditionally. Expansion is controlled with
|
|
784
|
+
`expanded` + `onToggleExpanded`, or UNCONTROLLED (omit `expanded`): open on mount while any
|
|
785
|
+
step is unchecked, then the user's toggle wins — checking the last step must not yank the
|
|
786
|
+
list shut. A11y: the expander keeps ONE stable name (`countLabel(missing)` → the
|
|
787
|
+
`checklist` locale slice's `subtasks(missing)` → English) with `aria-expanded` carrying the
|
|
788
|
+
state, and the children sit in a `role="group"` named by the row's own title, so they are
|
|
789
|
+
announced UNDER their parent instead of as loose checkboxes. Never hand-roll indented child
|
|
790
|
+
rows with a bespoke chevron. `expansion` is a BLOCK slot under the row on
|
|
791
|
+
the same indent — a transient inline fill editor, a drill-down — rendered only while open
|
|
753
792
|
(`tpl_record`'s Document set rows are the worked example). SUGGESTIONS are never rows:
|
|
754
793
|
offer the commons as `SuggestionChip`s under the list. Close the list with `CaptureRow`.
|
|
755
794
|
There is deliberately NO monolithic Task component — richer task-management rows compose
|
package/docs/composition.md
CHANGED
|
@@ -440,7 +440,9 @@ gate's SCOPE, once** — never prose beside the button, never revealed only on p
|
|
|
440
440
|
fact or consequence, never widget mechanics, never a second sentence; `warning` marks a
|
|
441
441
|
consequence, not decoration. Rows share ONE
|
|
442
442
|
alignment law: the row top-aligns and label · control · trailing each center within the
|
|
443
|
-
first control line — a tall value block never drags the label
|
|
443
|
+
first control line — a tall value block never drags the label, and a LABEL too long for its
|
|
444
|
+
column WRAPS (never clipped, never abbreviated to fit) with its first line still level with
|
|
445
|
+
the value. A FLAT value row
|
|
444
446
|
(`InlineStatic`, plain `Text`) sets **`DetailRow flat`**: the annotation tucks up by the
|
|
445
447
|
control band's slack so the perceived gap under the text equals the gap under a chip —
|
|
446
448
|
the band's invisible bottom half must never read as a hole above the description.
|
package/docs/data_entry.md
CHANGED
|
@@ -49,6 +49,31 @@ chip, hover reveals the input-family border (no extra grey wash, no pencil icon
|
|
|
49
49
|
layout), click OR keyboard focus swaps the input in **at the same height** (zero reflow, the whole
|
|
50
50
|
point), and it commits on blur (Enter saves, Escape reverts) or via `controls="buttons"` (✓ / ✕).
|
|
51
51
|
|
|
52
|
+
**Commit-on-blur would race the press that caused it — the kit closes that race for you.**
|
|
53
|
+
Pressing an action while an inline field is focused starts TWO things in ONE gesture: the
|
|
54
|
+
field's write (the blur, on mousedown) and the button's handler (on mouseup). A handler that
|
|
55
|
+
RE-READS the record — to print a document, mint an invoice, copy the row into a new one — would
|
|
56
|
+
overtake the still-in-flight write and act on the PRE-EDIT value, silently: the new value on
|
|
57
|
+
screen, the old one in the output. So **`useInlineEdit` registers every commit and
|
|
58
|
+
`Button`/`IconButton` hold their press until the commits already in flight have settled**
|
|
59
|
+
(`pending_commits.ts`), showing their busy spinner while they wait. Nothing to wire up: with no
|
|
60
|
+
commit pending the press dispatches synchronously exactly as before, and a FAILED commit
|
|
61
|
+
releases the gate too (the field surfaces its own error). A commit started after the press was
|
|
62
|
+
taken is deliberately not waited for — the gate holds a press for the edit that caused it, never
|
|
63
|
+
for later work.
|
|
64
|
+
|
|
65
|
+
**What the gate cannot see — an action must take an ID, not a captured object.** Holding the
|
|
66
|
+
press makes the STORE current; it cannot refresh a value the handler already closed over.
|
|
67
|
+
`onPress={() => onIssue(invoice)}` captures the invoice as it was at press time, so the
|
|
68
|
+
confirm dialog quotes — and the mint bills — the pre-edit total no matter how long the gate
|
|
69
|
+
waits. Pass the KEY and resolve the object where it is used
|
|
70
|
+
(`onPress={() => onIssue(invoice.key)}`, then `invoices.find(…)` at render): worked in
|
|
71
|
+
`tpl_record`'s Billing band, whose charge cells are inline editors and whose Issue is
|
|
72
|
+
irreversible. The same rule kills the variant with no race at all — a handler reading a server
|
|
73
|
+
row the screen copied into `useState` (`app-sdk` `docs/mutations.md` § A read must not overtake
|
|
74
|
+
an in-flight write) — as does a write started by something other than an inline commit (a
|
|
75
|
+
dialog save, a debounced autosave).
|
|
76
|
+
|
|
52
77
|
### Keyboard entry — type → Tab → type
|
|
53
78
|
|
|
54
79
|
Bulk entry never needs the mouse. KEYBOARD focus (Tab / Shift+Tab) landing on a closed
|
|
@@ -132,7 +157,9 @@ narrower than its neighbours'. Rows share ONE alignment law: the row top-aligns
|
|
|
132
157
|
control · trailing each center within the first control line — annotations (the three tones
|
|
133
158
|
under the value: `description` muted · `warning` amber · `error` danger, each indented to the
|
|
134
159
|
chip's 8px text inset) grow the row downward without dragging the label. The full annotation
|
|
135
|
-
vocabulary lives in composition.md §Field annotations.
|
|
160
|
+
vocabulary lives in composition.md §Field annotations. A long LABEL wraps inside its column
|
|
161
|
+
instead of clipping — never shorten a field name to fit the label width, and never reach for a
|
|
162
|
+
tooltip to recover the tail; the wrapped label's first line stays level with the value.
|
|
136
163
|
|
|
137
164
|
### The editability affordance
|
|
138
165
|
|
|
@@ -236,7 +263,19 @@ struck transparent `InlineTextInput`, or plain `Text`), then EITHER a `trailing`
|
|
|
236
263
|
surfaces (the per-task assignee `InlineMemberSelect`, sized by the list's `trailingWidth`) OR a
|
|
237
264
|
`meta` line under the title on NARROW surfaces (a drawer/popover checklist — the two are mutually
|
|
238
265
|
exclusive), plus a `menu` for the row's ⋯ options — never a decorative progress strip. `CaptureRow`
|
|
239
|
-
closes the list as its add-affordance. A
|
|
266
|
+
closes the list as its add-affordance. A task's own free text — the "why it's stuck" nothing else on
|
|
267
|
+
the record holds — is the row's **`note`** (`{value, onSave}`): a muted line under the title, ALWAYS
|
|
268
|
+
visible (a note behind an expansion gets written twice), pressed to edit in place, and REMOVED by
|
|
269
|
+
saving it empty — so it needs no ⋯ entry and no second field. A note-less row carries a small add
|
|
270
|
+
button that opens the focused editor. It composes with `trailing` AND `meta` — a second LINE, not a
|
|
271
|
+
replacement — so a per-row note is never a hand-rolled sub-line, a comment thread, or an
|
|
272
|
+
expansion. A task made of several ticks takes **`subtasks`** (`{items, expanded?,
|
|
273
|
+
onToggleExpanded?, countLabel?}`) rather than N sibling rows: the parent grows a chevron + the
|
|
274
|
+
count of unchecked steps, and the children — `{key, label, checked, onToggle}` and NOTHING else —
|
|
275
|
+
render indented to the title's text edge when open. The line is sharp: a step that needs its own
|
|
276
|
+
note, due date, assignee, or ⋯ menu is a TASK, so give it a row; subtasks are for the ticks that
|
|
277
|
+
only make sense under their parent. Omit `expanded` and the row opens itself while work is left,
|
|
278
|
+
then hands the state to the user. A NEW record starts with an EMPTY checklist — tasks truly
|
|
240
279
|
vary. The commons split in two: MANDATORY tasks are seeded by the app (a workflow on create, per
|
|
241
280
|
record type) — no human types them; common-but-OPTIONAL tasks appear as `SuggestionChip`s under
|
|
242
281
|
the list — a PILL, never a row, so a suggestion can't be mistaken for a task (tap = materialize,
|
package/docs/templates.md
CHANGED
|
@@ -292,7 +292,12 @@ billing, and quick-capture templates. Top → bottom:
|
|
|
292
292
|
= `dueTone` — red past due, amber ≤3d, else muted), the assignee an **`avatarOnly`** bare avatar
|
|
293
293
|
(dashed add-ghost when unset), sized to the list's `trailingWidth` so the title never overlaps.
|
|
294
294
|
(Contrast the General section's dates/assignee, the same editors in the default `variant="form"`
|
|
295
|
-
— a zinc-50 chip + hover-border + the full member chip.)
|
|
295
|
+
— a zinc-50 chip + hover-border + the full member chip.) Two rows carry a **`note`** — the kit's
|
|
296
|
+
per-row muted second line (press to edit, save empty to remove); the rest show its add button.
|
|
297
|
+
ONE row ("Book the carrier") carries **`subtasks`** — four child ticks behind the kit's chevron +
|
|
298
|
+
remaining-count expander, open because work is left; every other row passes an EMPTY list, which
|
|
299
|
+
renders nothing at all. It is the line to copy: a booking's steps carry only a tick, so they stay
|
|
300
|
+
child rows — anything needing its own note/due/assignee would be a task instead.
|
|
296
301
|
- **Documents** — the Agents "Document desk" pattern (this template is its worked example —
|
|
297
302
|
see the Agents chapter below): the register `Table` (search · Add files) whose selection
|
|
298
303
|
feeds the `FloatingActionBar` → ONE "Use AI" fork (extract / cross-check / edit-with-AI).
|
|
@@ -329,7 +334,10 @@ billing, and quick-capture templates. Top → bottom:
|
|
|
329
334
|
scope, once — a band-local problem (charged line missing its method) in the band, a broken
|
|
330
335
|
record premise (no customer / invalid tax ID) at the section top; self-evident empties
|
|
331
336
|
stay silent, and NEVER prose beside a CTA. Issuing gates on record premises only — never
|
|
332
|
-
silently on stage.
|
|
337
|
+
silently on stage. It is also the worked example of **an irreversible action taking an ID,
|
|
338
|
+
not a captured object**: `onIssue(inv.key)` with the invoice resolved where the confirm
|
|
339
|
+
renders, so the quoted total is the one that will be billed even when the press was held
|
|
340
|
+
for a charge cell's write (data_entry.md § Inline edit).
|
|
333
341
|
- **Document set — the OUTPUT desk, the last WORK section** (the composition rules' output
|
|
334
342
|
law worked on the record surface: the top is intake, the bottom produces on demand).
|
|
335
343
|
Forms group PER PARTY on the `SubsectionStack` beat — each party a `SubsectionHeading` +
|
|
@@ -2,7 +2,7 @@ import { useEffect, useState, useRef } from "react";
|
|
|
2
2
|
import { ScrollView, View } from "react-native";
|
|
3
3
|
import type { UIMessagePart, UIDataTypes, UITools } from "ai";
|
|
4
4
|
import { Text } from "@lotics/ui/text";
|
|
5
|
-
import { colors, type ColorName } from "@lotics/ui/colors";
|
|
5
|
+
import { colors, solid, type ColorName } from "@lotics/ui/colors";
|
|
6
6
|
import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
|
|
7
7
|
import { Alert } from "@lotics/ui/alert";
|
|
8
8
|
import { Badge } from "@lotics/ui/badge";
|
|
@@ -1391,12 +1391,12 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
|
|
|
1391
1391
|
{[...landed].sort((a, b) => Number(b.refFlagged) - Number(a.refFlagged)).map((l) => (
|
|
1392
1392
|
<ListItem
|
|
1393
1393
|
key={l.id}
|
|
1394
|
-
left={<Icon name={l.refFlagged ? "circle-alert" : "circle-check"} size={18} color={l.refFlagged ?
|
|
1394
|
+
left={<Icon name={l.refFlagged ? "circle-alert" : "circle-check"} size={18} color={l.refFlagged ? solid("amber") : solid("green")} />}
|
|
1395
1395
|
title={`Created ${l.title}`}
|
|
1396
1396
|
description={l.detail}
|
|
1397
1397
|
right={
|
|
1398
1398
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
1399
|
-
{l.refFlagged ? <Text size="xs" weight="medium" style={{ color:
|
|
1399
|
+
{l.refFlagged ? <Text size="xs" weight="medium" style={{ color: solid("amber") }}>1 to check</Text> : null}
|
|
1400
1400
|
<Icon name="chevron-right" size={16} color={colors.zinc[400]} />
|
|
1401
1401
|
</View>
|
|
1402
1402
|
}
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -165,6 +165,16 @@ interface StageTask {
|
|
|
165
165
|
/** ISO date (`""` = no due). The row shows it in a `variant="cell"` date field —
|
|
166
166
|
* no calendar glyph, coloured by urgency (`tone`), press to edit. */
|
|
167
167
|
due: string;
|
|
168
|
+
/** The task's own note (`""` = none). The `ChecklistRow` `note` contract owns
|
|
169
|
+
* the whole affordance — the muted second line, the tap-to-edit, the add
|
|
170
|
+
* button on a note-less row, and removal by saving empty. */
|
|
171
|
+
note: string;
|
|
172
|
+
/** The task's CHILD STEPS (`[]` = none, and then nothing renders — not even
|
|
173
|
+
* the expander). The `ChecklistRow` `subtasks` contract owns the whole
|
|
174
|
+
* affordance: the chevron + remaining count on the parent, the open/closed
|
|
175
|
+
* state, and the indented child rows. A step that needs its own note, due
|
|
176
|
+
* date, or assignee is a TASK — give it a row, not a subtask. */
|
|
177
|
+
subtasks: { key: string; label: string; done: boolean }[];
|
|
168
178
|
}
|
|
169
179
|
|
|
170
180
|
// A due date N days from today — keeps the seeded urgency states (overdue / soon /
|
|
@@ -203,13 +213,23 @@ const newTaskId = () => `t_${(taskSeq += 1)}`;
|
|
|
203
213
|
|
|
204
214
|
// The in-flight demo record's existing checklist (a NEW record gets []).
|
|
205
215
|
const TASK_SEEDS: StageTask[] = [
|
|
206
|
-
{ id: "t1", label: "Confirm pricing with the customer", stage: "sales", done: true, assignee: "mem_01", due: dueIn(-10) },
|
|
207
|
-
{ id: "t2", label: "Attach the signed quote", stage: "sales", done: true, assignee: "mem_01", due: "" },
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
{ id: "
|
|
211
|
-
|
|
212
|
-
|
|
216
|
+
{ id: "t1", label: "Confirm pricing with the customer", stage: "sales", done: true, assignee: "mem_01", due: dueIn(-10), note: "", subtasks: [] },
|
|
217
|
+
{ id: "t2", label: "Attach the signed quote", stage: "sales", done: true, assignee: "mem_01", due: "", note: "", subtasks: [] },
|
|
218
|
+
// two seeded NOTES — the "why it's stuck" a task carries and nothing else
|
|
219
|
+
// holds; every other row shows the add affordance instead
|
|
220
|
+
{ id: "t3", label: "Verify the customer's tax ID", stage: "sales", done: false, assignee: null, due: dueIn(-2), note: "Their finance desk answers after 15:00 only.", subtasks: [] },
|
|
221
|
+
// the one task with SUBTASKS — a booking is a sequence, and its steps carry
|
|
222
|
+
// nothing but a tick, so they stay child rows instead of four more tasks
|
|
223
|
+
// (the row opens with work left; press the chevron to close it)
|
|
224
|
+
{ id: "t4", label: "Book the carrier", stage: "operations", done: false, assignee: "mem_02", due: dueIn(1), note: "", subtasks: [
|
|
225
|
+
{ key: "s1", label: "Confirm the pickup window", done: true },
|
|
226
|
+
{ key: "s2", label: "Send the packing list", done: false },
|
|
227
|
+
{ key: "s3", label: "Get the booking reference", done: false },
|
|
228
|
+
{ key: "s4", label: "Share the reference with the customer", done: false },
|
|
229
|
+
] },
|
|
230
|
+
{ id: "t5", label: "Attach the delivery documents", stage: "operations", done: false, assignee: null, due: dueIn(6), note: "Waiting on the signed copy from the yard.", subtasks: [] },
|
|
231
|
+
{ id: "t6", label: "Issue every invoice", stage: "accounting", done: false, assignee: "mem_03", due: "", note: "", subtasks: [] },
|
|
232
|
+
{ id: "t7", label: "Reconcile the receipts", stage: "accounting", done: false, assignee: null, due: dueIn(14), note: "", subtasks: [] },
|
|
213
233
|
];
|
|
214
234
|
|
|
215
235
|
// The assignable roster — an app feeds `useMembers()` here.
|
|
@@ -345,7 +365,10 @@ function InvoiceBand({
|
|
|
345
365
|
gated: boolean;
|
|
346
366
|
onAmount: (chKey: string, v: number) => Promise<void>;
|
|
347
367
|
onMethod: (chKey: string, m: Method) => Promise<void>;
|
|
348
|
-
|
|
368
|
+
/** Takes the invoice's KEY, never the `inv` object: the press may be held while
|
|
369
|
+
* a charge cell finishes writing (Button waits out an inline commit), and a
|
|
370
|
+
* captured object would still be the pre-edit snapshot. Resolve at use. */
|
|
371
|
+
onIssue: (invKey: string) => void;
|
|
349
372
|
}) {
|
|
350
373
|
const total = invoiceTotal(inv);
|
|
351
374
|
const state = invoiceStatus(inv);
|
|
@@ -410,10 +433,10 @@ function InvoiceBand({
|
|
|
410
433
|
{state === "issued" ? (
|
|
411
434
|
<>
|
|
412
435
|
<Link size="xs" onPress={() => {}} accessibilityLabel={`Open invoice ${inv.ref}`}>{inv.ref}</Link>
|
|
413
|
-
<Button title="Re-issue" color="secondary" disabled={!ready} onPress={() => onIssue(inv)} />
|
|
436
|
+
<Button title="Re-issue" color="secondary" disabled={!ready} onPress={() => onIssue(inv.key)} />
|
|
414
437
|
</>
|
|
415
438
|
) : (
|
|
416
|
-
<Button title="Issue invoice" color="primary" disabled={!ready} onPress={() => onIssue(inv)} />
|
|
439
|
+
<Button title="Issue invoice" color="primary" disabled={!ready} onPress={() => onIssue(inv.key)} />
|
|
417
440
|
)}
|
|
418
441
|
</View>
|
|
419
442
|
</Subsection>
|
|
@@ -832,7 +855,9 @@ export function TplRecord() {
|
|
|
832
855
|
// ── billing — the bill-to tax ID comes off the CUSTOMER
|
|
833
856
|
const [invoices, setInvoices] = useState<Invoice[]>(BILLING_INITIAL);
|
|
834
857
|
const [deposit, setDeposit] = useState(0);
|
|
835
|
-
|
|
858
|
+
// The key, not the invoice: what the dialog shows and issues is derived below, so a
|
|
859
|
+
// charge that landed between the press and the confirm is reflected, never a stale total.
|
|
860
|
+
const [confirmIssueKey, setConfirmIssueKey] = useState<string | null>(null);
|
|
836
861
|
const seq = useRef(414);
|
|
837
862
|
const taxId = customer?.taxId ?? "";
|
|
838
863
|
const taxIdValid = TAX_ID_RE.test(taxId);
|
|
@@ -1133,11 +1158,11 @@ export function TplRecord() {
|
|
|
1133
1158
|
const addTask = () => {
|
|
1134
1159
|
const label = newTask.trim();
|
|
1135
1160
|
if (!label) return;
|
|
1136
|
-
setTasks((prev) => [...prev, { id: newTaskId(), label, stage: captureDesk, done: false, assignee: null, due: "" }]);
|
|
1161
|
+
setTasks((prev) => [...prev, { id: newTaskId(), label, stage: captureDesk, done: false, assignee: null, due: "", note: "", subtasks: [] }]);
|
|
1137
1162
|
setNewTask("");
|
|
1138
1163
|
};
|
|
1139
1164
|
const addSuggested = (desk: Desk, label: string) =>
|
|
1140
|
-
setTasks((prev) => [...prev, { id: newTaskId(), label, stage: desk, done: false, assignee: null, due: "" }]);
|
|
1165
|
+
setTasks((prev) => [...prev, { id: newTaskId(), label, stage: desk, done: false, assignee: null, due: "", note: "", subtasks: [] }]);
|
|
1141
1166
|
const removeTask = (tid: string) => setTasks((prev) => prev.filter((t) => t.id !== tid));
|
|
1142
1167
|
const moveTask = (tid: string, desk: Desk) =>
|
|
1143
1168
|
setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, stage: desk } : t)));
|
|
@@ -1148,6 +1173,15 @@ export function TplRecord() {
|
|
|
1148
1173
|
setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, assignee: member } : t)));
|
|
1149
1174
|
const dueTask = (tid: string, due: string) =>
|
|
1150
1175
|
setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, due } : t)));
|
|
1176
|
+
// Saving an EMPTY note is the removal — one write path, no separate delete.
|
|
1177
|
+
const noteTask = (tid: string, note: string) =>
|
|
1178
|
+
setTasks((prev) => prev.map((t) => (t.id === tid ? { ...t, note } : t)));
|
|
1179
|
+
// A child step patches by key — the kit owns the expander and the count, the
|
|
1180
|
+
// app owns nothing but the tick.
|
|
1181
|
+
const toggleSubtask = (tid: string, key: string, done: boolean) =>
|
|
1182
|
+
setTasks((prev) =>
|
|
1183
|
+
prev.map((t) => (t.id === tid ? { ...t, subtasks: t.subtasks.map((s) => (s.key === key ? { ...s, done } : s)) } : t)),
|
|
1184
|
+
);
|
|
1151
1185
|
// The Task-list grammar: a clearable Group-by + filter chips derive the
|
|
1152
1186
|
// sections; empty groups drop (except desks — the journey stays visible).
|
|
1153
1187
|
const [taskGroup, setTaskGroup] = useState<"desk" | "assignee" | "status" | null>("desk");
|
|
@@ -1238,6 +1272,9 @@ export function TplRecord() {
|
|
|
1238
1272
|
}, 350);
|
|
1239
1273
|
});
|
|
1240
1274
|
|
|
1275
|
+
// Resolved from the CURRENT invoices every render — the confirm dialog quotes the
|
|
1276
|
+
// total that will actually be billed, including a charge that landed after the press.
|
|
1277
|
+
const confirmIssue = invoices.find((i) => i.key === confirmIssueKey) ?? null;
|
|
1241
1278
|
const grandTotal = invoices.reduce((sum, inv) => sum + invoiceTotal(inv), 0);
|
|
1242
1279
|
const allMissing = invoices.flatMap(missingMethods);
|
|
1243
1280
|
const issuedCount = invoices.filter((i) => invoiceStatus(i) === "issued").length;
|
|
@@ -1246,7 +1283,7 @@ export function TplRecord() {
|
|
|
1246
1283
|
seq.current += 1;
|
|
1247
1284
|
const ref = `INV-2026-${String(seq.current).padStart(4, "0")}`;
|
|
1248
1285
|
setInvoices((prev) => prev.map((x) => (x.key === inv.key ? { ...x, ref } : x)));
|
|
1249
|
-
|
|
1286
|
+
setConfirmIssueKey(null);
|
|
1250
1287
|
logActivity("credit-card", `Invoice ${ref} issued — ${inv.title}`, { description: formatMoney(invoiceTotal(inv)) });
|
|
1251
1288
|
};
|
|
1252
1289
|
|
|
@@ -1858,6 +1895,23 @@ export function TplRecord() {
|
|
|
1858
1895
|
<InlineMemberSelect variant="cell" avatarOnly members={TEAM} value={t.assignee} onSave={(m) => assignTask(t.id, m)} accessibilityLabel={`Assignee · ${t.label}`} />
|
|
1859
1896
|
</View>
|
|
1860
1897
|
}
|
|
1898
|
+
// the row's own NOTE — the muted second line under the
|
|
1899
|
+
// title (the "why it's stuck" a task carries); the kit
|
|
1900
|
+
// owns the whole affordance, so nothing here hand-rolls
|
|
1901
|
+
// a line + editor. Empty = the add button on the row.
|
|
1902
|
+
note={{ value: t.note, onSave: (v) => noteTask(t.id, v), addLabel: `Add note: ${t.label}` }}
|
|
1903
|
+
// the row's CHILD STEPS — one task made of several
|
|
1904
|
+
// ticks. The kit renders the chevron + remaining count
|
|
1905
|
+
// on the parent and the indented child rows when open;
|
|
1906
|
+
// an EMPTY list renders nothing, so every row can pass
|
|
1907
|
+
// the prop unconditionally. Expansion is left
|
|
1908
|
+
// UNCONTROLLED: a row with work left opens itself.
|
|
1909
|
+
subtasks={{
|
|
1910
|
+
items: t.subtasks.map((s) => ({ key: s.key, label: s.label, checked: s.done, onToggle: (on) => toggleSubtask(t.id, s.key, on) })),
|
|
1911
|
+
// the row name matters once the list is long — the
|
|
1912
|
+
// locale default ("Subtasks: 3 left") can't know it
|
|
1913
|
+
countLabel: (missing) => (missing > 0 ? `Subtasks · ${t.label}: ${missing} left` : `Subtasks · ${t.label}: all done`),
|
|
1914
|
+
}}
|
|
1861
1915
|
menu={{ items: menuItems, accessibilityLabel: `Task options: ${t.label}` }}
|
|
1862
1916
|
>
|
|
1863
1917
|
<InlineTextInput
|
|
@@ -2124,7 +2178,7 @@ export function TplRecord() {
|
|
|
2124
2178
|
gated={customer === null || !taxIdValid}
|
|
2125
2179
|
onAmount={(chKey, v) => saveCharge(inv.key, chKey, { amount: v })}
|
|
2126
2180
|
onMethod={(chKey, m) => saveCharge(inv.key, chKey, { method: m })}
|
|
2127
|
-
onIssue={
|
|
2181
|
+
onIssue={setConfirmIssueKey}
|
|
2128
2182
|
/>
|
|
2129
2183
|
))}
|
|
2130
2184
|
|
|
@@ -2585,7 +2639,7 @@ export function TplRecord() {
|
|
|
2585
2639
|
</Dialog>
|
|
2586
2640
|
|
|
2587
2641
|
{/* issuing an e-invoice is irreversible — confirm in a Dialog (stage gate) */}
|
|
2588
|
-
<Dialog width={460} open={confirmIssue !== null} onOpenChange={(o) => { if (!o)
|
|
2642
|
+
<Dialog width={460} open={confirmIssue !== null} onOpenChange={(o) => { if (!o) setConfirmIssueKey(null); }}>
|
|
2589
2643
|
<DialogHeader>
|
|
2590
2644
|
<DialogHeaderTitle>{confirmIssue?.ref ? "Re-issue invoice?" : "Issue e-invoice?"}</DialogHeaderTitle>
|
|
2591
2645
|
</DialogHeader>
|
|
@@ -2601,7 +2655,7 @@ export function TplRecord() {
|
|
|
2601
2655
|
</Callout>
|
|
2602
2656
|
</View>
|
|
2603
2657
|
<DialogFooter>
|
|
2604
|
-
<Button title="Cancel" color="secondary" onPress={() =>
|
|
2658
|
+
<Button title="Cancel" color="secondary" onPress={() => setConfirmIssueKey(null)} />
|
|
2605
2659
|
<Button title="Issue" color="primary" onPress={() => confirmIssue && issue(confirmIssue)} />
|
|
2606
2660
|
</DialogFooter>
|
|
2607
2661
|
</Dialog>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "16.
|
|
3
|
+
"version": "16.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -124,6 +124,7 @@
|
|
|
124
124
|
"./menu_button": "./src/menu_button.tsx",
|
|
125
125
|
"./menu_list_item": "./src/menu_list_item.tsx",
|
|
126
126
|
"./pressable_highlight": "./src/pressable_highlight.tsx",
|
|
127
|
+
"./pending_commits": "./src/pending_commits.ts",
|
|
127
128
|
"./pressable_row": "./src/pressable_row.tsx",
|
|
128
129
|
"./press_door": "./src/press_door.tsx",
|
|
129
130
|
"./floating_action_bar": "./src/floating_action_bar.tsx",
|
|
@@ -151,6 +152,7 @@
|
|
|
151
152
|
"./use_screen_size": "./src/use_screen_size.ts",
|
|
152
153
|
"./use_auto_grow_height": "./src/use_auto_grow_height.ts",
|
|
153
154
|
"./use_focus_ring": "./src/use_focus_ring.ts",
|
|
155
|
+
"./use_gated_press": "./src/use_gated_press.ts",
|
|
154
156
|
"./interaction_modality": "./src/interaction_modality.ts",
|
|
155
157
|
"./inline_focus": "./src/inline_focus.ts",
|
|
156
158
|
"./use_hover": "./src/use_hover.ts",
|
|
@@ -312,4 +314,4 @@
|
|
|
312
314
|
"unified": "^11.0.5",
|
|
313
315
|
"vite": "^7.2.4"
|
|
314
316
|
}
|
|
315
|
-
}
|
|
317
|
+
}
|
package/src/button.tsx
CHANGED
|
@@ -12,11 +12,12 @@ import { Text } from "./text";
|
|
|
12
12
|
import { colors } from "./colors";
|
|
13
13
|
import { CONTROL_RADIUS, FOCUS_RING } from "./control_surface";
|
|
14
14
|
import { Icon, IconName } from "./icon";
|
|
15
|
-
import { Ref
|
|
15
|
+
import { Ref } from "react";
|
|
16
16
|
import { TextColor } from "./text_utils";
|
|
17
17
|
import { useTooltip, UseTooltipOptions } from "./tooltip";
|
|
18
18
|
import { useFocusRing, composeHandler } from "./use_focus_ring";
|
|
19
19
|
import { getButtonIconColor } from "./button_colors";
|
|
20
|
+
import { useGatedPress } from "./use_gated_press";
|
|
20
21
|
export type ButtonColor = "primary" | "secondary" | "danger" | "muted" | "danger-secondary";
|
|
21
22
|
export type ButtonIconPosition = "left" | "right";
|
|
22
23
|
|
|
@@ -60,20 +61,18 @@ export function Button(props: ButtonProps) {
|
|
|
60
61
|
accessibilityLabel,
|
|
61
62
|
} = props;
|
|
62
63
|
|
|
63
|
-
|
|
64
|
+
// The press waits out an inline commit the same gesture started (pending_commits.ts)
|
|
65
|
+
// and reports busy while it does — so `busy`, not the `loading` prop alone, drives the
|
|
66
|
+
// spinner, aria-busy, and the double-press block.
|
|
67
|
+
const { handlePress, waiting } = useGatedPress<NativeSyntheticEvent<any>>(onPress);
|
|
68
|
+
const busy = loading || waiting;
|
|
69
|
+
const disabledOrLoading = disabled || busy;
|
|
64
70
|
const tooltipProps = useTooltip(tooltip);
|
|
65
71
|
const { focusVisible, focusProps } = useFocusRing();
|
|
66
72
|
|
|
67
|
-
const handlePress = useCallback(
|
|
68
|
-
(event: NativeSyntheticEvent<any>) => {
|
|
69
|
-
onPress?.(event);
|
|
70
|
-
},
|
|
71
|
-
[onPress],
|
|
72
|
-
);
|
|
73
|
-
|
|
74
73
|
const children = (
|
|
75
74
|
<>
|
|
76
|
-
{
|
|
75
|
+
{busy ? (
|
|
77
76
|
<ActivityIndicator color={getButtonIconColor(color)} />
|
|
78
77
|
) : (
|
|
79
78
|
<>
|
|
@@ -107,7 +106,7 @@ export function Button(props: ButtonProps) {
|
|
|
107
106
|
(typeof tooltip === "string" ? tooltip : tooltip?.text) ||
|
|
108
107
|
undefined
|
|
109
108
|
}
|
|
110
|
-
aria-disabled={disabledOrLoading || undefined} aria-busy={
|
|
109
|
+
aria-disabled={disabledOrLoading || undefined} aria-busy={busy || undefined}
|
|
111
110
|
disabled={disabledOrLoading}
|
|
112
111
|
style={({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => {
|
|
113
112
|
const isPrimary = color === "primary" && !disabled;
|