@lotics/ui 5.4.0 → 5.5.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 +18 -11
- package/examples/tpl_extract.tsx +1 -1
- package/examples/tpl_lookup.tsx +1 -1
- package/examples/tpl_pick.tsx +22 -14
- package/examples/tpl_ratedesk.tsx +1 -1
- package/package.json +1 -2
- package/src/agent_progress.tsx +14 -4
- package/src/agent_run.tsx +128 -133
- package/src/animation_fade_in.tsx +12 -5
- package/src/share_or_download.test.ts +11 -0
- package/src/stepper.tsx +206 -65
- package/src/step_list.tsx +0 -128
package/AGENTS.md
CHANGED
|
@@ -75,9 +75,10 @@ Pick by capability, not by name. (→ the source file for the API.)
|
|
|
75
75
|
- **Files** — `FileDropzone`, `FileThumbnail` / `FileThumbnailGrid`, `FilePreview` /
|
|
76
76
|
`FileGalleryModal`, `ImageGallery`; for gated CRUD compose locally with `useSelectionMode` +
|
|
77
77
|
`shareOrDownloadFiles` + `rotateImageToBlob` (see §Data entry → Attachments).
|
|
78
|
-
- **Specialized work surfaces** — `ScanField` (scan/verify), `
|
|
79
|
-
|
|
80
|
-
`
|
|
78
|
+
- **Specialized work surfaces** — `ScanField` (scan/verify), `Stepper` (a guided run / progress
|
|
79
|
+
sequence — done · current · upcoming, horizontal OR vertical), `RemainderMeter` + `AllocationRow`
|
|
80
|
+
(allocation), `Timeline` (a heterogeneous event LOG — icons + expandable details, not progress),
|
|
81
|
+
`Calendar`, `Gantt`, `comments_thread`.
|
|
81
82
|
- **AI surfaces** — `Composer` (the adaptive command/chat composer — a compact pill when empty that
|
|
82
83
|
expands for long text + attachments; the surface that triggers agent work),
|
|
83
84
|
`AgentRun` (the live streaming work feed) + `AgentProgress` (its compact, floating, expandable
|
|
@@ -264,9 +265,13 @@ Compose the surfaces as a loop, and reach for the right one by job:
|
|
|
264
265
|
attaching HOLDS the file for review; the human presses Send. The canvas (`tpl_dieline`) uses it this
|
|
265
266
|
way; while running the host swaps `Composer` for `AgentProgress` (same pill geometry, so it reads as
|
|
266
267
|
a morph).
|
|
267
|
-
- **Show the work** — `AgentRun`: a live feed of the agent's
|
|
268
|
-
|
|
269
|
-
|
|
268
|
+
- **Show the work** — `AgentRun`: a live feed of the agent's work — its streamed narration (with a
|
|
269
|
+
blinking caret) + tool calls as a vertical `Stepper` of neutral dots (the latest pulsing while live,
|
|
270
|
+
a white-check dot once done). No title, no avatar — just the work. A terminal "Done" step (the
|
|
271
|
+
`complete` node) appears ONLY once the run finishes. A step can carry `peek` content to glance into
|
|
272
|
+
via a popover. Tool labels resolve from the raw tool name via a built-in default map + an optional
|
|
273
|
+
`labelForTool` override (localize THERE — the kit stays English). `doneLabel` localizes the terminal.
|
|
274
|
+
Transparent work, NEVER a bare spinner — the
|
|
270
275
|
intelligence is legible. On a canvas/composer app reach for `AgentProgress` — `AgentRun`
|
|
271
276
|
collapsed into a floating pill (avatar + current step) that EXPANDS on press; the composer
|
|
272
277
|
morphs into it while running, and reveals again when done.
|
|
@@ -365,9 +370,11 @@ controls — while **status/severity/diffs use functional colour** the way the r
|
|
|
365
370
|
- **Confidence** — a 3-tick meter + word, emerald / amber / zinc by level.
|
|
366
371
|
- **Finding severity** — a coloured dot `Badge` + metric: red critical, amber warning, blue note,
|
|
367
372
|
emerald on-track (+ stacking order, most severe first).
|
|
368
|
-
- **AgentRun** —
|
|
369
|
-
|
|
370
|
-
|
|
373
|
+
- **Stepper / AgentRun nodes** — progress dots on a spine: `current` a **pulsing accent ring** (white
|
|
374
|
+
centre), `done` a filled accent dot + **white check**, `upcoming` a faint **grey** ring, the terminal
|
|
375
|
+
`complete` a **blackish ring + black check**, `warning` **amber**. Default accent is neutral ink
|
|
376
|
+
(`color` themes it); `AgentRun` keeps the neutral default + appends the `complete` "Done" node only
|
|
377
|
+
when the run finishes.
|
|
371
378
|
- **ChangeReview diffs** — the old value struck in **red**, the new value in **green**.
|
|
372
379
|
- **Discrepancy** — the agent's pick gets a **blue** highlight; `Mismatch` reads amber, `Resolved`
|
|
373
380
|
emerald.
|
|
@@ -584,8 +591,8 @@ inline_time_picker (the Inline* family — per-field editors on `inline_edit`'s
|
|
|
584
591
|
list · list_item · menu_button · menu_list_item · detail_row · pressable_row · action_menu ·
|
|
585
592
|
floating_action_bar · filter_pill · column_filter (ColumnFilter — the typed per-column filter pill +
|
|
586
593
|
columnFilterToConditions; for a register filtering on several columns) · chip_group · search_input ·
|
|
587
|
-
sort_header · table · pagination · accordion · stepper ·
|
|
588
|
-
step_progress ·
|
|
594
|
+
sort_header · table · pagination · accordion · stepper (Stepper + Step — done/current/upcoming/warning/complete progress on a track (horizontal) or spine (vertical); compound `<Step status>children` OR data `steps[]`+`current`; the guided-run / agent-feed primitive — subsumes the old StepList) ·
|
|
595
|
+
step_progress · timeline (heterogeneous event LOG — per-row icon + expandable details, models the past; NOT progress) · drawer (+ DrawerFooter) · dialog · popover · tooltip ·
|
|
589
596
|
command_menu · alert · peek · empty_state · completion_state · callout (Callout · CalloutTitle ·
|
|
590
597
|
CalloutText · CalloutActions) · kpi_card · kpi_strip · metric · trend_chip · sparkline ·
|
|
591
598
|
bar_chart · line_chart · pie_chart · ring_gauge · progress_bar · stacked_progress_bar · breakdown ·
|
package/examples/tpl_extract.tsx
CHANGED
|
@@ -133,7 +133,7 @@ export function TplExtract() {
|
|
|
133
133
|
|
|
134
134
|
{phase === "extracting" ? (
|
|
135
135
|
<Card>
|
|
136
|
-
<AgentRun
|
|
136
|
+
<AgentRun steps={steps} state={streaming ? "streaming" : "done"} />
|
|
137
137
|
</Card>
|
|
138
138
|
) : null}
|
|
139
139
|
|
package/examples/tpl_lookup.tsx
CHANGED
|
@@ -201,7 +201,7 @@ export function TplLookup() {
|
|
|
201
201
|
|
|
202
202
|
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, gap: 16 }}>
|
|
203
203
|
{/* current run */}
|
|
204
|
-
<AgentRun
|
|
204
|
+
<AgentRun steps={steps} state={streaming ? "streaming" : "done"} />
|
|
205
205
|
|
|
206
206
|
<View style={{ gap: 8 }}>
|
|
207
207
|
<Text size="xs" color="muted" weight="medium">Nearest matches</Text>
|
package/examples/tpl_pick.tsx
CHANGED
|
@@ -11,7 +11,7 @@ import { Divider } from "@lotics/ui/divider";
|
|
|
11
11
|
import { Popover, PopoverContent, PopoverFooter, PopoverTrigger } from "@lotics/ui/popover";
|
|
12
12
|
import { ProgressBar } from "@lotics/ui/progress_bar";
|
|
13
13
|
import { ScanField } from "@lotics/ui/scan_field";
|
|
14
|
-
import {
|
|
14
|
+
import { Stepper, Step, type StepStatus } from "@lotics/ui/stepper";
|
|
15
15
|
import { CompletionState } from "@lotics/ui/completion_state";
|
|
16
16
|
|
|
17
17
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -155,18 +155,6 @@ function Complete({ lines, onReset }: { lines: PickLine[]; onReset: () => void }
|
|
|
155
155
|
// The right rail — the whole run at a glance: done · now · up next. Read-only;
|
|
156
156
|
// the run is sequential, so position is shown, not chosen.
|
|
157
157
|
function RunPath({ lines, currentIndex }: { lines: PickLine[]; currentIndex: number }) {
|
|
158
|
-
const steps: StepListItem[] = lines.map((l, i) => ({
|
|
159
|
-
id: l.id,
|
|
160
|
-
status: l.status === "short" ? "warning" : l.status === "picked" ? "done" : i === currentIndex ? "current" : "pending",
|
|
161
|
-
title: l.bin,
|
|
162
|
-
subtitle: l.item,
|
|
163
|
-
trailing:
|
|
164
|
-
l.status === "short" ? (
|
|
165
|
-
<Badge variant="dot" label={`${l.picked ?? 0} of ${l.qty}`} color="amber" />
|
|
166
|
-
) : (
|
|
167
|
-
<Text size="sm" color="muted" tabular>{`×${l.qty}`}</Text>
|
|
168
|
-
),
|
|
169
|
-
}));
|
|
170
158
|
return (
|
|
171
159
|
<Card style={{ padding: 0 }}>
|
|
172
160
|
<View style={{ paddingHorizontal: 16, paddingVertical: 12 }}>
|
|
@@ -174,7 +162,27 @@ function RunPath({ lines, currentIndex }: { lines: PickLine[]; currentIndex: num
|
|
|
174
162
|
</View>
|
|
175
163
|
<Divider />
|
|
176
164
|
<View style={{ paddingHorizontal: 16, paddingVertical: 14 }}>
|
|
177
|
-
<
|
|
165
|
+
<Stepper orientation="vertical" accessibilityLabel="Pick path">
|
|
166
|
+
{lines.map((l, i) => {
|
|
167
|
+
const status: StepStatus =
|
|
168
|
+
l.status === "short" ? "warning" : l.status === "picked" ? "done" : i === currentIndex ? "current" : "upcoming";
|
|
169
|
+
return (
|
|
170
|
+
<Step key={l.id} status={status}>
|
|
171
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 12, flex: 1 }}>
|
|
172
|
+
<View style={{ flex: 1, gap: 1 }}>
|
|
173
|
+
<Text size="sm">{l.bin}</Text>
|
|
174
|
+
<Text size="xs" color="muted" numberOfLines={1}>{l.item}</Text>
|
|
175
|
+
</View>
|
|
176
|
+
{l.status === "short" ? (
|
|
177
|
+
<Badge variant="dot" label={`${l.picked ?? 0} of ${l.qty}`} color="amber" />
|
|
178
|
+
) : (
|
|
179
|
+
<Text size="sm" color="muted" tabular>{`×${l.qty}`}</Text>
|
|
180
|
+
)}
|
|
181
|
+
</View>
|
|
182
|
+
</Step>
|
|
183
|
+
);
|
|
184
|
+
})}
|
|
185
|
+
</Stepper>
|
|
178
186
|
</View>
|
|
179
187
|
</Card>
|
|
180
188
|
);
|
|
@@ -238,7 +238,7 @@ export function TplRatedesk() {
|
|
|
238
238
|
</View>
|
|
239
239
|
) : null}
|
|
240
240
|
|
|
241
|
-
{phase === "reading" ? <AgentRun
|
|
241
|
+
{phase === "reading" ? <AgentRun steps={runSteps} state="streaming" /> : null}
|
|
242
242
|
|
|
243
243
|
{phase === "review" ? (
|
|
244
244
|
<ChangeReview
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
@@ -139,7 +139,6 @@
|
|
|
139
139
|
"./table": "./src/table.tsx",
|
|
140
140
|
"./detail_row": "./src/detail_row.tsx",
|
|
141
141
|
"./scan_field": "./src/scan_field.tsx",
|
|
142
|
-
"./step_list": "./src/step_list.tsx",
|
|
143
142
|
"./completion_state": "./src/completion_state.tsx",
|
|
144
143
|
"./remainder_meter": "./src/remainder_meter.tsx",
|
|
145
144
|
"./allocation_row": "./src/allocation_row.tsx",
|
package/src/agent_progress.tsx
CHANGED
|
@@ -4,7 +4,7 @@ import { colors } from "./colors";
|
|
|
4
4
|
import { Text } from "./text";
|
|
5
5
|
import { WaveAvatar } from "./wave_avatar";
|
|
6
6
|
import { PressableHighlight } from "./pressable_highlight";
|
|
7
|
-
import { AgentRun, type AgentRunStep } from "./agent_run";
|
|
7
|
+
import { AgentRun, resolveToolMeta, type AgentRunStep } from "./agent_run";
|
|
8
8
|
|
|
9
9
|
export interface AgentProgressProps {
|
|
10
10
|
steps: AgentRunStep[];
|
|
@@ -12,6 +12,11 @@ export interface AgentProgressProps {
|
|
|
12
12
|
/** The collapsed-pill label. Defaults to the running step's label (or
|
|
13
13
|
* "Working…" / "Done"). */
|
|
14
14
|
label?: string;
|
|
15
|
+
/** The agent's streamed text — forwarded to the expanded `AgentRun`. */
|
|
16
|
+
text?: string;
|
|
17
|
+
/** Localize / override a tool step's label by raw tool name — applied to both
|
|
18
|
+
* the collapsed pill and the expanded feed. */
|
|
19
|
+
labelForTool?: (toolName: string) => string | undefined;
|
|
15
20
|
defaultExpanded?: boolean;
|
|
16
21
|
}
|
|
17
22
|
|
|
@@ -24,11 +29,16 @@ export interface AgentProgressProps {
|
|
|
24
29
|
* label, so the surface stays calm until you ask to see the steps.
|
|
25
30
|
*/
|
|
26
31
|
export function AgentProgress(props: AgentProgressProps) {
|
|
27
|
-
const { steps, state = "streaming", label, defaultExpanded } = props;
|
|
32
|
+
const { steps, state = "streaming", label, text, labelForTool, defaultExpanded } = props;
|
|
28
33
|
const [expanded, setExpanded] = useState(defaultExpanded ?? false);
|
|
29
34
|
|
|
30
35
|
const running = steps.filter((s) => s.status === "running").at(-1);
|
|
31
|
-
const
|
|
36
|
+
const runningLabel = running
|
|
37
|
+
? running.kind === "tool"
|
|
38
|
+
? resolveToolMeta(running.label, labelForTool).label
|
|
39
|
+
: running.label
|
|
40
|
+
: undefined;
|
|
41
|
+
const compact = label ?? runningLabel ?? (state === "done" ? "Done" : state === "error" ? "Stopped" : "Working…");
|
|
32
42
|
const streaming = state === "streaming";
|
|
33
43
|
|
|
34
44
|
return (
|
|
@@ -36,7 +46,7 @@ export function AgentProgress(props: AgentProgressProps) {
|
|
|
36
46
|
{expanded ? (
|
|
37
47
|
<View style={styles.panel}>
|
|
38
48
|
<ScrollView style={{ maxHeight: 260 }} contentContainerStyle={{ padding: 16 }}>
|
|
39
|
-
<AgentRun steps={steps} state={state} />
|
|
49
|
+
<AgentRun steps={steps} state={state} text={text} labelForTool={labelForTool} />
|
|
40
50
|
</ScrollView>
|
|
41
51
|
</View>
|
|
42
52
|
) : null}
|
package/src/agent_run.tsx
CHANGED
|
@@ -1,174 +1,169 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { useEffect, useRef, type ReactNode } from "react";
|
|
2
|
+
import { Animated, StyleSheet, View } from "react-native";
|
|
3
|
+
import { colors, solid } from "./colors";
|
|
3
4
|
import { Text } from "./text";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { type IconName } from "./icon";
|
|
6
|
+
import { Peek } from "./peek";
|
|
7
|
+
import { Stepper, Step, type StepStatus } from "./stepper";
|
|
7
8
|
|
|
8
9
|
export type AgentStepStatus = "running" | "done" | "error";
|
|
9
10
|
|
|
10
11
|
export interface AgentRunStep {
|
|
11
12
|
id: string;
|
|
12
|
-
/**
|
|
13
|
-
*
|
|
13
|
+
/** A `tool` step carries the RAW tool name (`update_records`) — the feed maps
|
|
14
|
+
* it to a human label via the default map / `labelForTool`. A `step` carries
|
|
15
|
+
* an already-human label. */
|
|
14
16
|
label: string;
|
|
15
|
-
/**
|
|
16
|
-
* model streams; the host appends to it. */
|
|
17
|
+
/** A note under the label (a count, an input summary), muted. */
|
|
17
18
|
detail?: string;
|
|
18
19
|
status: AgentStepStatus;
|
|
19
|
-
/** A `tool` step renders its label in a monospace chip — it called a tool
|
|
20
|
-
* rather than reasoned. */
|
|
21
20
|
kind?: "step" | "tool";
|
|
21
|
+
/** Optional content shown in a `Peek` popover when the step is pressed —
|
|
22
|
+
* glance into what the step did (the tool input, a result summary). */
|
|
23
|
+
peek?: ReactNode;
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
export interface AgentRunProps {
|
|
25
27
|
steps: AgentRunStep[];
|
|
26
|
-
/**
|
|
27
|
-
*
|
|
28
|
-
|
|
28
|
+
/** The agent's streamed reasoning / answer text — shown as a narration block
|
|
29
|
+
* above the feed, with a live caret while streaming. */
|
|
30
|
+
text?: string;
|
|
31
|
+
/** Whole-run state. `streaming` keeps the caret blinking + the latest step
|
|
32
|
+
* pulsing; `done`/`error` settle it. Defaults to `streaming` while a step runs. */
|
|
29
33
|
state?: "streaming" | "done" | "error";
|
|
30
|
-
/**
|
|
31
|
-
*
|
|
32
|
-
|
|
34
|
+
/** Localize / override a TOOL step's display label by its raw tool name
|
|
35
|
+
* (`update_records` → "Đang cập nhật dữ liệu"). Return `undefined` to fall
|
|
36
|
+
* back to the built-in label. */
|
|
37
|
+
labelForTool?: (toolName: string) => string | undefined;
|
|
38
|
+
/** The always-last terminal step's label. Default "Done"; localize per app. */
|
|
39
|
+
doneLabel?: string;
|
|
33
40
|
accessibilityLabel?: string;
|
|
34
41
|
}
|
|
35
42
|
|
|
43
|
+
// ── Tool → display meta ───────────────────────────────────────────────────────
|
|
44
|
+
// The bounded set of platform tools an app agent can call. English by default
|
|
45
|
+
// (the kit is locale-neutral; apps localize via `labelForTool`). A tool the map
|
|
46
|
+
// doesn't know falls back to a prettified name + a neutral icon.
|
|
47
|
+
const TOOL_META: Record<string, { label: string; icon: IconName }> = {
|
|
48
|
+
query_records: { label: "Searching records", icon: "search" },
|
|
49
|
+
get_record: { label: "Reading a record", icon: "file-text" },
|
|
50
|
+
get_records: { label: "Reading records", icon: "file-text" },
|
|
51
|
+
create_records: { label: "Creating records", icon: "plus" },
|
|
52
|
+
update_records: { label: "Updating records", icon: "square-pen" },
|
|
53
|
+
delete_records: { label: "Removing records", icon: "trash" },
|
|
54
|
+
generate_pdf_from_template: { label: "Generating PDF", icon: "file-down" },
|
|
55
|
+
generate_excel_from_template: { label: "Generating spreadsheet", icon: "file-spreadsheet" },
|
|
56
|
+
generate_docx_from_template: { label: "Generating document", icon: "file-text" },
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function prettifyToolName(name: string): string {
|
|
60
|
+
const s = name.replace(/_/g, " ").trim();
|
|
61
|
+
return s ? s.charAt(0).toUpperCase() + s.slice(1) : name;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolve a tool's display label + icon: a caller override wins on the label,
|
|
65
|
+
* then the built-in map, then a prettified fallback. Exported so other agent
|
|
66
|
+
* surfaces (e.g. `AgentProgress`) render the same labels. */
|
|
67
|
+
export function resolveToolMeta(
|
|
68
|
+
toolName: string,
|
|
69
|
+
labelForTool?: (toolName: string) => string | undefined,
|
|
70
|
+
): { label: string; icon: IconName } {
|
|
71
|
+
const def =
|
|
72
|
+
TOOL_META[toolName] ??
|
|
73
|
+
(toolName.startsWith("generate_") && toolName.endsWith("_from_template")
|
|
74
|
+
? { label: "Generating document", icon: "file-text" as IconName }
|
|
75
|
+
: undefined);
|
|
76
|
+
return {
|
|
77
|
+
label: labelForTool?.(toolName) ?? def?.label ?? prettifyToolName(toolName),
|
|
78
|
+
icon: def?.icon ?? "list",
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
36
82
|
/**
|
|
37
|
-
* A live feed of an AI agent's work — the
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* run) and `ReviewCard` (the result to review). Unlike `StepList` (a known,
|
|
44
|
-
* guided run of fixed steps) the steps here arrive unknown-ahead.
|
|
83
|
+
* A live feed of an AI agent's work — the prose it streams + the tools it calls,
|
|
84
|
+
* on a vertical `Stepper`: one neutral dot per call (the latest pulsing while the
|
|
85
|
+
* run is live, a check once done) with a plain-text label, no chip/icon, ALWAYS
|
|
86
|
+
* ending on a "Done" terminal step. A step can carry `peek` content to glance
|
|
87
|
+
* into via a popover. Tool names map to labels via the built-in default + a
|
|
88
|
+
* `labelForTool` override. Pair with `Composer` + `ReviewCard`.
|
|
45
89
|
*/
|
|
46
90
|
export function AgentRun(props: AgentRunProps) {
|
|
47
|
-
const { steps,
|
|
91
|
+
const { steps, text, labelForTool, doneLabel = "Done", accessibilityLabel } = props;
|
|
48
92
|
const anyRunning = steps.some((s) => s.status === "running");
|
|
49
93
|
const state = props.state ?? (anyRunning ? "streaming" : "done");
|
|
50
94
|
const streaming = state === "streaming";
|
|
95
|
+
const last = steps.length - 1;
|
|
51
96
|
|
|
52
97
|
return (
|
|
53
|
-
<View accessibilityLabel={accessibilityLabel} style={{ gap:
|
|
54
|
-
{
|
|
55
|
-
<
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
<HeaderStatus state={state} streaming={streaming} />
|
|
60
|
-
</View>
|
|
98
|
+
<View accessibilityLabel={accessibilityLabel} style={{ gap: 12 }}>
|
|
99
|
+
{text ? (
|
|
100
|
+
<Text size="sm" style={styles.narration}>
|
|
101
|
+
{text}
|
|
102
|
+
{streaming ? <Caret /> : null}
|
|
103
|
+
</Text>
|
|
61
104
|
) : null}
|
|
62
105
|
|
|
63
|
-
<
|
|
106
|
+
<Stepper orientation="vertical" live={streaming}>
|
|
64
107
|
{steps.map((s, i) => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
<Text
|
|
83
|
-
size="sm"
|
|
84
|
-
weight={s.status === "running" ? "medium" : "regular"}
|
|
85
|
-
color={s.status === "done" ? "muted" : s.status === "error" ? "danger" : "default"}
|
|
86
|
-
>
|
|
87
|
-
{s.label}
|
|
88
|
-
</Text>
|
|
89
|
-
)}
|
|
90
|
-
{s.detail ? (
|
|
91
|
-
<Text size="xs" color="muted" style={{ marginTop: 2 }}>
|
|
92
|
-
{s.detail}
|
|
93
|
-
</Text>
|
|
94
|
-
) : null}
|
|
95
|
-
</View>
|
|
108
|
+
// Tools the SDK feeds arrive already "done"; show the most recent as
|
|
109
|
+
// the pulsing current while the run is still live.
|
|
110
|
+
const status: StepStatus =
|
|
111
|
+
s.status === "error"
|
|
112
|
+
? "warning"
|
|
113
|
+
: s.status === "running" || (streaming && i === last)
|
|
114
|
+
? "current"
|
|
115
|
+
: "done";
|
|
116
|
+
const label = s.kind === "tool" ? resolveToolMeta(s.label, labelForTool).label : s.label;
|
|
117
|
+
const body = (
|
|
118
|
+
<View style={{ gap: 1 }}>
|
|
119
|
+
<Text size="sm">{label}</Text>
|
|
120
|
+
{s.detail ? (
|
|
121
|
+
<Text size="xs" color={s.status === "error" ? "danger" : "muted"}>
|
|
122
|
+
{s.detail}
|
|
123
|
+
</Text>
|
|
124
|
+
) : null}
|
|
96
125
|
</View>
|
|
97
126
|
);
|
|
127
|
+
return (
|
|
128
|
+
<Step key={s.id} status={status}>
|
|
129
|
+
{s.peek ? (
|
|
130
|
+
<Peek accessibilityLabel={label} content={s.peek}>
|
|
131
|
+
{body}
|
|
132
|
+
</Peek>
|
|
133
|
+
) : (
|
|
134
|
+
body
|
|
135
|
+
)}
|
|
136
|
+
</Step>
|
|
137
|
+
);
|
|
98
138
|
})}
|
|
99
|
-
|
|
139
|
+
{state === "done" ? (
|
|
140
|
+
<Step key="__done" status="complete">
|
|
141
|
+
<Text size="sm" weight="medium">
|
|
142
|
+
{doneLabel}
|
|
143
|
+
</Text>
|
|
144
|
+
</Step>
|
|
145
|
+
) : null}
|
|
146
|
+
</Stepper>
|
|
100
147
|
</View>
|
|
101
148
|
);
|
|
102
149
|
}
|
|
103
150
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
<ActivityIndicator size={12} color={solid("blue")} />
|
|
114
|
-
</View>
|
|
151
|
+
// A blinking caret that trails the streamed text — the signature of live writing.
|
|
152
|
+
function Caret() {
|
|
153
|
+
const opacity = useRef(new Animated.Value(1)).current;
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
const loop = Animated.loop(
|
|
156
|
+
Animated.sequence([
|
|
157
|
+
Animated.timing(opacity, { toValue: 0, duration: 480, useNativeDriver: false }),
|
|
158
|
+
Animated.timing(opacity, { toValue: 1, duration: 480, useNativeDriver: false }),
|
|
159
|
+
]),
|
|
115
160
|
);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
<Icon name={s.icon} size={13} color={s.color} />
|
|
121
|
-
</View>
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function HeaderStatus({ state, streaming }: { state: "streaming" | "done" | "error"; streaming: boolean }) {
|
|
126
|
-
if (streaming) {
|
|
127
|
-
return (
|
|
128
|
-
<View style={styles.headerStatus}>
|
|
129
|
-
<ActivityIndicator size={12} color={solid("blue")} />
|
|
130
|
-
<Text size="xs" weight="medium" style={{ color: solid("blue") }}>
|
|
131
|
-
Working
|
|
132
|
-
</Text>
|
|
133
|
-
</View>
|
|
134
|
-
);
|
|
135
|
-
}
|
|
136
|
-
if (state === "error") {
|
|
137
|
-
return (
|
|
138
|
-
<View style={styles.headerStatus}>
|
|
139
|
-
<Icon name="circle-alert" size={13} color={solid("red")} />
|
|
140
|
-
<Text size="xs" weight="medium" color="danger">
|
|
141
|
-
Stopped
|
|
142
|
-
</Text>
|
|
143
|
-
</View>
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
return (
|
|
147
|
-
<View style={styles.headerStatus}>
|
|
148
|
-
<Icon name="circle-check" size={13} color={solid("emerald")} />
|
|
149
|
-
<Text size="xs" weight="medium" style={{ color: solid("emerald") }}>
|
|
150
|
-
Done
|
|
151
|
-
</Text>
|
|
152
|
-
</View>
|
|
153
|
-
);
|
|
161
|
+
loop.start();
|
|
162
|
+
return () => loop.stop();
|
|
163
|
+
}, [opacity]);
|
|
164
|
+
return <Animated.Text style={{ opacity, color: solid("blue") }}>▍</Animated.Text>;
|
|
154
165
|
}
|
|
155
166
|
|
|
156
|
-
const NODE = 22;
|
|
157
|
-
|
|
158
167
|
const styles = StyleSheet.create({
|
|
159
|
-
|
|
160
|
-
headerStatus: { flexDirection: "row", alignItems: "center", gap: 5 },
|
|
161
|
-
item: { flexDirection: "row", gap: 12 },
|
|
162
|
-
spineCol: { width: NODE, alignItems: "center", paddingTop: 1 },
|
|
163
|
-
spine: { width: 1.5, flex: 1, minHeight: 12, borderRadius: 1, backgroundColor: colors.zinc[200], marginTop: 3 },
|
|
164
|
-
contentCol: { flex: 1, paddingTop: 2 },
|
|
165
|
-
contentGap: { paddingBottom: 14 },
|
|
166
|
-
node: { width: NODE, height: NODE, borderRadius: NODE / 2, alignItems: "center", justifyContent: "center" },
|
|
167
|
-
toolChip: {
|
|
168
|
-
alignSelf: "flex-start",
|
|
169
|
-
paddingHorizontal: 8,
|
|
170
|
-
paddingVertical: 3,
|
|
171
|
-
borderRadius: 6,
|
|
172
|
-
backgroundColor: colors.zinc[100],
|
|
173
|
-
},
|
|
168
|
+
narration: { lineHeight: 21, color: colors.zinc[700] },
|
|
174
169
|
});
|
|
@@ -4,19 +4,26 @@ import { Animated, StyleProp, ViewStyle } from "react-native";
|
|
|
4
4
|
interface AnimationFadeInProps {
|
|
5
5
|
children: React.ReactNode;
|
|
6
6
|
style?: StyleProp<ViewStyle>;
|
|
7
|
+
/** Rise this many px while fading in — the "appearing into place" gesture.
|
|
8
|
+
* Default 0 (fade only). */
|
|
9
|
+
translateY?: number;
|
|
7
10
|
}
|
|
8
11
|
|
|
9
12
|
export function AnimationFadeIn(props: AnimationFadeInProps) {
|
|
10
|
-
const { children, style } = props;
|
|
11
|
-
const
|
|
13
|
+
const { children, style, translateY = 0 } = props;
|
|
14
|
+
const progress = useRef(new Animated.Value(0)).current;
|
|
12
15
|
|
|
13
16
|
useEffect(() => {
|
|
14
|
-
Animated.spring(
|
|
17
|
+
Animated.spring(progress, {
|
|
15
18
|
toValue: 1,
|
|
16
19
|
bounciness: 0,
|
|
17
20
|
useNativeDriver: true,
|
|
18
21
|
}).start();
|
|
19
|
-
}, [
|
|
22
|
+
}, [progress]);
|
|
20
23
|
|
|
21
|
-
|
|
24
|
+
const transform = translateY
|
|
25
|
+
? [{ translateY: progress.interpolate({ inputRange: [0, 1], outputRange: [translateY, 0] }) }]
|
|
26
|
+
: undefined;
|
|
27
|
+
|
|
28
|
+
return <Animated.View style={[{ opacity: progress, transform }, style]}>{children}</Animated.View>;
|
|
22
29
|
}
|
|
@@ -29,6 +29,17 @@ describe("shareOrDownloadFiles", () => {
|
|
|
29
29
|
expect(shared.title).toBe("Ảnh");
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
it("rides the given credentials on the blob fetch so an auth-gated proxy URL authorizes", async () => {
|
|
33
|
+
// Type the init param so mock.calls[0][1] is the RequestInit we assert on.
|
|
34
|
+
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => new Response(new Blob(["img"], { type: "image/png" })));
|
|
35
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
36
|
+
vi.stubGlobal("navigator", { canShare: () => true, share: vi.fn(async () => undefined) });
|
|
37
|
+
|
|
38
|
+
await shareOrDownloadFiles([{ url: "https://api.example/v1/files/k", filename: "a.png" }], { credentials: "include" });
|
|
39
|
+
|
|
40
|
+
expect(fetchMock.mock.calls[0][1]).toMatchObject({ credentials: "include" });
|
|
41
|
+
});
|
|
42
|
+
|
|
32
43
|
it("treats a cancelled share sheet (AbortError) as done — never falls back to downloading", async () => {
|
|
33
44
|
vi.stubGlobal("fetch", vi.fn(async () => new Response(new Blob(["img"], { type: "image/png" }))));
|
|
34
45
|
const share = vi.fn(async () => { throw new DOMException("cancelled", "AbortError"); });
|
package/src/stepper.tsx
CHANGED
|
@@ -1,88 +1,229 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
cloneElement,
|
|
4
|
+
createContext,
|
|
5
|
+
isValidElement,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useRef,
|
|
9
|
+
type ReactElement,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { Animated, StyleSheet, View } from "react-native";
|
|
13
|
+
import { colors, solid } from "./colors";
|
|
3
14
|
import { Icon } from "./icon";
|
|
4
15
|
import { Text } from "./text";
|
|
16
|
+
import { PressableHighlight } from "./pressable_highlight";
|
|
17
|
+
import { AnimationFadeIn } from "./animation_fade_in";
|
|
5
18
|
|
|
6
|
-
|
|
19
|
+
// A node's place in a sequence. `upcoming` = not reached (greyish); `current` =
|
|
20
|
+
// where we are (ring + white centre, pulses when live); `done` = passed (filled);
|
|
21
|
+
// `complete` = the terminal "finished" marker (filled + check); `warning` = an
|
|
22
|
+
// issue at a reached step.
|
|
23
|
+
export type StepStatus = "upcoming" | "current" | "done" | "warning" | "complete";
|
|
24
|
+
export type StepOrientation = "horizontal" | "vertical";
|
|
25
|
+
|
|
26
|
+
const reached = (s: StepStatus) => s !== "upcoming";
|
|
27
|
+
|
|
28
|
+
interface StepperConfig {
|
|
29
|
+
orientation: StepOrientation;
|
|
30
|
+
color: string;
|
|
31
|
+
/** Pulse the current node — signals an actively-running sequence (an agent
|
|
32
|
+
* run streaming its steps). Off for a static wizard/checklist. */
|
|
33
|
+
live: boolean;
|
|
34
|
+
}
|
|
35
|
+
const StepperContext = createContext<StepperConfig>({ orientation: "horizontal", color: colors.zinc[900], live: false });
|
|
7
36
|
|
|
8
37
|
export interface StepperProps {
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
38
|
+
/** Compound form: `<Stepper><Step status>…</Step></Stepper>` — swappable content. */
|
|
39
|
+
children?: ReactNode;
|
|
40
|
+
/** Data form: ordered labels; `current` derives each step's status. Ignored when `children` is set. */
|
|
41
|
+
steps?: string[];
|
|
42
|
+
current?: number;
|
|
43
|
+
orientation?: StepOrientation;
|
|
44
|
+
live?: boolean;
|
|
45
|
+
/** Accent ink for reached nodes + the connecting track. Defaults to neutral ink. */
|
|
15
46
|
color?: string;
|
|
16
47
|
accessibilityLabel?: string;
|
|
17
48
|
}
|
|
18
49
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
50
|
+
interface StepPositional {
|
|
51
|
+
_last?: boolean;
|
|
52
|
+
_leftFilled?: boolean;
|
|
53
|
+
_rightFilled?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface StepProps extends StepPositional {
|
|
57
|
+
status: StepStatus;
|
|
58
|
+
children: ReactNode;
|
|
59
|
+
/** Make the step navigable; the active one holds a wash so a panel can sit beside it (vertical). */
|
|
60
|
+
onPress?: () => void;
|
|
61
|
+
active?: boolean;
|
|
62
|
+
accessibilityLabel?: string;
|
|
23
63
|
}
|
|
24
64
|
|
|
25
65
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* `
|
|
66
|
+
* Progress through an ordered sequence — done · current · upcoming on a
|
|
67
|
+
* connecting track (horizontal) or spine (vertical). The node encodes STATUS,
|
|
68
|
+
* not identity: a filled dot once reached, a ring with a white centre at the
|
|
69
|
+
* current step (pulsing when `live`), a faint ring for what's ahead, a check for
|
|
70
|
+
* the terminal `complete`. Drive it the compound way (`<Step status>` children —
|
|
71
|
+
* the label is yours to compose) or the data way (`steps` + `current`). For a
|
|
72
|
+
* heterogeneous event LOG with per-row icons + expandable details use `Timeline`;
|
|
73
|
+
* for a compact stage bar use `StepProgress`.
|
|
32
74
|
*/
|
|
33
75
|
export function Stepper(props: StepperProps) {
|
|
34
|
-
const { steps, current, color = colors.zinc[
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
76
|
+
const { children, steps, current, orientation = "horizontal", live = false, color = colors.zinc[700], accessibilityLabel } = props;
|
|
77
|
+
|
|
78
|
+
const content: ReactNode =
|
|
79
|
+
children ??
|
|
80
|
+
(steps ?? []).map((label, i) => {
|
|
81
|
+
const status: StepStatus = current == null ? "upcoming" : i < current ? "done" : i === current ? "current" : "upcoming";
|
|
82
|
+
const isCurrent = status === "current";
|
|
83
|
+
return (
|
|
84
|
+
<Step key={label} status={status}>
|
|
85
|
+
<Text size="xs" color={isCurrent ? "default" : "muted"} weight={isCurrent ? "semibold" : "regular"} style={isCurrent ? { color } : undefined}>
|
|
86
|
+
{label}
|
|
87
|
+
</Text>
|
|
88
|
+
</Step>
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const items = Children.toArray(content).filter((c): c is ReactElement<StepProps> => isValidElement(c));
|
|
93
|
+
const statuses = items.map((c) => c.props.status);
|
|
94
|
+
const last = items.length - 1;
|
|
95
|
+
const a11y = accessibilityLabel ?? "Progress";
|
|
96
|
+
|
|
97
|
+
const positioned = items.map((child, i) =>
|
|
98
|
+
cloneElement(child, {
|
|
99
|
+
_last: i === last,
|
|
100
|
+
_leftFilled: i > 0 && reached(statuses[i - 1]),
|
|
101
|
+
_rightFilled: i < last && reached(statuses[i]),
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
38
104
|
|
|
39
105
|
return (
|
|
40
|
-
<
|
|
41
|
-
accessibilityRole="progressbar"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
>
|
|
45
|
-
{steps.map((label, i) => {
|
|
46
|
-
const status = statusOf(i, current);
|
|
47
|
-
const leftFilled = i <= current && i > 0;
|
|
48
|
-
const rightFilled = i < current && i < last;
|
|
49
|
-
const isCurrent = status === "current";
|
|
50
|
-
return (
|
|
51
|
-
<View key={label} style={{ flex: 1, alignItems: "center", gap: 8 }}>
|
|
52
|
-
<View style={{ flexDirection: "row", alignItems: "center", width: "100%" }}>
|
|
53
|
-
<View style={{ flex: 1, height: 2, backgroundColor: leftFilled ? color : colors.zinc[200] }} />
|
|
54
|
-
<StepDot status={status} color={color} />
|
|
55
|
-
<View style={{ flex: 1, height: 2, backgroundColor: rightFilled ? color : colors.zinc[200] }} />
|
|
56
|
-
</View>
|
|
57
|
-
<Text
|
|
58
|
-
size="xs"
|
|
59
|
-
color={isCurrent ? "default" : "muted"}
|
|
60
|
-
weight={isCurrent ? "semibold" : "regular"}
|
|
61
|
-
style={isCurrent ? { color } : undefined}
|
|
62
|
-
>
|
|
63
|
-
{label}
|
|
64
|
-
</Text>
|
|
65
|
-
</View>
|
|
66
|
-
);
|
|
67
|
-
})}
|
|
68
|
-
</View>
|
|
106
|
+
<StepperContext.Provider value={{ orientation, color, live }}>
|
|
107
|
+
<View accessibilityRole="progressbar" accessibilityLabel={a11y} style={orientation === "horizontal" ? styles.hRow : undefined}>
|
|
108
|
+
{positioned}
|
|
109
|
+
</View>
|
|
110
|
+
</StepperContext.Provider>
|
|
69
111
|
);
|
|
70
112
|
}
|
|
71
113
|
|
|
72
|
-
function
|
|
73
|
-
|
|
74
|
-
|
|
114
|
+
export function Step(props: StepProps) {
|
|
115
|
+
const { status, children, onPress, active, accessibilityLabel, _last, _leftFilled, _rightFilled } = props;
|
|
116
|
+
const { orientation, color, live } = useContext(StepperContext);
|
|
117
|
+
|
|
118
|
+
if (orientation === "horizontal") {
|
|
119
|
+
return (
|
|
120
|
+
<View style={styles.hStep}>
|
|
121
|
+
<View style={styles.hTrackRow}>
|
|
122
|
+
<View style={[styles.hTrack, { backgroundColor: _leftFilled ? color : colors.zinc[200] }]} />
|
|
123
|
+
<Marker status={status} color={color} live={live} />
|
|
124
|
+
<View style={[styles.hTrack, { backgroundColor: _rightFilled ? color : colors.zinc[200] }]} />
|
|
125
|
+
</View>
|
|
126
|
+
<View style={styles.hLabel}>{children}</View>
|
|
127
|
+
</View>
|
|
128
|
+
);
|
|
75
129
|
}
|
|
130
|
+
|
|
131
|
+
const body = <View style={[styles.vRowBox, active ? styles.vActive : null]}>{children}</View>;
|
|
132
|
+
// Each step rises + fades into place on mount — so a streamed feed reads as
|
|
133
|
+
// steps APPEARING, not popping in. Animates once (on mount); a status change
|
|
134
|
+
// (current → done) re-renders without re-animating.
|
|
76
135
|
return (
|
|
77
|
-
<
|
|
78
|
-
style={
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
136
|
+
<AnimationFadeIn translateY={6}>
|
|
137
|
+
<View style={styles.vItem}>
|
|
138
|
+
<View style={styles.vSpineCol}>
|
|
139
|
+
<Marker status={status} color={color} live={live} />
|
|
140
|
+
{!_last ? <View style={[styles.vSpine, { backgroundColor: reached(status) ? colors.zinc[300] : colors.zinc[200] }]} /> : null}
|
|
141
|
+
</View>
|
|
142
|
+
<View style={[styles.vContent, !_last ? styles.vGap : null]}>
|
|
143
|
+
{onPress ? (
|
|
144
|
+
<PressableHighlight onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel} style={styles.vPress}>
|
|
145
|
+
{body}
|
|
146
|
+
</PressableHighlight>
|
|
147
|
+
) : (
|
|
148
|
+
body
|
|
149
|
+
)}
|
|
150
|
+
</View>
|
|
151
|
+
</View>
|
|
152
|
+
</AnimationFadeIn>
|
|
87
153
|
);
|
|
88
154
|
}
|
|
155
|
+
|
|
156
|
+
// The node — status drives fill, never size. Monochrome: reached fills with ink,
|
|
157
|
+
// the current step is a ring around white (pulsing when live), what's ahead is a
|
|
158
|
+
// faint grey ring, the terminal is a checked fill.
|
|
159
|
+
function Marker({ status, color, live }: { status: StepStatus; color: string; live: boolean }) {
|
|
160
|
+
if (status === "done") {
|
|
161
|
+
// A passed step: filled in the accent ink with a white check.
|
|
162
|
+
return (
|
|
163
|
+
<View style={[styles.disc, { backgroundColor: color }]}>
|
|
164
|
+
<Icon name="check" size={10} color={colors.white} />
|
|
165
|
+
</View>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (status === "complete") {
|
|
169
|
+
// The terminal "Done" — a blackish ring with a white centre and a black check.
|
|
170
|
+
return (
|
|
171
|
+
<View style={[styles.disc, { backgroundColor: colors.white, borderWidth: 2, borderColor: colors.zinc[900] }]}>
|
|
172
|
+
<Icon name="check" size={10} color={colors.zinc[900]} />
|
|
173
|
+
</View>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (status === "warning") {
|
|
177
|
+
return (
|
|
178
|
+
<View style={[styles.disc, { backgroundColor: solid("amber") }]}>
|
|
179
|
+
<View style={styles.shortBar} />
|
|
180
|
+
</View>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
if (status === "current") {
|
|
184
|
+
return (
|
|
185
|
+
<View style={styles.discWrap}>
|
|
186
|
+
{live ? <Pulse color={color} /> : null}
|
|
187
|
+
<View style={[styles.disc, { backgroundColor: colors.white, borderWidth: 2.5, borderColor: color }]} />
|
|
188
|
+
</View>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return <View style={[styles.disc, { backgroundColor: colors.white, borderWidth: 1.5, borderColor: colors.zinc[300] }]} />;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// An expanding, fading halo behind the current node — the "live" pulse.
|
|
195
|
+
function Pulse({ color }: { color: string }) {
|
|
196
|
+
const v = useRef(new Animated.Value(0)).current;
|
|
197
|
+
useEffect(() => {
|
|
198
|
+
const loop = Animated.loop(Animated.timing(v, { toValue: 1, duration: 1500, useNativeDriver: false }));
|
|
199
|
+
loop.start();
|
|
200
|
+
return () => loop.stop();
|
|
201
|
+
}, [v]);
|
|
202
|
+
const transform = [{ scale: v.interpolate({ inputRange: [0, 1], outputRange: [1, 2.2] }) }];
|
|
203
|
+
const opacity = v.interpolate({ inputRange: [0, 1], outputRange: [0.35, 0] });
|
|
204
|
+
return <Animated.View style={[styles.pulse, { borderColor: color, transform, opacity }]} />;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const NODE = 18;
|
|
208
|
+
|
|
209
|
+
const styles = StyleSheet.create({
|
|
210
|
+
hRow: { flexDirection: "row" },
|
|
211
|
+
hStep: { flex: 1, alignItems: "center", gap: 8 },
|
|
212
|
+
hTrackRow: { flexDirection: "row", alignItems: "center", width: "100%" },
|
|
213
|
+
hTrack: { flex: 1, height: 2 },
|
|
214
|
+
hLabel: { alignItems: "center" },
|
|
215
|
+
|
|
216
|
+
vItem: { flexDirection: "row", gap: 12 },
|
|
217
|
+
vSpineCol: { width: NODE, alignItems: "center", paddingTop: 3 },
|
|
218
|
+
vSpine: { width: 1.5, flex: 1, minHeight: 14, borderRadius: 1, marginTop: 4 },
|
|
219
|
+
vContent: { flex: 1 },
|
|
220
|
+
vGap: { paddingBottom: 12 },
|
|
221
|
+
vPress: { borderRadius: 8, marginHorizontal: -10 },
|
|
222
|
+
vRowBox: { borderRadius: 8, paddingHorizontal: 10, paddingVertical: 2 },
|
|
223
|
+
vActive: { backgroundColor: colors.zinc[100] },
|
|
224
|
+
|
|
225
|
+
discWrap: { width: NODE, height: NODE, alignItems: "center", justifyContent: "center" },
|
|
226
|
+
disc: { width: NODE, height: NODE, borderRadius: NODE / 2, alignItems: "center", justifyContent: "center" },
|
|
227
|
+
pulse: { position: "absolute", width: NODE, height: NODE, borderRadius: NODE / 2, borderWidth: 2, pointerEvents: "none" },
|
|
228
|
+
shortBar: { width: 8, height: 2, borderRadius: 1, backgroundColor: colors.white },
|
|
229
|
+
});
|
package/src/step_list.tsx
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import { ReactNode } from "react";
|
|
2
|
-
import { StyleSheet, View } from "react-native";
|
|
3
|
-
import { colors, solid } from "./colors";
|
|
4
|
-
import { Text } from "./text";
|
|
5
|
-
import { Icon } from "./icon";
|
|
6
|
-
import { PressableHighlight } from "./pressable_highlight";
|
|
7
|
-
|
|
8
|
-
export type StepStatus = "pending" | "current" | "done" | "warning";
|
|
9
|
-
|
|
10
|
-
export interface StepListItem {
|
|
11
|
-
id: string;
|
|
12
|
-
status: StepStatus;
|
|
13
|
-
/** Primary line — a stage, a code, a name. */
|
|
14
|
-
title: string;
|
|
15
|
-
/** Secondary muted line. */
|
|
16
|
-
subtitle?: string;
|
|
17
|
-
/** Right-aligned node — a count, a `Badge`, a time. */
|
|
18
|
-
trailing?: ReactNode;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export interface StepListProps {
|
|
22
|
-
steps: StepListItem[];
|
|
23
|
-
/** When set, steps become pressable to navigate; the matching step is held
|
|
24
|
-
* highlighted so its panel can be shown elsewhere. */
|
|
25
|
-
selectedId?: string;
|
|
26
|
-
onStepPress?: (id: string) => void;
|
|
27
|
-
accessibilityLabel?: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* A vertical step sequence on a connecting spine — done · now · up next — for a
|
|
32
|
-
* guided run, a staged process, a checklist. Every status is the SAME 20px node
|
|
33
|
-
* (filled once reached, hollow while pending), so the spine reads as one line no
|
|
34
|
-
* matter which statuses appear or in what order. Unlike `Stepper` (horizontal) or
|
|
35
|
-
* `StepProgress` (a bar), and unlike `Timeline` (past events only), it models
|
|
36
|
-
* future/pending steps. Pass `onStepPress` to make the steps NAVIGABLE — the
|
|
37
|
-
* selected step is held highlighted so the host can show its panel beside the list.
|
|
38
|
-
*/
|
|
39
|
-
export function StepList(props: StepListProps) {
|
|
40
|
-
const { steps, selectedId, onStepPress, accessibilityLabel } = props;
|
|
41
|
-
return (
|
|
42
|
-
<View accessibilityLabel={accessibilityLabel}>
|
|
43
|
-
{steps.map((s, i) => {
|
|
44
|
-
// The focused step: the navigated one when navigable, else "now".
|
|
45
|
-
const active = selectedId != null ? s.id === selectedId : s.status === "current";
|
|
46
|
-
const past = s.status === "done" || s.status === "warning";
|
|
47
|
-
const content = (
|
|
48
|
-
<View style={styles.contentRow}>
|
|
49
|
-
<View style={{ flex: 1, gap: 1 }}>
|
|
50
|
-
<Text size="sm" weight={active ? "medium" : "regular"} color={past && !active ? "muted" : "default"}>
|
|
51
|
-
{s.title}
|
|
52
|
-
</Text>
|
|
53
|
-
{s.subtitle ? (
|
|
54
|
-
<Text size="xs" color="muted" numberOfLines={1}>{s.subtitle}</Text>
|
|
55
|
-
) : null}
|
|
56
|
-
</View>
|
|
57
|
-
{s.trailing}
|
|
58
|
-
</View>
|
|
59
|
-
);
|
|
60
|
-
return (
|
|
61
|
-
<View key={s.id} style={styles.item}>
|
|
62
|
-
<View style={styles.spineCol}>
|
|
63
|
-
<Marker status={s.status} />
|
|
64
|
-
{i < steps.length - 1 ? <View style={styles.spine} /> : null}
|
|
65
|
-
</View>
|
|
66
|
-
<View style={[styles.contentCol, i < steps.length - 1 ? styles.contentGap : null]}>
|
|
67
|
-
{onStepPress ? (
|
|
68
|
-
<PressableHighlight onPress={() => onStepPress(s.id)} style={[styles.rowBox, active ? styles.active : null]}>
|
|
69
|
-
{content}
|
|
70
|
-
</PressableHighlight>
|
|
71
|
-
) : (
|
|
72
|
-
<View style={[styles.rowBox, active ? styles.active : null]}>{content}</View>
|
|
73
|
-
)}
|
|
74
|
-
</View>
|
|
75
|
-
</View>
|
|
76
|
-
);
|
|
77
|
-
})}
|
|
78
|
-
</View>
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** One uniform 20px node; status drives fill, not size or shape. */
|
|
83
|
-
function Marker({ status }: { status: StepStatus }) {
|
|
84
|
-
if (status === "pending") {
|
|
85
|
-
return <View style={[styles.disc, styles.discPending]} />;
|
|
86
|
-
}
|
|
87
|
-
if (status === "current") {
|
|
88
|
-
return (
|
|
89
|
-
<View style={[styles.disc, { backgroundColor: solid("blue") }]}>
|
|
90
|
-
<View style={styles.currentDot} />
|
|
91
|
-
</View>
|
|
92
|
-
);
|
|
93
|
-
}
|
|
94
|
-
if (status === "warning") {
|
|
95
|
-
return (
|
|
96
|
-
<View style={[styles.disc, { backgroundColor: solid("amber") }]}>
|
|
97
|
-
<View style={styles.shortBar} />
|
|
98
|
-
</View>
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
return (
|
|
102
|
-
<View style={[styles.disc, { backgroundColor: solid("emerald") }]}>
|
|
103
|
-
<Icon name="check" size={12} color={colors.background} />
|
|
104
|
-
</View>
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const NODE = 20;
|
|
109
|
-
|
|
110
|
-
const styles = StyleSheet.create({
|
|
111
|
-
item: { flexDirection: "row", gap: 12 },
|
|
112
|
-
// paddingTop pairs with rowBox.paddingVertical so the node centres on the
|
|
113
|
-
// FIRST line of content, not on a multi-line block.
|
|
114
|
-
spineCol: { width: NODE, alignItems: "center", paddingTop: 4 },
|
|
115
|
-
disc: { width: NODE, height: NODE, borderRadius: NODE / 2, alignItems: "center", justifyContent: "center" },
|
|
116
|
-
discPending: { backgroundColor: colors.background, borderWidth: 1.5, borderColor: colors.zinc[300] },
|
|
117
|
-
currentDot: { width: 7, height: 7, borderRadius: 999, backgroundColor: colors.background },
|
|
118
|
-
shortBar: { width: 8, height: 2, borderRadius: 1, backgroundColor: colors.background },
|
|
119
|
-
spine: { width: 1.5, flex: 1, minHeight: 14, borderRadius: 1, backgroundColor: colors.zinc[200], marginTop: 4 },
|
|
120
|
-
contentCol: { flex: 1 },
|
|
121
|
-
// The inter-step gap — on every step but the last, so the list ends flush and
|
|
122
|
-
// a container's own padding isn't doubled at the bottom.
|
|
123
|
-
contentGap: { paddingBottom: 12 },
|
|
124
|
-
contentRow: { flexDirection: "row", alignItems: "flex-start", gap: 12, flex: 1 },
|
|
125
|
-
// press + plain share one box so the active wash looks identical either way.
|
|
126
|
-
rowBox: { borderRadius: 8, paddingHorizontal: 10, paddingVertical: 4, marginHorizontal: -10, flexDirection: "row", alignItems: "flex-start" },
|
|
127
|
-
active: { backgroundColor: colors.zinc[100] },
|
|
128
|
-
});
|