@lotics/ui 47.13.1 → 47.15.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.
@@ -11,6 +11,12 @@ export interface CellStackProps {
11
11
  *
12
12
  * One, not several: a stack of three is no longer a value and its annotation,
13
13
  * it is a row that wants to be a row.
14
+ *
15
+ * To RESERVE the line on a row that has nothing to say — which is how a column
16
+ * whose cells are sometimes two lines keeps one first-line height down the page
17
+ * (probe 8h-bis) — pass a whitespace string. `" "` is what everybody writes and
18
+ * the web renderer collapses it, so it is normalised to a non-breaking space
19
+ * here rather than in twelve call sites.
14
20
  */
15
21
  caption?: ReactNode;
16
22
  /**
@@ -24,6 +30,13 @@ export interface CellStackProps {
24
30
  * does, so the caption keeps body size beside one.
25
31
  */
26
32
  marked?: boolean;
33
+ /**
34
+ * The identity mark, drawn HERE — an `Avatar`, a `FileBadge`. Sets `marked`
35
+ * and owns the gap between the mark and the pair, so a register's subject
36
+ * column is not one more hand-rolled row of mark + stack with its own idea
37
+ * of the gap. The column still budgets the mark's width (`TableColumn.lead`).
38
+ */
39
+ leading?: ReactNode;
27
40
  /** The subject's rung. The pair takes ONE rung, so the caption follows it. */
28
41
  size?: TextSize;
29
42
  /** The subject's ink. The caption is always `muted` — it is supporting text. */
@@ -80,7 +93,8 @@ export function CellStack(props: CellStackProps) {
80
93
  title,
81
94
  caption,
82
95
  weight = "regular",
83
- marked = false,
96
+ marked: markedProp = false,
97
+ leading,
84
98
  size = "sm",
85
99
  color,
86
100
  tabular,
@@ -90,7 +104,16 @@ export function CellStack(props: CellStackProps) {
90
104
  testID,
91
105
  } = props;
92
106
 
93
- const hasCaption = caption !== undefined && caption !== null && caption !== false;
107
+ // A caption reserving the line with nothing in it. `" "` says "keep this line"
108
+ // unambiguously and collapses in the web renderer, so the reserved line loses
109
+ // its height and the cell drops to ONE line — while the row height, set by
110
+ // whichever sibling is two lines tall, never moves. The column then puts its
111
+ // first line at two different heights on rows that look identical to every
112
+ // spacing probe. One app wrote it twelve times.
113
+ const line = typeof caption === "string" && caption.length > 0 && caption.trim() === "" ? NBSP : caption;
114
+ const hasCaption = line !== undefined && line !== null && line !== false;
115
+ const hasLeading = leading !== undefined && leading !== null && leading !== false;
116
+ const marked = markedProp || hasLeading;
94
117
  // Rule 1 — the caption's rung, from what else is already separating the pair.
95
118
  const captionSize: TextSize = weight !== "regular" || marked ? size : "xs";
96
119
  // Rule 2 — tight closes a PAIR; `xs` is tight by construction, and a lone line
@@ -98,8 +121,8 @@ export function CellStack(props: CellStackProps) {
98
121
  const subjectTight = hasCaption ? "tight" : undefined;
99
122
  const captionTight = captionSize === "xs" ? undefined : "tight";
100
123
 
101
- return (
102
- <View style={[{ gap: 0, alignItems: ALIGN[align] }, style]} testID={testID}>
124
+ const stack = (
125
+ <View style={[{ gap: 0, alignItems: ALIGN[align] }, hasLeading ? { flex: 1, minWidth: 0 } : style]} testID={hasLeading ? undefined : testID}>
103
126
  {typeof title === "string" || typeof title === "number" ? (
104
127
  <Text
105
128
  size={size}
@@ -115,7 +138,7 @@ export function CellStack(props: CellStackProps) {
115
138
  title
116
139
  )}
117
140
  {hasCaption
118
- ? typeof caption === "string" || typeof caption === "number"
141
+ ? typeof line === "string" || typeof line === "number"
119
142
  ? (
120
143
  <Text
121
144
  size={captionSize}
@@ -124,15 +147,28 @@ export function CellStack(props: CellStackProps) {
124
147
  tabular={tabular}
125
148
  numberOfLines={numberOfLines}
126
149
  >
127
- {caption}
150
+ {line}
128
151
  </Text>
129
152
  )
130
- : caption
153
+ : line
131
154
  : null}
132
155
  </View>
133
156
  );
157
+ if (!hasLeading) return stack;
158
+ return (
159
+ <View style={[{ flexDirection: "row", alignItems: "center", gap: LEADING_GAP, minWidth: 0 }, style]} testID={testID}>
160
+ {leading}
161
+ {stack}
162
+ </View>
163
+ );
134
164
  }
135
165
 
166
+ const NBSP = "\u00a0";
167
+
168
+ /** Mark to pair \u2014 the ONE gap between an identity mark and the label beside it,
169
+ * wherever the kit draws that pair (`CellStack`, `Matrix`, `StateMatrix`). */
170
+ export const LEADING_GAP = 10;
171
+
136
172
  const ALIGN = {
137
173
  left: "flex-start",
138
174
  right: "flex-end",
@@ -147,12 +147,18 @@ export function ChipGroup<T extends string = string>(props: ChipGroupProps<T>) {
147
147
  color={option.iconColor ?? (active ? colors.zinc[900] : colors.zinc[500])}
148
148
  />
149
149
  ) : null}
150
- <Text
151
- userSelect="none"
152
- size="sm"
153
- weight={active ? "semibold" : "medium"}
154
- color={active ? "default" : "muted"}
155
- >
150
+ {/* MEDIUM on both, never semibold on the active one.
151
+
152
+ A chip already answers "which one is on" twice — a doubled
153
+ zinc-900 edge and a ring — so the weight was a third signal, and
154
+ the wrong kind: semibold is a separate, genuinely heavier FILE
155
+ and the heading ramp is semibold at every rung, so an active chip
156
+ rendered a filter value at heading weight. State steps up in INK,
157
+ which is what `color` does here. It also made the active chip a
158
+ singleton treatment on every screen carrying one, while the
159
+ sibling that answers the same question — `Tabs` — steps
160
+ regular → medium. One question, one answer. */}
161
+ <Text userSelect="none" size="sm" weight="medium" color={active ? "default" : "muted"}>
156
162
  {option.label}
157
163
  </Text>
158
164
  {option.count != null ? (
@@ -23,24 +23,32 @@ export interface DangerZoneProps {
23
23
  * a danger heading mark it as a hazard without shouting (the kit's `tint`/`solid`
24
24
  * discipline, never a raw hex); the consequence goes in `description`, and the
25
25
  * action(s) go in `children`.
26
+ *
27
+ * ONE LEFT EDGE, and it is `Callout`'s anatomy that gets it: the icon sits in an
28
+ * outer ROW beside a column holding title, consequence and actions, so all three
29
+ * derive from the same edge. Put the icon INSIDE the heading row instead — the
30
+ * obvious way to write it — and only the TITLE moves right, so the loudest line
31
+ * in the block sits 22px past the sentence explaining it and the button under
32
+ * both starts somewhere else again. Three edges inside one 16px-padded box, on
33
+ * the one primitive that exists so a hazard is not hand-rolled.
26
34
  */
27
35
  export function DangerZone(props: DangerZoneProps) {
28
36
  const locale = useLoticsLocale();
29
37
  const { title = locale.dangerZone.title, description, children } = props;
30
38
  return (
31
39
  <View style={styles.zone}>
32
- <View style={styles.heading}>
33
- <Icon name="triangle-alert" size={16} color={solid("red")} />
40
+ <Icon name="triangle-alert" size={16} color={solid("red")} />
41
+ <View style={styles.body}>
34
42
  <Text size="sm" weight="semibold" color="danger">
35
43
  {title}
36
44
  </Text>
45
+ {description ? (
46
+ <Text size="sm" color="muted">
47
+ {description}
48
+ </Text>
49
+ ) : null}
50
+ <View style={styles.actions}>{children}</View>
37
51
  </View>
38
- {description ? (
39
- <Text size="sm" color="muted">
40
- {description}
41
- </Text>
42
- ) : null}
43
- <View style={styles.actions}>{children}</View>
44
52
  </View>
45
53
  );
46
54
  }
@@ -49,13 +57,18 @@ const styles = StyleSheet.create({
49
57
  // A soft danger frame: a low-alpha red hairline over the faintest red wash —
50
58
  // the "set apart, not shouting" treatment via the kit's tint discipline.
51
59
  zone: {
52
- gap: 8,
60
+ flexDirection: "row",
61
+ alignItems: "flex-start",
62
+ gap: 10,
53
63
  padding: 16,
54
64
  borderRadius: 10,
55
65
  borderWidth: 1,
56
66
  borderColor: tint("red", 0.3),
57
67
  backgroundColor: tint("red", 0.03),
58
68
  },
59
- heading: { flexDirection: "row", alignItems: "center", gap: 6 },
69
+ // The column every line in the block starts from — 1px down so the first line
70
+ // of type sits optically level with the 16px mark beside it, exactly as
71
+ // `Callout` does it.
72
+ body: { flex: 1, gap: 8, paddingTop: 1 },
60
73
  actions: { flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 4 },
61
74
  });
@@ -33,6 +33,10 @@ export interface DateFilterLabels extends SegmentLabels {
33
33
  thisWeek: string;
34
34
  thisMonth: string;
35
35
  lastMonth: string;
36
+ thisQuarter: string;
37
+ lastQuarter: string;
38
+ thisYear: string;
39
+ lastYear: string;
36
40
  from: string;
37
41
  to: string;
38
42
  /** Accessible name of the field that OPENS this panel (`DateRangeFilterField`). */
@@ -71,6 +75,14 @@ function presetLabel(id: PresetId, labels: DateFilterLabels): string {
71
75
  return labels.thisMonth;
72
76
  case "last_month":
73
77
  return labels.lastMonth;
78
+ case "this_quarter":
79
+ return labels.thisQuarter;
80
+ case "last_quarter":
81
+ return labels.lastQuarter;
82
+ case "this_year":
83
+ return labels.thisYear;
84
+ case "last_year":
85
+ return labels.lastYear;
74
86
  }
75
87
  }
76
88
 
@@ -224,12 +236,10 @@ export function DateFilter(props: DateFilterProps) {
224
236
  (id: PresetId) => {
225
237
  // A preset replaces the whole range, so any half-picked start is abandoned.
226
238
  setPendingStart(null);
227
- // Re-clicking the active preset clears the filter
228
- if (activePresetId === id) {
229
- onValueChange({ start: { date: null, time: null }, end: { date: null, time: null } });
230
- return;
231
- }
232
-
239
+ // Resolved against today on EVERY press, the active one included: a preset
240
+ // names a period, so re-pressing it re-anchors a range that has gone stale
241
+ // (a panel left open across midnight) rather than doing nothing. Clearing
242
+ // is the footer's Clear button — this list holds no null case.
233
243
  const presetValue = getPresetValue(id, new Date());
234
244
  onValueChange(presetValue);
235
245
 
@@ -240,7 +250,7 @@ export function DateFilter(props: DateFilterProps) {
240
250
  );
241
251
  }
242
252
  },
243
- [onValueChange, activePresetId],
253
+ [onValueChange],
244
254
  );
245
255
 
246
256
  // `selected` + `role="option"` is the kit's listbox row, and `MenuButton` owns
@@ -10,7 +10,11 @@ export type PresetId =
10
10
  | "tomorrow"
11
11
  | "this_week"
12
12
  | "this_month"
13
- | "last_month";
13
+ | "last_month"
14
+ | "this_quarter"
15
+ | "last_quarter"
16
+ | "this_year"
17
+ | "last_year";
14
18
 
15
19
  /**
16
20
  * Display order. Every id here SETS a range — that is what makes the list a set
@@ -22,6 +26,9 @@ export type PresetId =
22
26
  * the obvious move for "let me pick my own dates", threw the range away. The
23
27
  * calendar above it is the custom picker, and the panel's Clear button is the
24
28
  * clear; the item was a third name for two controls that were already there.
29
+ *
30
+ * Ordered by GRAIN, shortest first — a reader scanning for "this quarter"
31
+ * looks past the days and the month rather than hunting an alphabetical list.
25
32
  */
26
33
  export const PRESET_IDS: PresetId[] = [
27
34
  "today",
@@ -30,6 +37,10 @@ export const PRESET_IDS: PresetId[] = [
30
37
  "this_week",
31
38
  "this_month",
32
39
  "last_month",
40
+ "this_quarter",
41
+ "last_quarter",
42
+ "this_year",
43
+ "last_year",
33
44
  ];
34
45
 
35
46
  function startOfDay(date: Date): Date {
@@ -73,6 +84,27 @@ function range(start: Date, end: Date): DateFilterValue {
73
84
  return { start: { date: start, time: null }, end: { date: end, time: null } };
74
85
  }
75
86
 
87
+ /** The 0-based quarter a month falls in. */
88
+ function quarterOf(date: Date): number {
89
+ return Math.floor(date.getMonth() / 3);
90
+ }
91
+
92
+ /**
93
+ * The whole quarter `q` (0-based) of `year`, as a range.
94
+ *
95
+ * Built from the quarter INDEX rather than by shifting a date three months:
96
+ * `setMonth(getMonth() - 3)` on the 31st of a month lands in the month after
97
+ * the one meant (31 May → 31 February → 3 March), and the quarter then comes
98
+ * out one too late for every long month.
99
+ */
100
+ function quarterRange(year: number, q: number): DateFilterValue {
101
+ return range(new Date(year, q * 3, 1), new Date(year, q * 3 + 3, 0, 23, 59, 59, 999));
102
+ }
103
+
104
+ function yearRange(year: number): DateFilterValue {
105
+ return range(new Date(year, 0, 1), new Date(year, 11, 31, 23, 59, 59, 999));
106
+ }
107
+
76
108
  /**
77
109
  * Resolve a preset to a concrete date range relative to `now`. Total — every
78
110
  * `PresetId` names a range. The boundary math is identical to the view-page
@@ -101,5 +133,181 @@ export function getPresetValue(id: PresetId, now: Date): DateFilterValue {
101
133
  d.setMonth(d.getMonth() - 1);
102
134
  return range(startOfMonth(d), endOfMonth(d));
103
135
  }
136
+ case "this_quarter":
137
+ return quarterRange(now.getFullYear(), quarterOf(now));
138
+ case "last_quarter": {
139
+ const q = quarterOf(now) - 1;
140
+ return q < 0 ? quarterRange(now.getFullYear() - 1, 3) : quarterRange(now.getFullYear(), q);
141
+ }
142
+ case "this_year":
143
+ return yearRange(now.getFullYear());
144
+ case "last_year":
145
+ return yearRange(now.getFullYear() - 1);
104
146
  }
105
147
  }
148
+
149
+ const DAY_MS = 86_400_000;
150
+
151
+ function isWholeMonth(start: Date, end: Date): boolean {
152
+ return (
153
+ start.getDate() === 1 &&
154
+ start.getFullYear() === end.getFullYear() &&
155
+ start.getMonth() === end.getMonth() &&
156
+ end.getDate() === new Date(end.getFullYear(), end.getMonth() + 1, 0).getDate()
157
+ );
158
+ }
159
+
160
+ function isWholeQuarter(start: Date, end: Date): boolean {
161
+ return (
162
+ start.getDate() === 1 &&
163
+ start.getMonth() % 3 === 0 &&
164
+ start.getFullYear() === end.getFullYear() &&
165
+ end.getMonth() === start.getMonth() + 2 &&
166
+ end.getDate() === new Date(end.getFullYear(), end.getMonth() + 1, 0).getDate()
167
+ );
168
+ }
169
+
170
+ function isWholeYear(start: Date, end: Date): boolean {
171
+ return (
172
+ start.getFullYear() === end.getFullYear() &&
173
+ start.getMonth() === 0 &&
174
+ start.getDate() === 1 &&
175
+ end.getMonth() === 11 &&
176
+ end.getDate() === 31
177
+ );
178
+ }
179
+
180
+ /**
181
+ * THE COMPARATOR for a selected range — the period a report means by "so với kỳ
182
+ * trước". Derived from the range the reader already picked, so a dashboard needs
183
+ * no second control beside its date field to say what it is comparing against.
184
+ *
185
+ * A range that IS a whole calendar month, quarter or year steps back one WHOLE
186
+ * period, never a fixed number of days: February against March is 28 days
187
+ * against 31, and sliding a 31-day window back would compare March against the
188
+ * last three days of January plus February. Any other complete range — a
189
+ * hand-picked fortnight, a week — steps back by its own LENGTH, ending the day
190
+ * before it starts, so two adjacent windows of equal size are compared.
191
+ *
192
+ * Whole days only. The bounds come back day-aligned and untimed, matching the
193
+ * presets, because a period comparison is a comparison of periods; a timed
194
+ * window has no previous one to speak of. `null` when either bound is missing —
195
+ * an open range names no period, and a comparator invented for one would be a
196
+ * figure the reader never asked for.
197
+ */
198
+ export function previousPeriod(value: DateFilterValue): DateFilterValue | null {
199
+ const { date: startDate } = value.start;
200
+ const { date: endDate } = value.end;
201
+ if (!startDate || !endDate) return null;
202
+
203
+ const start = startOfDay(startDate);
204
+ const end = startOfDay(endDate);
205
+
206
+ if (isWholeYear(start, end)) return yearRange(start.getFullYear() - 1);
207
+ if (isWholeQuarter(start, end)) {
208
+ const q = quarterOf(start) - 1;
209
+ return q < 0 ? quarterRange(start.getFullYear() - 1, 3) : quarterRange(start.getFullYear(), q);
210
+ }
211
+ if (isWholeMonth(start, end)) {
212
+ const d = new Date(start.getFullYear(), start.getMonth() - 1, 1);
213
+ return range(startOfMonth(d), endOfMonth(d));
214
+ }
215
+
216
+ // Rounded, not floored: a range spanning a DST change is 23 or 25 hours short
217
+ // of a whole number of days, and a floor would silently drop one from it.
218
+ const days = Math.round((end.getTime() - start.getTime()) / DAY_MS) + 1;
219
+ const prevEnd = new Date(start);
220
+ prevEnd.setDate(prevEnd.getDate() - 1);
221
+ const prevStart = new Date(prevEnd);
222
+ prevStart.setDate(prevStart.getDate() - (days - 1));
223
+ return range(startOfDay(prevStart), endOfDay(prevEnd));
224
+ }
225
+
226
+ /** A selected period read against the clock, with its comparator already cut. */
227
+ export interface PeriodToDate {
228
+ /** The selection, ending at `now` while the period is still RUNNING. */
229
+ current: DateFilterValue;
230
+ /** `previousPeriod`, cut to the same elapsed days. `null` when the selection
231
+ * is open-ended and so names no period to step back from. */
232
+ previous: DateFilterValue | null;
233
+ /** `now` falls before the selection's last day. */
234
+ running: boolean;
235
+ /** Days of the selection already behind us — 0 for a period still ahead. */
236
+ elapsedDays: number;
237
+ /** Days the whole selection holds, elapsed or not. */
238
+ totalDays: number;
239
+ }
240
+
241
+ /** Whole days between two day-aligned dates, inclusive. Rounded for the reason
242
+ * `previousPeriod` rounds: a DST change makes a span 23 or 25 hours. */
243
+ function dayCount(from: Date, to: Date): number {
244
+ return Math.round((to.getTime() - from.getTime()) / DAY_MS) + 1;
245
+ }
246
+
247
+ function addDays(date: Date, days: number): Date {
248
+ const d = new Date(date);
249
+ d.setDate(d.getDate() + days);
250
+ return d;
251
+ }
252
+
253
+ /**
254
+ * A RUNNING period is compared TO DATE: both sides cut to the days that have
255
+ * actually elapsed.
256
+ *
257
+ * Six days of this month placed beside a whole previous month reads as a
258
+ * collapse that never happened, and it lands on the first figure anyone looks
259
+ * at. So the selection ends at `now` while it is still running, and the
260
+ * comparator `previousPeriod` names is cut to the same count from ITS first
261
+ * day — six days against the first six, a quarter's 68 days against the
262
+ * previous quarter's first 68. The cut is clamped to the previous period's own
263
+ * end, so thirty elapsed days of March compare against all 28 of February
264
+ * rather than running past it.
265
+ *
266
+ * A period already closed keeps both sides whole. A period entirely in the
267
+ * future has nothing elapsed: `current` holds its first day and `elapsedDays`
268
+ * is 0, so a caller can say it has not started instead of drawing a −100%.
269
+ *
270
+ * `null` when either bound is missing, matching `previousPeriod`.
271
+ */
272
+ export function periodToDate(value: DateFilterValue, now: Date): PeriodToDate | null {
273
+ const { date: startDate } = value.start;
274
+ const { date: endDate } = value.end;
275
+ if (!startDate || !endDate) return null;
276
+
277
+ const from = startOfDay(startDate);
278
+ const to = startOfDay(endDate);
279
+ const today = startOfDay(now);
280
+ const totalDays = dayCount(from, to);
281
+ const whole = previousPeriod(value);
282
+
283
+ if (today.getTime() >= to.getTime()) {
284
+ return {
285
+ current: range(from, endOfDay(to)),
286
+ previous: whole,
287
+ running: false,
288
+ elapsedDays: totalDays,
289
+ totalDays,
290
+ };
291
+ }
292
+
293
+ const elapsedDays = today.getTime() < from.getTime() ? 0 : dayCount(from, today);
294
+ const previous = (() => {
295
+ const prevStart = whole?.start.date;
296
+ const prevEnd = whole?.end.date;
297
+ if (!prevStart || !prevEnd) return null;
298
+ const opens = startOfDay(prevStart);
299
+ const closes = startOfDay(prevEnd);
300
+ // `max(elapsed, 1)`: a period not yet begun still names one day on each
301
+ // side, so the pair has the same shape whichever side of `now` it sits.
302
+ const cut = addDays(opens, Math.max(elapsedDays, 1) - 1);
303
+ return range(opens, endOfDay(cut.getTime() > closes.getTime() ? closes : cut));
304
+ })();
305
+
306
+ return {
307
+ current: range(from, endOfDay(elapsedDays === 0 ? from : today)),
308
+ previous,
309
+ running: true,
310
+ elapsedDays,
311
+ totalDays,
312
+ };
313
+ }
@@ -63,20 +63,30 @@ function formatBound(date: Date | null, time: string | null, locale: string | un
63
63
  /**
64
64
  * Recognized whole periods display compactly — a range that IS a calendar
65
65
  * month reads "Tháng 6 năm 2026" (sentence-cased via the locale), a whole
66
- * year "2026", a single day one date. Anything else falls back to
67
- * "start – end". Keeps the trigger scannable where dashboards live in
68
- * period rhythm, not date pairs.
66
+ * quarter "Quý 2 năm 2026", a whole year "2026", a single day one date.
67
+ * Anything else falls back to "start – end". Keeps the trigger scannable where
68
+ * dashboards live in period rhythm, not date pairs.
69
69
  */
70
70
  function formatRangeDisplay(start: Date, end: Date, locale: string | undefined): string {
71
71
  if (start.toDateString() === end.toDateString()) return formatDate(start, { locale });
72
72
 
73
+ const lastDayOfEndMonth = new Date(end.getFullYear(), end.getMonth() + 1, 0).getDate();
74
+
73
75
  const wholeMonth =
74
76
  start.getDate() === 1 &&
75
77
  start.getMonth() === end.getMonth() &&
76
78
  start.getFullYear() === end.getFullYear() &&
77
- end.getDate() === new Date(end.getFullYear(), end.getMonth() + 1, 0).getDate();
79
+ end.getDate() === lastDayOfEndMonth;
78
80
  if (wholeMonth) return formatDate(start, { format: "monthYear", locale });
79
81
 
82
+ const wholeQuarter =
83
+ start.getDate() === 1 &&
84
+ start.getMonth() % 3 === 0 &&
85
+ start.getFullYear() === end.getFullYear() &&
86
+ end.getMonth() === start.getMonth() + 2 &&
87
+ end.getDate() === lastDayOfEndMonth;
88
+ if (wholeQuarter) return formatDate(start, { format: "quarterYear", locale });
89
+
80
90
  const wholeYear =
81
91
  start.getFullYear() === end.getFullYear() &&
82
92
  start.getMonth() === 0 &&
@@ -167,32 +167,32 @@ export function DetailRow(props: DetailRowProps) {
167
167
  // thing on two surfaces; they may not say a fault two different ways.
168
168
  const annotations = <FieldAnnotations description={description} warning={warning} error={error} flat={flat} />;
169
169
  if (table?.stacked) {
170
- // Stacked mode wears the FORM grammar: the label renders exactly like a
171
- // `FormField` label (medium, default ink), so a narrow record surface
172
- // reads as one vocabulary with forms. The COMPONENTS stay separate a
173
- // FormField wraps a draft control validated and committed together, an
174
- // inline editor self-persists — only the look converges.
170
+ // Stacked mode wears the form LOOK the label renders like a `FormField`
171
+ // label (medium, default ink), so a narrow record surface reads as one
172
+ // vocabulary with forms but keeps the RECORD's reading order: label,
173
+ // value, then whatever has to be said about it.
174
+ //
175
+ // Not the form ORDER, which puts the description above the control. A form's
176
+ // description is guidance you need BEFORE typing; a record's qualifies a
177
+ // value that already exists, so ahead of it the reader gets "còn 116 ngày"
178
+ // before any date and "con số là cho cả hợp đồng" before any con số. Reading
179
+ // a caption before its subject is not one job per element, and it made one
180
+ // row read two ways at two widths — the horizontal branch has always put
181
+ // annotations UNDER the value.
182
+ //
183
+ // The annotations are the shared `FieldAnnotations` anatomy, the same one
184
+ // the horizontal branch renders, so a fault says itself one way at both
185
+ // widths — hand-rolled here they came out a rung larger and off the
186
+ // control's own text inset.
175
187
  return (
176
188
  <View style={styles.stackedRow}>
177
189
  {/* Wraps, like the horizontal label — a stacked row has the FULL width
178
190
  to spend, so clipping here would be gratuitous. */}
179
191
  <Text weight="medium">{label}</Text>
180
- {/* stacked wears the FORM grammar exactly: label, description,
181
- control, warning, error (the `FormField` order) */}
182
- {description != null ? <Text color="muted">{description}</Text> : null}
183
192
  <View style={[styles.stackedValueRow, { minHeight }]}>
184
193
  <View style={styles.value}>{children}</View>
185
194
  </View>
186
- {warning != null ? (
187
- <Text size="xs" color="warning" accessibilityRole="alert" aria-live="polite">
188
- {warning}
189
- </Text>
190
- ) : null}
191
- {error != null ? (
192
- <Text size="xs" color="danger" accessibilityRole="alert" aria-live="polite">
193
- {error}
194
- </Text>
195
- ) : null}
195
+ {annotations}
196
196
  </View>
197
197
  );
198
198
  }