@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "21.1.0",
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
- removeTitle: "Remove attachment?",
293
- removeMessage: "This removes the file.",
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: "Tệp sẽ bị gỡ khỏi bản ghi.",
444
+ removeMessage: "Các tệp đã chọn sẽ bị xóa.",
442
445
  removeCancel: "Hủy",
443
446
  removeConfirm: "Xóa",
444
447
  },
@@ -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
+ }
@@ -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.toLocaleString(localeTag)}/${max.toLocaleString(localeTag)}`;
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.toLocaleString(localeTag)} / ${max.toLocaleString(localeTag)} · ${Math.round(ratio)}%`
90
+ ? `${num(value)} / ${num(max)} · ${Math.round(ratio)}%`
79
91
  : format === "percentage"
80
92
  ? `${Math.round(ratio)}%`
81
93
  : null;
@@ -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
- interface StepPositional {
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 accessibilityRole="progressbar" accessibilityLabel={a11y} style={orientation === "horizontal" ? styles.hRow : undefined}>
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
- <AnimationFadeIn translateY={6}>
156
- <View style={styles.vItem}>
157
- <View style={styles.vSpineCol}>
158
- <Marker status={status} color={color} live={live} />
159
- {!_last ? <View style={[styles.vSpine, { backgroundColor: reached(status) ? colors.zinc[300] : colors.zinc[200] }]} /> : null}
160
- </View>
161
- <View style={[styles.vContent, !_last ? styles.vGap : null]}>
162
- {onPress ? (
163
- <PressableHighlight focusRing onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel} style={styles.vPress}>
164
- {body}
165
- </PressableHighlight>
166
- ) : (
167
- body
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
- </View>
171
- </AnimationFadeIn>
190
+ </AnimationFadeIn>
191
+ </View>
172
192
  );
173
193
  }
174
194