@lotics/ui 2.4.0 → 2.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/package.json +27 -8
- package/src/accordion.tsx +146 -63
- package/src/action_menu.tsx +72 -0
- package/src/allocation_row.tsx +54 -0
- package/src/avatar.web.tsx +102 -0
- package/src/badge.tsx +40 -9
- package/src/breakdown.tsx +121 -0
- package/src/card.tsx +150 -0
- package/src/cell_select.tsx +3 -2
- package/src/chip_group.tsx +65 -0
- package/src/colors.ts +61 -0
- package/src/column_filter.tsx +9 -24
- package/src/completion_state.tsx +43 -0
- package/src/control_surface.ts +32 -0
- package/src/counter.tsx +58 -0
- package/src/date_range_filter_field.tsx +44 -12
- package/src/detail_row.tsx +45 -0
- package/src/dialog.tsx +0 -24
- package/src/download.ts +2 -1
- package/src/drawer.tsx +94 -2
- package/src/empty_state.tsx +37 -0
- package/src/file_badge.tsx +27 -4
- package/src/file_dropzone.tsx +188 -0
- package/src/file_picker.ts +45 -0
- package/src/filter_pill.tsx +106 -0
- package/src/floating_action_bar.tsx +57 -0
- package/src/fonts.css +10 -13
- package/src/format_money.ts +38 -0
- package/src/heatmap.tsx +153 -0
- package/src/icon.tsx +2 -0
- package/src/icon_button.tsx +16 -2
- package/src/index.css +4 -3
- package/src/info_popover.tsx +4 -6
- package/src/kpi_card.tsx +19 -6
- package/src/kpi_strip.tsx +89 -0
- package/src/line_chart.tsx +61 -34
- package/src/link_button.tsx +50 -0
- package/src/metric.tsx +21 -12
- package/src/pagination.tsx +5 -9
- package/src/peek.tsx +68 -0
- package/src/picker.tsx +13 -1
- package/src/picker_menu.tsx +8 -16
- package/src/pie_chart.tsx +29 -8
- package/src/pill_button.tsx +10 -8
- package/src/popover.tsx +14 -4
- package/src/pressable_highlight.tsx +10 -1
- package/src/pressable_row.tsx +91 -0
- package/src/progress_bar.tsx +47 -17
- package/src/radio_picker.tsx +20 -9
- package/src/range_slider.tsx +185 -0
- package/src/remainder_meter.tsx +48 -0
- package/src/ring_gauge.tsx +5 -5
- package/src/scan_field.tsx +58 -0
- package/src/search_input.tsx +12 -0
- package/src/sort_header.tsx +102 -0
- package/src/stacked_progress_bar.tsx +51 -16
- package/src/status_grid.tsx +187 -0
- package/src/step_list.tsx +128 -0
- package/src/step_progress.tsx +145 -0
- package/src/stepper.tsx +9 -4
- package/src/table.tsx +168 -112
- package/src/text.tsx +15 -0
- package/src/text_utils.ts +10 -0
- package/src/timeline.tsx +90 -57
- package/src/trend_footer.tsx +2 -2
- package/src/alert_row.tsx +0 -81
- package/src/table.web.tsx +0 -235
- package/src/table_picker.tsx +0 -305
- package/src/table_types.ts +0 -47
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { colors, solid } from "./colors";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { TextInputField } from "./text_input_field";
|
|
5
|
+
|
|
6
|
+
export type ScanStatus = "idle" | "match" | "mismatch";
|
|
7
|
+
|
|
8
|
+
export interface ScanFieldProps {
|
|
9
|
+
value: string;
|
|
10
|
+
onChangeText: (text: string) => void;
|
|
11
|
+
placeholder?: string;
|
|
12
|
+
/** Verify state — drives the border colour, the leading glyph, and the inline
|
|
13
|
+
* message. Stays "idle" until the consumer compares the scan to the expected
|
|
14
|
+
* code and reports the result. */
|
|
15
|
+
status?: ScanStatus;
|
|
16
|
+
/** Inline confirmation shown when `status` is "match". */
|
|
17
|
+
matchHint?: string;
|
|
18
|
+
/** Inline correction shown when `status` is "mismatch". */
|
|
19
|
+
mismatchHint?: string;
|
|
20
|
+
/** Fired on Enter — a scan gun sends a return after the code, so this is the
|
|
21
|
+
* "scanned" signal (also a manual submit). */
|
|
22
|
+
onScan?: () => void;
|
|
23
|
+
accessibilityLabel?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The scan/verify input for operations work — scan (or type) a code to confirm
|
|
28
|
+
* you're at the right bin, on the right item, handling the right tote. Unlike a
|
|
29
|
+
* plain field it carries a VERIFY state: the border, the leading glyph, and an
|
|
30
|
+
* inline message go green on a match and red on a mismatch, giving the operator a
|
|
31
|
+
* go / no-go before they act. Reused across pick / pack / receive / count / ship.
|
|
32
|
+
*/
|
|
33
|
+
export function ScanField(props: ScanFieldProps) {
|
|
34
|
+
const { value, onChangeText, placeholder, status = "idle", matchHint, mismatchHint, onScan, accessibilityLabel } = props;
|
|
35
|
+
const borderColor = status === "match" ? solid("emerald") : status === "mismatch" ? solid("red") : colors.zinc[300];
|
|
36
|
+
return (
|
|
37
|
+
<View style={{ gap: 6 }}>
|
|
38
|
+
<TextInputField
|
|
39
|
+
value={value}
|
|
40
|
+
onChangeText={onChangeText}
|
|
41
|
+
onSubmitEditing={onScan}
|
|
42
|
+
placeholder={placeholder}
|
|
43
|
+
icon={status === "match" ? "circle-check" : "scan"}
|
|
44
|
+
accessibilityLabel={accessibilityLabel}
|
|
45
|
+
style={[styles.field, { borderColor }]}
|
|
46
|
+
/>
|
|
47
|
+
{status === "match" && matchHint ? <Text size="xs" color="success">{matchHint}</Text> : null}
|
|
48
|
+
{status === "mismatch" && mismatchHint ? <Text size="xs" color="danger">{mismatchHint}</Text> : null}
|
|
49
|
+
</View>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const styles = StyleSheet.create({
|
|
54
|
+
field: {
|
|
55
|
+
borderRadius: 10,
|
|
56
|
+
backgroundColor: colors.white,
|
|
57
|
+
},
|
|
58
|
+
});
|
package/src/search_input.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
View,
|
|
9
9
|
type ViewStyle,
|
|
10
10
|
} from "react-native";
|
|
11
|
+
import { colors } from "./colors";
|
|
11
12
|
import { TextInputField } from "./text_input_field";
|
|
12
13
|
|
|
13
14
|
type SearchInputProps = Omit<
|
|
@@ -55,7 +56,18 @@ export function SearchInput(props: SearchInputProps) {
|
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
const styles = StyleSheet.create({
|
|
59
|
+
// Search reads as a view-control by SHAPE (rounded pill + the leading
|
|
60
|
+
// search glyph). White fill pops against the zinc-50 canvas where most
|
|
61
|
+
// toolbars live; a zinc-300 border (one step up from the form-input
|
|
62
|
+
// hairline) keeps it defined on white cards too, where the fill alone
|
|
63
|
+
// would vanish. 40px (TextInputField default) — the system control height
|
|
64
|
+
// every band aligns to.
|
|
58
65
|
pill: {
|
|
59
66
|
borderRadius: 999,
|
|
67
|
+
backgroundColor: colors.white,
|
|
68
|
+
// A thin 1px resting border (TextInputField's default width) — emphasis on
|
|
69
|
+
// interaction comes from the input's own focus outline, so the pill stays
|
|
70
|
+
// quiet at rest. zinc-300 keeps it defined on white cards.
|
|
71
|
+
borderColor: colors.zinc[300],
|
|
60
72
|
},
|
|
61
73
|
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { StyleSheet, type ViewStyle } from "react-native";
|
|
2
|
+
import { Text } from "./text";
|
|
3
|
+
import { Icon } from "./icon";
|
|
4
|
+
import { colors } from "./colors";
|
|
5
|
+
import { PressableHighlight } from "./pressable_highlight";
|
|
6
|
+
|
|
7
|
+
export type SortDir = "asc" | "desc";
|
|
8
|
+
export interface SortState {
|
|
9
|
+
key: string;
|
|
10
|
+
dir: SortDir;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Cycle a SINGLE-column sort on press: none → asc → desc → none (the third
|
|
15
|
+
* toggle clears it). The parent holds one `SortState | null`.
|
|
16
|
+
*/
|
|
17
|
+
export function cycleSort(current: SortState | null, key: string): SortState | null {
|
|
18
|
+
if (current?.key !== key) return { key, dir: "asc" };
|
|
19
|
+
if (current.dir === "asc") return { key, dir: "desc" };
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Order a COPY of `items` by the active column. `getValue` maps (item, key) to a
|
|
25
|
+
* comparable (lowercase strings for case-insensitive order). Returns `items`
|
|
26
|
+
* unchanged when nothing is sorted.
|
|
27
|
+
*/
|
|
28
|
+
export function sortBy<T>(
|
|
29
|
+
items: T[],
|
|
30
|
+
sort: SortState | null,
|
|
31
|
+
getValue: (item: T, key: string) => string | number,
|
|
32
|
+
): T[] {
|
|
33
|
+
if (!sort) return items;
|
|
34
|
+
const dir = sort.dir === "asc" ? 1 : -1;
|
|
35
|
+
return [...items].sort((a, b) => {
|
|
36
|
+
const va = getValue(a, sort.key);
|
|
37
|
+
const vb = getValue(b, sort.key);
|
|
38
|
+
return va < vb ? -dir : va > vb ? dir : 0;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SortHeaderProps {
|
|
43
|
+
label: string;
|
|
44
|
+
sortKey: string;
|
|
45
|
+
sort: SortState | null;
|
|
46
|
+
onSort: (key: string) => void;
|
|
47
|
+
/** Right-align for numeric columns — the arrow then sits LEFT of the label. */
|
|
48
|
+
align?: "left" | "right";
|
|
49
|
+
style?: ViewStyle;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A sortable column header — the eyebrow label's pressable sibling. Press
|
|
54
|
+
* cycles a single-column sort (none → asc → desc → none) via `cycleSort`,
|
|
55
|
+
* showing a direction chevron when active; the parent orders rows with `sortBy`.
|
|
56
|
+
* One sort at a time. The hover wash bleeds (negative margin) so the label stays
|
|
57
|
+
* flush with the column content beneath it.
|
|
58
|
+
*/
|
|
59
|
+
export function SortHeader(props: SortHeaderProps) {
|
|
60
|
+
const { label, sortKey, sort, onSort, align = "left", style } = props;
|
|
61
|
+
const active = sort?.key === sortKey;
|
|
62
|
+
const arrow = active ? (sort.dir === "asc" ? "chevron-up" : "chevron-down") : undefined;
|
|
63
|
+
const dirText = active ? (sort.dir === "asc" ? ", ascending" : ", descending") : "";
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<PressableHighlight
|
|
67
|
+
accessibilityRole="button"
|
|
68
|
+
accessibilityLabel={`Sort by ${label}${dirText}`}
|
|
69
|
+
onPress={() => onSort(sortKey)}
|
|
70
|
+
style={[styles.header, align === "right" ? styles.right : null, style]}
|
|
71
|
+
>
|
|
72
|
+
{align === "right" && arrow ? <Icon name={arrow} size={12} color={colors.zinc[500]} /> : null}
|
|
73
|
+
<Text
|
|
74
|
+
size="xs"
|
|
75
|
+
color={active ? "default" : "muted"}
|
|
76
|
+
weight={active ? "medium" : "regular"}
|
|
77
|
+
transform="uppercase"
|
|
78
|
+
numberOfLines={1}
|
|
79
|
+
>
|
|
80
|
+
{label}
|
|
81
|
+
</Text>
|
|
82
|
+
{align === "left" && arrow ? <Icon name={arrow} size={12} color={colors.zinc[500]} /> : null}
|
|
83
|
+
</PressableHighlight>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const styles = StyleSheet.create({
|
|
88
|
+
// Negative margin so the wash bleeds while the label stays flush with the
|
|
89
|
+
// column content below (same idea as LinkButton).
|
|
90
|
+
header: {
|
|
91
|
+
flexDirection: "row",
|
|
92
|
+
alignItems: "center",
|
|
93
|
+
gap: 4,
|
|
94
|
+
paddingVertical: 4,
|
|
95
|
+
paddingHorizontal: 6,
|
|
96
|
+
marginHorizontal: -6,
|
|
97
|
+
borderRadius: 6,
|
|
98
|
+
},
|
|
99
|
+
right: {
|
|
100
|
+
justifyContent: "flex-end",
|
|
101
|
+
},
|
|
102
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { View, StyleSheet } from "react-native";
|
|
2
2
|
import { colors } from "./colors";
|
|
3
|
+
import { Text } from "./text";
|
|
3
4
|
|
|
4
5
|
interface Segment {
|
|
5
6
|
/** Identifier for the segment — used as a React key. */
|
|
@@ -16,6 +17,11 @@ interface StackedProgressBarProps {
|
|
|
16
17
|
* states render consistently (an empty array would otherwise look like
|
|
17
18
|
* "data loaded with no values"). */
|
|
18
19
|
total: number;
|
|
20
|
+
/** What this bar breaks down ("Phễu bán hàng") — the xs muted uppercase
|
|
21
|
+
* eyebrow above the bar. Omit inside a band that already names it. */
|
|
22
|
+
title?: string;
|
|
23
|
+
/** Context above-right of the bar ("97 cơ hội"). xs muted tabular. */
|
|
24
|
+
caption?: string;
|
|
19
25
|
/** Bar pixel height. Default 14 — the dashboard hero-progress size.
|
|
20
26
|
* Drop to 6-8 for inline status bars in tight rows; bump to 20-24
|
|
21
27
|
* when the bar IS the section's main visualization. */
|
|
@@ -30,30 +36,59 @@ interface StackedProgressBarProps {
|
|
|
30
36
|
* width, and a single dominant value renders as one long segment rather
|
|
31
37
|
* than a "broken" bar chart with five empty stages.
|
|
32
38
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
39
|
+
* Shares the labeled-meter anatomy with `ProgressBar`/`StepProgress`:
|
|
40
|
+
* optional title eyebrow left, caption right, bar below. Pair with
|
|
41
|
+
* `<LegendItem />` rows below to name the colored segments — without a
|
|
42
|
+
* legend, the colored bar is decorative.
|
|
36
43
|
*/
|
|
37
44
|
export function StackedProgressBar(props: StackedProgressBarProps) {
|
|
38
|
-
const { segments, total, height = 14, loading } = props;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
const { segments, total, title, caption, height = 14, loading } = props;
|
|
46
|
+
const bar =
|
|
47
|
+
loading || total === 0 ? (
|
|
48
|
+
<View style={[styles.bar, { height, backgroundColor: colors.zinc[100] }]} />
|
|
49
|
+
) : (
|
|
50
|
+
<View style={[styles.bar, styles.barFilled, { height }]}>
|
|
51
|
+
{segments
|
|
52
|
+
.filter((s) => s.value > 0)
|
|
53
|
+
.map((seg) => (
|
|
54
|
+
<View key={seg.key} style={{ flex: seg.value, backgroundColor: seg.color }} />
|
|
55
|
+
))}
|
|
56
|
+
</View>
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
if (!title && !caption) return bar;
|
|
42
60
|
return (
|
|
43
|
-
<View style={
|
|
44
|
-
{
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
<View style={styles.container}>
|
|
62
|
+
<View style={styles.header}>
|
|
63
|
+
{title ? (
|
|
64
|
+
<Text size="xs" color="muted" transform="uppercase">
|
|
65
|
+
{title}
|
|
66
|
+
</Text>
|
|
67
|
+
) : null}
|
|
68
|
+
<View style={styles.spacer} />
|
|
69
|
+
{caption ? (
|
|
70
|
+
<Text size="xs" color="muted" tabular>
|
|
71
|
+
{caption}
|
|
72
|
+
</Text>
|
|
73
|
+
) : null}
|
|
74
|
+
</View>
|
|
75
|
+
{bar}
|
|
52
76
|
</View>
|
|
53
77
|
);
|
|
54
78
|
}
|
|
55
79
|
|
|
56
80
|
const styles = StyleSheet.create({
|
|
81
|
+
container: {
|
|
82
|
+
gap: 6,
|
|
83
|
+
},
|
|
84
|
+
header: {
|
|
85
|
+
flexDirection: "row",
|
|
86
|
+
alignItems: "baseline",
|
|
87
|
+
gap: 12,
|
|
88
|
+
},
|
|
89
|
+
spacer: {
|
|
90
|
+
flex: 1,
|
|
91
|
+
},
|
|
57
92
|
bar: {
|
|
58
93
|
borderRadius: 999,
|
|
59
94
|
overflow: "hidden",
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Pressable, StyleSheet, View } from "react-native";
|
|
3
|
+
import { colors, solid, tint, type ColorName } from "./colors";
|
|
4
|
+
import { PressableHighlight } from "./pressable_highlight";
|
|
5
|
+
import { Text } from "./text";
|
|
6
|
+
|
|
7
|
+
export interface StatusGridState {
|
|
8
|
+
key: string;
|
|
9
|
+
label: string;
|
|
10
|
+
/** Palette family name — the cell fill and legend dot derive their shades
|
|
11
|
+
* from it (solid for the cell, a tint when dimmed). Pass the name, not a
|
|
12
|
+
* hex, so one state is one color everywhere. */
|
|
13
|
+
color: ColorName;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface StatusGridItem {
|
|
17
|
+
key: string;
|
|
18
|
+
label: string;
|
|
19
|
+
state: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface StatusGridProps {
|
|
23
|
+
items: StatusGridItem[];
|
|
24
|
+
states: StatusGridState[];
|
|
25
|
+
/** The drilled-in state (from `StatusLegend`). Cells in other states dim
|
|
26
|
+
* and stop being pressable — they're outside the current slice. */
|
|
27
|
+
selectedState?: string | null;
|
|
28
|
+
onPressItem?: (key: string) => void;
|
|
29
|
+
/** The open/highlighted item (e.g. the unit in the drawer). */
|
|
30
|
+
activeKey?: string | null;
|
|
31
|
+
/** Cell edge in px. Default 16 — readable at hundreds of cells. */
|
|
32
|
+
cellSize?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Live-state scan for hundreds of units — the wallboard pattern (machine
|
|
37
|
+
* park, fleet, gate bank, sensor field). Every unit is one color-coded
|
|
38
|
+
* cell; the job is scanning for red, not reading labels. Pressing a cell
|
|
39
|
+
* opens the unit; selecting a state in `StatusLegend` dims everything else.
|
|
40
|
+
* Render one grid per group (zone, site) and share a single legend +
|
|
41
|
+
* selection in the host — same lifted-state idiom as `Breakdown`.
|
|
42
|
+
*/
|
|
43
|
+
export function StatusGrid(props: StatusGridProps) {
|
|
44
|
+
const { items, states, selectedState = null, onPressItem, activeKey = null, cellSize = 16 } = props;
|
|
45
|
+
const stateOf = (key: string) => states.find((s) => s.key === key);
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<View style={styles.grid}>
|
|
49
|
+
{items.map((item) => {
|
|
50
|
+
const state = stateOf(item.state);
|
|
51
|
+
const dimmed = selectedState !== null && item.state !== selectedState;
|
|
52
|
+
const cellColor = state ? (dimmed ? tint(state.color, 0.2) : solid(state.color)) : colors.zinc[300];
|
|
53
|
+
return (
|
|
54
|
+
<Cell
|
|
55
|
+
key={item.key}
|
|
56
|
+
label={`${item.label} — ${state?.label ?? item.state}`}
|
|
57
|
+
color={cellColor}
|
|
58
|
+
size={cellSize}
|
|
59
|
+
active={activeKey === item.key}
|
|
60
|
+
onPress={onPressItem && !dimmed ? () => onPressItem(item.key) : undefined}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
</View>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface CellProps {
|
|
69
|
+
label: string;
|
|
70
|
+
color: string;
|
|
71
|
+
size: number;
|
|
72
|
+
active: boolean;
|
|
73
|
+
onPress?: () => void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function Cell(props: CellProps) {
|
|
77
|
+
const { label, color, size, active, onPress } = props;
|
|
78
|
+
const [hovered, setHovered] = useState(false);
|
|
79
|
+
const base = {
|
|
80
|
+
width: size,
|
|
81
|
+
height: size,
|
|
82
|
+
backgroundColor: color,
|
|
83
|
+
borderColor: active ? colors.zinc[900] : hovered && onPress ? "rgba(255,255,255,0.75)" : "transparent",
|
|
84
|
+
};
|
|
85
|
+
if (!onPress) return <View style={[styles.cell, base]} />;
|
|
86
|
+
return (
|
|
87
|
+
<Pressable
|
|
88
|
+
accessibilityRole="button"
|
|
89
|
+
accessibilityLabel={label}
|
|
90
|
+
onPress={onPress}
|
|
91
|
+
onHoverIn={() => setHovered(true)}
|
|
92
|
+
onHoverOut={() => setHovered(false)}
|
|
93
|
+
style={[styles.cell, base]}
|
|
94
|
+
/>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface StatusLegendProps {
|
|
99
|
+
/** ALL items the legend summarizes (across every grid it governs). */
|
|
100
|
+
items: StatusGridItem[];
|
|
101
|
+
states: StatusGridState[];
|
|
102
|
+
selectedKey?: string | null;
|
|
103
|
+
/** Press a state to drill into it (press again to clear). Omit for an
|
|
104
|
+
* informational legend. */
|
|
105
|
+
onSelect?: (key: string | null) => void;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The legend + counts for one or more `StatusGrid`s. Pressable states drill
|
|
110
|
+
* the grids (host holds the selection); counts are derived here so the
|
|
111
|
+
* legend can never disagree with the cells.
|
|
112
|
+
*/
|
|
113
|
+
export function StatusLegend(props: StatusLegendProps) {
|
|
114
|
+
const { items, states, selectedKey = null, onSelect } = props;
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<View style={styles.legend}>
|
|
118
|
+
{states.map((state) => {
|
|
119
|
+
const count = items.reduce((n, item) => n + (item.state === state.key ? 1 : 0), 0);
|
|
120
|
+
const selected = selectedKey === state.key;
|
|
121
|
+
const dimmed = selectedKey !== null && !selected;
|
|
122
|
+
const row = (
|
|
123
|
+
<>
|
|
124
|
+
<View style={[styles.swatch, { backgroundColor: dimmed ? tint(state.color, 0.3) : solid(state.color) }]} />
|
|
125
|
+
<Text size="xs" color={dimmed ? "muted" : "default"}>{state.label}</Text>
|
|
126
|
+
<Text size="xs" weight={selected ? "semibold" : "regular"} color={dimmed ? "muted" : "default"} tabular>
|
|
127
|
+
{count.toLocaleString("en-US")}
|
|
128
|
+
</Text>
|
|
129
|
+
</>
|
|
130
|
+
);
|
|
131
|
+
return onSelect ? (
|
|
132
|
+
<PressableHighlight
|
|
133
|
+
key={state.key}
|
|
134
|
+
accessibilityRole="button"
|
|
135
|
+
accessibilityState={{ selected }}
|
|
136
|
+
accessibilityLabel={`${state.label}: ${count}`}
|
|
137
|
+
onPress={() => onSelect(selected ? null : state.key)}
|
|
138
|
+
style={[styles.legendItem, styles.legendPressable, selected ? styles.legendSelected : null]}
|
|
139
|
+
>
|
|
140
|
+
{row}
|
|
141
|
+
</PressableHighlight>
|
|
142
|
+
) : (
|
|
143
|
+
<View key={state.key} style={styles.legendItem}>
|
|
144
|
+
{row}
|
|
145
|
+
</View>
|
|
146
|
+
);
|
|
147
|
+
})}
|
|
148
|
+
</View>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const styles = StyleSheet.create({
|
|
153
|
+
grid: {
|
|
154
|
+
flexDirection: "row",
|
|
155
|
+
flexWrap: "wrap",
|
|
156
|
+
gap: 3,
|
|
157
|
+
},
|
|
158
|
+
cell: {
|
|
159
|
+
borderRadius: 4,
|
|
160
|
+
borderWidth: 2,
|
|
161
|
+
},
|
|
162
|
+
legend: {
|
|
163
|
+
flexDirection: "row",
|
|
164
|
+
flexWrap: "wrap",
|
|
165
|
+
alignItems: "center",
|
|
166
|
+
columnGap: 8,
|
|
167
|
+
rowGap: 4,
|
|
168
|
+
},
|
|
169
|
+
legendItem: {
|
|
170
|
+
flexDirection: "row",
|
|
171
|
+
alignItems: "center",
|
|
172
|
+
gap: 6,
|
|
173
|
+
minHeight: 28,
|
|
174
|
+
},
|
|
175
|
+
legendPressable: {
|
|
176
|
+
borderRadius: 6,
|
|
177
|
+
paddingHorizontal: 8,
|
|
178
|
+
},
|
|
179
|
+
legendSelected: {
|
|
180
|
+
backgroundColor: colors.zinc[100],
|
|
181
|
+
},
|
|
182
|
+
swatch: {
|
|
183
|
+
width: 8,
|
|
184
|
+
height: 8,
|
|
185
|
+
borderRadius: 999,
|
|
186
|
+
},
|
|
187
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
});
|