@lotics/ui 47.13.0 → 47.14.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.
@@ -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
  }
package/src/drawer.tsx CHANGED
@@ -104,6 +104,30 @@ export function Drawer(props: DrawerProps) {
104
104
  return () => document.removeEventListener("keydown", handler);
105
105
  }, [open, onPrev, onNext]);
106
106
 
107
+ // ONE definition for both placements. Written twice, the wide bar and the
108
+ // narrow line would drift the first time either gained a control.
109
+ const RecordNav = () => (
110
+ <View style={styles.nav}>
111
+ <IconButton
112
+ icon="chevron-left"
113
+ accessibilityLabel={loc.previous}
114
+ onPress={onPrev ?? (() => {})}
115
+ disabled={!onPrev}
116
+ />
117
+ {position ? (
118
+ <Text size="xs" color="muted" tabular>
119
+ {position}
120
+ </Text>
121
+ ) : null}
122
+ <IconButton
123
+ icon="chevron-right"
124
+ accessibilityLabel={loc.next}
125
+ onPress={onNext ?? (() => {})}
126
+ disabled={!onNext}
127
+ />
128
+ </View>
129
+ );
130
+
107
131
  // Mounted only while OPEN — see `overlay_layer.ts`. react-native-web appends a
108
132
  // `Modal`'s body-level div on first render and never re-orders it, so an
109
133
  // always-mounted overlay claims its slot ahead of one opened later and covers
