@lotics/ui 21.0.0 → 21.2.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 +28 -8
- package/docs/templates.md +70 -21
- package/examples/tpl_record.tsx +181 -353
- package/package.json +2 -1
- package/src/data_grid.tsx +44 -11
- package/src/ledger.tsx +16 -3
- package/src/pipeline.tsx +156 -0
- 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.2.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/data_grid.tsx
CHANGED
|
@@ -11,9 +11,17 @@ export interface DataGridColumn<T> {
|
|
|
11
11
|
label: string;
|
|
12
12
|
/** Fixed width in px; omit for the flexible primary column (usually the first). */
|
|
13
13
|
width?: number;
|
|
14
|
+
/** Cell + header alignment. Money and counts read RIGHT so digits line up under
|
|
15
|
+
* their heading; text reads left (the default). */
|
|
16
|
+
align?: "left" | "right";
|
|
14
17
|
sortable?: boolean;
|
|
15
18
|
/** Render the cell for this column — any field: a `Text`, an inline editor, a badge. */
|
|
16
19
|
cell: (item: T) => ReactNode;
|
|
20
|
+
/** This column's cell in the group's FOOTER row — a subtotal, a resolved measure.
|
|
21
|
+
* Declared per COLUMN so the grid lays it out in that column's own box: a total
|
|
22
|
+
* can never drift from the values it sums, and no consumer re-implements the row.
|
|
23
|
+
* The footer row renders when ANY column defines one. */
|
|
24
|
+
footer?: (group: DataGridGroup<T>) => ReactNode;
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
export interface DataGridGroup<T> {
|
|
@@ -39,7 +47,9 @@ export interface DataGridProps<T> {
|
|
|
39
47
|
/** Controlled collapse — the set of collapsed group keys. */
|
|
40
48
|
collapsed?: Set<string>;
|
|
41
49
|
onToggleCollapse?: (key: string) => void;
|
|
42
|
-
/** Rendered under each group's rows
|
|
50
|
+
/** Rendered under each group's rows AND under the columns' own footer row —
|
|
51
|
+
* for content that does NOT sit in a column, typically a per-group add row.
|
|
52
|
+
* For a column-aligned subtotal use `DataGridColumn.footer`. */
|
|
43
53
|
renderGroupFooter?: (group: DataGridGroup<T>) => ReactNode;
|
|
44
54
|
}
|
|
45
55
|
|
|
@@ -57,13 +67,19 @@ export interface DataGridProps<T> {
|
|
|
57
67
|
export function DataGrid<T>(props: DataGridProps<T>) {
|
|
58
68
|
const { columns, groups, getRowKey, leading, sort, onSort, labels, collapsed, onToggleCollapse, renderGroupFooter } = props;
|
|
59
69
|
const lead = leading?.width ?? 0;
|
|
70
|
+
const hasFooter = columns.some((c) => c.footer != null);
|
|
60
71
|
|
|
61
72
|
return (
|
|
62
73
|
<View>
|
|
63
74
|
<View style={[styles.row, styles.head]}>
|
|
64
75
|
{lead > 0 ? <View style={{ width: lead }} /> : null}
|
|
76
|
+
{/* The header label rides the SAME column box as the cells below it, so a
|
|
77
|
+
right-aligned column's heading sits over its digits instead of drifting
|
|
78
|
+
to the column's left edge. */}
|
|
65
79
|
{columns.map((c) => (
|
|
66
|
-
<
|
|
80
|
+
<View key={c.key} style={colStyle(c)}>
|
|
81
|
+
<SortLabel label={c.label} sortKey={c.key} sortable={c.sortable !== false && !!onSort} sort={sort ?? null} onSort={onSort} labels={labels} />
|
|
82
|
+
</View>
|
|
67
83
|
))}
|
|
68
84
|
</View>
|
|
69
85
|
|
|
@@ -95,6 +111,14 @@ export function DataGrid<T>(props: DataGridProps<T>) {
|
|
|
95
111
|
))}
|
|
96
112
|
</View>
|
|
97
113
|
))}
|
|
114
|
+
{hasFooter ? (
|
|
115
|
+
<View style={styles.row}>
|
|
116
|
+
{lead > 0 ? <View style={{ width: lead }} /> : null}
|
|
117
|
+
{columns.map((c) => (
|
|
118
|
+
<View key={c.key} style={colStyle(c)}>{c.footer?.(g)}</View>
|
|
119
|
+
))}
|
|
120
|
+
</View>
|
|
121
|
+
) : null}
|
|
98
122
|
{renderGroupFooter?.(g)}
|
|
99
123
|
</>
|
|
100
124
|
) : null}
|
|
@@ -105,13 +129,20 @@ export function DataGrid<T>(props: DataGridProps<T>) {
|
|
|
105
129
|
);
|
|
106
130
|
}
|
|
107
131
|
|
|
108
|
-
|
|
109
|
-
|
|
132
|
+
/** The shared column box — width (or flex) plus alignment. ONE definition, used by
|
|
133
|
+
* the header, every cell and every footer cell, so the three cannot drift. */
|
|
134
|
+
function colStyleImpl(c: { width?: number; align?: "left" | "right" }): StyleProp<ViewStyle> {
|
|
135
|
+
return [
|
|
136
|
+
c.width != null ? { width: c.width } : styles.flexCol,
|
|
137
|
+
c.align === "right" ? { alignItems: "flex-end" as const } : null,
|
|
138
|
+
];
|
|
110
139
|
}
|
|
140
|
+
const colStyle = colStyleImpl;
|
|
111
141
|
|
|
112
|
-
// The
|
|
113
|
-
//
|
|
114
|
-
//
|
|
142
|
+
// The column heading. It sits in the SAME column box as the cells, and its own
|
|
143
|
+
// padding is cancelled by an equal negative margin — so the hover pill has room
|
|
144
|
+
// while the label still starts exactly on its cell's edge. Non-sortable headings
|
|
145
|
+
// render as plain text: no press, no pointer, no button in the a11y tree.
|
|
115
146
|
function SortLabel(props: { label: string; sortKey: string; sortable: boolean; sort: SortState | null; onSort?: (k: string) => void; labels?: SortHeaderLabels; style?: StyleProp<ViewStyle> }) {
|
|
116
147
|
const { label, sortKey, sortable, sort, onSort, labels, style } = props;
|
|
117
148
|
const active = sort?.key === sortKey;
|
|
@@ -126,7 +157,7 @@ function SortLabel(props: { label: string; sortKey: string; sortable: boolean; s
|
|
|
126
157
|
const sortByLabel = (labels?.sortBy ?? ((l: string) => `Sort by ${l}`))(label);
|
|
127
158
|
const dirText = active ? (sort?.dir === "asc" ? (labels?.ascending ?? ", ascending") : (labels?.descending ?? ", descending")) : "";
|
|
128
159
|
return (
|
|
129
|
-
<FocusRingPressable onPress={() => onSort?.(sortKey)} accessibilityRole="button" accessibilityLabel={`${sortByLabel}${dirText}`} style={({ hovered }: { hovered?: boolean }) => [styles.sortLabel, hovered ? styles.sortLabelHover : null, style]}>
|
|
160
|
+
<FocusRingPressable onPress={() => onSort?.(sortKey)} accessibilityRole="button" accessibilityLabel={`${sortByLabel}${dirText}`} style={({ hovered }: { hovered?: boolean }) => [styles.sortLabel, styles.sortLabelPressable, hovered ? styles.sortLabelHover : null, style]}>
|
|
130
161
|
<Text size="xs" weight="semibold" color={active ? "default" : "muted"} numberOfLines={1} style={styles.headLabel}>{label}</Text>
|
|
131
162
|
{arrow ? <Icon name={arrow} size={12} color={colors.zinc[500]} /> : null}
|
|
132
163
|
</FocusRingPressable>
|
|
@@ -139,7 +170,6 @@ export const gridRowStyle: ViewStyle = {
|
|
|
139
170
|
alignItems: "center",
|
|
140
171
|
gap: 12,
|
|
141
172
|
minHeight: 44,
|
|
142
|
-
paddingHorizontal: 8,
|
|
143
173
|
};
|
|
144
174
|
|
|
145
175
|
const styles = StyleSheet.create({
|
|
@@ -152,9 +182,12 @@ const styles = StyleSheet.create({
|
|
|
152
182
|
marginBottom: 4,
|
|
153
183
|
},
|
|
154
184
|
headLabel: { letterSpacing: 0.3, textTransform: "uppercase" },
|
|
155
|
-
|
|
185
|
+
// Padded for the hover pill but zero-width in flow (negative margin), so the label
|
|
186
|
+
// sits on its cell's edge instead of 8px inside it.
|
|
187
|
+
sortLabel: { flexDirection: "row", alignItems: "center", gap: 4, paddingHorizontal: 6, marginHorizontal: -6, paddingVertical: 2, borderRadius: 6 },
|
|
188
|
+
sortLabelPressable: { cursor: "pointer" },
|
|
156
189
|
sortLabelHover: { backgroundColor: colors.zinc[50] },
|
|
157
190
|
groupSep: { borderTopWidth: 1, borderTopColor: colors.zinc[100], marginTop: 8, paddingTop: 8 },
|
|
158
|
-
section: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 8, paddingVertical: 7, borderRadius: 8 },
|
|
191
|
+
section: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 8, marginHorizontal: -8, paddingVertical: 7, borderRadius: 8 },
|
|
159
192
|
sectionHover: { backgroundColor: colors.zinc[50] },
|
|
160
193
|
});
|
package/src/ledger.tsx
CHANGED
|
@@ -77,7 +77,7 @@ export function LedgerGroup(props: LedgerGroupProps) {
|
|
|
77
77
|
const { format } = useLedger();
|
|
78
78
|
return (
|
|
79
79
|
<View style={styles.group}>
|
|
80
|
-
<View style={styles.
|
|
80
|
+
<View style={styles.groupHead}>
|
|
81
81
|
<Text size="xs" weight="medium" color="muted" style={styles.grow}>
|
|
82
82
|
{label}
|
|
83
83
|
</Text>
|
|
@@ -198,8 +198,11 @@ const styles = StyleSheet.create({
|
|
|
198
198
|
// Outdent by the rows' 8px inset (the ListItem/Timeline technique) so row text +
|
|
199
199
|
// the money column align with the section heading and the container edges;
|
|
200
200
|
// the pressable door's wash bleeds into the gutter instead of squeezing text.
|
|
201
|
-
|
|
202
|
-
|
|
201
|
+
// Grouping reads by PROXIMITY: the gap between groups must be decisively larger
|
|
202
|
+
// than the gap inside one, or the eyebrow floats midway and belongs to neither
|
|
203
|
+
// the group above nor the rows below. 18 between, 2 within.
|
|
204
|
+
ledger: { gap: 16, marginHorizontal: -8 },
|
|
205
|
+
group: { gap: 0 },
|
|
203
206
|
// EVERY line (group header, rows, the total) shares the 8px text inset, so
|
|
204
207
|
// labels and the money column sit on one edge whether a row peeks or not —
|
|
205
208
|
// the pressable door's wash simply fills the same padded box.
|
|
@@ -210,6 +213,16 @@ const styles = StyleSheet.create({
|
|
|
210
213
|
minHeight: 28,
|
|
211
214
|
paddingHorizontal: 8,
|
|
212
215
|
},
|
|
216
|
+
// The group eyebrow is a CAPTION on the rows beneath it, not a line of its own —
|
|
217
|
+
// it takes only the height its xs text needs, so it hugs what it labels instead
|
|
218
|
+
// of sitting in a full 28px row box.
|
|
219
|
+
groupHead: {
|
|
220
|
+
flexDirection: "row",
|
|
221
|
+
alignItems: "center",
|
|
222
|
+
gap: 10,
|
|
223
|
+
minHeight: 16,
|
|
224
|
+
paddingHorizontal: 8,
|
|
225
|
+
},
|
|
213
226
|
rowPress: {
|
|
214
227
|
borderRadius: 8,
|
|
215
228
|
},
|
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/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
|
|