@lotics/ui 21.1.0 → 21.3.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 +7 -0
- package/MIGRATION.md +16 -0
- package/docs/catalog.md +24 -7
- package/docs/templates.md +70 -21
- package/examples/tpl_record.tsx +181 -353
- package/package.json +2 -1
- package/src/locale.tsx +6 -3
- package/src/pipeline.tsx +156 -0
- package/src/progress_bar.tsx +14 -2
- package/src/step_progress.tsx +8 -0
- package/src/stepper.tsx +40 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "21.
|
|
3
|
+
"version": "21.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -204,6 +204,7 @@
|
|
|
204
204
|
"./card": "./src/card.tsx",
|
|
205
205
|
"./accordion": "./src/accordion.tsx",
|
|
206
206
|
"./stepper": "./src/stepper.tsx",
|
|
207
|
+
"./pipeline": "./src/pipeline.tsx",
|
|
207
208
|
"./step_progress": "./src/step_progress.tsx",
|
|
208
209
|
"./tabs": "./src/tabs.tsx",
|
|
209
210
|
"./segmented_control": "./src/segmented_control.tsx",
|
package/src/locale.tsx
CHANGED
|
@@ -289,8 +289,11 @@ export const en: LoticsLocale = {
|
|
|
289
289
|
downloadAll: "Download all",
|
|
290
290
|
downloadSelected: (n: number) => `Download (${n})`,
|
|
291
291
|
delete: "Delete",
|
|
292
|
-
|
|
293
|
-
|
|
292
|
+
// `FilesEditorRemove` removes the whole SELECTION, so the confirm is phrased for a set —
|
|
293
|
+
// singular copy under-reports what the button is about to do. And the kit does not know what
|
|
294
|
+
// the files hang off, so it says what it can see: the files, and that they are selected.
|
|
295
|
+
removeTitle: "Remove files?",
|
|
296
|
+
removeMessage: "The selected files will be removed.",
|
|
294
297
|
removeCancel: "Cancel",
|
|
295
298
|
removeConfirm: "Remove",
|
|
296
299
|
},
|
|
@@ -438,7 +441,7 @@ export const vi: LoticsLocale = {
|
|
|
438
441
|
downloadSelected: (n: number) => `Tải ${n} tệp`,
|
|
439
442
|
delete: "Xóa",
|
|
440
443
|
removeTitle: "Xóa tệp?",
|
|
441
|
-
removeMessage: "
|
|
444
|
+
removeMessage: "Các tệp đã chọn sẽ bị xóa.",
|
|
442
445
|
removeCancel: "Hủy",
|
|
443
446
|
removeConfirm: "Xóa",
|
|
444
447
|
},
|
package/src/pipeline.tsx
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Stepper, Step, type StepPositional, type StepStatus } from "./stepper";
|
|
5
|
+
|
|
6
|
+
export interface PipelineProps {
|
|
7
|
+
children?: ReactNode;
|
|
8
|
+
/** Accent for reached nodes + the spine. Defaults to `Stepper`'s neutral ink. */
|
|
9
|
+
color?: string;
|
|
10
|
+
accessibilityLabel?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A pipeline an item WALKS — ordered milestones where each one carries its own
|
|
15
|
+
* controls: the fields that milestone owns, the conditions attached to it, and
|
|
16
|
+
* the one act that leaves it.
|
|
17
|
+
*
|
|
18
|
+
* **Pick this over `TaskList` when the rows are STAGES, not work items.** A task
|
|
19
|
+
* list models N things you tick in any order, each row structurally identical.
|
|
20
|
+
* A pipeline models ONE thing moving through N positions, where the position
|
|
21
|
+
* decides what you can see and do — a dossier at "awaiting review" offers approve
|
|
22
|
+
* and return; the same dossier two stages on offers neither and shows a portal
|
|
23
|
+
* account instead. Rendering that as a checklist forces every row to carry every
|
|
24
|
+
* control, and the reader has to scan all of them to find the one that is theirs.
|
|
25
|
+
*
|
|
26
|
+
* **And over a bare `Stepper` when the stages need bodies.** `Stepper` renders
|
|
27
|
+
* position — done, current, upcoming on a spine. This adds the anatomy that turns
|
|
28
|
+
* position into a workspace: a title that reads by status, a meta line, per-stage
|
|
29
|
+
* fields, notes and actions.
|
|
30
|
+
*
|
|
31
|
+
* The rules the anatomy encodes:
|
|
32
|
+
* - **Only the current stage should carry an act.** A control on an unreached
|
|
33
|
+
* stage invites acting out of order; on a passed one it re-offers something
|
|
34
|
+
* already done. The component does not enforce this — the caller decides what
|
|
35
|
+
* each stage renders — but the styling assumes it.
|
|
36
|
+
* - **A condition belongs to its stage.** Something being wrong at stage 2 is a
|
|
37
|
+
* fact about stage 2, so `PipelineNote` sits inside it rather than floating
|
|
38
|
+
* above the run where it reads as "something is wrong with this record".
|
|
39
|
+
* - **A passed stage stays correctable.** Whatever a stage owns should remain
|
|
40
|
+
* editable behind you, or the only way to fix a mis-entry is direct table
|
|
41
|
+
* access.
|
|
42
|
+
*
|
|
43
|
+
* ```tsx
|
|
44
|
+
* <Pipeline>
|
|
45
|
+
* <PipelineStage status="done" title="Submitted">
|
|
46
|
+
* <PipelineField label="Date"><InlineDatePicker … /></PipelineField>
|
|
47
|
+
* </PipelineStage>
|
|
48
|
+
* <PipelineStage status="current" title="In review" meta="Waiting 3d · Ops">
|
|
49
|
+
* <PipelineNote tone="warning">Sent back — missing payslips.</PipelineNote>
|
|
50
|
+
* <PipelineActions>
|
|
51
|
+
* <Button title="Approve" color="primary" />
|
|
52
|
+
* <Button title="Return" color="danger-secondary" />
|
|
53
|
+
* </PipelineActions>
|
|
54
|
+
* </PipelineStage>
|
|
55
|
+
* <PipelineStage status="upcoming" title="Filed" />
|
|
56
|
+
* </Pipeline>
|
|
57
|
+
* ```
|
|
58
|
+
*/
|
|
59
|
+
export function Pipeline({ children, color, accessibilityLabel }: PipelineProps) {
|
|
60
|
+
return (
|
|
61
|
+
<Stepper orientation="vertical" color={color} accessibilityLabel={accessibilityLabel}>
|
|
62
|
+
{children}
|
|
63
|
+
</Stepper>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface PipelineStageProps extends StepPositional {
|
|
68
|
+
status: StepStatus;
|
|
69
|
+
/** The milestone's name — the one thing every stage shows. */
|
|
70
|
+
title: string;
|
|
71
|
+
/** A muted line under the title: how long it has sat here, whose desk it is on.
|
|
72
|
+
* Prose, not a value the reader sets. */
|
|
73
|
+
meta?: string;
|
|
74
|
+
/** The stage's own body — `PipelineNote`, `PipelineField`, `PipelineActions`,
|
|
75
|
+
* or anything else. A stage with no body renders as its title alone, which is
|
|
76
|
+
* what an unreached stage should be. */
|
|
77
|
+
children?: ReactNode;
|
|
78
|
+
accessibilityLabel?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One milestone. Its title reads by STATUS — the current one in full ink and
|
|
82
|
+
* medium weight, everything else muted — so the eye lands on the stage that is
|
|
83
|
+
* live without reading a word.
|
|
84
|
+
*
|
|
85
|
+
* Deliberately NOT pressable. `Step` can be (a wizard whose steps navigate),
|
|
86
|
+
* but a pipeline stage is a workspace, not a destination — its body already
|
|
87
|
+
* holds the controls, and a press target wrapping them would swallow their taps. */
|
|
88
|
+
export function PipelineStage(props: PipelineStageProps) {
|
|
89
|
+
const { status, title, meta, children, accessibilityLabel, ...positional } = props;
|
|
90
|
+
const isCurrent = status === "current";
|
|
91
|
+
return (
|
|
92
|
+
<Step
|
|
93
|
+
status={status}
|
|
94
|
+
accessibilityLabel={accessibilityLabel ?? title}
|
|
95
|
+
{...positional}
|
|
96
|
+
>
|
|
97
|
+
<View style={{ gap: 6 }}>
|
|
98
|
+
<View style={{ gap: 2 }}>
|
|
99
|
+
<Text size="sm" color={isCurrent ? "default" : "muted"} weight={isCurrent ? "medium" : "regular"}>
|
|
100
|
+
{title}
|
|
101
|
+
</Text>
|
|
102
|
+
{meta ? <Text size="xs" color="muted">{meta}</Text> : null}
|
|
103
|
+
</View>
|
|
104
|
+
{children}
|
|
105
|
+
</View>
|
|
106
|
+
</Step>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface PipelineNoteProps {
|
|
111
|
+
children: ReactNode;
|
|
112
|
+
/** `warning` for something outstanding at this stage, `danger` for a refusal,
|
|
113
|
+
* `muted` for a plain remark. */
|
|
114
|
+
tone?: "muted" | "warning" | "danger";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A CONDITION attached to one stage — "sent back", "rejected", "waiting on the
|
|
119
|
+
* customer". Prose, never a control: it says what is true, and the controls that
|
|
120
|
+
* answer it sit under it in the same stage.
|
|
121
|
+
*
|
|
122
|
+
* Deliberately not a `Callout`. A callout is a page-level interruption with its
|
|
123
|
+
* own box and tone fill; inside a stage that box competes with the spine and
|
|
124
|
+
* reads as an alert about the whole record. A stage's condition is a line of text
|
|
125
|
+
* in the stage's own column.
|
|
126
|
+
*/
|
|
127
|
+
export function PipelineNote({ children, tone = "muted" }: PipelineNoteProps) {
|
|
128
|
+
return <Text size="xs" color={tone}>{children}</Text>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface PipelineFieldProps {
|
|
132
|
+
/** The value's name. Stacked ABOVE the control rather than in a label column:
|
|
133
|
+
* a stage's content column is already indented past the spine, and a second
|
|
134
|
+
* fixed column inside it leaves nothing for the value. */
|
|
135
|
+
label?: string;
|
|
136
|
+
children: ReactNode;
|
|
137
|
+
/** Cap the control's width so a lone text input does not run the full column. */
|
|
138
|
+
maxWidth?: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A value this stage OWNS, editable in place. Render it on a passed stage too —
|
|
142
|
+
* a milestone being behind you is not a reason its facts stop being wrong. */
|
|
143
|
+
export function PipelineField({ label, children, maxWidth = 320 }: PipelineFieldProps) {
|
|
144
|
+
return (
|
|
145
|
+
<View style={{ gap: 2, maxWidth }}>
|
|
146
|
+
{label ? <Text size="xs" color="muted">{label}</Text> : null}
|
|
147
|
+
{children}
|
|
148
|
+
</View>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The act(s) that leave this stage. Wraps on a narrow column so a stage with two
|
|
153
|
+
* verbs never pushes the spine sideways. */
|
|
154
|
+
export function PipelineActions({ children }: { children: ReactNode }) {
|
|
155
|
+
return <View style={{ flexDirection: "row", gap: 8, flexWrap: "wrap", paddingTop: 2 }}>{children}</View>;
|
|
156
|
+
}
|
package/src/progress_bar.tsx
CHANGED
|
@@ -16,6 +16,16 @@ export interface ProgressBarProps {
|
|
|
16
16
|
* "1,250 / 2,500 · 50%" (separators follow the reader's locale). Reports the
|
|
17
17
|
* TRUE ratio — over `max` it reads "105%" while the track stays clamped. */
|
|
18
18
|
format?: ProgressBarFormat;
|
|
19
|
+
/**
|
|
20
|
+
* How `value` and `max` render inside the caption. Defaults to the reader's locale grouping.
|
|
21
|
+
*
|
|
22
|
+
* The escape hatch for a quantity whose natural precision is not its DISPLAY precision — a
|
|
23
|
+
* fractional credit balance metered to whole credits, a byte count shown as GB. Still the bar's
|
|
24
|
+
* job rather than the caller's: pass the true `value`/`max` so the track and the percentage stay
|
|
25
|
+
* exact, and let this reshape only the text. Pre-rounding the numbers you pass in moves the fill
|
|
26
|
+
* and the percentage too, which is how a 99.6%-full meter starts claiming it is exactly full.
|
|
27
|
+
*/
|
|
28
|
+
formatValue?: (n: number) => string;
|
|
19
29
|
color?: string;
|
|
20
30
|
completeColor?: string;
|
|
21
31
|
/** COMPACT: one row — the track (flex) with a plain sm tabular count beside
|
|
@@ -37,6 +47,7 @@ export function ProgressBar(props: ProgressBarProps) {
|
|
|
37
47
|
max,
|
|
38
48
|
title,
|
|
39
49
|
format = "percentage",
|
|
50
|
+
formatValue,
|
|
40
51
|
color = colors.blue["500"],
|
|
41
52
|
completeColor = colors.green["500"],
|
|
42
53
|
compact = false,
|
|
@@ -55,10 +66,11 @@ export function ProgressBar(props: ProgressBarProps) {
|
|
|
55
66
|
const ratio = max > 0 ? Math.max(0, (value / max) * 100) : 0;
|
|
56
67
|
const percentage = Math.min(100, ratio);
|
|
57
68
|
const isComplete = ratio >= 100;
|
|
69
|
+
const num = formatValue ?? ((n: number) => n.toLocaleString(localeTag));
|
|
58
70
|
|
|
59
71
|
if (compact) {
|
|
60
72
|
const label =
|
|
61
|
-
format === "percentage" ? `${Math.round(ratio)}%` : `${value
|
|
73
|
+
format === "percentage" ? `${Math.round(ratio)}%` : `${num(value)}/${num(max)}`;
|
|
62
74
|
return (
|
|
63
75
|
<View style={styles.compactRow}>
|
|
64
76
|
<View style={[styles.track, styles.compactTrack]}>
|
|
@@ -75,7 +87,7 @@ export function ProgressBar(props: ProgressBarProps) {
|
|
|
75
87
|
|
|
76
88
|
const caption =
|
|
77
89
|
format === "fraction"
|
|
78
|
-
? `${value
|
|
90
|
+
? `${num(value)} / ${num(max)} · ${Math.round(ratio)}%`
|
|
79
91
|
: format === "percentage"
|
|
80
92
|
? `${Math.round(ratio)}%`
|
|
81
93
|
: null;
|
package/src/step_progress.tsx
CHANGED
|
@@ -59,10 +59,18 @@ export function StepProgress(props: StepProgressProps) {
|
|
|
59
59
|
: `${Math.max(0, safe + 1)}/${count}${safe >= 0 ? ` · ${names[safe]}` : ""}`
|
|
60
60
|
: undefined);
|
|
61
61
|
|
|
62
|
+
// `progressbar` IS right here — the segments are decoration over one quantity,
|
|
63
|
+
// so nothing is lost to its presentational children — but only once it carries
|
|
64
|
+
// the quantity. Without valuenow/min/max it announces as an indeterminate
|
|
65
|
+
// "busy", which is the opposite of a bar whose whole job is "4 of 7". (A
|
|
66
|
+
// sequence whose steps carry their OWN content is a list — that is `Stepper`.)
|
|
62
67
|
const bar = (
|
|
63
68
|
<View
|
|
64
69
|
accessibilityRole="progressbar"
|
|
65
70
|
accessibilityLabel={accessibilityLabel ?? caption ?? `${Math.max(0, safe + 1)} of ${count}`}
|
|
71
|
+
aria-valuenow={isComplete ? count : Math.max(0, safe + 1)}
|
|
72
|
+
aria-valuemin={0}
|
|
73
|
+
aria-valuemax={count}
|
|
66
74
|
style={{ flexDirection: "row", gap: 3, flex: 1 }}
|
|
67
75
|
>
|
|
68
76
|
{Array.from({ length: count }, (_, i) => (
|
package/src/stepper.tsx
CHANGED
|
@@ -47,7 +47,11 @@ export interface StepperProps {
|
|
|
47
47
|
accessibilityLabel?: string;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
|
|
50
|
+
/** The props `Stepper` CLONES onto its direct children. Exported so a component
|
|
51
|
+
* that stands where a `Step` would (`PipelineStage`) can accept and forward the
|
|
52
|
+
* whole set — extend this and spread it through, never re-list the keys, or a
|
|
53
|
+
* new positional prop is dropped silently by everything but `Step`. */
|
|
54
|
+
export interface StepPositional {
|
|
51
55
|
_last?: boolean;
|
|
52
56
|
_leftFilled?: boolean;
|
|
53
57
|
_rightFilled?: boolean;
|
|
@@ -94,6 +98,12 @@ export function Stepper(props: StepperProps) {
|
|
|
94
98
|
const statuses = items.map((c) => c.props.status);
|
|
95
99
|
const last = items.length - 1;
|
|
96
100
|
const a11y = accessibilityLabel ?? "Progress";
|
|
101
|
+
// A LIST of named positions, never `progressbar`. `progressbar` is a RANGE
|
|
102
|
+
// widget — it wants valuenow/min/max, announces as indeterminate without them,
|
|
103
|
+
// and its children are PRESENTATIONAL, which strips every step's label (and,
|
|
104
|
+
// in a `Pipeline`, its date pickers, selects and buttons) out of the
|
|
105
|
+
// accessibility tree. A discrete named sequence is a list whose live entry
|
|
106
|
+
// carries `aria-current="step"`. The proportional bar is `StepProgress`.
|
|
97
107
|
|
|
98
108
|
const positioned = items.map((child, i) =>
|
|
99
109
|
cloneElement(child, {
|
|
@@ -105,7 +115,7 @@ export function Stepper(props: StepperProps) {
|
|
|
105
115
|
|
|
106
116
|
return (
|
|
107
117
|
<StepperContext.Provider value={{ orientation, color, live }}>
|
|
108
|
-
<View
|
|
118
|
+
<View role="list" accessibilityLabel={a11y} style={orientation === "horizontal" ? styles.hRow : undefined}>
|
|
109
119
|
{positioned}
|
|
110
120
|
</View>
|
|
111
121
|
</StepperContext.Provider>
|
|
@@ -115,6 +125,11 @@ export function Stepper(props: StepperProps) {
|
|
|
115
125
|
export function Step(props: StepProps) {
|
|
116
126
|
const { status, children, onPress, active, accessibilityLabel, _last, _leftFilled, _rightFilled } = props;
|
|
117
127
|
const { orientation, color, live } = useContext(StepperContext);
|
|
128
|
+
// Each step is one `listitem`, and the live one says so with `aria-current`.
|
|
129
|
+
// It goes on the ITEM, not the label: "current" is a fact about this position
|
|
130
|
+
// in the sequence, and a reader jumping between list items needs it announced
|
|
131
|
+
// with the item, not buried in whatever the caller composed inside.
|
|
132
|
+
const item = { role: "listitem" as const, "aria-current": status === "current" ? ("step" as const) : undefined };
|
|
118
133
|
|
|
119
134
|
if (orientation === "horizontal") {
|
|
120
135
|
const inner = (
|
|
@@ -131,7 +146,7 @@ export function Step(props: StepProps) {
|
|
|
131
146
|
// target — generous, no dead zones; `active` washes the selected one.
|
|
132
147
|
if (onPress) {
|
|
133
148
|
return (
|
|
134
|
-
<View style={styles.hStepSlot}>
|
|
149
|
+
<View {...item} style={styles.hStepSlot}>
|
|
135
150
|
<PressableHighlight
|
|
136
151
|
focusRing
|
|
137
152
|
onPress={onPress}
|
|
@@ -144,31 +159,36 @@ export function Step(props: StepProps) {
|
|
|
144
159
|
</View>
|
|
145
160
|
);
|
|
146
161
|
}
|
|
147
|
-
return <View style={styles.hStep}>{inner}</View>;
|
|
162
|
+
return <View {...item} style={styles.hStep}>{inner}</View>;
|
|
148
163
|
}
|
|
149
164
|
|
|
150
165
|
const body = <View style={[styles.vRowBox, active ? styles.vActive : null]}>{children}</View>;
|
|
151
166
|
// Each step rises + fades into place on mount — so a streamed feed reads as
|
|
152
167
|
// steps APPEARING, not popping in. Animates once (on mount); a status change
|
|
153
168
|
// (current → done) re-renders without re-animating.
|
|
169
|
+
// The listitem wraps the animation rather than sitting inside it: a `list`
|
|
170
|
+
// owns its `listitem`s DIRECTLY, and the fade's own element between them would
|
|
171
|
+
// break that ownership. It carries no style, so the layout is unchanged.
|
|
154
172
|
return (
|
|
155
|
-
<
|
|
156
|
-
<
|
|
157
|
-
<View style={styles.
|
|
158
|
-
<
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
{
|
|
163
|
-
|
|
164
|
-
{
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
173
|
+
<View {...item}>
|
|
174
|
+
<AnimationFadeIn translateY={6}>
|
|
175
|
+
<View style={styles.vItem}>
|
|
176
|
+
<View style={styles.vSpineCol}>
|
|
177
|
+
<Marker status={status} color={color} live={live} />
|
|
178
|
+
{!_last ? <View style={[styles.vSpine, { backgroundColor: reached(status) ? colors.zinc[300] : colors.zinc[200] }]} /> : null}
|
|
179
|
+
</View>
|
|
180
|
+
<View style={[styles.vContent, !_last ? styles.vGap : null]}>
|
|
181
|
+
{onPress ? (
|
|
182
|
+
<PressableHighlight focusRing onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel} style={styles.vPress}>
|
|
183
|
+
{body}
|
|
184
|
+
</PressableHighlight>
|
|
185
|
+
) : (
|
|
186
|
+
body
|
|
187
|
+
)}
|
|
188
|
+
</View>
|
|
169
189
|
</View>
|
|
170
|
-
</
|
|
171
|
-
</
|
|
190
|
+
</AnimationFadeIn>
|
|
191
|
+
</View>
|
|
172
192
|
);
|
|
173
193
|
}
|
|
174
194
|
|