@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.
@@ -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
+ });
@@ -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. Omit (or leave undefined on the first item) and
81
- * the control renders disabled, so the column never changes width. */
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
- <View style={styles.controls}>
130
- <IconButton
131
- icon="chevron-up"
132
- tooltip={labels.moveUp}
133
- accessibilityLabel={named(labels.moveUp)}
134
- disabled={first || onMoveUp == null}
135
- onPress={() => onMoveUp?.()}
136
- />
137
- <IconButton
138
- icon="chevron-down"
139
- tooltip={labels.moveDown}
140
- accessibilityLabel={named(labels.moveDown)}
141
- disabled={last || onMoveDown == null}
142
- onPress={() => onMoveDown?.()}
143
- />
144
- <IconButton
145
- icon="x"
146
- tooltip={labels.remove}
147
- accessibilityLabel={named(labels.remove)}
148
- disabled={onRemove == null}
149
- onPress={() => onRemove?.()}
150
- />
151
- </View>
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
  );
@@ -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
- ? `Complete (${count}/${count})`
64
+ ? words.stepProgress.complete(count)
63
65
  : safe >= 0
64
- ? `${names[safe]} (${safe + 1}/${count})`
65
- : `0/${count}`
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 ?? `${Math.max(0, safe + 1)} of ${count}`}
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
- export type StepStatus = "upcoming" | "current" | "done" | "warning" | "complete";
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. Default = the column's index (so an
25
- * unannotated register sheds from the right). The FIRST column is the row's
26
- * identity — it never drops. */
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
- /** Fit-math width a flexible column needs to stay usable — below this the flex
63
- * column is crushed to ellipsis soup, so it counts as this wide when deciding
64
- * what fits. Layout still lets it grow (`flex`) or shrink (`minWidth: 0`). */
65
- const FLEX_MIN_WIDTH = 120;
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)