@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.
- package/AGENTS.md +16 -2
- package/docs/catalog.md +225 -27
- package/docs/composition.md +83 -4
- package/docs/data_entry.md +17 -7
- package/docs/reviewing.md +30 -0
- package/docs/templates.md +1 -1
- package/examples/tpl_attendance.tsx +51 -80
- package/examples/tpl_pivot.tsx +10 -1
- package/examples/tpl_record.tsx +33 -107
- package/package.json +9 -1
- package/src/avatar_size.ts +9 -8
- package/src/axis_ticks.ts +63 -0
- package/src/bar_chart.tsx +69 -50
- package/src/cell_stack.tsx +43 -7
- package/src/chip_group.tsx +12 -6
- package/src/danger_zone.tsx +23 -10
- package/src/date_filter.tsx +17 -7
- package/src/date_filter_presets.ts +209 -1
- package/src/date_range_filter_field.tsx +14 -4
- package/src/detail_row.tsx +18 -18
- package/src/drawer.tsx +66 -28
- package/src/format_date.ts +31 -3
- package/src/index.css +18 -0
- package/src/ledger.tsx +23 -15
- package/src/line_chart.tsx +194 -61
- package/src/list_item.tsx +22 -3
- package/src/locale.tsx +25 -0
- package/src/matrix.tsx +116 -16
- package/src/matrix_totals.ts +71 -3
- package/src/month_math.ts +16 -0
- package/src/month_stepper.tsx +62 -0
- package/src/organization_name.ts +94 -0
- package/src/search_match.ts +19 -0
- package/src/section_nav.tsx +163 -0
- package/src/state_matrix.tsx +265 -0
- package/src/table.tsx +67 -5
- package/src/timetable.tsx +167 -0
- package/src/toggle_strip.tsx +259 -0
|
@@ -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.
|
|
67
|
-
* "start – end". Keeps the trigger scannable where
|
|
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() ===
|
|
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 &&
|
package/src/detail_row.tsx
CHANGED
|
@@ -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
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
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
|
-
{
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
{
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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",
|
package/src/format_date.ts
CHANGED
|
@@ -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"
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
260
|
-
// (
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
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
|
-
) :
|
|
276
|
-
|
|
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
|
|
393
|
-
//
|
|
394
|
-
//
|
|
395
|
-
//
|
|
396
|
-
//
|
|
397
|
-
//
|
|
398
|
-
|
|
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
|