@@ -117,36 +141,32 @@ export function Drawer(props: DrawerProps) {
117
141
  <Pressable style={styles.scrim} onPress={handleClose} accessibilityLabel={loc.close} tabIndex={-1} />
118
142
  <View style={[styles.panel, { width: screenSize.small ? "100%" : width }]}>
119
143
  <PortalHost>
120
- <View style={styles.header}>
121
- {typeof title === "string" ? (
122
- <Text size="lg" weight="semibold" style={{ flex: 1 }}>
123
- {title}
124
- </Text>
125
- ) : (
126
- <View style={{ flex: 1 }}>{title}</View>
127
- )}
128
- {hasNav ? (
129
- <View style={styles.nav}>
130
- <IconButton
131
- icon="chevron-left"
132
- accessibilityLabel={loc.previous}
133
- onPress={onPrev ?? (() => {})}
134
- disabled={!onPrev}
135
- />
136
- {position ? (
137
- <Text size="xs" color="muted" tabular>
138
- {position}
139
- </Text>
140
- ) : null}
141
- <IconButton
142
- icon="chevron-right"
143
- accessibilityLabel={loc.next}
144
- onPress={onNext ?? (() => {})}
145
- disabled={!onNext}
146
- />
144
+ {/* THE TITLE IS THE LAST THING THE BAR GIVES UP.
145
+ It names the record whose file is open — and on one bar with a
146
+ pager and three icon buttons it is the only element that CAN
147
+ give way, because every one of those is a fixed width. At 375
148
+ that made the drawer cut its own subject ("Hộ kinh doanh Vận tải
149
+ Đức Thắng" needed 261px and got 206) while the chrome beside it
150
+ sat whole. So below the split width the record navigation drops
151
+ to its own line and the name takes the full bar; the close stays
152
+ up top, where a reader reaches for it. */}
153
+ <View style={[styles.header, screenSize.small && hasNav ? styles.headerStacked : null]}>
154
+ <View style={styles.headerTopLine}>
155
+ {typeof title === "string" ? (
156
+ <Text size="lg" weight="semibold" style={{ flex: 1 }}>
157
+ {title}
158
+ </Text>
159
+ ) : (
160
+ <View style={{ flex: 1, minWidth: 0 }}>{title}</View>
161
+ )}
162
+ {hasNav && !screenSize.small ? <RecordNav /> : null}
163
+ <IconButton icon="x" size="lg" accessibilityLabel={loc.close} onPress={handleClose} />
164
+ </View>
165
+ {hasNav && screenSize.small ? (
166
+ <View style={styles.navLine}>
167
+ <RecordNav />
147
168
  </View>
148
169
  ) : null}
149
- <IconButton icon="x" size="lg" accessibilityLabel={loc.close} onPress={handleClose} />
150
170
  </View>
151
171
  <SizeBoundary testID={testID} style={styles.body}>
152
172
  {children}
@@ -263,6 +283,24 @@ const styles = StyleSheet.create({
263
283
  borderLeftColor: colors.border,
264
284
  boxShadow: "-8px 0 24px rgba(0, 0, 0, 0.08)",
265
285
  },
286
+ // The narrow bar is a COLUMN: the title's line, then the record navigation
287
+ // under it. Two lines cost 32px; a cut record name costs the reader the one
288
+ // fact that says which file they are in.
289
+ headerStacked: {
290
+ flexDirection: "column",
291
+ alignItems: "stretch",
292
+ gap: 4,
293
+ },
294
+ headerTopLine: {
295
+ flexDirection: "row",
296
+ alignItems: "center",
297
+ gap: 8,
298
+ minWidth: 0,
299
+ },
300
+ navLine: {
301
+ flexDirection: "row",
302
+ justifyContent: "flex-start",
303
+ },
266
304
  header: {
267
305
  flexDirection: "row",
268
306
  alignItems: "center",
@@ -9,7 +9,8 @@ export type DateFormatStyle =
9
9
  | "medium" // 22 thg 5, 2026 / Sep 22, 2026 — readable, abbreviated month
10
10
  | "long" // 22 tháng 5, 2026 / September 22, 2026 — readable, full month
11
11
  | "dayMonth" // 22 thg 5 / Sep 22 — day + abbreviated month, no year
12
- | "monthYear"; // Tháng 5 2026 / May 2026 — a period label (sentence-cased)
12
+ | "monthYear" // Tháng 5 năm 2026 / May 2026 — a period label (sentence-cased)
13
+ | "quarterYear"; // Quý 2 năm 2026 / Q2 2026 — the quarter the value falls in
13
14
 
14
15
  export interface FormatDateOptions {
15
16
  /** The date style. Default "date". */
@@ -34,6 +35,20 @@ const READABLE_OPTS: Record<"medium" | "long" | "dayMonth" | "monthYear", Intl.D
34
35
  monthYear: { month: "long", year: "numeric" },
35
36
  };
36
37
 
38
+ /**
39
+ * How a quarter is SAID, by language.
40
+ *
41
+ * A table rather than `Intl`: CLDR carries no quarter pattern for
42
+ * `DateTimeFormat`, so there is nothing to ask it for. Every other language
43
+ * falls to the international "Q2 2026" — which is what an unlocalized surface
44
+ * should print, rather than a Vietnamese sentence it never asked for.
45
+ */
46
+ const QUARTER_YEAR: Record<string, (quarter: number, year: number) => string> = {
47
+ vi: (quarter, year) => `Quý ${quarter} năm ${year}`,
48
+ };
49
+
50
+ const defaultQuarterYear = (quarter: number, year: number): string => `Q${quarter} ${year}`;
51
+
37
52
  const ISO_YEAR = /^(\d{4})$/;
38
53
  const ISO_YEAR_MONTH = /^(\d{4})-(\d{2})$/;
39
54
  const ISO_DATE_TIME = /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2}))?)?$/;
@@ -104,7 +119,8 @@ export function toISODate(value: Date | string | null | undefined): string {
104
119
  * THE date-value formatter — the date sibling of `formatMoney`. Accepts a `Date` OR an ISO string
105
120
  * and returns a localized display string; defaults to the home market. `format` picks the date
106
121
  * style: `date` → `22/05/2026`; `medium` → `22 thg 5, 2026`; `long` → `22 tháng 5, 2026`;
107
- * `dayMonth` → `22 thg 5`, `monthYear` → `Tháng 5 2026`. **`time: true` prepends the 24h time to
122
+ * `dayMonth` → `22 thg 5`, `monthYear` → `Tháng 5 năm 2026`, `quarterYear` `Quý 2 năm 2026`
123
+ * (`Q2 2026` outside Vietnamese). **`time: true` prepends the 24h time to
108
124
  * ANY style** — `14:30 22/05/2026`, `14:30 22 tháng 5, 2026` (time-first). `compact` drops the
109
125
  * year on the numeric `date` style. Never hand-roll a date with `padStart` / `getMonth` / a raw
110
126
  * `Intl.DateTimeFormat` for VALUE display — call this. (Component-internal chrome is exempt — see
@@ -124,6 +140,16 @@ export function formatDate(value: Date | string | null | undefined, options: For
124
140
  if (precision === "year") {
125
141
  return String(date.getFullYear());
126
142
  }
143
+ // A quarter is read off the MONTH, so a month-precision value serves it
144
+ // exactly as a full date does — and, being a calendar period, it carries no
145
+ // day and no clock whatever `time` says.
146
+ if (format === "quarterYear") {
147
+ const language = locale.split("-")[0].toLowerCase();
148
+ return (QUARTER_YEAR[language] ?? defaultQuarterYear)(
149
+ Math.floor(date.getMonth() / 3) + 1,
150
+ date.getFullYear(),
151
+ );
152
+ }
127
153
  if (precision === "month") {
128
154
  return formatMonthYear(date, format, locale, compact, emptyLabel);
129
155
  }
@@ -166,7 +192,9 @@ export function formatDate(value: Date | string | null | undefined, options: For
166
192
  */
167
193
  function formatMonthYear(
168
194
  date: Date,
169
- format: DateFormatStyle,
195
+ // `quarterYear` never arrives here — it answers before precision is consulted,
196
+ // and it takes no month options to strip a day out of.
197
+ format: Exclude<DateFormatStyle, "quarterYear">,
170
198
  locale: string,
171
199
  compact: boolean,
172
200
  emptyLabel: string,
package/src/index.css CHANGED
@@ -351,6 +351,24 @@ html {
351
351
  outline: none;
352
352
  }
353
353
 
354
+ /* Native UA number-stepper reset — subtractive, like the focus reset above.
355
+ `NumberInput` renders `type="number"` for the numeric keypad and the parse,
356
+ and the browser throws in a stepper: two arrows that sit ON the right edge of
357
+ a right-aligned figure and take width out of the field, so a numeric column is
358
+ one width at rest and a narrower one the moment somebody focuses a cell.
359
+ Nothing in the kit steps a value by pressing an arrow — `Counter` draws its
360
+ own — so it is a control that costs layout and does nothing. */
361
+ input[type="number"]::-webkit-outer-spin-button,
362
+ input[type="number"]::-webkit-inner-spin-button {
363
+ -webkit-appearance: none;
364
+ margin: 0;
365
+ }
366
+
367
+ input[type="number"] {
368
+ -moz-appearance: textfield;
369
+ appearance: textfield;
370
+ }
371
+
354
372
  /* @font-face declarations are NOT included here — each app provides its own
355
373
  font loading because paths differ per platform:
356
374
  - Frontend: /fonts/Inter_*.woff2 (served from public/)
package/src/ledger.tsx CHANGED
@@ -256,24 +256,27 @@ export function LedgerRow(props: LedgerRowProps) {
256
256
  const { label, meta, value, tone = "default", peek, peekWidth = 300, reference, accessibilityLabel } = props;
257
257
  const rowDetails = useLoticsLocale().ledger.rowDetails;
258
258
  const { format, dropMeta } = useLedger();
259
- // The caption goes entirely rather than shrinking to nothing. Yielding first
260
- // (below) is the right ORDER but not a floor: at phone width a long caption
261
- // still claims most of the text budget, clips, and takes the label down with
262
- // it. Two separate
263
- // authors had already worked around this by dropping `meta` at small widths
264
- // in their own apps, which is the component's job.
259
+ // Below `META_MIN_ROW_WIDTH` the caption goes entirely rather than surviving as
260
+ // a sliver. Yielding first (see `yield`) is the right ORDER but not a floor: at
261
+ // phone width a caption clipped to two syllables is noise, and the space reads
262
+ // better spent on nothing. Two separate authors had already worked around this
263
+ // by dropping `meta` at small widths in their own apps, which is the
264
+ // component's job.
265
265
  const showMeta = meta != null && meta !== "" && !dropMeta;
266
266
  const content = (
267
267
  <>
268
268
  <Text size="sm" numberOfLines={1} style={styles.shrink}>
269
269
  {label}
270
270
  </Text>
271
+ {/* The caption IS the row's filler — it takes what the label and the figure
272
+ leave. With no caption to hold it open, an empty one does the same job. */}
271
273
  {showMeta ? (
272
274
  <Text size="xs" color="muted" numberOfLines={1} style={styles.yield}>
273
275
  {meta}
274
276
  </Text>
275
- ) : null}
276
- <View style={styles.grow} />
277
+ ) : (
278
+ <View style={styles.grow} />
279
+ )}
277
280
  {!peek && reference ? (
278
281
  <Link size="xs" onPress={reference.onPress} accessibilityLabel={reference.label}>
279
282
  {reference.label}
@@ -389,13 +392,18 @@ const styles = StyleSheet.create({
389
392
  rowHovered: { backgroundColor: colors.zinc[50] },
390
393
  grow: { flexGrow: 1, flexShrink: 1 },
391
394
  shrink: { flexShrink: 1 },
392
- // The META yields BEFORE the label, and by more. Only the label carried a
393
- // shrink, so under width pressure the row'''s IDENTITY was the one thing that
394
- // gave way while its subordinate qualifier held full width a narrow ledger
395
- // read "Tổng thu (chư…" beside an intact "03/06, Cash". A caption qualifies a
396
- // number; the label names it, and the name is what a reader needs when the
397
- // row is too tight for both.
398
- yield: { flexShrink: 3 },
395
+ // The META yields ENTIRELY before the label yields at all. A shrink RATIO (this
396
+ // was 3 against the label's 1) buys an order of magnitude, not an order of
397
+ // service: flexbox scales a shrink factor by the item's own width, so a caption
398
+ // longer than the label still handed part of the loss back to it, and a 375px
399
+ // ledger read "Ghi công / nằm trong số còn phải t…" both halves clipped,
400
+ // and the FIGURE squeezed along with them. Sizing the caption off the leftovers
401
+ // instead (`flexBasis: 0` + `flexGrow: 1`, so it is also the row's filler) makes
402
+ // the order exact: the label and the figure take their content width first, the
403
+ // caption gets what remains, and only once it is down to nothing does the label
404
+ // start clipping. A caption qualifies a number; the label names it, and the name
405
+ // is what a reader needs when the row is too tight for both.
406
+ yield: { flexGrow: 1, flexShrink: 1, flexBasis: 0 },
399
407
  total: { gap: 6 },
400
408
  // The BASIS row, the only one whose two sides can differ in height: its label may
401
409
  // carry a stacked `meta` while the figure stays one line. Centring then measures the