@lotics/ui 25.0.0 → 26.0.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 +11 -1
- package/MIGRATION.md +51 -0
- package/docs/catalog.md +39 -7
- package/docs/composition.md +40 -9
- package/docs/templates.md +6 -3
- package/examples/tpl_record.tsx +41 -34
- package/package.json +2 -1
- package/src/callout.tsx +4 -1
- package/src/empty_state.tsx +17 -10
- package/src/error_state.tsx +75 -0
- package/src/inline_select.tsx +12 -2
- package/src/locale.tsx +5 -0
- package/src/pipeline.tsx +48 -8
- package/src/reference_field.tsx +42 -10
- package/src/stepper.tsx +14 -6
- package/src/stepper_layout.ts +38 -0
package/AGENTS.md
CHANGED
|
@@ -27,13 +27,23 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
27
27
|
license to hand-roll.
|
|
28
28
|
- **One canonical component per data role** (member → `MemberChip`, select → `OptionBadge`,
|
|
29
29
|
files → `FilePreview` family, …) — the catalog's Reach-by-role outranks neighboring code.
|
|
30
|
+
- **A section's ADD rides its heading row, right edge** — Add files, Add fee, New line: a
|
|
31
|
+
`primary` `Button` beside `SectionHeadingTitle`, rendered empty or full. Under the rows it
|
|
32
|
+
extends, the verb MOVES with the row count and vanishes off-screen on a long list; a heading is
|
|
33
|
+
the one place it doesn't. → [composition.md §The add-placement law](./docs/composition.md).
|
|
34
|
+
- **`EmptyState` carries NO verb, and a FAILED read is not an empty one.** Four region states,
|
|
35
|
+
picked by what the region can ASSERT: `Skeleton`/`Loading` in flight → **`ErrorState`**
|
|
36
|
+
(`message`/`detail`/`onRetry`) on failure → `EmptyState` (succeeded, found nothing) →
|
|
37
|
+
`CompletionState` on done. An alert glyph inside an empty state claims the read succeeded when
|
|
38
|
+
nothing is known. → [catalog.md §Status / feedback](./docs/catalog.md).
|
|
30
39
|
- **Progress: rows of WORK vs positions of ONE thing.** N items ticked in any order, every row
|
|
31
40
|
the same shape → `task` (`TaskList`). ONE record walking ordered stages where the stage decides
|
|
32
41
|
which fields, conditions and act are even offered → **`pipeline`** (`Pipeline` +
|
|
33
42
|
`PipelineStage`, over `stepper`) — worked in `examples/tpl_record.tsx` § Progress, which it took
|
|
34
43
|
over FROM a per-desk checklist. Rendering positions as a checklist forces every row to carry
|
|
35
44
|
every control and never says where the record sits; rendering work items as a pipeline implies
|
|
36
|
-
an order that is not there.
|
|
45
|
+
an order that is not there. A long ladder whose stages each own one value goes dense with
|
|
46
|
+
`PipelineStage.trailing` rather than a hand-rolled title row — see `docs/catalog.md`.
|
|
37
47
|
- **`Badge` = STATUS only; supporting detail is the muted second line.** A type / category /
|
|
38
48
|
attribute / count is not a status — it belongs under its identity as `size="xs" color="muted"`,
|
|
39
49
|
never a second chip. A chip beside a name reads as its PEER (a colored one reads louder),
|
package/MIGRATION.md
CHANGED
|
@@ -4,6 +4,57 @@ Breaking changes, newest first — normally per major, plus the rare minor that
|
|
|
4
4
|
anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
|
|
5
5
|
this file exists only to move an app from one release to the next.
|
|
6
6
|
|
|
7
|
+
## 26.0.0 — a section's ADD moves to its heading row; `EmptyState` loses its verb
|
|
8
|
+
|
|
9
|
+
**`EmptyState.action` is REMOVED — no replacement on that component.** Two different things
|
|
10
|
+
were going into it, and neither belonged:
|
|
11
|
+
|
|
12
|
+
- **"Create the first one"** gave the section's add a SECOND home that exists only while the
|
|
13
|
+
list is empty, so the verb jumped elsewhere the moment the first row landed — and on a long
|
|
14
|
+
list the other copy sat off-screen below the rows. The add now lives on the section's heading
|
|
15
|
+
row, at the right, rendered whether the collection is empty or full (`AGENTS.md` iron rules,
|
|
16
|
+
`composition.md` § The add-placement law).
|
|
17
|
+
- **"The read failed, try again"** was a failure wearing an empty state's clothes. An empty
|
|
18
|
+
state asserts the read SUCCEEDED and found nothing; a failed read does not know whether there
|
|
19
|
+
is anything. That is now **`ErrorState`** (NEW, `@lotics/ui/error_state`).
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
// BEFORE — the add lived in the empty state, and again under the rows
|
|
23
|
+
<SectionHeading><SectionHeadingTitle>Fees</SectionHeadingTitle></SectionHeading>
|
|
24
|
+
{fees.length === 0
|
|
25
|
+
? <EmptyState message="No fees" action={<Button title="Add fee" onPress={addFee} />} />
|
|
26
|
+
: <Table …/>}
|
|
27
|
+
{fees.length > 0 ? <Button title="Add fee" onPress={addFee} /> : null}
|
|
28
|
+
|
|
29
|
+
// AFTER — one add, one place, never moves
|
|
30
|
+
<SectionHeading>
|
|
31
|
+
<SectionHeadingTitle>Fees</SectionHeadingTitle>
|
|
32
|
+
<Button title="Add fee" color="primary" onPress={addFee} />
|
|
33
|
+
</SectionHeading>
|
|
34
|
+
{fees.length === 0 ? <EmptyState message="No fees" /> : <Table …/>}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
// BEFORE — a failure rendered as an empty, with a hand-written retry
|
|
39
|
+
<EmptyState icon="triangle-alert" message="Couldn't load trips" hint={err}
|
|
40
|
+
action={<Button title="Reload" onPress={() => q.refetch()} />} />
|
|
41
|
+
|
|
42
|
+
// AFTER — its own state; the kit words the button (locale `errorState.retry`)
|
|
43
|
+
<ErrorState message="Couldn't load trips" detail={err} onRetry={() => q.refetch()} />
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**Per call site:** an `action` that CREATED → delete it, put the button in the heading. One that
|
|
47
|
+
RECOVERED from a failed read → switch the whole component to `ErrorState` and drop the label.
|
|
48
|
+
One that cleared filters on a no-results empty → delete it; that empty is hint-only and the
|
|
49
|
+
filter controls carry their own clear.
|
|
50
|
+
|
|
51
|
+
**Locale packs** gain `errorState: { retry }`. A custom pack won't compile until it's filled —
|
|
52
|
+
which is the point.
|
|
53
|
+
|
|
54
|
+
**Also new (additive, no migration):** `Step.headHeight` and `PipelineStage.trailing` — a stage
|
|
55
|
+
whose title row carries an inline control. Hand-rolling that row leaves the marker centred on a
|
|
56
|
+
text line while the label centres in the taller row, so every label reads ~10px low.
|
|
57
|
+
|
|
7
58
|
## 25.0.0 — `ReferenceField` edits in place; `onRemove` becomes `onClear`
|
|
8
59
|
|
|
9
60
|
**`onRemove` is now `onClear` — a pure rename, identical behaviour.** The act was always
|
package/docs/catalog.md
CHANGED
|
@@ -236,8 +236,10 @@ reference), `InfoPopover` (the ⓘ explainer).
|
|
|
236
236
|
|
|
237
237
|
### Status / feedback
|
|
238
238
|
|
|
239
|
-
`Badge` / `StatusBadge`, `Callout` (
|
|
240
|
-
`
|
|
239
|
+
`Badge` / `StatusBadge`, `Callout` (a failure INSIDE a flow), and the four REGION states a read
|
|
240
|
+
passes through — `Skeleton` / `Loading` (in flight) → `ErrorState` (failed) → `EmptyState`
|
|
241
|
+
(succeeded, nothing) → `CompletionState` (done). Pick by what the region can ASSERT: an empty
|
|
242
|
+
says the read succeeded and found nothing, a failed read knows neither.
|
|
241
243
|
|
|
242
244
|
### Files
|
|
243
245
|
|
|
@@ -411,7 +413,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
411
413
|
`weight="medium"` opt-down only) + `info` for an ⓘ provenance popover after the title,
|
|
412
414
|
same as `CardHeaderTitle.info`. `SubsectionHeadingTitle` is the `###` lg-semibold level-3
|
|
413
415
|
title of a named group inside a section (same `info` ⓘ affordance as the section title) —
|
|
414
|
-
heading-row siblings ride its right edge.
|
|
416
|
+
heading-row siblings ride its right edge. **A section's ADD is one of those siblings** — a
|
|
417
|
+
`primary` `Button` beside the title, rendered whether the collection is empty or full, never
|
|
418
|
+
under the rows it extends and never repeated in the `EmptyState`
|
|
419
|
+
(composition.md § The add-placement law). `DialogSectionHeadingTitle` is the `####`
|
|
415
420
|
md-semibold rung with the SAME `icon`/`description`/`info` slots as the section title, so a
|
|
416
421
|
dialog surface loses only the type size, never an affordance. The heading ramp is FIXED:
|
|
417
422
|
`#` xxl / `##` xl / `###` lg / `####` md, no size props.
|
|
@@ -553,8 +558,18 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
553
558
|
- **`callout`** — `Callout`, `CalloutTitle`, `CalloutText`, `CalloutActions` (`tone`
|
|
554
559
|
info|success|warning|error|neutral): inline status band — a MESSAGE (`warning`/`error`
|
|
555
560
|
are ARIA `alert`s). For a form / nested content surface use **`Inset`**, never a Callout.
|
|
556
|
-
- **`empty_state`** — `EmptyState`: centered placeholder for
|
|
557
|
-
`message` + `hint
|
|
561
|
+
- **`empty_state`** — `EmptyState`: centered placeholder for a read that SUCCEEDED and found
|
|
562
|
+
nothing — `message` + `hint` + an optional `icon` anchor, and **no verb at all**. It cannot hold
|
|
563
|
+
the section's add (that lives on the heading row, where it does not move — § The add-placement
|
|
564
|
+
law in composition.md), a no-results empty is HINT-only since the filters that emptied it carry
|
|
565
|
+
their own clear, and a FAILED read is `ErrorState`, not this.
|
|
566
|
+
- **`error_state`** — `ErrorState`: the region-scale FAILED read — `message` + optional `detail`
|
|
567
|
+
+ `onRetry` (the kit renders the button and words it from the locale pack, so "try again" reads
|
|
568
|
+
the same everywhere). The fourth of the region states: `Skeleton`/`Loading` in flight → this on
|
|
569
|
+
failure → `EmptyState` on nothing → `CompletionState` on done. Rendering a failure as an empty
|
|
570
|
+
asserts the read succeeded and found nothing, when nothing is known. For a failure INSIDE a
|
|
571
|
+
flow (a form that won't save) use `Callout tone="error"`; this is for a region with no content
|
|
572
|
+
to show, where a tinted strip leaves the area collapsed.
|
|
558
573
|
- **`completion_state`** — `CompletionState`: the "all done" terminal state.
|
|
559
574
|
- **`skeleton`** — `Skeleton`: loading placeholder blocks.
|
|
560
575
|
- **`loading`** — `Loading`: the centered indeterminate loading state (composes
|
|
@@ -720,7 +735,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
720
735
|
just text. **`InlineSelect` is single OR multi** — pass `multi` for a tag SET (`value: T[]`,
|
|
721
736
|
commits the new set on popover-CLOSE; selected tags render as badges via `renderSelected`),
|
|
722
737
|
mirroring `Select`'s `multi` axis; there is NO separate tag component. Both modes take
|
|
723
|
-
`allowCustom` (a create-a-tag/option row) + `searchable
|
|
738
|
+
`allowCustom` (a create-a-tag/option row) + `searchable`, plus **`autoFocus`** to open the list
|
|
739
|
+
on mount — for the picker a `ReferenceField`'s `Change` just dropped the reader into, so the
|
|
740
|
+
correction stays one gesture; leave it off for a field that is merely empty, since stealing the
|
|
741
|
+
list open on load is a different act. **`InlineMemberSelect` takes `avatarOnly`**
|
|
724
742
|
— the resting display is the bare AVATAR (no name/chevron, a dashed "add" ghost when unset) for a
|
|
725
743
|
DENSE row where the name won't fit (a task-row assignee); the dropdown rows still show avatar + name.
|
|
726
744
|
`InlineDatePicker` takes a **`tone`**
|
|
@@ -858,7 +876,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
858
876
|
`.save` / `.saving` / `.cancel`), while `openLabel` stays a prop because it names the
|
|
859
877
|
DESTINATION for a screen reader, which only the caller knows. **EDIT IN THE PEEK** (`onSave`):
|
|
860
878
|
a fact carrying a `name` is editable and that key is what it saves under (omit `name` for a
|
|
861
|
-
derived value that rides along read-only); `multiline` for an address or an account block
|
|
879
|
+
derived value that rides along read-only); `multiline` for an address or an account block;
|
|
880
|
+
`type: "date"` swaps the draft's text input for a `DatePicker` and formats the read value —
|
|
881
|
+
the fact's `value` stays the canonical ISO string on both sides, so a date is never edited as
|
|
882
|
+
free text and the format ambiguity never reaches the record.
|
|
862
883
|
`Edit` swaps the SAME grid's value cells for inputs — a DRAFT, so nothing commits until `Save`,
|
|
863
884
|
which fires `onSave` with **only the facts that CHANGED** (never a snapshot, so a lock or
|
|
864
885
|
`before_update` sees the real edit). This is what lets a peek hold editors at all: a
|
|
@@ -1169,6 +1190,12 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1169
1190
|
moving, one stage live, different controls per stage; `task` is N identical rows ticked in any
|
|
1170
1191
|
order. `PipelineNote` is a condition ON a stage (never a `Callout` above the run);
|
|
1171
1192
|
`PipelineField` stays editable on a PASSED stage, which is what makes a mis-entry fixable.
|
|
1193
|
+
A field stacks UNDER the title (two lines per stage); on a ladder long enough that this
|
|
1194
|
+
pushes the run past a screenful, **`PipelineStage.trailing`** puts ONE value on the title's
|
|
1195
|
+
own row instead. Never hand-roll that row: a control is twice a text line's height, so the
|
|
1196
|
+
marker — which centres on the first row — reads half the difference too high against it.
|
|
1197
|
+
`trailing` fixes the row at `INLINE_CONTROL_HEIGHT` and tells the `Step`, so the ladder keeps
|
|
1198
|
+
one rhythm whatever the value is. An ACT still belongs in `PipelineActions`.
|
|
1172
1199
|
Worked screen: `examples/tpl_record.tsx` § Progress — the desks (Sales → Operations →
|
|
1173
1200
|
Accounting) ARE the stages, each owning the facts it stamps and, on the live one, the act
|
|
1174
1201
|
that leaves it. A `PipelineStage` is deliberately NOT pressable: its body holds the controls.
|
|
@@ -1179,6 +1206,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1179
1206
|
switcher; the guided-run / agent-feed primitive. It is a **`list` of `listitem`s** with
|
|
1180
1207
|
`aria-current="step"` on the live one — a sequence of NAMED positions whose steps carry
|
|
1181
1208
|
their own content is a list, never a `progressbar`.
|
|
1209
|
+
VERTICAL: the marker centres on the content's FIRST ROW, assumed to be one line of `sm` text.
|
|
1210
|
+
Put anything taller on that row — an inline editor beside the label — and pass
|
|
1211
|
+
**`Step.headHeight`** (`INLINE_CONTROL_HEIGHT` for an editor) or the marker stays pinned to
|
|
1212
|
+
the text line while the label centres in the taller row. Prefer `PipelineStage.trailing`,
|
|
1213
|
+
which does this for you.
|
|
1182
1214
|
|
|
1183
1215
|
### Files
|
|
1184
1216
|
|
package/docs/composition.md
CHANGED
|
@@ -44,9 +44,9 @@ restyle a heading level per-page.
|
|
|
44
44
|
numbers) + optional `CardHeaderMeta` (a count/unit/period, xs muted tabular).
|
|
45
45
|
- **Section title** — ONE construct: `Section` › `SectionHeading` › `SectionHeadingTitle`
|
|
46
46
|
(`##` — xl semibold, optional muted `description` and `info` popover), everything left-aligned
|
|
47
|
-
at the column edge; `SectionHeadingMeta
|
|
48
|
-
(the title's `flex: 1` pushes them) —
|
|
49
|
-
|
|
47
|
+
at the column edge; `SectionHeadingMeta`, any VIEW control, and the section's own ADD ride the
|
|
48
|
+
heading row's right edge (the title's `flex: 1` pushes them) — see the add-placement law
|
|
49
|
+
below. The page column is a **`SectionStack`** — it owns the
|
|
50
50
|
between-section law (a fixed 56px beat + a bare hairline separating one section from the NEXT;
|
|
51
51
|
null/false children are skipped so a conditional section never leaves a stray hairline). The
|
|
52
52
|
`Divider` NEVER goes directly under a heading — that orphans the title from its own content.
|
|
@@ -101,6 +101,30 @@ restyle a heading level per-page.
|
|
|
101
101
|
- **Gate header** — a `Dialog` uses `DialogHeaderTitle`; a popover form uses
|
|
102
102
|
`Text size="sm" weight="semibold"` + an optional xs muted subtitle.
|
|
103
103
|
|
|
104
|
+
### The add-placement law — a section's ADD rides its heading row, right edge
|
|
105
|
+
|
|
106
|
+
**Add files / Add fee / Add member / New line — the verb that EXTENDS a section's collection
|
|
107
|
+
sits on that section's heading row, at the right, and nowhere else.** One `Button` as a sibling
|
|
108
|
+
of `SectionHeadingTitle` (whose `flex: 1` pushes it there); the same at subsection and card
|
|
109
|
+
altitude (`SubsectionHeading`, `CardHeader`).
|
|
110
|
+
|
|
111
|
+
The rule is about a position that does not MOVE. Put the add under the register it extends and
|
|
112
|
+
where the reader looks for it depends on how many rows there already are — past a screenful the
|
|
113
|
+
verb is off-screen, and on an empty list there is no last row to sit under, so it has to become
|
|
114
|
+
a second button inside the `EmptyState`. That is one verb with two homes, neither findable
|
|
115
|
+
without scanning. The heading row is the section's control line — it already carries the meta
|
|
116
|
+
and the view controls — so the add belongs on it, in the same spot whether the section holds
|
|
117
|
+
nought or forty.
|
|
118
|
+
|
|
119
|
+
- **Weight is the section's**, not a hedge: a section's add is the act that section offers, so
|
|
120
|
+
`primary`. Don't drop to `secondary` because the list is full — an add that changes weight
|
|
121
|
+
with row count is the fault, not the cure.
|
|
122
|
+
- **It renders unconditionally**, empty or not. A heading whose CTA appears only once there is
|
|
123
|
+
data cannot be used to create the first row.
|
|
124
|
+
- **The `EmptyState` then takes NO `action`** — see § Empty states.
|
|
125
|
+
- **Only the ADD.** A verb that acts on a SELECTION (delete, export, run) belongs to the
|
|
126
|
+
selection — `FloatingActionBar` — and a verb about ONE row stays on the row.
|
|
127
|
+
|
|
104
128
|
## Period filters for time-constrained data
|
|
105
129
|
|
|
106
130
|
Time-constrained data gets a **`DateRangeFilterField`** in the header band — never a static period
|
|
@@ -832,13 +856,20 @@ one exists).
|
|
|
832
856
|
|
|
833
857
|
### Empty states — confirm, orient, one action
|
|
834
858
|
|
|
835
|
-
An empty region has three jobs (NN/g): confirm this is EMPTY
|
|
836
|
-
what belongs here
|
|
837
|
-
|
|
838
|
-
|
|
859
|
+
An empty region has three jobs (NN/g): confirm this is EMPTY — not loading, not broken — and
|
|
860
|
+
say what belongs here. The `EmptyState` props are that anatomy and nothing more (`message` =
|
|
861
|
+
what's empty, `hint` = what to do about it). "Not broken" is why the failed read has its own
|
|
862
|
+
component: an alert glyph inside an empty state says both at once and means neither.
|
|
863
|
+
|
|
864
|
+
**`EmptyState` carries NO verb.** The add is on the heading row (§ The add-placement law) and
|
|
865
|
+
duplicating it here would put two buttons for one act in view and make the add jump the moment
|
|
866
|
+
the first row lands. A read that FAILED is not an empty one — it does not know whether there is
|
|
867
|
+
anything — so it gets `ErrorState` (`message` + `detail` + `onRetry`), never an empty state
|
|
868
|
+
wearing an alert glyph. The three empties get DIFFERENT copy:
|
|
839
869
|
|
|
840
|
-
- **First use** → teach + invite: message names what will live here,
|
|
841
|
-
|
|
870
|
+
- **First use** → teach + invite: message names what will live here, and the CTA — the
|
|
871
|
+
heading's, or `action` where there is no heading — creates the first one ("Chưa có khoản phí"
|
|
872
|
+
+ "Thêm khoản thu/chi cho lô").
|
|
842
873
|
- **User cleared it** → stay quiet: the user knows why it's empty; confirmation only, no tutorial.
|
|
843
874
|
- **No search/filter results** → help recover: restate the scope ("Không có lô nào khớp
|
|
844
875
|
'ABC'"), hint the fix ("Thử từ khóa khác / xóa bộ lọc"). Never a blank panel or bare "No data".
|
package/docs/templates.md
CHANGED
|
@@ -309,8 +309,9 @@ billing, and quick-capture templates. Top → bottom:
|
|
|
309
309
|
just Documents) — a drag or Ctrl/Cmd+V ANYWHERE on the record (`disabled` while the intake
|
|
310
310
|
dialog runs so a paste can't start a second one) — all landing in ONE `intakeFiles` fork
|
|
311
311
|
dialog. The Documents section therefore carries NO drop target of its own (the whole-record
|
|
312
|
-
one covers it), only its Add-files CTA and a muted
|
|
313
|
-
paste, or click to add files") that keeps the
|
|
312
|
+
one covers it), only its Add-files CTA on the heading row's right edge and a muted
|
|
313
|
+
`SectionHeadingTitle description` ("Drag, paste, or click to add files") that keeps the
|
|
314
|
+
otherwise-invisible paths discoverable.
|
|
314
315
|
Every row's leading visual is a `FileThumbnail` in ONE square 32px slot — an image file
|
|
315
316
|
fills it as a real thumbnail, a document centers its badge in it — never a bare `FileBadge`
|
|
316
317
|
(mixed footprints misalign the identity column). The desk holds what ARRIVES; generation
|
|
@@ -332,7 +333,9 @@ billing, and quick-capture templates. Top → bottom:
|
|
|
332
333
|
a `priority`-annotated register `Table` (Fee, Type, Party, Amount, Status — status in
|
|
333
334
|
plain ink, danger only when overdue) → EVERY row opens a right-docked entity `Drawer`
|
|
334
335
|
(◀ ▶ + position stepping) with all fields inline-editable and a confirmed Remove in the
|
|
335
|
-
`DrawerFooter`. "Add fee"
|
|
336
|
+
`DrawerFooter`. "Add fee" rides the section heading's right edge — the one place it does not
|
|
337
|
+
move as rows arrive — and is create-then-refine: a blank fee opens straight in the drawer. The
|
|
338
|
+
empty state carries no button of its own, because that one is already on screen.
|
|
336
339
|
- **Billing — a REGISTER, the same shape as Fees.** Both sections are a list of money items
|
|
337
340
|
where each item has detail and a per-item act, so both are a compact `Table` whose row opens
|
|
338
341
|
its FULL detail in a right-docked `Drawer` (◀ ▶ step the invoices) — the drill-down law.
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -127,16 +127,19 @@ interface Customer {
|
|
|
127
127
|
/** Runs past one line at a peek's width — the case a fixture of short values
|
|
128
128
|
* never exercises, and the one where a read value and its editor diverge. */
|
|
129
129
|
address: string;
|
|
130
|
+
/** ISO. A date fact, so the peek's draft has one non-text editor to render —
|
|
131
|
+
* left as text it would take whatever shape the reader typed. */
|
|
132
|
+
since: string;
|
|
130
133
|
}
|
|
131
134
|
|
|
132
135
|
// Atlas ships WITHOUT a tax ID — attach it to see the billing gate + the
|
|
133
136
|
// inline Tax ID fix-up in the Customer section.
|
|
134
137
|
const KNOWN_CUSTOMERS: Customer[] = [
|
|
135
|
-
{ id: "cus_01", name: "Northwind Traders", code: "KH-0148", taxId: "0312456780", contact: "Mara Lindqvist", city: "Gothenburg", address: "Ringvägen 118, 4 tr, 116 61 Stockholm, Sweden" },
|
|
136
|
-
{ id: "cus_02", name: "Harbor Freight Lines", code: "KH-0203", taxId: "0312998820", contact: "Diego Alvarez", city: "Rotterdam", address: "Waalhaven Oostzijde 81, 3087 BM Rotterdam, Netherlands" },
|
|
137
|
-
{ id: "cus_03", name: "Summit Packaging Co.", code: "KH-0231", taxId: "0301557742", contact: "Priya Nair", city: "Singapore", address: "9 Tuas Bay Walk, #03-14, Singapore 637803" },
|
|
138
|
-
{ id: "cus_04", name: "Atlas Distribution", code: "KH-0117", taxId: "", contact: "Tom Becker", city: "Hamburg", address: "Grosser Grasbrook 9, 20457 Hamburg, Germany" },
|
|
139
|
-
{ id: "cus_05", name: "Bluewater Logistics", code: "KH-0294", taxId: "0312004455", contact: "Lena Fischer", city: "Antwerp", address: "Noorderlaan 127, 2030 Antwerpen, Belgium" },
|
|
138
|
+
{ id: "cus_01", name: "Northwind Traders", code: "KH-0148", taxId: "0312456780", contact: "Mara Lindqvist", city: "Gothenburg", address: "Ringvägen 118, 4 tr, 116 61 Stockholm, Sweden", since: "2019-03-14" },
|
|
139
|
+
{ id: "cus_02", name: "Harbor Freight Lines", code: "KH-0203", taxId: "0312998820", contact: "Diego Alvarez", city: "Rotterdam", address: "Waalhaven Oostzijde 81, 3087 BM Rotterdam, Netherlands", since: "2021-11-02" },
|
|
140
|
+
{ id: "cus_03", name: "Summit Packaging Co.", code: "KH-0231", taxId: "0301557742", contact: "Priya Nair", city: "Singapore", address: "9 Tuas Bay Walk, #03-14, Singapore 637803", since: "2023-06-19" },
|
|
141
|
+
{ id: "cus_04", name: "Atlas Distribution", code: "KH-0117", taxId: "", contact: "Tom Becker", city: "Hamburg", address: "Grosser Grasbrook 9, 20457 Hamburg, Germany", since: "2018-01-30" },
|
|
142
|
+
{ id: "cus_05", name: "Bluewater Logistics", code: "KH-0294", taxId: "0312004455", contact: "Lena Fischer", city: "Antwerp", address: "Noorderlaan 127, 2030 Antwerpen, Belgium", since: "2022-09-08" },
|
|
140
143
|
];
|
|
141
144
|
|
|
142
145
|
const TAX_ID_RE = /^\d{10}(\d{3})?$/;
|
|
@@ -736,6 +739,7 @@ function PartyRow({ role, rec, options, placeholder, onPick, onOpen, onUnset, on
|
|
|
736
739
|
{ label: "City", value: rec.city, name: "city" },
|
|
737
740
|
{ label: "Tax ID", value: rec.taxId, name: "taxId" },
|
|
738
741
|
{ label: "Address", value: rec.address, name: "address", multiline: true },
|
|
742
|
+
{ label: "Customer since", value: rec.since, name: "since", type: "date" },
|
|
739
743
|
]}
|
|
740
744
|
accessibilityLabel={`${rec.name} — details`}
|
|
741
745
|
openLabel={`Open ${rec.name}`}
|
|
@@ -899,6 +903,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
899
903
|
city: patch.city ?? c.city,
|
|
900
904
|
taxId: patch.taxId ?? c.taxId,
|
|
901
905
|
address: patch.address ?? c.address,
|
|
906
|
+
since: patch.since ?? c.since,
|
|
902
907
|
}
|
|
903
908
|
: c,
|
|
904
909
|
),
|
|
@@ -1241,6 +1246,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
1241
1246
|
// Not collected at create — a new party is attached from three fields and
|
|
1242
1247
|
// the rest is filled in later, which is what the peek's Edit is for.
|
|
1243
1248
|
address: "",
|
|
1249
|
+
since: "",
|
|
1244
1250
|
};
|
|
1245
1251
|
setCustomers((prev) => [...prev, c]);
|
|
1246
1252
|
setCustomerId(c.id);
|
|
@@ -1714,6 +1720,7 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
1714
1720
|
{ label: "Contact", value: customer.contact, name: "contact" },
|
|
1715
1721
|
{ label: "City", value: customer.city, name: "city" },
|
|
1716
1722
|
{ label: "Address", value: customer.address, name: "address", multiline: true },
|
|
1723
|
+
{ label: "Customer since", value: customer.since, name: "since", type: "date" },
|
|
1717
1724
|
]}
|
|
1718
1725
|
accessibilityLabel={`${customer.name} — details`}
|
|
1719
1726
|
/* NO `onOpen`. The peek carries every fact this record
|
|
@@ -2023,6 +2030,17 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
2023
2030
|
{/* The standard files-section affordance line — drag / paste / click
|
|
2024
2031
|
stay discoverable even though the drop target is the whole record. */}
|
|
2025
2032
|
<SectionHeadingTitle description="Everything that ARRIVED on this record. Drag, paste, or click to add.">Files</SectionHeadingTitle>
|
|
2033
|
+
{/* The section's ADD, on the heading row — see the Fees section for
|
|
2034
|
+
the rule. PENDING until the user chooses: saving is a decision the
|
|
2035
|
+
dialog asks for (save only / run a task), never a side effect of
|
|
2036
|
+
picking — closing the dialog discards. */}
|
|
2037
|
+
<Button
|
|
2038
|
+
title="Add files"
|
|
2039
|
+
color="primary"
|
|
2040
|
+
onPress={() => {
|
|
2041
|
+
void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then(intakeFiles);
|
|
2042
|
+
}}
|
|
2043
|
+
/>
|
|
2026
2044
|
</SectionHeading>
|
|
2027
2045
|
<Table
|
|
2028
2046
|
columns={DOC_COLUMNS}
|
|
@@ -2065,19 +2083,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
2065
2083
|
</TableRow>
|
|
2066
2084
|
))}
|
|
2067
2085
|
</Table>
|
|
2068
|
-
{/* the Add, under the register it extends — see the Fees section for
|
|
2069
|
-
why it is not in the heading. PENDING until the user chooses:
|
|
2070
|
-
saving is a decision the dialog asks for (save only / run a task),
|
|
2071
|
-
never a side effect of picking — closing the dialog discards. */}
|
|
2072
|
-
<View style={{ flexDirection: "row" }}>
|
|
2073
|
-
<Button
|
|
2074
|
-
title="Add files"
|
|
2075
|
-
color="primary"
|
|
2076
|
-
onPress={() => {
|
|
2077
|
-
void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then(intakeFiles);
|
|
2078
|
-
}}
|
|
2079
|
-
/>
|
|
2080
|
-
</View>
|
|
2081
2086
|
</Section>
|
|
2082
2087
|
</View>
|
|
2083
2088
|
|
|
@@ -2323,6 +2328,18 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
2323
2328
|
<Section>
|
|
2324
2329
|
<SectionHeading>
|
|
2325
2330
|
<SectionHeadingTitle description="Every fee the order incurs or charges — its party, due date and paid state.">Fees</SectionHeadingTitle>
|
|
2331
|
+
{/* THE SECTION'S ADD RIDES THE HEADING ROW, right edge — the one
|
|
2332
|
+
place it can sit that does not MOVE. Under the register it sat
|
|
2333
|
+
below the last row, so where a reader looks for "how do I add
|
|
2334
|
+
one" depended on how many there already were: past a screenful
|
|
2335
|
+
the verb is off-screen entirely, and on an empty list there is no
|
|
2336
|
+
last row to sit under, so it had to be a SECOND button inside the
|
|
2337
|
+
`EmptyState`. One verb, two renderings, neither findable without
|
|
2338
|
+
scanning. The heading row is the section's control line — it
|
|
2339
|
+
already carries `SectionHeadingMeta` and the title grows to push
|
|
2340
|
+
its siblings right — so the add belongs on it, in the same spot
|
|
2341
|
+
whether the list holds nought or forty. */}
|
|
2342
|
+
<Button title="Add fee" color="primary" onPress={addFee} />
|
|
2326
2343
|
</SectionHeading>
|
|
2327
2344
|
<SummaryLine
|
|
2328
2345
|
items={[
|
|
@@ -2331,8 +2348,13 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
2331
2348
|
{ label: "to pay", value: fees.filter((f) => f.direction === "cost" && !f.paid).reduce((s, f) => s + f.amount, 0), format: "currency", compact: true, tone: fees.some((f) => f.direction === "cost" && feeOverdue(f)) ? "warning" : undefined },
|
|
2332
2349
|
]}
|
|
2333
2350
|
/>
|
|
2351
|
+
{/* The empty state takes NO `action` — the heading's Add is the only
|
|
2352
|
+
one, and it is already on screen. Repeating the section's verb here
|
|
2353
|
+
puts two buttons for one act in view at once, and makes the add MOVE
|
|
2354
|
+
the moment the first row lands. The empty state says what the
|
|
2355
|
+
section holds; the heading says how to fill it. */}
|
|
2334
2356
|
{fees.length === 0 ? (
|
|
2335
|
-
<EmptyState icon="receipt" message="No fees on this order" hint="Add the first charge or cost — it expands ready to fill in."
|
|
2357
|
+
<EmptyState icon="receipt" message="No fees on this order" hint="Add the first charge or cost — it expands ready to fill in." />
|
|
2336
2358
|
) : (
|
|
2337
2359
|
<Table columns={FEE_COLUMNS}>
|
|
2338
2360
|
{fees.map((f) => {
|
|
@@ -2450,21 +2472,6 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
2450
2472
|
})}
|
|
2451
2473
|
</Table>
|
|
2452
2474
|
)}
|
|
2453
|
-
{/* THE ADD SITS WHERE ITS RESULT APPEARS — under the last row, on the
|
|
2454
|
-
list's own left edge. In the heading it sat ABOVE the thing it
|
|
2455
|
-
extends and at a different altitude from it, and it competed with
|
|
2456
|
-
the title for the one line that names the section. Secondary and
|
|
2457
|
-
left, and PRIMARY — in the empty state too, which is what makes it
|
|
2458
|
-
consistent. A section's add is the act that section offers, so it
|
|
2459
|
-
carries the section's weight; a lone `secondary` button reads as
|
|
2460
|
-
though the real action were somewhere else. One verb keeps ONE
|
|
2461
|
-
weight either way: the fault to avoid is an add that is primary on
|
|
2462
|
-
an empty list and secondary on a full one. */}
|
|
2463
|
-
{fees.length > 0 ? (
|
|
2464
|
-
<View style={{ flexDirection: "row" }}>
|
|
2465
|
-
<Button title="Add fee" color="primary" onPress={addFee} />
|
|
2466
|
-
</View>
|
|
2467
|
-
) : null}
|
|
2468
2475
|
</Section>
|
|
2469
2476
|
</View>
|
|
2470
2477
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "26.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -63,6 +63,7 @@
|
|
|
63
63
|
"./kpi_strip": "./src/kpi_strip.tsx",
|
|
64
64
|
"./summary_line": "./src/summary_line.tsx",
|
|
65
65
|
"./empty_state": "./src/empty_state.tsx",
|
|
66
|
+
"./error_state": "./src/error_state.tsx",
|
|
66
67
|
"./format_date": "./src/format_date.ts",
|
|
67
68
|
"./format_money": "./src/format_money.ts",
|
|
68
69
|
"./calendar": "./src/calendar/index.ts",
|
package/src/callout.tsx
CHANGED
|
@@ -40,7 +40,10 @@ const TONES: Record<CalloutTone, ToneStyle> = {
|
|
|
40
40
|
/**
|
|
41
41
|
* An inline callout — a tinted, bordered box carrying a short status message
|
|
42
42
|
* (info / success / warning / error / neutral). Use it inline in a flow: form
|
|
43
|
-
* feedback, a heads-up,
|
|
43
|
+
* feedback, a heads-up, a failure INSIDE a flow. NOT for a dead region — a list
|
|
44
|
+
* that came back empty is `EmptyState`, one that FAILED is `ErrorState`; a
|
|
45
|
+
* tinted strip there leaves the area collapsed and reading as a render bug.
|
|
46
|
+
* For a blocking, dismissible
|
|
44
47
|
* prompt use `Alert`; for a one-word status use `Badge`. The tone is carried by
|
|
45
48
|
* the icon + tint + border (never color alone — the icon and text stay legible),
|
|
46
49
|
* so it reads on a glance and meets contrast on the light tint.
|
package/src/empty_state.tsx
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { ReactNode } from "react";
|
|
2
1
|
import { View, StyleSheet } from "react-native";
|
|
3
2
|
import { Text } from "./text";
|
|
4
3
|
import { Icon, type IconName } from "./icon";
|
|
@@ -12,16 +11,28 @@ interface EmptyStateProps {
|
|
|
12
11
|
/** Optional Lucide glyph centered above the message — a visual anchor so the
|
|
13
12
|
* empty region reads as a deliberate state, not two lone lines of muted text. */
|
|
14
13
|
icon?: IconName;
|
|
15
|
-
/** Optional call-to-action below the text (e.g. a Button) for an actionable
|
|
16
|
-
* empty state ("no records yet → create one"). */
|
|
17
|
-
action?: ReactNode;
|
|
18
14
|
}
|
|
19
15
|
|
|
20
16
|
/**
|
|
21
17
|
* Centered placeholder for an empty list/filter result. Generous vertical
|
|
22
18
|
* space on purpose — an empty region that collapses to nothing reads as a
|
|
23
|
-
* rendering bug, not a state.
|
|
24
|
-
*
|
|
19
|
+
* rendering bug, not a state.
|
|
20
|
+
*
|
|
21
|
+
* IT CARRIES NO VERB — the prop is gone, not narrowed. This took an
|
|
22
|
+
* `action?: ReactNode` and two different things went into it, both wrong here:
|
|
23
|
+
*
|
|
24
|
+
* - **"Create the first one"** put the section's add in a SECOND place that only
|
|
25
|
+
* exists while the list is empty, so the verb jumped elsewhere the moment the
|
|
26
|
+
* first row landed. It belongs on the section's heading row, where it does not
|
|
27
|
+
* move (composition.md § The add-placement law).
|
|
28
|
+
* - **"The read failed, try again"** was a failure wearing an empty state's
|
|
29
|
+
* clothes. That asserts the read SUCCEEDED and found nothing, when the truth
|
|
30
|
+
* is that nothing is known — use `ErrorState`. Retrying a true empty just
|
|
31
|
+
* returns the same nothing.
|
|
32
|
+
*
|
|
33
|
+
* What is left says one thing and offers nothing to press: this region is empty,
|
|
34
|
+
* and here is what would live in it. A no-results empty is hint-only too — the
|
|
35
|
+
* filters that emptied it carry their own clear.
|
|
25
36
|
*/
|
|
26
37
|
export function EmptyState(props: EmptyStateProps) {
|
|
27
38
|
return (
|
|
@@ -39,7 +50,6 @@ export function EmptyState(props: EmptyStateProps) {
|
|
|
39
50
|
{props.hint}
|
|
40
51
|
</Text>
|
|
41
52
|
) : null}
|
|
42
|
-
{props.action ? <View style={styles.action}>{props.action}</View> : null}
|
|
43
53
|
</View>
|
|
44
54
|
);
|
|
45
55
|
}
|
|
@@ -53,7 +63,4 @@ const styles = StyleSheet.create({
|
|
|
53
63
|
icon: {
|
|
54
64
|
marginBottom: 4,
|
|
55
65
|
},
|
|
56
|
-
action: {
|
|
57
|
-
marginTop: 8,
|
|
58
|
-
},
|
|
59
66
|
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { View, StyleSheet } from "react-native";
|
|
2
|
+
import { Text } from "./text";
|
|
3
|
+
import { Button } from "./button";
|
|
4
|
+
import { Icon } from "./icon";
|
|
5
|
+
import { solid } from "./colors";
|
|
6
|
+
import { useLoticsLocale } from "./locale";
|
|
7
|
+
|
|
8
|
+
export interface ErrorStateProps {
|
|
9
|
+
/** What failed, in plain language ("Couldn't load customers"). */
|
|
10
|
+
message: string;
|
|
11
|
+
/** The cause when the system knows it — the server's own message. Never a
|
|
12
|
+
* stack trace or an error code alone: neither tells the reader what to do. */
|
|
13
|
+
detail?: string;
|
|
14
|
+
/** Re-run the read. Omitted for a failure the reader cannot retry (denied,
|
|
15
|
+
* not found) — the kit renders the button and words it from the locale pack,
|
|
16
|
+
* so "try again" is phrased the same everywhere instead of per app. */
|
|
17
|
+
onRetry?: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The read FAILED — the region-scale sibling of `EmptyState`, and the missing
|
|
22
|
+
* fourth in the family (`Skeleton`/`Loading` in flight → this on failure →
|
|
23
|
+
* `EmptyState` on nothing → `CompletionState` on done).
|
|
24
|
+
*
|
|
25
|
+
* IT EXISTS BECAUSE EMPTY AND FAILED ARE DIFFERENT ASSERTIONS. "No fees on this
|
|
26
|
+
* order" says the read succeeded and there is nothing; a failed read does not
|
|
27
|
+
* know whether there is anything. Apps were rendering the failure through
|
|
28
|
+
* `EmptyState` with an alert glyph and a hand-written retry — which states a
|
|
29
|
+
* fact the system does not have (unknown drawn as none), and left every app
|
|
30
|
+
* inventing its own wording for the same verb. A retry on a TRUE empty is
|
|
31
|
+
* equally wrong: running it again returns the same nothing.
|
|
32
|
+
*
|
|
33
|
+
* Use `Callout tone="error"` instead for a failure inside a flow — a form that
|
|
34
|
+
* would not save. This is for a whole REGION that has no content to show, where
|
|
35
|
+
* a tinted strip would leave the area collapsed and reading as a render bug.
|
|
36
|
+
*/
|
|
37
|
+
export function ErrorState(props: ErrorStateProps) {
|
|
38
|
+
const words = useLoticsLocale();
|
|
39
|
+
return (
|
|
40
|
+
<View style={styles.container}>
|
|
41
|
+
<View style={styles.icon}>
|
|
42
|
+
<Icon name="triangle-alert" size={28} color={solid("red")} />
|
|
43
|
+
</View>
|
|
44
|
+
<Text size="sm" color="muted">
|
|
45
|
+
{props.message}
|
|
46
|
+
</Text>
|
|
47
|
+
{props.detail ? (
|
|
48
|
+
<Text size="xs" color="muted">
|
|
49
|
+
{props.detail}
|
|
50
|
+
</Text>
|
|
51
|
+
) : null}
|
|
52
|
+
{props.onRetry ? (
|
|
53
|
+
<View style={styles.action}>
|
|
54
|
+
<Button title={words.errorState.retry} color="secondary" onPress={props.onRetry} />
|
|
55
|
+
</View>
|
|
56
|
+
) : null}
|
|
57
|
+
</View>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// The same rhythm as `EmptyState` — the two swap into one another as a read
|
|
62
|
+
// settles, and a region that changed height on failure would jump the page.
|
|
63
|
+
const styles = StyleSheet.create({
|
|
64
|
+
container: {
|
|
65
|
+
paddingVertical: 48,
|
|
66
|
+
alignItems: "center",
|
|
67
|
+
gap: 4,
|
|
68
|
+
},
|
|
69
|
+
icon: {
|
|
70
|
+
marginBottom: 4,
|
|
71
|
+
},
|
|
72
|
+
action: {
|
|
73
|
+
marginTop: 8,
|
|
74
|
+
},
|
|
75
|
+
});
|
package/src/inline_select.tsx
CHANGED
|
@@ -13,6 +13,16 @@ import { useLoticsLocale } from "./locale";
|
|
|
13
13
|
|
|
14
14
|
interface InlineSelectBaseProps<T extends string, D = unknown> {
|
|
15
15
|
options: PickerOption<T, D>[];
|
|
16
|
+
/**
|
|
17
|
+
* Open the list on mount.
|
|
18
|
+
*
|
|
19
|
+
* For a picker that REPLACED something the reader just dismissed — a
|
|
20
|
+
* `ReferenceField` whose `onChange` fired, which means "wrong one, I'm about
|
|
21
|
+
* to pick another". Landing them on a closed picker charges a second press to
|
|
22
|
+
* resume a correction they already started. Leave it off for a field that is
|
|
23
|
+
* merely empty: stealing the list open on load is not the same act.
|
|
24
|
+
*/
|
|
25
|
+
autoFocus?: boolean;
|
|
16
26
|
/** Custom option content in the dropdown (icon + label, two-line, a badge…).
|
|
17
27
|
* Omit for a plain label list — both render through the same `OptionList`. */
|
|
18
28
|
renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
|
|
@@ -119,9 +129,9 @@ function InlineSelectShell(props: {
|
|
|
119
129
|
}
|
|
120
130
|
|
|
121
131
|
export function InlineSelect<T extends string, D = unknown>(props: InlineSelectProps<T, D>) {
|
|
122
|
-
const { options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, allowCustom = false, customOptionLabel, variant, actions } = props;
|
|
132
|
+
const { options, renderOptionContent, renderSelected, placeholder, disabled, accessibilityLabel, searchable = false, allowCustom = false, customOptionLabel, variant, actions, autoFocus = false } = props;
|
|
123
133
|
const labels = useLoticsLocale().inline;
|
|
124
|
-
const [open, setOpen] = useState(
|
|
134
|
+
const [open, setOpen] = useState(autoFocus);
|
|
125
135
|
const [saving, setSaving] = useState(false);
|
|
126
136
|
const [error, setError] = useState<string | null>(null);
|
|
127
137
|
const [draft, setDraft] = useState<T[]>(props.multi ? props.value : []);
|
package/src/locale.tsx
CHANGED
|
@@ -70,6 +70,9 @@ export interface LoticsLocale {
|
|
|
70
70
|
ledger: { rowDetails: (label: string) => string };
|
|
71
71
|
/** `SectionHeadingTitle`: the info-popover trigger's screen-reader name. */
|
|
72
72
|
sectionHeading: { info: string };
|
|
73
|
+
/** `ErrorState`'s retry button. The kit owns the wording so "try again" is
|
|
74
|
+
* phrased identically everywhere instead of hand-written per app. */
|
|
75
|
+
errorState: { retry: string };
|
|
73
76
|
/** `Chip`: the ✕ default name when no `dismissTooltip` is given. */
|
|
74
77
|
chip: { remove: string };
|
|
75
78
|
/** `SequenceItem`: the reorder + drop controls on one position of a `Sequence`. */
|
|
@@ -234,6 +237,7 @@ export const en: LoticsLocale = {
|
|
|
234
237
|
clarify: { otherPlaceholder: "Or type your own answer…", back: "Back", next: "Next", cancel: "Cancel", submit: "Submit" },
|
|
235
238
|
ledger: { rowDetails: (label) => `${label} details` },
|
|
236
239
|
sectionHeading: { info: "About this data" },
|
|
240
|
+
errorState: { retry: "Try again" },
|
|
237
241
|
chip: { remove: "Remove" },
|
|
238
242
|
sequence: { moveUp: "Move up", moveDown: "Move down", remove: "Remove" },
|
|
239
243
|
trendFooter: { up: "Up", down: "Down" },
|
|
@@ -393,6 +397,7 @@ export const vi: LoticsLocale = {
|
|
|
393
397
|
clarify: { otherPlaceholder: "Hoặc nhập câu trả lời khác…", back: "Quay lại", next: "Tiếp", cancel: "Hủy", submit: "Gửi" },
|
|
394
398
|
ledger: { rowDetails: (label) => `Chi tiết ${label}` },
|
|
395
399
|
sectionHeading: { info: "Giải thích dữ liệu" },
|
|
400
|
+
errorState: { retry: "Thử lại" },
|
|
396
401
|
chip: { remove: "Xóa" },
|
|
397
402
|
sequence: { moveUp: "Lên trên", moveDown: "Xuống dưới", remove: "Xóa" },
|
|
398
403
|
trendFooter: { up: "Tăng", down: "Giảm" },
|
package/src/pipeline.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { type ReactNode } from "react";
|
|
|
2
2
|
import { View } from "react-native";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { Stepper, Step, type StepPositional, type StepStatus } from "./stepper";
|
|
5
|
+
import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
|
|
5
6
|
|
|
6
7
|
export interface PipelineProps {
|
|
7
8
|
children?: ReactNode;
|
|
@@ -40,11 +41,16 @@ export interface PipelineProps {
|
|
|
40
41
|
* editable behind you, or the only way to fix a mis-entry is direct table
|
|
41
42
|
* access.
|
|
42
43
|
*
|
|
44
|
+
* A stage's values stack UNDER its title by default (`PipelineField`), which
|
|
45
|
+
* costs two lines each. On a ladder long enough that this pushes the run past a
|
|
46
|
+
* screenful, `trailing` puts one value on the title's own row instead.
|
|
47
|
+
*
|
|
43
48
|
* ```tsx
|
|
44
49
|
* <Pipeline>
|
|
45
50
|
* <PipelineStage status="done" title="Submitted">
|
|
46
51
|
* <PipelineField label="Date"><InlineDatePicker … /></PipelineField>
|
|
47
52
|
* </PipelineStage>
|
|
53
|
+
* <PipelineStage status="done" title="Received" trailing={<InlineDatePicker … />} />
|
|
48
54
|
* <PipelineStage status="current" title="In review" meta="Waiting 3d, Ops">
|
|
49
55
|
* <PipelineNote tone="warning">Sent back — missing payslips.</PipelineNote>
|
|
50
56
|
* <PipelineActions>
|
|
@@ -71,6 +77,18 @@ export interface PipelineStageProps extends StepPositional {
|
|
|
71
77
|
/** A muted line under the title: how long it has sat here, whose desk it is on.
|
|
72
78
|
* Prose, not a value the reader sets. */
|
|
73
79
|
meta?: string;
|
|
80
|
+
/**
|
|
81
|
+
* ONE value the stage owns, on the TITLE's row rather than stacked under it —
|
|
82
|
+
* the date a milestone was reached, its reference number. Use it when the
|
|
83
|
+
* ladder is long enough that a `PipelineField` per stage costs two lines each
|
|
84
|
+
* and pushes the whole run past a screenful; use `PipelineField` when the value
|
|
85
|
+
* needs a label to be read, or when there is more than one.
|
|
86
|
+
*
|
|
87
|
+
* The row is fixed at `INLINE_CONTROL_HEIGHT` whatever you put here, so the
|
|
88
|
+
* marker lines up with the title and the stages keep one rhythm down the spine.
|
|
89
|
+
* An ACT still belongs in `PipelineActions` — this is for a value.
|
|
90
|
+
*/
|
|
91
|
+
trailing?: ReactNode;
|
|
74
92
|
/** The stage's own body — `PipelineNote`, `PipelineField`, `PipelineActions`,
|
|
75
93
|
* or anything else. A stage with no body renders as its title alone, which is
|
|
76
94
|
* what an unreached stage should be. */
|
|
@@ -78,27 +96,49 @@ export interface PipelineStageProps extends StepPositional {
|
|
|
78
96
|
accessibilityLabel?: string;
|
|
79
97
|
}
|
|
80
98
|
|
|
81
|
-
/** One milestone. Its title reads by STATUS — the
|
|
82
|
-
* medium weight, everything else muted —
|
|
83
|
-
*
|
|
99
|
+
/** One milestone. Its title reads by STATUS — the LIVE one (`current`, or the
|
|
100
|
+
* terminal `complete`) in full ink and medium weight, everything else muted —
|
|
101
|
+
* so the eye lands on where the record sits without reading a word.
|
|
84
102
|
*
|
|
85
103
|
* Deliberately NOT pressable. `Step` can be (a wizard whose steps navigate),
|
|
86
104
|
* but a pipeline stage is a workspace, not a destination — its body already
|
|
87
105
|
* holds the controls, and a press target wrapping them would swallow their taps. */
|
|
88
106
|
export function PipelineStage(props: PipelineStageProps) {
|
|
89
|
-
const { status, title, meta, children, accessibilityLabel, ...positional } = props;
|
|
90
|
-
|
|
107
|
+
const { status, title, meta, trailing, children, accessibilityLabel, ...positional } = props;
|
|
108
|
+
// `complete` is the terminal stage REACHED — where a finished record sits, not
|
|
109
|
+
// one it walked past. Muting it like an unreached stage leaves a completed run
|
|
110
|
+
// with nothing in full ink, so the eye has no landing point and the last thing
|
|
111
|
+
// that happened reads as the thing that hasn't.
|
|
112
|
+
const isLive = status === "current" || status === "complete";
|
|
113
|
+
const titleText = (
|
|
114
|
+
<Text size="sm" color={isLive ? "default" : "muted"} weight={isLive ? "medium" : "regular"}>
|
|
115
|
+
{title}
|
|
116
|
+
</Text>
|
|
117
|
+
);
|
|
91
118
|
return (
|
|
92
119
|
<Step
|
|
93
120
|
status={status}
|
|
94
121
|
accessibilityLabel={accessibilityLabel ?? title}
|
|
122
|
+
// The marker centres on the FIRST ROW, so it has to be told when that row
|
|
123
|
+
// is a control band and not a line of text — otherwise it stays pinned to
|
|
124
|
+
// the text and every title reads low by half the difference.
|
|
125
|
+
headHeight={trailing != null ? INLINE_CONTROL_HEIGHT : undefined}
|
|
95
126
|
{...positional}
|
|
96
127
|
>
|
|
97
128
|
<View style={{ gap: 6 }}>
|
|
98
129
|
<View style={{ gap: 2 }}>
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
130
|
+
{trailing != null ? (
|
|
131
|
+
// Fixed at the control band whatever `trailing` holds: a stage whose
|
|
132
|
+
// row height followed its content would step the marker in and out of
|
|
133
|
+
// alignment down the ladder, and a badge would sit on a shorter row
|
|
134
|
+
// than a date picker two stages up.
|
|
135
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: INLINE_CONTROL_HEIGHT }}>
|
|
136
|
+
<View style={{ flex: 1, minWidth: 0 }}>{titleText}</View>
|
|
137
|
+
{trailing}
|
|
138
|
+
</View>
|
|
139
|
+
) : (
|
|
140
|
+
titleText
|
|
141
|
+
)}
|
|
102
142
|
{meta ? <Text size="xs" color="muted">{meta}</Text> : null}
|
|
103
143
|
</View>
|
|
104
144
|
{children}
|
package/src/reference_field.tsx
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { useRef, useState } from "react";
|
|
2
2
|
import { View } from "react-native";
|
|
3
3
|
import { Button } from "./button";
|
|
4
|
+
import { DatePicker } from "./date_picker";
|
|
4
5
|
import { DetailRow, DetailTable } from "./detail_row";
|
|
6
|
+
import { formatDate } from "./format_date";
|
|
5
7
|
import { Divider } from "./divider";
|
|
6
8
|
import { InlineEditView } from "./inline_edit";
|
|
7
9
|
import { InlineStatic } from "./inline_static";
|
|
@@ -10,7 +12,7 @@ import { DialogSectionHeadingTitle } from "./section_heading";
|
|
|
10
12
|
import { Text } from "./text";
|
|
11
13
|
import { TextInputField } from "./text_input_field";
|
|
12
14
|
import { TextLink } from "./text_link";
|
|
13
|
-
import { useLoticsLocale } from "./locale";
|
|
15
|
+
import { useLocaleTag, useLoticsLocale } from "./locale";
|
|
14
16
|
|
|
15
17
|
/**
|
|
16
18
|
* A REFERENCE to another record, rendered as a FIELD VALUE.
|
|
@@ -73,6 +75,17 @@ export interface ReferenceFact {
|
|
|
73
75
|
name?: string;
|
|
74
76
|
/** A value that runs past one line — an address, a bank account block. */
|
|
75
77
|
multiline?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* What the draft edits this fact WITH. `text` (default) is a text input;
|
|
80
|
+
* `date` is the kit's `DatePicker`, and the value is its ISO string.
|
|
81
|
+
*
|
|
82
|
+
* A date is not a short string that happens to look like one. Left as text it
|
|
83
|
+
* takes any shape the reader types, and the field it saves into accepts one —
|
|
84
|
+
* so the format ambiguity is only discovered by whatever reads the record
|
|
85
|
+
* next, which is exactly the kind of wrong a draft is supposed to catch at
|
|
86
|
+
* the input.
|
|
87
|
+
*/
|
|
88
|
+
type?: "text" | "date";
|
|
76
89
|
}
|
|
77
90
|
|
|
78
91
|
export interface ReferenceFieldProps {
|
|
@@ -130,6 +143,9 @@ export function ReferenceField(props: ReferenceFieldProps) {
|
|
|
130
143
|
// The peek's verbs are the component's OWN chrome, so they come from the
|
|
131
144
|
// pack — hardcoding them shipped "Open"/"Remove" into every localized app.
|
|
132
145
|
const t = useLoticsLocale().referenceField;
|
|
146
|
+
// The tag a date control renders in — `vi` yields dd/MM/yyyy with no per-
|
|
147
|
+
// instance prop, which is the same resolution `DatePicker` does internally.
|
|
148
|
+
const localeTag = useLocaleTag();
|
|
133
149
|
const anchor = useRef<View>(null);
|
|
134
150
|
const [peekOpen, setPeekOpen] = useState(false);
|
|
135
151
|
const [draft, setDraft] = useState<Record<string, string> | null>(null);
|
|
@@ -263,14 +279,22 @@ export function ReferenceField(props: ReferenceFieldProps) {
|
|
|
263
279
|
{facts.map((f) =>
|
|
264
280
|
editing && f.name != null ? (
|
|
265
281
|
<DetailRow key={f.label} label={f.label}>
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
282
|
+
{f.type === "date" ? (
|
|
283
|
+
<DatePicker
|
|
284
|
+
value={draft[f.name] || null}
|
|
285
|
+
onValueChange={(v) => setDraft({ ...draft, [f.name as string]: v })}
|
|
286
|
+
disabled={saving}
|
|
287
|
+
/>
|
|
288
|
+
) : (
|
|
289
|
+
<TextInputField
|
|
290
|
+
value={draft[f.name] ?? ""}
|
|
291
|
+
onChangeText={(v) => setDraft({ ...draft, [f.name as string]: v })}
|
|
292
|
+
multiline={f.multiline}
|
|
293
|
+
autoGrow={f.multiline}
|
|
294
|
+
disabled={saving}
|
|
295
|
+
accessibilityLabel={f.label}
|
|
296
|
+
/>
|
|
297
|
+
)}
|
|
274
298
|
</DetailRow>
|
|
275
299
|
) : (
|
|
276
300
|
<DetailRow key={f.label} label={f.label}>
|
|
@@ -281,7 +305,15 @@ export function ReferenceField(props: ReferenceFieldProps) {
|
|
|
281
305
|
editors, border and all, and reaching for it means the
|
|
282
306
|
alignment survives the control geometry changing. Copying
|
|
283
307
|
the box here instead would drift the first time it does. */}
|
|
284
|
-
<InlineStatic
|
|
308
|
+
<InlineStatic
|
|
309
|
+
/* A date's canonical value is its ISO string — that is what
|
|
310
|
+
the picker reads and what Save sends — so the FORMATTING
|
|
311
|
+
happens here, where the type is known. Handing the caller
|
|
312
|
+
that job would make `value` mean two things (display in
|
|
313
|
+
read, ISO in the draft) and the two would drift. */
|
|
314
|
+
value={f.type === "date" ? formatDate(f.value, { locale: localeTag }) : f.value}
|
|
315
|
+
multiline={f.multiline}
|
|
316
|
+
/>
|
|
285
317
|
</DetailRow>
|
|
286
318
|
),
|
|
287
319
|
)}
|
package/src/stepper.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import { Icon } from "./icon";
|
|
|
15
15
|
import { Text } from "./text";
|
|
16
16
|
import { PressableHighlight } from "./pressable_highlight";
|
|
17
17
|
import { AnimationFadeIn } from "./animation_fade_in";
|
|
18
|
+
import { NODE, STEP_HEAD_TEXT_LINE, STEP_ROW_PAD, markerTopOffset } from "./stepper_layout";
|
|
18
19
|
|
|
19
20
|
// A node's place in a sequence. `upcoming` = not reached (greyish); `current` =
|
|
20
21
|
// where we are (ring + white centre, pulses when live); `done` = passed (filled);
|
|
@@ -64,6 +65,15 @@ export interface StepProps extends StepPositional {
|
|
|
64
65
|
* `active` washes the selected one (a panel can also sit beside it in vertical). */
|
|
65
66
|
onPress?: () => void;
|
|
66
67
|
active?: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* VERTICAL only. Height of the content's FIRST ROW — the marker centres on it.
|
|
70
|
+
* Defaults to one line of `sm` text, which is what a step's first row is unless
|
|
71
|
+
* you put something taller on it. Pass `INLINE_CONTROL_HEIGHT` when the row
|
|
72
|
+
* carries an inline editor beside the label, or the marker centres on the text
|
|
73
|
+
* while the label centres in the taller row and reads low by half the
|
|
74
|
+
* difference. Ignored horizontally, where the marker sits above the label.
|
|
75
|
+
*/
|
|
76
|
+
headHeight?: number;
|
|
67
77
|
accessibilityLabel?: string;
|
|
68
78
|
}
|
|
69
79
|
|
|
@@ -123,7 +133,7 @@ export function Stepper(props: StepperProps) {
|
|
|
123
133
|
}
|
|
124
134
|
|
|
125
135
|
export function Step(props: StepProps) {
|
|
126
|
-
const { status, children, onPress, active, accessibilityLabel, _last, _leftFilled, _rightFilled } = props;
|
|
136
|
+
const { status, children, onPress, active, headHeight = STEP_HEAD_TEXT_LINE, accessibilityLabel, _last, _leftFilled, _rightFilled } = props;
|
|
127
137
|
const { orientation, color, live } = useContext(StepperContext);
|
|
128
138
|
// Each step is one `listitem`, and the live one says so with `aria-current`.
|
|
129
139
|
// It goes on the ITEM, not the label: "current" is a fact about this position
|
|
@@ -173,7 +183,7 @@ export function Step(props: StepProps) {
|
|
|
173
183
|
<View {...item}>
|
|
174
184
|
<AnimationFadeIn translateY={6}>
|
|
175
185
|
<View style={styles.vItem}>
|
|
176
|
-
<View style={styles.vSpineCol}>
|
|
186
|
+
<View style={[styles.vSpineCol, { paddingTop: markerTopOffset(headHeight) }]}>
|
|
177
187
|
<Marker status={status} color={color} live={live} />
|
|
178
188
|
{!_last ? <View style={[styles.vSpine, { backgroundColor: reached(status) ? colors.zinc[300] : colors.zinc[200] }]} /> : null}
|
|
179
189
|
</View>
|
|
@@ -244,8 +254,6 @@ function Pulse({ color }: { color: string }) {
|
|
|
244
254
|
return <Animated.View style={[styles.pulse, { borderColor: color, transform, opacity }]} />;
|
|
245
255
|
}
|
|
246
256
|
|
|
247
|
-
const NODE = 18;
|
|
248
|
-
|
|
249
257
|
const styles = StyleSheet.create({
|
|
250
258
|
hRow: { flexDirection: "row" },
|
|
251
259
|
hStep: { flex: 1, alignItems: "center", gap: 8 },
|
|
@@ -257,12 +265,12 @@ const styles = StyleSheet.create({
|
|
|
257
265
|
hLabel: { alignItems: "center" },
|
|
258
266
|
|
|
259
267
|
vItem: { flexDirection: "row", gap: 12 },
|
|
260
|
-
vSpineCol: { width: NODE, alignItems: "center"
|
|
268
|
+
vSpineCol: { width: NODE, alignItems: "center" },
|
|
261
269
|
vSpine: { width: 1.5, flex: 1, minHeight: 14, borderRadius: 1, marginTop: 4 },
|
|
262
270
|
vContent: { flex: 1 },
|
|
263
271
|
vGap: { paddingBottom: 12 },
|
|
264
272
|
vPress: { borderRadius: 8, marginHorizontal: -10 },
|
|
265
|
-
vRowBox: { borderRadius: 8, paddingHorizontal: 10, paddingVertical:
|
|
273
|
+
vRowBox: { borderRadius: 8, paddingHorizontal: 10, paddingVertical: STEP_ROW_PAD },
|
|
266
274
|
vActive: { backgroundColor: colors.zinc[100] },
|
|
267
275
|
|
|
268
276
|
discWrap: { width: NODE, height: NODE, alignItems: "center", justifyContent: "center" },
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where a vertical `Step`'s marker sits against its content.
|
|
3
|
+
*
|
|
4
|
+
* The marker centres on the content's FIRST ROW — not on the content box, which
|
|
5
|
+
* can be many lines tall. That row is usually a line of text, but a dense stage
|
|
6
|
+
* puts a control on it (a date beside the milestone's name), and a control is
|
|
7
|
+
* twice a text line's height. Centring on the wrong one is a visible drift on
|
|
8
|
+
* every row of the ladder, so the offset is derived from the row's height rather
|
|
9
|
+
* than tuned to whatever the first caller happened to render.
|
|
10
|
+
*
|
|
11
|
+
* RN-free so the arithmetic is testable: `stepper.tsx` imports `react-native`,
|
|
12
|
+
* which Vitest cannot parse.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The marker disc's diameter. */
|
|
16
|
+
export const NODE = 18;
|
|
17
|
+
|
|
18
|
+
/** `vRowBox`'s vertical padding — the gap above the content's first row. */
|
|
19
|
+
export const STEP_ROW_PAD = 2;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A single line of `Text size="sm"` on web (`text.css`), which is what a step's
|
|
23
|
+
* first row is unless it carries a control. NOT the native StyleSheet's 24: the
|
|
24
|
+
* kit renders on web, and the two scales disagree.
|
|
25
|
+
*/
|
|
26
|
+
export const STEP_HEAD_TEXT_LINE = 20;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Top padding for the spine column so the marker's centre lands on the centre of
|
|
30
|
+
* a first row `headHeight` tall.
|
|
31
|
+
*
|
|
32
|
+
* Never negative: a row SHORTER than the marker would otherwise lift the disc
|
|
33
|
+
* above the content box and out of the step's own bounds, clipping it against
|
|
34
|
+
* whatever sits above.
|
|
35
|
+
*/
|
|
36
|
+
export function markerTopOffset(headHeight: number): number {
|
|
37
|
+
return Math.max(0, STEP_ROW_PAD + headHeight / 2 - NODE / 2);
|
|
38
|
+
}
|