@lotics/ui 44.7.1 → 44.9.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 +15 -3
- package/MIGRATION.md +110 -5
- package/docs/ai_patterns.md +20 -0
- package/docs/catalog.md +100 -8
- package/docs/composition.md +100 -6
- package/docs/data_entry.md +60 -6
- package/docs/reviewing.md +44 -3
- package/examples/tpl_item_list.tsx +8 -4
- package/package.json +4 -1
- package/src/accordion.tsx +9 -1
- package/src/agent_run_pane.tsx +7 -5
- package/src/card.tsx +7 -1
- package/src/card_select_item.tsx +15 -2
- package/src/checklist.tsx +23 -3
- package/src/control_surface.ts +18 -4
- package/src/data_grid.tsx +9 -1
- package/src/date_range_filter_field.tsx +9 -2
- package/src/date_stamp.tsx +8 -2
- package/src/detail_row.tsx +17 -12
- package/src/empty_state.tsx +9 -2
- package/src/error_state.tsx +9 -2
- package/src/field_annotations.tsx +25 -2
- package/src/finding.tsx +19 -10
- package/src/format_money.ts +16 -2
- package/src/inline_edit.tsx +29 -9
- package/src/inline_select.tsx +8 -1
- package/src/inline_text_input.tsx +6 -7
- package/src/ledger.tsx +5 -2
- package/src/locale.tsx +35 -1
- package/src/number_input.tsx +8 -1
- package/src/reference_field.tsx +9 -2
- package/src/related_record_row.tsx +80 -0
- package/src/running_ledger.tsx +196 -0
- package/src/section_heading.tsx +17 -5
- package/src/select.tsx +16 -16
- package/src/sequence.tsx +41 -25
- package/src/step_progress.tsx +6 -4
- package/src/stepper.tsx +26 -2
- package/src/table_fit.ts +18 -8
- package/src/tabs.tsx +19 -0
- package/src/text_input_field.tsx +35 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Eyebrow } from "./eyebrow";
|
|
5
|
+
import { Icon } from "./icon";
|
|
6
|
+
import { colors } from "./colors";
|
|
7
|
+
import { PressableHighlight } from "./pressable_highlight";
|
|
8
|
+
import { useLoticsLocale } from "./locale";
|
|
9
|
+
|
|
10
|
+
export interface RelatedRecordRowProps {
|
|
11
|
+
/** What KIND of record this is — the table/app it lives in ("Kế hoạch sản
|
|
12
|
+
* xuất", "Lệnh sản xuất"). Rendered as an `Eyebrow` naming the value below
|
|
13
|
+
* it, the same job an `Eyebrow` does in a cell or a metric caption. */
|
|
14
|
+
kind: string;
|
|
15
|
+
/** The record's own human code ("KH-2026-0001"). */
|
|
16
|
+
code: string;
|
|
17
|
+
/** The record's status, rendered as-is beside the code — pass a `Badge`.
|
|
18
|
+
* Omit for a reference with no lifecycle state worth carrying here. */
|
|
19
|
+
status?: ReactNode;
|
|
20
|
+
/**
|
|
21
|
+
* Opens the record. THE HOST performs the navigation — typically
|
|
22
|
+
* `openExternal(url)` from `@lotics/app-sdk` into a sibling app's own
|
|
23
|
+
* deployed URL, since a suite of apps like this one is several SEPARATELY
|
|
24
|
+
* DEPLOYED bundles with no shared router; only the calling app knows the
|
|
25
|
+
* target app's subdomain and its own route shape for a record page. Omit
|
|
26
|
+
* for a report-only reference with nowhere to send the reader (a linked
|
|
27
|
+
* record whose app cannot be reached, or a same-app case better served by
|
|
28
|
+
* an in-app navigate instead of this component at all).
|
|
29
|
+
*/
|
|
30
|
+
onOpen?: () => void;
|
|
31
|
+
/** Accessible name for the row when `onOpen` is set. Defaults to
|
|
32
|
+
* "<kind> <code>" — override when that reads ambiguously (the same kind
|
|
33
|
+
* repeated down a list of several). */
|
|
34
|
+
accessibilityLabel?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A pointer to ONE record living in a DIFFERENT app's table — never a route
|
|
39
|
+
* inside this bundle, because a document-desk suite deploys one app per
|
|
40
|
+
* department with no in-app path to a sibling's record. Composes as a
|
|
41
|
+
* `DetailRow`'s value for a single relationship ("Kế hoạch sản xuất" →
|
|
42
|
+
* one linked plan) or repeats plainly in a list for several (an order's
|
|
43
|
+
* three downstream production plans) — the surrounding label/heading is the
|
|
44
|
+
* caller's choice, this renders one reference.
|
|
45
|
+
*
|
|
46
|
+
* NOT `ReferenceField`: that component edits an in-workspace link (peek,
|
|
47
|
+
* Change, Clear, an editable facts draft) for a reference the CURRENT app's
|
|
48
|
+
* own table holds. This one only ever reports — the target record's fields
|
|
49
|
+
* live in a table another app's bundle renders, so there is no draft to
|
|
50
|
+
* open and no picker to reassign from here.
|
|
51
|
+
*/
|
|
52
|
+
export function RelatedRecordRow(props: RelatedRecordRowProps) {
|
|
53
|
+
const { kind, code, status, onOpen, accessibilityLabel } = props;
|
|
54
|
+
const locale = useLoticsLocale();
|
|
55
|
+
const content = (
|
|
56
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
57
|
+
<View style={{ flex: 1, minWidth: 0, gap: 2 }}>
|
|
58
|
+
<Eyebrow numberOfLines={1}>{kind}</Eyebrow>
|
|
59
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
60
|
+
<Text size="sm" numberOfLines={1}>
|
|
61
|
+
{code}
|
|
62
|
+
</Text>
|
|
63
|
+
{status}
|
|
64
|
+
</View>
|
|
65
|
+
</View>
|
|
66
|
+
{onOpen ? <Icon name="external-link" size={14} color={colors.zinc[400]} /> : null}
|
|
67
|
+
</View>
|
|
68
|
+
);
|
|
69
|
+
if (!onOpen) return content;
|
|
70
|
+
return (
|
|
71
|
+
<PressableHighlight
|
|
72
|
+
focusRing
|
|
73
|
+
onPress={onOpen}
|
|
74
|
+
accessibilityLabel={accessibilityLabel ?? locale.relatedRecordRow.open(kind, code)}
|
|
75
|
+
style={{ paddingVertical: 4 }}
|
|
76
|
+
>
|
|
77
|
+
{content}
|
|
78
|
+
</PressableHighlight>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { Divider } from "./divider";
|
|
3
|
+
import { Link } from "./link";
|
|
4
|
+
import { signed } from "./ledger";
|
|
5
|
+
import { Text } from "./text";
|
|
6
|
+
import { formatDate } from "./format_date";
|
|
7
|
+
import { useLoticsLocale } from "./locale";
|
|
8
|
+
|
|
9
|
+
export interface RunningLedgerOpening {
|
|
10
|
+
/** What the anchor IS ("Tồn đầu kỳ", "Số dư đầu kỳ"). */
|
|
11
|
+
label: string;
|
|
12
|
+
/** ISO date, when the anchor has one — a stock count date, a statement's
|
|
13
|
+
* period start. Omit for a balance with no dated origin. */
|
|
14
|
+
date?: string;
|
|
15
|
+
/** The balance BEFORE the first row. */
|
|
16
|
+
balance: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RunningLedgerRow {
|
|
20
|
+
id: string;
|
|
21
|
+
/** ISO date. Rows render in the order given — this component does not sort
|
|
22
|
+
* them, so the caller's own query order IS the chronology on screen. */
|
|
23
|
+
date: string;
|
|
24
|
+
/** What happened ("Nhập kho theo PO-2026-0001", "Thu tiền hóa đơn HD-014"). */
|
|
25
|
+
label: string;
|
|
26
|
+
/** The movement. Signed: positive is IN/debit, negative is OUT/credit — ONE
|
|
27
|
+
* field rather than paired in/out columns, so the same row shape serves a
|
|
28
|
+
* stock quantity and a money statement without either caller picking the
|
|
29
|
+
* other's vocabulary. */
|
|
30
|
+
amount: number;
|
|
31
|
+
/** A trailing reference link (the source PO, the invoice this receipt was
|
|
32
|
+
* matched against). */
|
|
33
|
+
reference?: { label: string; onPress: () => void };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RunningLedgerProps {
|
|
37
|
+
opening: RunningLedgerOpening;
|
|
38
|
+
/** Chronological rows — oldest first. Each row's running balance is this
|
|
39
|
+
* component's own DERIVED value (`opening.balance` plus every `amount` up
|
|
40
|
+
* to and including that row), never a value the caller supplies: a running
|
|
41
|
+
* balance is exactly the row that MUST equal what it accumulates, and a
|
|
42
|
+
* caller-supplied figure is the one shape of drift no reader can catch by
|
|
43
|
+
* looking. */
|
|
44
|
+
rows: RunningLedgerRow[];
|
|
45
|
+
/** Formats every figure on the balance/amount column — `formatMoney` for a
|
|
46
|
+
* statement, or a quantity formatter (`(n) => \`${n.toLocaleString()} kg\`
|
|
47
|
+
* `) for a stock ledger. ONE formatter for the whole ledger: every row here
|
|
48
|
+
* shares one unit, which is what lets the column state it once instead of
|
|
49
|
+
* per row. */
|
|
50
|
+
format: (n: number) => string;
|
|
51
|
+
/** A row whose running balance lands AT OR BELOW this reads in danger tone
|
|
52
|
+
* — a stock-out, an account gone negative. Omit for a ledger with no
|
|
53
|
+
* critical floor (most money statements; a stock ledger typically passes
|
|
54
|
+
* 0). */
|
|
55
|
+
criticalAtOrBelow?: number;
|
|
56
|
+
/**
|
|
57
|
+
* The closing line's label. Default resolves from the locale pack
|
|
58
|
+
* ("Current balance"), which fits a money statement but not every domain
|
|
59
|
+
* this serves both of on day one — a stock kardex wants "Tồn kho hiện
|
|
60
|
+
* tại", specific vocabulary the locale pack cannot own for every future
|
|
61
|
+
* caller. Override per instance the same way `Sources.label` does.
|
|
62
|
+
*/
|
|
63
|
+
closingLabel?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A chronological, self-totalling ledger — a stock kardex, a statement of
|
|
68
|
+
* account — where every row carries the balance AS OF that row. NOT `Ledger`:
|
|
69
|
+
* that component closes a fixed set of charge/receipt GROUPS into one total
|
|
70
|
+
* and has no chronological concept at all; reaching for it here would mean
|
|
71
|
+
* inventing a "group" for what is actually a time series and losing the
|
|
72
|
+
* running balance a kardex or a statement is FOR. Composes a `Divider`-closed
|
|
73
|
+
* current-balance line the same way `Ledger`'s own `LedgerTotal` does, so a
|
|
74
|
+
* page mixing this with a `Ledger` elsewhere still reads as one design
|
|
75
|
+
* language.
|
|
76
|
+
*
|
|
77
|
+
* The balance column is DERIVED — see `rows`' own doc. A platform whose
|
|
78
|
+
* rollup engine cannot produce an ordered cumulative aggregate (no per-row
|
|
79
|
+
* stored balance exists for a stock movement today) is exactly the case this
|
|
80
|
+
* is for: opening balance + a plain list of signed deltas needs no schema
|
|
81
|
+
* change on either side to render correctly.
|
|
82
|
+
*/
|
|
83
|
+
export function RunningLedger(props: RunningLedgerProps) {
|
|
84
|
+
const { opening, rows, format, criticalAtOrBelow, closingLabel } = props;
|
|
85
|
+
const locale = useLoticsLocale();
|
|
86
|
+
let running = opening.balance;
|
|
87
|
+
const computed = rows.map((r) => {
|
|
88
|
+
running += r.amount;
|
|
89
|
+
return { row: r, balance: running };
|
|
90
|
+
});
|
|
91
|
+
const closing = computed.length > 0 ? computed[computed.length - 1].balance : opening.balance;
|
|
92
|
+
const closingCritical = criticalAtOrBelow != null && closing <= criticalAtOrBelow;
|
|
93
|
+
// Evaluated independently of `closingCritical` (and of whether there are
|
|
94
|
+
// any rows at all) — an opening balance that already sits at/below the
|
|
95
|
+
// floor must read as critical on ITS OWN row; the closing line below only
|
|
96
|
+
// renders when there is at least one row, so it can never be the sole
|
|
97
|
+
// carrier of this signal.
|
|
98
|
+
const openingCritical = criticalAtOrBelow != null && opening.balance <= criticalAtOrBelow;
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<View style={styles.ledger}>
|
|
102
|
+
<View style={styles.openingRow}>
|
|
103
|
+
<View style={styles.grow}>
|
|
104
|
+
<Text size="sm" weight="medium">
|
|
105
|
+
{opening.label}
|
|
106
|
+
</Text>
|
|
107
|
+
{opening.date ? (
|
|
108
|
+
<Text size="xs" color="muted">
|
|
109
|
+
{formatDate(opening.date)}
|
|
110
|
+
</Text>
|
|
111
|
+
) : null}
|
|
112
|
+
</View>
|
|
113
|
+
<Text size="sm" weight="medium" tabular color={openingCritical ? "danger" : undefined}>
|
|
114
|
+
{signed(format, opening.balance)}
|
|
115
|
+
</Text>
|
|
116
|
+
</View>
|
|
117
|
+
{computed.map(({ row, balance }) => {
|
|
118
|
+
const critical = criticalAtOrBelow != null && balance <= criticalAtOrBelow;
|
|
119
|
+
return (
|
|
120
|
+
<View key={row.id} style={styles.row}>
|
|
121
|
+
<Text size="xs" color="muted" numberOfLines={1} style={styles.date}>
|
|
122
|
+
{formatDate(row.date)}
|
|
123
|
+
</Text>
|
|
124
|
+
<Text size="sm" numberOfLines={1} style={styles.shrink}>
|
|
125
|
+
{row.label}
|
|
126
|
+
</Text>
|
|
127
|
+
{row.reference ? (
|
|
128
|
+
<Link size="xs" onPress={row.reference.onPress} accessibilityLabel={row.reference.label}>
|
|
129
|
+
{row.reference.label}
|
|
130
|
+
</Link>
|
|
131
|
+
) : null}
|
|
132
|
+
<View style={styles.grow} />
|
|
133
|
+
{/* Neutral, never auto-toned by sign: an OUT movement is routine
|
|
134
|
+
business (a stock issue to a filled order, a payment applied)
|
|
135
|
+
as often as an IN one, and colouring every negative red would
|
|
136
|
+
claim a meaning the number alone does not carry — the same
|
|
137
|
+
restraint `LedgerRow`'s own `tone` leaves to its caller. The
|
|
138
|
+
one signal this component DOES own is `criticalAtOrBelow`,
|
|
139
|
+
which is a real domain rule the caller opted into, not a
|
|
140
|
+
guess from the arithmetic sign. */}
|
|
141
|
+
<Text size="sm" tabular style={styles.amount}>
|
|
142
|
+
{signed(format, row.amount)}
|
|
143
|
+
</Text>
|
|
144
|
+
<Text size="sm" weight="medium" tabular color={critical ? "danger" : undefined} style={styles.balance}>
|
|
145
|
+
{signed(format, balance)}
|
|
146
|
+
</Text>
|
|
147
|
+
</View>
|
|
148
|
+
);
|
|
149
|
+
})}
|
|
150
|
+
{computed.length > 0 ? (
|
|
151
|
+
<View style={styles.total}>
|
|
152
|
+
<Divider />
|
|
153
|
+
<View style={styles.row}>
|
|
154
|
+
<Text size="md" weight="semibold" style={styles.grow}>
|
|
155
|
+
{closingLabel ?? locale.runningLedger.currentBalance}
|
|
156
|
+
</Text>
|
|
157
|
+
<Text size="md" weight="semibold" tabular color={closingCritical ? "danger" : undefined}>
|
|
158
|
+
{signed(format, closing)}
|
|
159
|
+
</Text>
|
|
160
|
+
</View>
|
|
161
|
+
</View>
|
|
162
|
+
) : null}
|
|
163
|
+
</View>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const styles = StyleSheet.create({
|
|
168
|
+
// Mirrors `Ledger`'s own outdent + row rhythm so the two read as one design
|
|
169
|
+
// language wherever a page uses both.
|
|
170
|
+
ledger: { gap: 0, marginHorizontal: -8 },
|
|
171
|
+
row: {
|
|
172
|
+
flexDirection: "row",
|
|
173
|
+
alignItems: "center",
|
|
174
|
+
gap: 10,
|
|
175
|
+
minHeight: 28,
|
|
176
|
+
paddingHorizontal: 8,
|
|
177
|
+
},
|
|
178
|
+
openingRow: {
|
|
179
|
+
flexDirection: "row",
|
|
180
|
+
alignItems: "baseline",
|
|
181
|
+
gap: 10,
|
|
182
|
+
minHeight: 28,
|
|
183
|
+
paddingHorizontal: 8,
|
|
184
|
+
marginBottom: 4,
|
|
185
|
+
},
|
|
186
|
+
grow: { flexGrow: 1, flexShrink: 1 },
|
|
187
|
+
shrink: { flexShrink: 1 },
|
|
188
|
+
date: { width: 72 },
|
|
189
|
+
// Fixed widths so the amount and balance columns each keep ONE right edge
|
|
190
|
+
// down the whole run, whatever an individual figure's digit count — the
|
|
191
|
+
// same reasoning `Ledger`'s single money column applies to two columns
|
|
192
|
+
// instead of one.
|
|
193
|
+
amount: { minWidth: 90, textAlign: "right" },
|
|
194
|
+
balance: { minWidth: 100, textAlign: "right" },
|
|
195
|
+
total: { gap: 6, marginTop: 6 },
|
|
196
|
+
});
|
package/src/section_heading.tsx
CHANGED
|
@@ -77,9 +77,21 @@ export interface SubsectionHeadingProps {
|
|
|
77
77
|
style?: StyleProp<ViewStyle>;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* The narrowest a heading's TITLE column may be before its row wraps and the
|
|
82
|
+
* trailing slot (an ADD verb, a meta, a badge) drops to the next line.
|
|
83
|
+
*
|
|
84
|
+
* Needed because RN-Web resolves `flex: 1` to `flex-basis: 0%` with
|
|
85
|
+
* `min-width: 0`: with no floor, a title beside a button that holds its
|
|
86
|
+
* intrinsic width is the half that gives way, so the HEADING breaks — one word
|
|
87
|
+
* per line — while the verb beside it sits untouched. That is backwards. The
|
|
88
|
+
* title names the section; the verb is the thing that can move.
|
|
89
|
+
*/
|
|
90
|
+
const HEADING_TITLE_MIN_WIDTH = 200;
|
|
91
|
+
|
|
80
92
|
export function SubsectionHeading(props: SubsectionHeadingProps) {
|
|
81
93
|
return (
|
|
82
|
-
<View style={[{ flexDirection: "row", alignItems: "center", gap: 10 }, props.style]}>
|
|
94
|
+
<View style={[{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 10 }, props.style]}>
|
|
83
95
|
{props.children}
|
|
84
96
|
</View>
|
|
85
97
|
);
|
|
@@ -116,7 +128,7 @@ export function SubsectionHeadingTitle(props: SubsectionHeadingTitleProps) {
|
|
|
116
128
|
// wrapper carries the `flex: 1` that lets a heading row push meta to its right
|
|
117
129
|
// edge. Without the wrapper the description would land on the title's LINE.
|
|
118
130
|
return (
|
|
119
|
-
<View style={{ flex: 1, gap: 2 }}>
|
|
131
|
+
<View style={{ flex: 1, minWidth: HEADING_TITLE_MIN_WIDTH, gap: 2 }}>
|
|
120
132
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
121
133
|
<Text level={level} size="lg" weight="semibold">
|
|
122
134
|
{children}
|
|
@@ -173,7 +185,7 @@ export function DialogSectionHeadingTitle(props: DialogSectionHeadingTitleProps)
|
|
|
173
185
|
const { children, description, icon, level = 4, info } = props;
|
|
174
186
|
const words = useLoticsLocale();
|
|
175
187
|
return (
|
|
176
|
-
<View style={{ flex: 1, gap: 2 }}>
|
|
188
|
+
<View style={{ flex: 1, minWidth: HEADING_TITLE_MIN_WIDTH, gap: 2 }}>
|
|
177
189
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
178
190
|
{icon ? <Icon name={icon} size={16} /> : null}
|
|
179
191
|
<Text level={level} size="md" weight="semibold">
|
|
@@ -197,7 +209,7 @@ export interface SectionHeadingProps {
|
|
|
197
209
|
|
|
198
210
|
export function SectionHeading(props: SectionHeadingProps) {
|
|
199
211
|
return (
|
|
200
|
-
<View style={[{ flexDirection: "row", alignItems: "center", gap: 12 }, props.style]}>
|
|
212
|
+
<View style={[{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 12 }, props.style]}>
|
|
201
213
|
{props.children}
|
|
202
214
|
</View>
|
|
203
215
|
);
|
|
@@ -229,7 +241,7 @@ export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
|
|
|
229
241
|
// render `info` appears or disappears.
|
|
230
242
|
const words = useLoticsLocale();
|
|
231
243
|
return (
|
|
232
|
-
<View style={{ flex: 1, gap: 2 }}>
|
|
244
|
+
<View style={{ flex: 1, minWidth: HEADING_TITLE_MIN_WIDTH, gap: 2 }}>
|
|
233
245
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
234
246
|
{icon ? <Icon name={icon} size={20} /> : null}
|
|
235
247
|
<Text level={level} weight={weight} size="xl">
|
package/src/select.tsx
CHANGED
|
@@ -8,8 +8,8 @@ import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
|
8
8
|
import { OptionList } from "./option_list";
|
|
9
9
|
import type { PickerOption, PickerValue, PickerOnValueChange, PickerOnClose } from "./picker";
|
|
10
10
|
|
|
11
|
-
export interface SelectProps<T extends string = string, MULTI extends boolean = false> {
|
|
12
|
-
options?: (PickerOption<T> | undefined | false)[];
|
|
11
|
+
export interface SelectProps<T extends string = string, MULTI extends boolean = false, D = unknown> {
|
|
12
|
+
options?: (PickerOption<T, D> | undefined | false)[];
|
|
13
13
|
placeholder?: string;
|
|
14
14
|
/** Accessible name for the control. Required in spirit whenever there's no
|
|
15
15
|
* visible label beside it — the selected text describes the value, not the control. */
|
|
@@ -17,15 +17,15 @@ export interface SelectProps<T extends string = string, MULTI extends boolean =
|
|
|
17
17
|
/** How each OPTION renders in the menu (a colour-dot badge, a member chip).
|
|
18
18
|
* Also the default for the trigger's selection display when `renderSelected`
|
|
19
19
|
* is omitted. */
|
|
20
|
-
renderOptionContent?: (option: PickerOption<T>) => ReactNode;
|
|
20
|
+
renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
|
|
21
21
|
/** How each SELECTED item renders in the anchor — a render FUNCTION, not a preset.
|
|
22
22
|
* Use `controls.remove` to detach it in place: render `<Chip onDismiss={remove}>`
|
|
23
23
|
* for a removable chip box, an `OptionBadge`, or any custom display. Omit it for
|
|
24
24
|
* a comma summary (single: the one value, `remove` clears it). The single seam
|
|
25
25
|
* for "how the selection looks" — there's no `display` mode. */
|
|
26
|
-
renderSelected?: (item: PickerOption<T>, controls: { remove: () => void }) => ReactNode;
|
|
26
|
+
renderSelected?: (item: PickerOption<T, D>, controls: { remove: () => void }) => ReactNode;
|
|
27
27
|
/** A single-line subtitle under each option's label in the menu. */
|
|
28
|
-
getOptionDescription?: (option: PickerOption<T>) => string | undefined;
|
|
28
|
+
getOptionDescription?: (option: PickerOption<T, D>) => string | undefined;
|
|
29
29
|
/** Show a search field in the menu to filter the options. */
|
|
30
30
|
searchable?: boolean;
|
|
31
31
|
/** Offer a "create" row when the query matches no option — picking it adds the
|
|
@@ -59,7 +59,7 @@ export interface SelectProps<T extends string = string, MULTI extends boolean =
|
|
|
59
59
|
* select use the native `Picker`; to SEARCH a large/remote set with the input AS
|
|
60
60
|
* the control (type-in-place, free text reflected in the field) use `Combobox`.
|
|
61
61
|
*/
|
|
62
|
-
export function Select<T extends string, MULTI extends boolean = false>(props: SelectProps<T, MULTI>) {
|
|
62
|
+
export function Select<T extends string, MULTI extends boolean = false, D = unknown>(props: SelectProps<T, MULTI, D>) {
|
|
63
63
|
const {
|
|
64
64
|
testID,
|
|
65
65
|
options = [],
|
|
@@ -139,7 +139,7 @@ export function Select<T extends string, MULTI extends boolean = false>(props: S
|
|
|
139
139
|
field/footer OUTSIDE it. The default body ScrollView would be a redundant
|
|
140
140
|
second scroll that also clips the autofocused search field's outset ring. */}
|
|
141
141
|
<PopoverContent disableBodyScroll>
|
|
142
|
-
<OptionList<T, MULTI>
|
|
142
|
+
<OptionList<T, MULTI, D>
|
|
143
143
|
search={{ mode: searchable || allowCustom ? "internal" : "none" }}
|
|
144
144
|
options={options}
|
|
145
145
|
multi={multi}
|
|
@@ -163,18 +163,18 @@ export function Select<T extends string, MULTI extends boolean = false>(props: S
|
|
|
163
163
|
);
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
function useSelectedItems<T extends string>(
|
|
166
|
+
function useSelectedItems<T extends string, D = unknown>(
|
|
167
167
|
multi: boolean,
|
|
168
168
|
value: T | T[] | undefined | null,
|
|
169
|
-
options: (PickerOption<T> | undefined | false)[],
|
|
170
|
-
): PickerOption<T>[] {
|
|
169
|
+
options: (PickerOption<T, D> | undefined | false)[],
|
|
170
|
+
): PickerOption<T, D>[] {
|
|
171
171
|
return useMemo(() => {
|
|
172
172
|
const optionsMap = new Map(
|
|
173
|
-
options.filter((opt): opt is PickerOption<T> => !!opt).map((opt) => [opt.value, opt]),
|
|
173
|
+
options.filter((opt): opt is PickerOption<T, D> => !!opt).map((opt) => [opt.value, opt]),
|
|
174
174
|
);
|
|
175
175
|
// A value not in `options` (e.g. an `allowCustom` creation) still shows —
|
|
176
176
|
// fall back to a {value, label} so it renders.
|
|
177
|
-
const resolve = (val: T): PickerOption<T> => optionsMap.get(val) ?? { value: val, label: val };
|
|
177
|
+
const resolve = (val: T): PickerOption<T, D> => optionsMap.get(val) ?? { value: val, label: val };
|
|
178
178
|
if (multi) {
|
|
179
179
|
const multiValue = Array.isArray(value) ? value : [];
|
|
180
180
|
return multiValue.map(resolve);
|
|
@@ -187,7 +187,7 @@ function useSelectedItems<T extends string>(
|
|
|
187
187
|
}, [multi, value, options]);
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
function SelectTrigger<T extends string>({
|
|
190
|
+
function SelectTrigger<T extends string, D = unknown>({
|
|
191
191
|
ref,
|
|
192
192
|
testID,
|
|
193
193
|
open,
|
|
@@ -206,10 +206,10 @@ function SelectTrigger<T extends string>({
|
|
|
206
206
|
open: boolean;
|
|
207
207
|
style?: StyleProp<ViewStyle>;
|
|
208
208
|
onPress: () => void;
|
|
209
|
-
renderOptionContent?: (option: PickerOption<T>) => ReactNode;
|
|
210
|
-
renderSelected?: (item: PickerOption<T>, controls: { remove: () => void }) => ReactNode;
|
|
209
|
+
renderOptionContent?: (option: PickerOption<T, D>) => ReactNode;
|
|
210
|
+
renderSelected?: (item: PickerOption<T, D>, controls: { remove: () => void }) => ReactNode;
|
|
211
211
|
onRemove?: (value: T) => void;
|
|
212
|
-
selectedItems: PickerOption<T>[];
|
|
212
|
+
selectedItems: PickerOption<T, D>[];
|
|
213
213
|
placeholder?: string;
|
|
214
214
|
accessibilityLabel?: string;
|
|
215
215
|
disabled?: boolean;
|
package/src/sequence.tsx
CHANGED
|
@@ -77,8 +77,11 @@ export interface SequenceItemProps {
|
|
|
77
77
|
role?: string;
|
|
78
78
|
/** The item's editors / content. */
|
|
79
79
|
children: ReactNode;
|
|
80
|
-
/** Swap with the item above.
|
|
81
|
-
*
|
|
80
|
+
/** Swap with the item above. On an item that is editable at all, omitting
|
|
81
|
+
* this (or leaving it undefined on the first item) renders the control
|
|
82
|
+
* DISABLED rather than absent, so the column never changes width between
|
|
83
|
+
* items. Omit all three and the item is not editable: the control column
|
|
84
|
+
* goes away entirely — see the note on the component. */
|
|
82
85
|
onMoveUp?: () => void;
|
|
83
86
|
onMoveDown?: () => void;
|
|
84
87
|
onRemove?: () => void;
|
|
@@ -98,6 +101,13 @@ export interface SequenceItemProps {
|
|
|
98
101
|
* phone, and a list of three-to-six positions does not need the expressiveness.
|
|
99
102
|
* They render even where they cannot act (first item, last item) so the row's
|
|
100
103
|
* right edge never shifts between items.
|
|
104
|
+
*
|
|
105
|
+
* That steadiness is between items that CAN be edited. An item passing NO
|
|
106
|
+
* handler at all is not an edge case of editing, it is a read-only row, and it
|
|
107
|
+
* renders no control column: three permanently dead buttons on every row of a
|
|
108
|
+
* sequence nobody can reorder are dead affordances, which the kit's own
|
|
109
|
+
* reviewing gates count as a defect. A read-only `Sequence` is a legitimate and
|
|
110
|
+
* common shape — an ordered list of legs already run, a route as recorded.
|
|
101
111
|
*/
|
|
102
112
|
export function SequenceItem(props: SequenceItemProps) {
|
|
103
113
|
const { role, children, onMoveUp, onMoveDown, onRemove, accessibilityName, roleWidth = 74 } = props;
|
|
@@ -106,6 +116,10 @@ export function SequenceItem(props: SequenceItemProps) {
|
|
|
106
116
|
const first = pos == null || pos.index === 0;
|
|
107
117
|
const last = pos == null || pos.index === pos.count - 1;
|
|
108
118
|
const named = (verb: string) => (accessibilityName ? `${verb} ${accessibilityName}` : verb);
|
|
119
|
+
// Any ONE handler makes the item editable and brings the whole cluster, so a
|
|
120
|
+
// row that can be removed but not reordered still holds the column steady
|
|
121
|
+
// against its neighbours. Only "no handler at all" drops it.
|
|
122
|
+
const editable = onMoveUp != null || onMoveDown != null || onRemove != null;
|
|
109
123
|
return (
|
|
110
124
|
<View style={styles.item}>
|
|
111
125
|
{/* THE RAIL — a leading segment, the dot on the control line, then a
|
|
@@ -126,29 +140,31 @@ export function SequenceItem(props: SequenceItemProps) {
|
|
|
126
140
|
</View>
|
|
127
141
|
) : null}
|
|
128
142
|
<View style={styles.content}>{children}</View>
|
|
129
|
-
|
|
130
|
-
<
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
143
|
+
{editable ? (
|
|
144
|
+
<View style={styles.controls}>
|
|
145
|
+
<IconButton
|
|
146
|
+
icon="chevron-up"
|
|
147
|
+
tooltip={labels.moveUp}
|
|
148
|
+
accessibilityLabel={named(labels.moveUp)}
|
|
149
|
+
disabled={first || onMoveUp == null}
|
|
150
|
+
onPress={() => onMoveUp?.()}
|
|
151
|
+
/>
|
|
152
|
+
<IconButton
|
|
153
|
+
icon="chevron-down"
|
|
154
|
+
tooltip={labels.moveDown}
|
|
155
|
+
accessibilityLabel={named(labels.moveDown)}
|
|
156
|
+
disabled={last || onMoveDown == null}
|
|
157
|
+
onPress={() => onMoveDown?.()}
|
|
158
|
+
/>
|
|
159
|
+
<IconButton
|
|
160
|
+
icon="x"
|
|
161
|
+
tooltip={labels.remove}
|
|
162
|
+
accessibilityLabel={named(labels.remove)}
|
|
163
|
+
disabled={onRemove == null}
|
|
164
|
+
onPress={() => onRemove?.()}
|
|
165
|
+
/>
|
|
166
|
+
</View>
|
|
167
|
+
) : null}
|
|
152
168
|
</View>
|
|
153
169
|
</View>
|
|
154
170
|
);
|
package/src/step_progress.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import { colors } from "./colors";
|
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { Eyebrow } from "./eyebrow";
|
|
5
5
|
import { useTooltip } from "./tooltip";
|
|
6
|
+
import { useLoticsLocale } from "./locale";
|
|
6
7
|
|
|
7
8
|
export interface StepProgressProps {
|
|
8
9
|
/** The stages: pass the NAMES (enables the built-in "In (4/7)" caption
|
|
@@ -50,6 +51,7 @@ export function StepProgress(props: StepProgressProps) {
|
|
|
50
51
|
// much is done", the same claim the one filled button makes. Still a prop, so a
|
|
51
52
|
// caller that means a specific hue (a status, a series) passes one.
|
|
52
53
|
const { steps, current, color = colors.primary, title, label, captionTone = "default", captionBelow = false, height = 10, accessibilityLabel } = props;
|
|
54
|
+
const words = useLoticsLocale();
|
|
53
55
|
const captionColor = captionTone === "danger" ? "danger" : "muted";
|
|
54
56
|
const names = typeof steps === "number" ? null : steps;
|
|
55
57
|
const count = Math.max(1, typeof steps === "number" ? steps : steps.length);
|
|
@@ -59,10 +61,10 @@ export function StepProgress(props: StepProgressProps) {
|
|
|
59
61
|
label ??
|
|
60
62
|
(names
|
|
61
63
|
? isComplete
|
|
62
|
-
?
|
|
64
|
+
? words.stepProgress.complete(count)
|
|
63
65
|
: safe >= 0
|
|
64
|
-
?
|
|
65
|
-
:
|
|
66
|
+
? words.stepProgress.stage(names[safe], safe + 1, count)
|
|
67
|
+
: words.stepProgress.none(count)
|
|
66
68
|
: undefined);
|
|
67
69
|
|
|
68
70
|
// `progressbar` IS right here — the segments are decoration over one quantity,
|
|
@@ -73,7 +75,7 @@ export function StepProgress(props: StepProgressProps) {
|
|
|
73
75
|
const bar = (
|
|
74
76
|
<View
|
|
75
77
|
accessibilityRole="progressbar"
|
|
76
|
-
accessibilityLabel={accessibilityLabel ?? caption ??
|
|
78
|
+
accessibilityLabel={accessibilityLabel ?? caption ?? words.stepProgress.position(Math.max(0, safe + 1), count)}
|
|
77
79
|
aria-valuenow={isComplete ? count : Math.max(0, safe + 1)}
|
|
78
80
|
aria-valuemin={0}
|
|
79
81
|
aria-valuemax={count}
|
package/src/stepper.tsx
CHANGED
|
@@ -23,8 +23,13 @@ import { NODE, STEP_HEAD_TEXT_LINE, STEP_MARKER_GAP, STEP_ROW_INSET, STEP_ROW_PA
|
|
|
23
23
|
// A node's place in a sequence. `upcoming` = not reached (greyish); `current` =
|
|
24
24
|
// where we are (ring + white centre, pulses when live); `done` = passed (filled);
|
|
25
25
|
// `complete` = the terminal "finished" marker (filled + check); `warning` = an
|
|
26
|
-
// issue at a reached step
|
|
27
|
-
|
|
26
|
+
// issue at a reached step; `fail` = a reached step whose OUTCOME is negative —
|
|
27
|
+
// a verdict, not an issue still open. Distinct from `warning`: amber asks the
|
|
28
|
+
// reader to look, red tells them the answer is already no (a QC reject, a
|
|
29
|
+
// failed match) — collapsing the two into one amber "something's off" loses
|
|
30
|
+
// the one bit that actually matters, whether the row is still actionable or
|
|
31
|
+
// already decided.
|
|
32
|
+
export type StepStatus = "upcoming" | "current" | "done" | "warning" | "fail" | "complete";
|
|
28
33
|
export type StepOrientation = "horizontal" | "vertical";
|
|
29
34
|
|
|
30
35
|
const reached = (s: StepStatus) => s !== "upcoming";
|
|
@@ -302,6 +307,14 @@ export function Step(props: StepProps) {
|
|
|
302
307
|
{onToggle && mark === "box" ? (
|
|
303
308
|
// SELECTION: the square, in the ring's own box so the column and the
|
|
304
309
|
// spine keep their rhythm whichever mark a surface uses.
|
|
310
|
+
// `fail` deliberately reads as unchecked here, not done: neither
|
|
311
|
+
// mark below has a fail-shaped state, and forcing one to `done`
|
|
312
|
+
// would draw a rejected step in the same ink as a passed one —
|
|
313
|
+
// the one distinction a verdict exists to keep. A verdict is
|
|
314
|
+
// reported, not toggled, so a real caller never reaches this
|
|
315
|
+
// combination (`ChecklistItem` withholds `onToggle` once
|
|
316
|
+
// `verdict` is set); this stays correct for anyone driving
|
|
317
|
+
// `Step` directly.
|
|
305
318
|
<View style={{ width: NODE, height: NODE, alignItems: "center", justifyContent: "center" }}>
|
|
306
319
|
<CheckboxInput
|
|
307
320
|
checked={status === "done" || status === "complete"}
|
|
@@ -376,6 +389,17 @@ export function Marker({ status, color, live }: { status: StepStatus; color: str
|
|
|
376
389
|
</View>
|
|
377
390
|
);
|
|
378
391
|
}
|
|
392
|
+
if (status === "fail") {
|
|
393
|
+
// A decided negative: filled in the same solid-red the kit uses for danger
|
|
394
|
+
// everywhere else (deadline.ts's own tone→hue mapping), with a white X —
|
|
395
|
+
// the "done" disc's shape, wrong-answer color, so a scanning eye reads
|
|
396
|
+
// both as "reached and settled" and only the color says which way.
|
|
397
|
+
return (
|
|
398
|
+
<View style={[styles.disc, { backgroundColor: solid("red") }]}>
|
|
399
|
+
<Icon name="x" size={10} color={colors.white} />
|
|
400
|
+
</View>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
379
403
|
if (status === "current") {
|
|
380
404
|
return (
|
|
381
405
|
<View style={styles.discWrap}>
|
package/src/table_fit.ts
CHANGED
|
@@ -21,9 +21,17 @@ export interface TableFitColumn {
|
|
|
21
21
|
/** Fixed width in px; omit for a flexible column. */
|
|
22
22
|
width?: number;
|
|
23
23
|
/** Drop precedence when the container can't fit every column: HIGHER numbers
|
|
24
|
-
* drop first, ties drop right-to-left.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
24
|
+
* drop first, ties drop right-to-left. The FIRST column is the row's identity
|
|
25
|
+
* — it never drops.
|
|
26
|
+
*
|
|
27
|
+
* An unannotated column defaults to `columns.length + index`, which is above
|
|
28
|
+
* every hand-written priority rather than interleaved with them, so an
|
|
29
|
+
* unannotated register still sheds right-to-left AND an explicit priority is
|
|
30
|
+
* strictly safer than none. Defaulting to the bare index made annotation
|
|
31
|
+
* actively harmful: marking your most important column `priority: 1` TIED it
|
|
32
|
+
* with the unannotated column at index 1, and the right-to-left tie-break
|
|
33
|
+
* then dropped yours — losing the amount column, the fact the register exists
|
|
34
|
+
* for, on a screen that reviewed as correctly annotated. */
|
|
27
35
|
priority?: number;
|
|
28
36
|
}
|
|
29
37
|
|
|
@@ -59,10 +67,12 @@ export const ROW_HEIGHT = 72;
|
|
|
59
67
|
*/
|
|
60
68
|
export const ROW_GUTTER = 0;
|
|
61
69
|
const ROW_H_PADDING = ROW_GUTTER * 2;
|
|
62
|
-
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
|
|
70
|
+
/** Width a flexible column needs to stay usable — below this it is crushed to
|
|
71
|
+
* ellipsis soup. `Table` counts a flex column as this wide when deciding what
|
|
72
|
+
* fits; `DataGrid`, which sheds nothing, uses it as the column's hard floor so
|
|
73
|
+
* the squeeze overflows the grid instead of erasing its identity column.
|
|
74
|
+
* Exported so the two cannot drift. */
|
|
75
|
+
export const FLEX_MIN_WIDTH = 120;
|
|
66
76
|
/** Fewer side-by-side columns than this stops being a register — stack instead. */
|
|
67
77
|
const MIN_VISIBLE_COLUMNS = 2;
|
|
68
78
|
|
|
@@ -91,7 +101,7 @@ export function computeTableFit(
|
|
|
91
101
|
};
|
|
92
102
|
|
|
93
103
|
const allKeys = new Set(columns.map((c) => c.key));
|
|
94
|
-
const priorityOf = (c: TableFitColumn) => c.priority ?? columns.indexOf(c);
|
|
104
|
+
const priorityOf = (c: TableFitColumn) => c.priority ?? columns.length + columns.indexOf(c);
|
|
95
105
|
// Column 0 never enters the drop order — it's the row's identity.
|
|
96
106
|
const dropOrder = columns
|
|
97
107
|
.slice(1)
|