@lotics/ui 27.8.0 → 27.10.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 +4 -2
- package/docs/catalog.md +27 -1
- package/docs/composition.md +14 -0
- package/examples/tpl_record.tsx +169 -21
- package/package.json +2 -1
- package/src/check_circle.tsx +3 -2
- package/src/date_stamp.tsx +121 -0
- package/src/locale.tsx +6 -0
- package/src/pipeline.tsx +51 -16
- package/src/stepper.tsx +72 -5
- package/src/stepper_layout.ts +18 -2
- package/src/task.tsx +4 -3
package/AGENTS.md
CHANGED
|
@@ -42,8 +42,10 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
42
42
|
`PipelineStage`, over `stepper`) — worked in `examples/tpl_record.tsx` § Progress, which it took
|
|
43
43
|
over FROM a per-desk checklist. Rendering positions as a checklist forces every row to carry
|
|
44
44
|
every control and never says where the record sits; rendering work items as a pipeline implies
|
|
45
|
-
an order that is not there. A
|
|
46
|
-
|
|
45
|
+
an order that is not there. A ladder is TICKED — `PipelineStage.onToggle` makes the marker the
|
|
46
|
+
completion control (never a checkbox beside it), and the stamped date rides the title row via
|
|
47
|
+
`trailing` as a `DateStamp`: muted text, no frame, click to correct. One ladder per record,
|
|
48
|
+
never a handoff run beside a milestone run — see `docs/catalog.md`.
|
|
47
49
|
- **`Badge` = STATUS only; supporting detail is the muted second line.** A type / category /
|
|
48
50
|
attribute / count is not a status — it belongs under its identity as `size="xs" color="muted"`,
|
|
49
51
|
never a second chip. A chip beside a name reads as its PEER (a colored one reads louder),
|
package/docs/catalog.md
CHANGED
|
@@ -1003,6 +1003,15 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1003
1003
|
|
|
1004
1004
|
### Tasks & checklists
|
|
1005
1005
|
|
|
1006
|
+
- **`date_stamp`** — `DateStamp`: a recorded date shown QUIETLY and correctable — small muted
|
|
1007
|
+
text at rest, no frame, no icon, no control band; underlined on hover; the press opens the
|
|
1008
|
+
kit's `DatePickerPanel`. For a date the SYSTEM writes and a person only occasionally
|
|
1009
|
+
overrides (a milestone ticked, a status flipped). NOT `InlineDatePicker` with its surface
|
|
1010
|
+
removed: that is a form FIELD — calendar icon, control band, typed `DateField`, em-dash when
|
|
1011
|
+
empty — and a ladder of fields is a form with invisible borders. Reach for `InlineDatePicker`
|
|
1012
|
+
when a person is expected to ENTER the date, and this when the system already knows it.
|
|
1013
|
+
Empty renders a thin rule, not a placeholder; read-only (omit `onChange`) renders text alone.
|
|
1014
|
+
Worked screen: `examples/tpl_record.tsx` § Progress.
|
|
1006
1015
|
- **`check_circle`** — `CheckCircle`: the completion ring — `state="none" | "partial" | "done"`,
|
|
1007
1016
|
springing to a filled check when done, distinct from the square checkbox; the
|
|
1008
1017
|
task/to-do/checklist control. `partial` half-fills it, for the row that is underway but not
|
|
@@ -1261,7 +1270,24 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1261
1270
|
one rhythm whatever the value is. An ACT still belongs in `PipelineActions`.
|
|
1262
1271
|
Worked screen: `examples/tpl_record.tsx` § Progress — the desks (Sales → Operations →
|
|
1263
1272
|
Accounting) ARE the stages, each owning the facts it stamps and, on the live one, the act
|
|
1264
|
-
that leaves it. A `PipelineStage` is deliberately NOT pressable:
|
|
1273
|
+
that leaves it. A `PipelineStage` body is deliberately NOT pressable: it holds the controls.
|
|
1274
|
+
**`PipelineStage.onToggle`** is the one exception and lives on the MARKER — the node becomes a
|
|
1275
|
+
pressable `CheckCircle` instead of a status dot, for a ladder the reader TICKS OFF rather than
|
|
1276
|
+
only reads. Reach for it before putting a checkbox in `trailing`: that lands a second completion
|
|
1277
|
+
affordance beside a ring that already looks like one, and the reader has to guess which is real.
|
|
1278
|
+
**Which of the two shapes you want**: if finishing a step must capture anything beyond "it
|
|
1279
|
+
happened" — who took it, a reason — it is a HANDOFF, and the act belongs in `PipelineActions`
|
|
1280
|
+
with the marker only reporting. If it does not, it is a MILESTONE and the marker IS the act.
|
|
1281
|
+
ONE ladder per record, never two. A rung that needs more than a tick (a dialog, an owner, a
|
|
1282
|
+
reason) renders it in its BODY — the tick and the act coexist on the same stage, which is what
|
|
1283
|
+
a real pipeline looks like. Splitting them into a handoff run and a milestone run puts "where
|
|
1284
|
+
is this" in two places the reader has to watch together. Worked screen:
|
|
1285
|
+
`examples/tpl_record.tsx` § Progress — tickable markers, the stamped date on the title row as a
|
|
1286
|
+
`bare` `InlineDatePicker` (reads as text, border on hover, click to edit), and the live stage
|
|
1287
|
+
still carrying its owner, its note and the act that leaves it. The date stays EDITABLE on
|
|
1288
|
+
purpose: a tick always writes today, so inert text would make a ladder filled in after the
|
|
1289
|
+
fact — every imported record — impossible to enter correctly. An unstamped rung shows no
|
|
1290
|
+
placeholder; its empty ring already says "not yet".
|
|
1265
1291
|
- **`stepper`** — `Stepper` + `Step` — done/current/upcoming/warning/complete progress on a
|
|
1266
1292
|
track (horizontal) or spine (vertical); compound `<Step status>children` OR data
|
|
1267
1293
|
`steps[]`+`current`; **navigable** via `Step.onPress` (both orientations — the whole step
|
package/docs/composition.md
CHANGED
|
@@ -286,6 +286,11 @@ leaves the reader no reason to open the chevron.
|
|
|
286
286
|
|
|
287
287
|
## No dead rows — registers & tables
|
|
288
288
|
|
|
289
|
+
**The identifier leads.** A register's FIRST column is the thing the reader came to find the row
|
|
290
|
+
by — its key, code, or id — because that is what they are scanning against a document, an email,
|
|
291
|
+
or someone on the phone. Pushing it right of a name or a status makes every lookup a hunt across
|
|
292
|
+
the row, and no other column can take its place: a name repeats, a status is shared by dozens.
|
|
293
|
+
|
|
289
294
|
Every listed record is actionable. PRIMARY entity rows press-open the workspace `Drawer`
|
|
290
295
|
(sequenced — see [master-detail](#master-detail--list--workspace-drawer-with-sequencing));
|
|
291
296
|
read-only drill-downs expand via `Accordion` or glance via `Peek`; every other row gets an
|
|
@@ -770,6 +775,15 @@ Anything pressable is ≥ 40px tall (8px minimum gap between pressables). 16px b
|
|
|
770
775
|
canvas padding, 16–20 inside bands, 10–12 between content lines. Density comes from alignment +
|
|
771
776
|
hierarchy, not cramming.
|
|
772
777
|
|
|
778
|
+
**The trim test — does this element RESTATE something already on the screen?** Run it over every
|
|
779
|
+
screen before shipping: a per-row proportion bar when the percentage is already on the row; the
|
|
780
|
+
percentage itself when the column holds four values and a total; a state line that only says
|
|
781
|
+
nothing has happened; a gloss explaining the WIDGET rather than the domain. A gloss earns its
|
|
782
|
+
place only by stating a truth that is otherwise INVISIBLE. This is the hardest slop to see and the
|
|
783
|
+
most common, because it survives every other check: each piece is defensible alone, and only the
|
|
784
|
+
question "what does this tell me that the screen does not already say" catches decoration wearing
|
|
785
|
+
an information costume.
|
|
786
|
+
|
|
773
787
|
## Testing an overlay component — assert through the logic, prove in a browser
|
|
774
788
|
|
|
775
789
|
**A `Popover`-backed surface does not mount under jsdom.** It positions in a `useLayoutEffect` +
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment, useEffect, useRef, useState } from "react";
|
|
2
|
-
import { ScrollView, View } from "react-native";
|
|
2
|
+
import { Pressable, ScrollView, View } from "react-native";
|
|
3
3
|
import { Text } from "@lotics/ui/text";
|
|
4
4
|
import { colors } from "@lotics/ui/colors";
|
|
5
5
|
import { Button } from "@lotics/ui/button";
|
|
@@ -16,6 +16,9 @@ import { Inset } from "@lotics/ui/inset";
|
|
|
16
16
|
import { Section, SectionHeading, SectionHeadingMeta, SectionHeadingTitle, Subsection, SubsectionHeading, SubsectionHeadingTitle } from "@lotics/ui/section_heading";
|
|
17
17
|
import { SectionStack, SubsectionStack } from "@lotics/ui/section_stack";
|
|
18
18
|
import { Pipeline, PipelineActions, PipelineField, PipelineNote, PipelineStage } from "@lotics/ui/pipeline";
|
|
19
|
+
import { DateStamp } from "@lotics/ui/date_stamp";
|
|
20
|
+
import { TextLink } from "@lotics/ui/text_link";
|
|
21
|
+
import { MemberChip } from "@lotics/ui/member_chip";
|
|
19
22
|
import { TaskCaption, TaskDetail, TaskItem, TaskList, TaskStatus, TaskTitle } from "@lotics/ui/task";
|
|
20
23
|
import { MenuButton } from "@lotics/ui/menu_button";
|
|
21
24
|
import { Popover, PopoverContent } from "@lotics/ui/popover";
|
|
@@ -158,12 +161,36 @@ const DESKS: { key: Desk; label: string }[] = [
|
|
|
158
161
|
{ key: "operations", label: "Operations" },
|
|
159
162
|
{ key: "accounting", label: "Accounting" },
|
|
160
163
|
];
|
|
164
|
+
/**
|
|
165
|
+
* The PROGRESS ladder. Not the same list as `DESKS`: a desk is a place the record
|
|
166
|
+
* SITS and owns facts (who took it, the handoff that leaves it), while most of a
|
|
167
|
+
* run is milestones that own nothing at all — they happened, on a day, and that
|
|
168
|
+
* is the whole record. A ladder built only from desks hides the majority of what
|
|
169
|
+
* a reader wants to see, and one that gives every step an owner field asks four
|
|
170
|
+
* questions nobody has answers to.
|
|
171
|
+
*
|
|
172
|
+
* `desk` marks the steps that carry facts and the handoff act. The rest are a
|
|
173
|
+
* tick and a date.
|
|
174
|
+
*/
|
|
175
|
+
const STEPS: { key: string; label: string; desk?: Desk }[] = [
|
|
176
|
+
{ key: "sales", label: "Sales", desk: "sales" },
|
|
177
|
+
{ key: "quoted", label: "Quote sent" },
|
|
178
|
+
{ key: "operations", label: "Operations", desk: "operations" },
|
|
179
|
+
{ key: "collected", label: "Collected" },
|
|
180
|
+
{ key: "cleared", label: "Customs cleared" },
|
|
181
|
+
{ key: "delivered", label: "Delivered" },
|
|
182
|
+
{ key: "accounting", label: "Accounting", desk: "accounting" },
|
|
183
|
+
{ key: "pod", label: "POD received" },
|
|
184
|
+
];
|
|
185
|
+
|
|
161
186
|
const STAGES: { key: Stage; label: string }[] = [
|
|
162
187
|
...DESKS,
|
|
163
188
|
{ key: "closed", label: "Closed" },
|
|
164
189
|
];
|
|
165
190
|
const stageOf = (st: Stage) => STAGES.find((x) => x.key === st) ?? STAGES[0];
|
|
166
191
|
|
|
192
|
+
|
|
193
|
+
|
|
167
194
|
// How long the live desk has held the record — the PipelineStage meta line.
|
|
168
195
|
// Prose, derived: the date itself is the stage's own editable field.
|
|
169
196
|
function heldFor(since: string): string | undefined {
|
|
@@ -1257,8 +1284,18 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
1257
1284
|
// Each desk owns two facts about its own custody: when it took the record and
|
|
1258
1285
|
// who holds it. Stored per desk so a passed desk keeps them editable — a date
|
|
1259
1286
|
// typed wrong must not become unreachable the moment the record moves on.
|
|
1260
|
-
const [deskSince, setDeskSince] = useState<Record<
|
|
1261
|
-
|
|
1287
|
+
const [deskSince, setDeskSince] = useState<Record<string, string>>({ sales: "2026-06-15" });
|
|
1288
|
+
// Which passed stage has been opened for editing. A stage BEHIND the live one
|
|
1289
|
+
// shows its facts and nothing else — the ladder is read far more often than it
|
|
1290
|
+
// is corrected, and a column of live editors on history invites changing it by
|
|
1291
|
+
// accident. "Edit" is the deliberate step that turns one stage back into a form.
|
|
1292
|
+
//
|
|
1293
|
+
// ONE key, so opening a second stage closes the first. That is the intent, not a
|
|
1294
|
+
// limitation: the editors commit as you leave them, so nothing is lost by the
|
|
1295
|
+
// close, and two open stages would put two versions of the same ladder on screen
|
|
1296
|
+
// — one being read, one being changed — with no cue for which is which.
|
|
1297
|
+
const [editingDesk, setEditingDesk] = useState("");
|
|
1298
|
+
const [deskOwner, setDeskOwner] = useState<Record<string, string | null>>({ sales: "mem_01" });
|
|
1262
1299
|
|
|
1263
1300
|
const customerOptions: PickerOption<string, Customer>[] = customers.map((c) => ({
|
|
1264
1301
|
value: c.id,
|
|
@@ -1461,6 +1498,8 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
1461
1498
|
{ key: "receipt", label: "Delivery receipt", icon: "log-in" },
|
|
1462
1499
|
{ key: "danger", label: "Danger zone", icon: "circle-alert" },
|
|
1463
1500
|
] as const;
|
|
1501
|
+
// Kept in step with `SECTIONS` above by hand — a key added there and forgotten
|
|
1502
|
+
// here type-errors at every `nav.register` call, which is the cheap failure.
|
|
1464
1503
|
const nav = useSectionNav(["general", "files", "transport", "progress", "fees", "billing", "docset", "receipt", "danger"] as const);
|
|
1465
1504
|
// ONE record, ONE page. A section is a place you SCROLL to, never a
|
|
1466
1505
|
// destination you swap to: routing a record was tried here and lost, because
|
|
@@ -2321,31 +2360,126 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
2321
2360
|
<SectionHeadingTitle description="One desk owns the record at a time. The live desk carries the handoff.">Progress</SectionHeadingTitle>
|
|
2322
2361
|
</SectionHeading>
|
|
2323
2362
|
<Pipeline accessibilityLabel="Desk handoff progress">
|
|
2324
|
-
{
|
|
2325
|
-
|
|
2363
|
+
{/* The ladder reads its own STAMPS, and nothing else. Driving `status`
|
|
2364
|
+
off the record's `stage` while the tick wrote a date left two
|
|
2365
|
+
sources of truth for one position: the ring could never change
|
|
2366
|
+
state, so pressing it did nothing visible — no fill, no spring, no
|
|
2367
|
+
movement — which is what "the progress is broken" looks like.
|
|
2368
|
+
A rung is done when IT carries a date, independently of its
|
|
2369
|
+
neighbours, so any rung can be ticked and un-ticked on its own. */}
|
|
2370
|
+
{STEPS.map((d, i) => {
|
|
2371
|
+
const stampedAt = deskSince[d.key] ?? "";
|
|
2372
|
+
const firstOpen = STEPS.findIndex((x) => !deskSince[x.key]);
|
|
2373
|
+
const status: "done" | "current" | "upcoming" = stampedAt ? "done" : i === firstOpen ? "current" : "upcoming";
|
|
2326
2374
|
const owner = deskOwner[d.key];
|
|
2327
2375
|
return (
|
|
2328
2376
|
<PipelineStage
|
|
2329
2377
|
key={d.key}
|
|
2330
2378
|
status={status}
|
|
2331
2379
|
title={d.label}
|
|
2332
|
-
// Prose the reader can't set — how long it has sat here.
|
|
2333
|
-
|
|
2334
|
-
|
|
2380
|
+
// Prose the reader can't set — how long it has sat here.
|
|
2381
|
+
meta={status === "current" ? heldFor(stampedAt) : undefined}
|
|
2382
|
+
// The tick IS the stamp: pressing the ring records today, pressing
|
|
2383
|
+
// it again clears the stage. ANY ring takes a press (`Pipeline`'s
|
|
2384
|
+
// default) because this ladder is a RECORD — entering three stages
|
|
2385
|
+
// that already happened must not cost three presses plus three
|
|
2386
|
+
// date corrections.
|
|
2387
|
+
onToggle={(next) => {
|
|
2388
|
+
if (next) {
|
|
2389
|
+
// Ticking AHEAD fills the gap. A ladder is a run, not a set
|
|
2390
|
+
// of independent boxes: leaving 3 and 4 blank because someone
|
|
2391
|
+
// ticked 5 renders "done, blank, blank, done", which claims a
|
|
2392
|
+
// record reached the end without passing through the middle.
|
|
2393
|
+
// Every rung at or below the one pressed that has no stamp
|
|
2394
|
+
// gets today — the day this is being recorded, which is the
|
|
2395
|
+
// same thing a single tick already means. Each one is then
|
|
2396
|
+
// one click away from its real date, and the ones that
|
|
2397
|
+
// already carry a date are left exactly as they are.
|
|
2398
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
2399
|
+
setDeskSince((prev) => {
|
|
2400
|
+
const filled = { ...prev };
|
|
2401
|
+
STEPS.slice(0, i + 1).forEach((x) => { if (!filled[x.key]) filled[x.key] = today; });
|
|
2402
|
+
return filled;
|
|
2403
|
+
});
|
|
2404
|
+
return;
|
|
2405
|
+
}
|
|
2406
|
+
// Un-ticking walks the record BACK, and everything the stages
|
|
2407
|
+
// above it recorded goes with it — their dates and the facts
|
|
2408
|
+
// they own. That is destructive and invisible from the ring,
|
|
2409
|
+
// so it is named before it happens: which stages, and that it
|
|
2410
|
+
// is their data too, not just their dates. A stage with
|
|
2411
|
+
// nothing above it needs no warning — there is nothing to lose.
|
|
2412
|
+
const above = STEPS.slice(i + 1).filter((x) => deskSince[x.key] || deskOwner[x.key]);
|
|
2413
|
+
const revert = () =>
|
|
2414
|
+
STEPS.slice(i).forEach((x) => {
|
|
2415
|
+
setDeskSince((prev) => ({ ...prev, [x.key]: "" }));
|
|
2416
|
+
setDeskOwner((prev) => ({ ...prev, [x.key]: "" }));
|
|
2417
|
+
});
|
|
2418
|
+
if (above.length === 0) { revert(); return; }
|
|
2419
|
+
Alert.alert(
|
|
2420
|
+
`Reopen ${d.label}?`,
|
|
2421
|
+
`${above.map((x) => x.label).join(" and ")} ${above.length > 1 ? "are" : "is"} recorded after it. Reopening clears their dates and everything they hold — owner included. This can't be undone.`,
|
|
2422
|
+
[
|
|
2423
|
+
{ text: "Cancel", style: "cancel" },
|
|
2424
|
+
{ text: "Reopen and clear", style: "destructive", onPress: revert },
|
|
2425
|
+
],
|
|
2426
|
+
);
|
|
2427
|
+
}}
|
|
2428
|
+
// The date rides the title's own row and READS as text — `bare`
|
|
2429
|
+
// shows nothing at rest and fades its border in on hover, so the
|
|
2430
|
+
// ladder stays a column of dates to scan rather than a column of
|
|
2431
|
+
// controls, and the one that needs correcting is still one click.
|
|
2432
|
+
//
|
|
2433
|
+
// That click is how a stamp gets BACKDATED, which the tick alone
|
|
2434
|
+
// cannot do: ticking always writes today, and a ladder filled in
|
|
2435
|
+
// after the fact is full of days that already passed. Rendering
|
|
2436
|
+
// the date as inert text would have cost exactly that.
|
|
2437
|
+
//
|
|
2438
|
+
// An unstamped rung shows nothing — its empty ring already says
|
|
2439
|
+
// "not yet", and a "Not recorded" placeholder on every rung ahead
|
|
2440
|
+
// says the same thing again in more words.
|
|
2441
|
+
// What the stage HOLDS, beside its name: the date it was
|
|
2442
|
+
// stamped, and any value it owns that has actually been set.
|
|
2443
|
+
// A stage's facts are otherwise only visible once you read down
|
|
2444
|
+
// into its body, so a ladder of five says nothing but five
|
|
2445
|
+
// names — the reader has to open each to learn anything. Only
|
|
2446
|
+
// values that EXIST are shown; an empty one would print a label
|
|
2447
|
+
// with nothing after it on every rung ahead.
|
|
2448
|
+
// The stage's own line carries what is ABOUT the stage — when it
|
|
2449
|
+
// happened, and the way into changing it — while the body holds
|
|
2450
|
+
// what it CONTAINS. Edit sat under the values, which put the verb
|
|
2451
|
+
// below the thing it acts on and moved it down every time a stage
|
|
2452
|
+
// gained a field.
|
|
2453
|
+
trailing={
|
|
2454
|
+
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 10 }}>
|
|
2455
|
+
<DateStamp
|
|
2456
|
+
value={stampedAt || null}
|
|
2457
|
+
onChange={(iso) => setDeskSince((prev) => ({ ...prev, [d.key]: iso }))}
|
|
2458
|
+
locale="en-GB"
|
|
2459
|
+
accessibilityLabel={"Date for " + d.label}
|
|
2460
|
+
/>
|
|
2461
|
+
{/* Edit ⇄ Done, one control in one place. It is a text LINK
|
|
2462
|
+
and not a button on purpose: the inline editors commit on
|
|
2463
|
+
their own (blur, Enter, a pick), so nothing is pending when
|
|
2464
|
+
you press this — it only puts the stage back to reading.
|
|
2465
|
+
A `Button` would promise a SAVE that already happened and
|
|
2466
|
+
make a reader wonder what they lose by navigating away. */}
|
|
2467
|
+
{d.desk && status !== "upcoming" && status !== "current" ? (
|
|
2468
|
+
<Pressable
|
|
2469
|
+
onPress={() => setEditingDesk(editingDesk === d.key ? "" : d.key)}
|
|
2470
|
+
accessibilityRole="button"
|
|
2471
|
+
accessibilityLabel={`${editingDesk === d.key ? "Done editing" : "Edit"} ${d.label}`}
|
|
2472
|
+
>
|
|
2473
|
+
<TextLink size="xs">{editingDesk === d.key ? "Done" : "Edit"}</TextLink>
|
|
2474
|
+
</Pressable>
|
|
2475
|
+
) : null}
|
|
2476
|
+
</View>
|
|
2477
|
+
}
|
|
2335
2478
|
>
|
|
2336
2479
|
{/* A reached desk owns its own facts — and keeps owning them
|
|
2337
2480
|
once the record has moved on. */}
|
|
2338
|
-
{
|
|
2339
|
-
|
|
2340
|
-
<PipelineField label="Taken on" maxWidth={200}>
|
|
2341
|
-
<InlineDatePicker
|
|
2342
|
-
value={deskSince[d.key] || null}
|
|
2343
|
-
onSave={(v) => setDeskSince((p) => ({ ...p, [d.key]: v ?? "" }))}
|
|
2344
|
-
onClear={() => setDeskSince((p) => ({ ...p, [d.key]: "" }))}
|
|
2345
|
-
placeholder="Not recorded"
|
|
2346
|
-
accessibilityLabel={`Taken on for ${d.label}`}
|
|
2347
|
-
/>
|
|
2348
|
-
</PipelineField>
|
|
2481
|
+
{status !== "upcoming" && d.desk ? (
|
|
2482
|
+
status === "current" || editingDesk === d.key ? (
|
|
2349
2483
|
<PipelineField label="Owner" maxWidth={260}>
|
|
2350
2484
|
<InlineMemberSelect
|
|
2351
2485
|
members={TEAM}
|
|
@@ -2355,13 +2489,27 @@ export function TplRecord({ chrome = "page", code = "RC-2026-0418" }: { chrome?:
|
|
|
2355
2489
|
accessibilityLabel={`Owner for ${d.label}`}
|
|
2356
2490
|
/>
|
|
2357
2491
|
</PipelineField>
|
|
2358
|
-
|
|
2492
|
+
) : (
|
|
2493
|
+
// Read-only, but still SHOWN — and shown by whatever renders
|
|
2494
|
+
// that kind of value properly: a member as a `MemberChip`, a
|
|
2495
|
+
// select as its `Badge`. Collapsing them to muted text would
|
|
2496
|
+
// make the same fact look different depending on whether the
|
|
2497
|
+
// stage happened to be the live one, and a reader would have
|
|
2498
|
+
// to decode two renderings of one field. Only what is SET
|
|
2499
|
+
// appears; an empty field has nothing to display read-only.
|
|
2500
|
+
// No label. A label tells you what you are FILLING IN —
|
|
2501
|
+
// read-only, the value says what it is: a face and a name is
|
|
2502
|
+
// an owner, a badge is a status. Printing "Owner" over a
|
|
2503
|
+
// member chip is the field talking about itself, and down a
|
|
2504
|
+
// ladder it doubles the lines for nothing.
|
|
2505
|
+
owner ? <MemberChip name={TEAM.find((x) => x.id === owner)?.name ?? owner} size={22} /> : null
|
|
2506
|
+
)
|
|
2359
2507
|
) : null}
|
|
2360
2508
|
{/* A condition belongs to the desk it is about. */}
|
|
2361
2509
|
{status === "current" && stage !== "closed" && deliverBy !== "" && new Date(deliverBy) < new Date() ? (
|
|
2362
2510
|
<PipelineNote tone="warning">Past the due date — chase it or move the date.</PipelineNote>
|
|
2363
2511
|
) : null}
|
|
2364
|
-
{status === "current" && gate ? (
|
|
2512
|
+
{status === "current" && d.desk === stage && gate ? (
|
|
2365
2513
|
<PipelineActions>
|
|
2366
2514
|
<Button
|
|
2367
2515
|
title={gate.cta}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "27.
|
|
3
|
+
"version": "27.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -218,6 +218,7 @@
|
|
|
218
218
|
"./page_content": "./src/page_content.tsx",
|
|
219
219
|
"./page_header": "./src/page_header.tsx",
|
|
220
220
|
"./date_picker": "./src/date_picker.tsx",
|
|
221
|
+
"./date_stamp": "./src/date_stamp.tsx",
|
|
221
222
|
"./date_filter": "./src/date_filter.tsx",
|
|
222
223
|
"./date_range_filter_field": "./src/date_range_filter_field.tsx",
|
|
223
224
|
"./time_picker": "./src/time_picker.tsx",
|
package/src/check_circle.tsx
CHANGED
|
@@ -25,7 +25,8 @@ export interface CheckCircleProps {
|
|
|
25
25
|
onChange?: (done: boolean) => void;
|
|
26
26
|
/** Fill colour when done (and the half-fill when partial). Default zinc-900. */
|
|
27
27
|
color?: string;
|
|
28
|
-
/** Diameter. Default
|
|
28
|
+
/** Diameter. Default 24 — the same box `Checkbox` occupies, so the two forms of
|
|
29
|
+
* "mark this" reserve one gutter width wherever a surface hosts either. */
|
|
29
30
|
size?: number;
|
|
30
31
|
disabled?: boolean;
|
|
31
32
|
accessibilityLabel: string;
|
|
@@ -43,7 +44,7 @@ export interface CheckCircleProps {
|
|
|
43
44
|
* "how far", not "which state".
|
|
44
45
|
*/
|
|
45
46
|
export function CheckCircle(props: CheckCircleProps) {
|
|
46
|
-
const { state, onChange, color = colors.zinc[900], size =
|
|
47
|
+
const { state, onChange, color = colors.zinc[900], size = 24, disabled, accessibilityLabel } = props;
|
|
47
48
|
const done = state === "done";
|
|
48
49
|
const v = useRef(new Animated.Value(done ? 1 : 0)).current;
|
|
49
50
|
useEffect(() => {
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Pressable, View } from "react-native";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Icon } from "./icon";
|
|
5
|
+
import { colors } from "./colors";
|
|
6
|
+
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
7
|
+
import { DatePickerPanel } from "./date_picker";
|
|
8
|
+
import { formatDate } from "./format_date";
|
|
9
|
+
import { useLocaleTag } from "./locale";
|
|
10
|
+
|
|
11
|
+
export interface DateStampProps {
|
|
12
|
+
/** ISO date, or `""`/`null` for a stamp that has not been made. */
|
|
13
|
+
value: string | null;
|
|
14
|
+
/** Commit a correction. Omit for a read-only stamp — then nothing is pressable. */
|
|
15
|
+
onChange?: (iso: string) => void;
|
|
16
|
+
/** Locale tag for the rendered date. Defaults to the kit's locale. */
|
|
17
|
+
locale?: string;
|
|
18
|
+
accessibilityLabel: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A recorded date, shown quietly — and correctable.
|
|
23
|
+
*
|
|
24
|
+
* The value a SYSTEM writes and a person only occasionally overrides: the day a
|
|
25
|
+
* milestone was ticked, the day a status flipped. It is a READOUT first, so at
|
|
26
|
+
* rest it is nothing but small muted text — no frame, no icon, no control band.
|
|
27
|
+
* Hovering underlines it, which is the one cue prose has to say it opens; the
|
|
28
|
+
* press opens the kit's own `DatePickerPanel`.
|
|
29
|
+
*
|
|
30
|
+
* **Why this is not `InlineDatePicker` with a variant.** That component is a
|
|
31
|
+
* form FIELD: it renders a calendar icon, sits in a control band, keeps a typed
|
|
32
|
+
* `DateField` for keyboard entry and shows an em-dash when empty — the whole
|
|
33
|
+
* anatomy of "fill me in". Stripping its surface to get this left the icon and
|
|
34
|
+
* the sizing behind, because those belong to the field, not to the frame. A
|
|
35
|
+
* ladder of fields is a form with invisible borders; a ladder of these is a list
|
|
36
|
+
* of dates you can read at a glance. Different concept, different component.
|
|
37
|
+
*
|
|
38
|
+
* Reach for `InlineDatePicker` when the date is something a person is expected
|
|
39
|
+
* to ENTER, and for this when it is something the system already knows.
|
|
40
|
+
*
|
|
41
|
+
* Empty renders NOTHING (not a dash, not a placeholder): there is no value to
|
|
42
|
+
* report, whatever named the stamp already says it has not happened, and a
|
|
43
|
+
* column of dashes is noise pretending to be data. A read-only stamp with no
|
|
44
|
+
* value therefore renders nothing at all.
|
|
45
|
+
*/
|
|
46
|
+
export function DateStamp(props: DateStampProps) {
|
|
47
|
+
const { value, onChange, locale, accessibilityLabel } = props;
|
|
48
|
+
const localeTag = useLocaleTag();
|
|
49
|
+
const tag = locale ?? localeTag;
|
|
50
|
+
const [hovered, setHovered] = useState(false);
|
|
51
|
+
const [open, setOpen] = useState(false);
|
|
52
|
+
const [draft, setDraft] = useState<string | null>(value);
|
|
53
|
+
|
|
54
|
+
const shown = value ? formatDate(value, { locale: tag }) : "";
|
|
55
|
+
|
|
56
|
+
// Read-only, or nothing to show and no way to add one: render the text alone
|
|
57
|
+
// so a caller can drop this into a column without a conditional.
|
|
58
|
+
if (!onChange) return shown ? <Text size="xs" color="muted">{shown}</Text> : null;
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<Popover
|
|
62
|
+
open={open}
|
|
63
|
+
onOpenChange={(next) => {
|
|
64
|
+
setOpen(next);
|
|
65
|
+
// Commit on CLOSE, matching `InlineDatePicker`: a single pick and the
|
|
66
|
+
// panel's Today button both auto-close through here, so committing on
|
|
67
|
+
// change instead would fire twice and a dismissed panel would still write.
|
|
68
|
+
if (!next && draft !== value) onChange(draft ?? "");
|
|
69
|
+
if (next) setDraft(value);
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<PopoverTrigger>
|
|
73
|
+
<Pressable
|
|
74
|
+
onPress={() => setOpen(true)}
|
|
75
|
+
onHoverIn={() => setHovered(true)}
|
|
76
|
+
onHoverOut={() => setHovered(false)}
|
|
77
|
+
accessibilityRole="button"
|
|
78
|
+
accessibilityLabel={accessibilityLabel}
|
|
79
|
+
>
|
|
80
|
+
{/* The underline is a BOTTOM BORDER, not a text decoration: it can then
|
|
81
|
+
be transparent at rest and coloured on hover with no reflow, and it
|
|
82
|
+
sits under the text rather than through its descenders. */}
|
|
83
|
+
<View
|
|
84
|
+
style={{
|
|
85
|
+
borderBottomWidth: 1,
|
|
86
|
+
borderBottomColor: hovered ? colors.zinc[400] : "transparent",
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
{/* An empty stamp still needs somewhere to press, or the only way to
|
|
90
|
+
record a date the tick got wrong is to tick and re-tick. A muted
|
|
91
|
+
calendar glyph says "a date goes here" AND looks pressable; the
|
|
92
|
+
thin rule that was here first read as a divider and invited no
|
|
93
|
+
click at all. The icon appears ONLY when there is no date — once
|
|
94
|
+
one exists the date itself is the target, and an icon beside it
|
|
95
|
+
would be chrome on every row of the ladder. */}
|
|
96
|
+
{shown ? (
|
|
97
|
+
<Text size="xs" color="muted">{shown}</Text>
|
|
98
|
+
) : (
|
|
99
|
+
// An icon has no text baseline, so in a `baseline` row it hangs off
|
|
100
|
+
// its own bottom edge. Centring it in the label's LINE BOX is not
|
|
101
|
+
// the fix either, and that is the version that shipped looking
|
|
102
|
+
// wrong: a line box carries descender space below the baseline that
|
|
103
|
+
// an icon has no equivalent of, so the label's optical centre (its
|
|
104
|
+
// cap band) sits ~2px above the box centre. Matching box centres
|
|
105
|
+
// therefore hangs the icon low against the text every time.
|
|
106
|
+
//
|
|
107
|
+
// Measured on this row: label box 344–364, cap band ~348–358. The
|
|
108
|
+
// lift is that gap, not a taste adjustment.
|
|
109
|
+
<View style={{ height: 16, justifyContent: "center", alignSelf: "center", marginBottom: 2 }}>
|
|
110
|
+
<Icon name="calendar" size={13} color={hovered ? colors.zinc[500] : colors.zinc[400]} />
|
|
111
|
+
</View>
|
|
112
|
+
)}
|
|
113
|
+
</View>
|
|
114
|
+
</Pressable>
|
|
115
|
+
</PopoverTrigger>
|
|
116
|
+
<PopoverContent>
|
|
117
|
+
<DatePickerPanel value={draft} onValueChange={setDraft} format="date" locale={tag} onRequestClose={() => setOpen(false)} />
|
|
118
|
+
</PopoverContent>
|
|
119
|
+
</Popover>
|
|
120
|
+
);
|
|
121
|
+
}
|
package/src/locale.tsx
CHANGED
|
@@ -69,6 +69,10 @@ export interface LoticsLocale {
|
|
|
69
69
|
/** `Ledger`: the screen-reader name of a peekable row. */
|
|
70
70
|
ledger: { rowDetails: (label: string) => string };
|
|
71
71
|
/** `SectionHeadingTitle`: the info-popover trigger's screen-reader name. */
|
|
72
|
+
/** `Step`/`PipelineStage`'s toggleable MARKER, when the caller names no
|
|
73
|
+
* label of its own. A pipeline stage always passes its title, so this is the
|
|
74
|
+
* bare-`Step` fallback. */
|
|
75
|
+
stepper: { complete: string; progress: string };
|
|
72
76
|
sectionHeading: { info: string };
|
|
73
77
|
/** `ErrorState`'s retry button. The kit owns the wording so "try again" is
|
|
74
78
|
* phrased identically everywhere instead of hand-written per app. */
|
|
@@ -236,6 +240,7 @@ export const en: LoticsLocale = {
|
|
|
236
240
|
inline: { saveError: "Couldn't save. Try again.", save: "Save", cancel: "Cancel" },
|
|
237
241
|
clarify: { otherPlaceholder: "Or type your own answer…", back: "Back", next: "Next", cancel: "Cancel", submit: "Submit" },
|
|
238
242
|
ledger: { rowDetails: (label) => `${label} details` },
|
|
243
|
+
stepper: { complete: "Complete step", progress: "Progress" },
|
|
239
244
|
sectionHeading: { info: "About this data" },
|
|
240
245
|
errorState: { retry: "Try again" },
|
|
241
246
|
chip: { remove: "Remove" },
|
|
@@ -396,6 +401,7 @@ export const vi: LoticsLocale = {
|
|
|
396
401
|
inline: { saveError: "Không lưu được. Thử lại.", save: "Lưu", cancel: "Hủy" },
|
|
397
402
|
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" },
|
|
398
403
|
ledger: { rowDetails: (label) => `Chi tiết ${label}` },
|
|
404
|
+
stepper: { complete: "Hoàn thành bước", progress: "Tiến trình" },
|
|
399
405
|
sectionHeading: { info: "Giải thích dữ liệu" },
|
|
400
406
|
errorState: { retry: "Thử lại" },
|
|
401
407
|
chip: { remove: "Xóa" },
|
package/src/pipeline.tsx
CHANGED
|
@@ -2,7 +2,6 @@ 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";
|
|
6
5
|
|
|
7
6
|
export interface PipelineProps {
|
|
8
7
|
children?: ReactNode;
|
|
@@ -84,45 +83,69 @@ export interface PipelineStageProps extends StepPositional {
|
|
|
84
83
|
* and pushes the whole run past a screenful; use `PipelineField` when the value
|
|
85
84
|
* needs a label to be read, or when there is more than one.
|
|
86
85
|
*
|
|
87
|
-
* The row is
|
|
88
|
-
*
|
|
89
|
-
*
|
|
86
|
+
* The row is a TEXT line and the value sits on the title's own baseline, so a
|
|
87
|
+
* `DateStamp` beside a stage name reads as one line about one milestone. Put a
|
|
88
|
+
* value here, never a control: an editor drags the row to a control band, the
|
|
89
|
+
* marker follows it, and the ladder stops being something you scan. An ACT
|
|
90
|
+
* belongs in `PipelineActions`.
|
|
90
91
|
*/
|
|
91
92
|
trailing?: ReactNode;
|
|
93
|
+
|
|
92
94
|
/** The stage's own body — `PipelineNote`, `PipelineField`, `PipelineActions`,
|
|
93
95
|
* or anything else. A stage with no body renders as its title alone, which is
|
|
94
96
|
* what an unreached stage should be. */
|
|
95
97
|
children?: ReactNode;
|
|
98
|
+
/**
|
|
99
|
+
* Make the stage's MARKER the completion control — the node becomes a
|
|
100
|
+
* pressable ring instead of a status dot. Reach for it when the ladder is a
|
|
101
|
+
* worklist the reader ticks off, not just a readout.
|
|
102
|
+
*
|
|
103
|
+
* Without it, a caller who wants a tickable ladder puts a checkbox in
|
|
104
|
+
* `trailing`, which lands a second completion affordance beside a ring that
|
|
105
|
+
* already looks like one and reads as two controls for one fact.
|
|
106
|
+
*/
|
|
107
|
+
onToggle?: (done: boolean) => void;
|
|
96
108
|
accessibilityLabel?: string;
|
|
97
109
|
}
|
|
98
110
|
|
|
99
|
-
/** One milestone.
|
|
100
|
-
*
|
|
101
|
-
*
|
|
111
|
+
/** One milestone. Every title reads the same — full ink, medium — because the
|
|
112
|
+
* MARKER carries the status: filled behind, empty ahead. Emphasising one name
|
|
113
|
+
* encoded that a second time, and muting the passed ones read as "these matter
|
|
114
|
+
* less" about the part of the record that already happened.
|
|
102
115
|
*
|
|
103
|
-
*
|
|
104
|
-
* but a pipeline stage is a workspace, not a destination
|
|
105
|
-
* holds the controls, and a press target wrapping them would
|
|
116
|
+
* The stage BODY is deliberately not a press target. `Step` can be (a wizard
|
|
117
|
+
* whose steps navigate), but a pipeline stage is a workspace, not a destination
|
|
118
|
+
* — its body already holds the controls, and a press target wrapping them would
|
|
119
|
+
* swallow their taps. `onToggle` is the exception and stays on the MARKER,
|
|
120
|
+
* which owns no other content and stops its own press. */
|
|
106
121
|
export function PipelineStage(props: PipelineStageProps) {
|
|
107
|
-
const { status, title, meta, trailing, children, accessibilityLabel, ...positional } = props;
|
|
122
|
+
const { status, title, meta, trailing, children, onToggle, accessibilityLabel, ...positional } = props;
|
|
108
123
|
// `complete` is the terminal stage REACHED — where a finished record sits, not
|
|
109
124
|
// one it walked past. Muting it like an unreached stage leaves a completed run
|
|
110
125
|
// with nothing in full ink, so the eye has no landing point and the last thing
|
|
111
126
|
// that happened reads as the thing that hasn't.
|
|
112
|
-
|
|
127
|
+
// Every stage name reads the same: full ink, medium. The MARKER already says
|
|
128
|
+
// where the record sits — a filled ring behind, an empty one ahead — so muting
|
|
129
|
+
// the passed names said it a second time, and said it wrongly: a stage that is
|
|
130
|
+
// done is the RECORD of what happened, not something to de-emphasise. The kit
|
|
131
|
+
// makes the same argument for `CheckCircle`, which is monochrome at every
|
|
132
|
+
// position precisely so a row does not double-code its own state.
|
|
113
133
|
const titleText = (
|
|
114
|
-
<Text size="sm" color=
|
|
134
|
+
<Text size="sm" color="default" weight="medium">
|
|
115
135
|
{title}
|
|
116
136
|
</Text>
|
|
117
137
|
);
|
|
118
138
|
return (
|
|
119
139
|
<Step
|
|
120
140
|
status={status}
|
|
141
|
+
onToggle={onToggle}
|
|
121
142
|
accessibilityLabel={accessibilityLabel ?? title}
|
|
122
143
|
// The marker centres on the FIRST ROW, so it has to be told when that row
|
|
123
144
|
// is a control band and not a line of text — otherwise it stays pinned to
|
|
124
145
|
// the text and every title reads low by half the difference.
|
|
125
|
-
|
|
146
|
+
// The row is a text line whatever rides it, so the marker centres on
|
|
147
|
+
// the text rather than on a control band that is no longer there.
|
|
148
|
+
headHeight={undefined}
|
|
126
149
|
{...positional}
|
|
127
150
|
>
|
|
128
151
|
<View style={{ gap: 6 }}>
|
|
@@ -132,9 +155,21 @@ export function PipelineStage(props: PipelineStageProps) {
|
|
|
132
155
|
// row height followed its content would step the marker in and out of
|
|
133
156
|
// alignment down the ladder, and a badge would sit on a shorter row
|
|
134
157
|
// than a date picker two stages up.
|
|
135
|
-
|
|
136
|
-
|
|
158
|
+
// BASELINE, not centre: the value sits beside the title as prose, and
|
|
159
|
+
// two texts of different sizes centred in one row share no baseline —
|
|
160
|
+
// the smaller one floats, which is exactly what "not aligned with the
|
|
161
|
+
// label" looks like. The row also stops forcing a control band, since
|
|
162
|
+
// what rides it now is text, not an editor.
|
|
163
|
+
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 8 }}>
|
|
164
|
+
{/* The title keeps its NATURAL width and the slack goes after the
|
|
165
|
+
value, so the value sits against the label — "Booked · 16 Jun"
|
|
166
|
+
reads as one line about one milestone. Pinning it to the row's
|
|
167
|
+
right edge instead made a column of dates that scanned well and
|
|
168
|
+
said nothing: the reader had to carry the label across the gap
|
|
169
|
+
to know which milestone the date belonged to. */}
|
|
170
|
+
<View style={{ flexShrink: 1, minWidth: 0 }}>{titleText}</View>
|
|
137
171
|
{trailing}
|
|
172
|
+
<View style={{ flex: 1 }} />
|
|
138
173
|
</View>
|
|
139
174
|
) : (
|
|
140
175
|
titleText
|
package/src/stepper.tsx
CHANGED
|
@@ -15,6 +15,8 @@ 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 { CheckCircle } from "./check_circle";
|
|
19
|
+
import { useLoticsLocale } from "./locale";
|
|
18
20
|
import { NODE, STEP_HEAD_TEXT_LINE, STEP_ROW_PAD, markerTopOffset } from "./stepper_layout";
|
|
19
21
|
|
|
20
22
|
// A node's place in a sequence. `upcoming` = not reached (greyish); `current` =
|
|
@@ -53,6 +55,7 @@ export interface StepperProps {
|
|
|
53
55
|
* whole set — extend this and spread it through, never re-list the keys, or a
|
|
54
56
|
* new positional prop is dropped silently by everything but `Step`. */
|
|
55
57
|
export interface StepPositional {
|
|
58
|
+
_first?: boolean;
|
|
56
59
|
_last?: boolean;
|
|
57
60
|
_leftFilled?: boolean;
|
|
58
61
|
_rightFilled?: boolean;
|
|
@@ -65,6 +68,22 @@ export interface StepProps extends StepPositional {
|
|
|
65
68
|
* `active` washes the selected one (a panel can also sit beside it in vertical). */
|
|
66
69
|
onPress?: () => void;
|
|
67
70
|
active?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* VERTICAL only. Make the MARKER itself the completion control — the node
|
|
73
|
+
* becomes a `CheckCircle` the reader can press to finish (or reopen) the step,
|
|
74
|
+
* instead of a status dot that only reports.
|
|
75
|
+
*
|
|
76
|
+
* The alternative every caller reaches for otherwise is a checkbox parked in
|
|
77
|
+
* the row beside the marker, which puts two completion affordances on one line
|
|
78
|
+
* — a ring that looks tickable and a box that is — and asks the reader to
|
|
79
|
+
* guess which one is real. The ring already reads as "press me" at every other
|
|
80
|
+
* task surface in this kit; here it finally is.
|
|
81
|
+
*
|
|
82
|
+
* `onPress` still owns navigation, and the two compose: the ring stops its own
|
|
83
|
+
* press, so a navigable step with a toggleable marker does both without the
|
|
84
|
+
* marker's tap opening the panel.
|
|
85
|
+
*/
|
|
86
|
+
onToggle?: (done: boolean) => void;
|
|
68
87
|
/**
|
|
69
88
|
* VERTICAL only. Height of the content's FIRST ROW — the marker centres on it.
|
|
70
89
|
* Defaults to one line of `sm` text, which is what a step's first row is unless
|
|
@@ -89,6 +108,7 @@ export interface StepProps extends StepPositional {
|
|
|
89
108
|
*/
|
|
90
109
|
export function Stepper(props: StepperProps) {
|
|
91
110
|
const { children, steps, current, orientation = "horizontal", live = false, color = colors.zinc[700], accessibilityLabel } = props;
|
|
111
|
+
const locale = useLoticsLocale();
|
|
92
112
|
|
|
93
113
|
const content: ReactNode =
|
|
94
114
|
children ??
|
|
@@ -107,7 +127,7 @@ export function Stepper(props: StepperProps) {
|
|
|
107
127
|
const items = Children.toArray(content).filter((c): c is ReactElement<StepProps> => isValidElement(c));
|
|
108
128
|
const statuses = items.map((c) => c.props.status);
|
|
109
129
|
const last = items.length - 1;
|
|
110
|
-
const a11y = accessibilityLabel ??
|
|
130
|
+
const a11y = accessibilityLabel ?? locale.stepper.progress;
|
|
111
131
|
// A LIST of named positions, never `progressbar`. `progressbar` is a RANGE
|
|
112
132
|
// widget — it wants valuenow/min/max, announces as indeterminate without them,
|
|
113
133
|
// and its children are PRESENTATIONAL, which strips every step's label (and,
|
|
@@ -117,6 +137,7 @@ export function Stepper(props: StepperProps) {
|
|
|
117
137
|
|
|
118
138
|
const positioned = items.map((child, i) =>
|
|
119
139
|
cloneElement(child, {
|
|
140
|
+
_first: i === 0,
|
|
120
141
|
_last: i === last,
|
|
121
142
|
_leftFilled: i > 0 && reached(statuses[i - 1]),
|
|
122
143
|
_rightFilled: i < last && reached(statuses[i]),
|
|
@@ -133,8 +154,9 @@ export function Stepper(props: StepperProps) {
|
|
|
133
154
|
}
|
|
134
155
|
|
|
135
156
|
export function Step(props: StepProps) {
|
|
136
|
-
const { status, children, onPress, active, headHeight = STEP_HEAD_TEXT_LINE, accessibilityLabel, _last, _leftFilled, _rightFilled } = props;
|
|
157
|
+
const { status, children, onPress, active, onToggle, headHeight = STEP_HEAD_TEXT_LINE, accessibilityLabel, _first, _last, _leftFilled, _rightFilled } = props;
|
|
137
158
|
const { orientation, color, live } = useContext(StepperContext);
|
|
159
|
+
const locale = useLoticsLocale();
|
|
138
160
|
// Each step is one `listitem`, and the live one says so with `aria-current`.
|
|
139
161
|
// It goes on the ITEM, not the label: "current" is a fact about this position
|
|
140
162
|
// in the sequence, and a reader jumping between list items needs it announced
|
|
@@ -183,8 +205,48 @@ export function Step(props: StepProps) {
|
|
|
183
205
|
<View {...item}>
|
|
184
206
|
<AnimationFadeIn translateY={6}>
|
|
185
207
|
<View style={styles.vItem}>
|
|
186
|
-
<View style={
|
|
187
|
-
|
|
208
|
+
<View style={styles.vSpineCol}>
|
|
209
|
+
{/* The spine is TWO segments per step, above the marker and below it,
|
|
210
|
+
because the marker does not sit at the top of its row: it centres
|
|
211
|
+
on the first content row, so there is a `markerTopOffset` band of
|
|
212
|
+
empty column above it. A single below-the-marker segment cannot
|
|
213
|
+
reach into the NEXT step's band, so the line broke at every joint
|
|
214
|
+
— ~4px under each marker (this segment's old top margin) plus the
|
|
215
|
+
next one's offset above it. Measured on the record template: 4px
|
|
216
|
+
and 3px, at every stage, which reads as dashes rather than a spine.
|
|
217
|
+
The upper segment fills that band, so the line is continuous from
|
|
218
|
+
the first marker to the last. It also replaces the column's old
|
|
219
|
+
`paddingTop`, which is why the marker lands in the same place. */}
|
|
220
|
+
<View
|
|
221
|
+
style={[
|
|
222
|
+
styles.vSpineUp,
|
|
223
|
+
{ height: markerTopOffset(headHeight) },
|
|
224
|
+
// The first step has nothing above it to connect to, so its band
|
|
225
|
+
// is spacing, not line.
|
|
226
|
+
_first ? null : { backgroundColor: reached(status) ? colors.zinc[300] : colors.zinc[200] },
|
|
227
|
+
]}
|
|
228
|
+
/>
|
|
229
|
+
{onToggle ? (
|
|
230
|
+
// Sized to NODE so a toggleable step keeps the spine's rhythm — a
|
|
231
|
+
// ring a couple of pixels wider would bow the column at that row.
|
|
232
|
+
<CheckCircle
|
|
233
|
+
// `done`/`complete` only — NOT `reached`, which counts `current`
|
|
234
|
+
// too. `reached` is right for the status DOT (the step you are on
|
|
235
|
+
// is a place you have got to), but a completion RING that fills on
|
|
236
|
+
// the current step says the step is finished when it is the one
|
|
237
|
+
// still open. Every interaction then inverts on that step: the
|
|
238
|
+
// press reports "reopen" when the reader meant "finish", so
|
|
239
|
+
// pressing the next step appeared to do nothing, or to complete
|
|
240
|
+
// the whole run.
|
|
241
|
+
state={status === "done" || status === "complete" ? "done" : "none"}
|
|
242
|
+
onChange={onToggle}
|
|
243
|
+
color={color}
|
|
244
|
+
size={NODE}
|
|
245
|
+
accessibilityLabel={accessibilityLabel ?? locale.stepper.complete}
|
|
246
|
+
/>
|
|
247
|
+
) : (
|
|
248
|
+
<Marker status={status} color={color} live={live} />
|
|
249
|
+
)}
|
|
188
250
|
{!_last ? <View style={[styles.vSpine, { backgroundColor: reached(status) ? colors.zinc[300] : colors.zinc[200] }]} /> : null}
|
|
189
251
|
</View>
|
|
190
252
|
<View style={[styles.vContent, !_last ? styles.vGap : null]}>
|
|
@@ -266,7 +328,12 @@ const styles = StyleSheet.create({
|
|
|
266
328
|
|
|
267
329
|
vItem: { flexDirection: "row", gap: 12 },
|
|
268
330
|
vSpineCol: { width: NODE, alignItems: "center" },
|
|
269
|
-
|
|
331
|
+
// No top margin: the segment starts AT the marker, or the line detaches from
|
|
332
|
+
// the node it is supposed to leave.
|
|
333
|
+
vSpine: { width: 1.5, flex: 1, minHeight: 14, borderRadius: 1 },
|
|
334
|
+
// The band above the marker — width matches `vSpine` so the two segments
|
|
335
|
+
// read as one line through the node.
|
|
336
|
+
vSpineUp: { width: 1.5 },
|
|
270
337
|
vContent: { flex: 1 },
|
|
271
338
|
vGap: { paddingBottom: 12 },
|
|
272
339
|
vPress: { borderRadius: 8, marginHorizontal: -10 },
|
package/src/stepper_layout.ts
CHANGED
|
@@ -12,8 +12,24 @@
|
|
|
12
12
|
* which Vitest cannot parse.
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* The marker disc's diameter — and, deliberately, `CheckCircle`'s default size.
|
|
17
|
+
*
|
|
18
|
+
* A toggleable step renders a real `CheckCircle` in this slot, so the two have to
|
|
19
|
+
* agree: at 18 the spine was SHRINKING the kit's completion ring below the size
|
|
20
|
+
* it is everywhere else, and the same control read smaller on a ladder than on a
|
|
21
|
+
* task list for no reason a reader could name. Pinning `NODE` to the ring's own
|
|
22
|
+
* size makes the geometry follow the control instead of the other way round.
|
|
23
|
+
*
|
|
24
|
+
* 24 is that size, and it is `Checkbox`'s box as well: a ring and a box are two
|
|
25
|
+
* forms of one act, they share the leading gutter on `Table` and `TaskList`, and
|
|
26
|
+
* a gutter whose width depended on which one a surface hosted is a difference no
|
|
27
|
+
* reader could name either.
|
|
28
|
+
*
|
|
29
|
+
* Every other measurement here derives from it (`markerTopOffset`, the spine
|
|
30
|
+
* column's width, the disc and its pulse), so this is the one number to change.
|
|
31
|
+
*/
|
|
32
|
+
export const NODE = 24;
|
|
17
33
|
|
|
18
34
|
/** `vRowBox`'s vertical padding — the gap above the content's first row. */
|
|
19
35
|
export const STEP_ROW_PAD = 2;
|
package/src/task.tsx
CHANGED
|
@@ -115,7 +115,7 @@ export type TaskDensity = keyof typeof TASK_ROW_BAND;
|
|
|
115
115
|
const TASK_ACTION_WIDTH = 28;
|
|
116
116
|
|
|
117
117
|
const TaskListContext = createContext<TaskListContextValue>({
|
|
118
|
-
controlWidth:
|
|
118
|
+
controlWidth: 24,
|
|
119
119
|
actionWidth: TASK_ACTION_WIDTH,
|
|
120
120
|
labelWidth: DETAIL_LABEL_WIDTH,
|
|
121
121
|
depth: 0,
|
|
@@ -143,8 +143,9 @@ export const TASK_TEXT_INSET = CONTROL_TEXT_INSET;
|
|
|
143
143
|
export interface TaskListProps {
|
|
144
144
|
children: ReactNode;
|
|
145
145
|
/**
|
|
146
|
-
* Width of every row's leading control, which the LEFT gutter is reserved for
|
|
147
|
-
*
|
|
146
|
+
* Width of every row's leading control, which the LEFT gutter is reserved for. Both leading
|
|
147
|
+
* controls are 24 — `CheckCircle` and `CheckboxInput` — so the default fits either, and only a
|
|
148
|
+
* custom control needs this. It also sets the INDENT: everything hanging under a
|
|
148
149
|
* task — its sub-rows, a nested list — steps right by this plus the row gap. Set on the ROOT
|
|
149
150
|
* list; a nested list inherits it.
|
|
150
151
|
*/
|