@lotics/ui 47.13.1 → 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 +13 -6
- 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
package/src/matrix.tsx
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
import { Children, createContext, isValidElement, useContext, useMemo, type ReactNode } from "react";
|
|
2
|
-
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { ScrollView, StyleSheet, View } from "react-native";
|
|
3
3
|
import { colors, withAlpha, type ColorName } from "./colors";
|
|
4
4
|
import { Text } from "./text";
|
|
5
5
|
import { FocusRingPressable } from "./focus_ring_pressable";
|
|
6
|
-
import {
|
|
6
|
+
import { LEADING_GAP } from "./cell_stack";
|
|
7
|
+
import {
|
|
8
|
+
matrixHeatScale,
|
|
9
|
+
matrixTotals,
|
|
10
|
+
type MatrixAxisItem,
|
|
11
|
+
type MatrixCellRef,
|
|
12
|
+
type MatrixHeatScale,
|
|
13
|
+
} from "./matrix_totals";
|
|
7
14
|
import { useLoticsLocale } from "./locale";
|
|
8
15
|
|
|
9
16
|
type Display = "number" | "heat" | "both";
|
|
10
17
|
|
|
18
|
+
/** How much of the hue a wash may spend, per display. Behind a NUMBER the wash
|
|
19
|
+
* has to stay under the glyphs; when the colour IS the value it takes the lot. */
|
|
20
|
+
const HEAT_BAND: Record<Display, [number, number]> = {
|
|
21
|
+
both: [0.05, 0.45],
|
|
22
|
+
heat: [0.15, 1],
|
|
23
|
+
number: [0.05, 0.45],
|
|
24
|
+
};
|
|
25
|
+
|
|
11
26
|
interface MatrixContextValue {
|
|
12
27
|
rows: MatrixAxisItem[];
|
|
13
28
|
cols: MatrixAxisItem[];
|
|
@@ -20,6 +35,10 @@ interface MatrixContextValue {
|
|
|
20
35
|
selected: MatrixCellRef | null;
|
|
21
36
|
onSelectCell?: (cell: MatrixCellRef | null) => void;
|
|
22
37
|
heatBase: string;
|
|
38
|
+
/** Value → alpha. The cells and the legend read this ONE function. */
|
|
39
|
+
heatAlpha: (value: number) => number;
|
|
40
|
+
/** The legend's swatch alphas, sampled from `heatAlpha`. */
|
|
41
|
+
heatLegend: number[];
|
|
23
42
|
formatValue: (n: number) => string;
|
|
24
43
|
rowLabelWidth: number;
|
|
25
44
|
}
|
|
@@ -32,6 +51,12 @@ export const MATRIX_COL_GAP = 3;
|
|
|
32
51
|
* cần một khoảng với mép nền; dán sát thì nó đọc như bị cắt. Tiêu đề cột cộng
|
|
33
52
|
* đúng số này vào `paddingRight` của nó để hai bên kết thúc trên một đường. */
|
|
34
53
|
const MATRIX_CELL_PAD = 6;
|
|
54
|
+
/** Sàn bề ngang của một ô. `styles.cell` và phép tính bề ngang tối thiểu của cả
|
|
55
|
+
* lưới phải đọc CÙNG một con số: lệch nhau là lưới tự tin mình vừa khung trong
|
|
56
|
+
* khi ô đã bị bóp. */
|
|
57
|
+
const MATRIX_CELL_MIN = 52 + MATRIX_CELL_PAD * 2;
|
|
58
|
+
/** Sàn của cột Tổng — xem `styles.totalCell`. */
|
|
59
|
+
const MATRIX_TOTAL_MIN = 64;
|
|
35
60
|
|
|
36
61
|
const MatrixContext = createContext<MatrixContextValue | null>(null);
|
|
37
62
|
|
|
@@ -56,6 +81,12 @@ export interface MatrixProps {
|
|
|
56
81
|
onSelectCell?: (cell: MatrixCellRef | null) => void;
|
|
57
82
|
/** Heat hue. Intensity is alpha within it; zero cells go neutral. Default blue. */
|
|
58
83
|
heatColor?: ColorName;
|
|
84
|
+
/**
|
|
85
|
+
* How a value becomes ink. `rank` spends the band on the cells' ORDER, `linear`
|
|
86
|
+
* on their share of the largest cell. Default: `rank` behind numbers, `linear`
|
|
87
|
+
* for a pure heatmap, where the colour is the only statement of magnitude.
|
|
88
|
+
*/
|
|
89
|
+
heatScale?: MatrixHeatScale;
|
|
59
90
|
formatValue?: (n: number) => string;
|
|
60
91
|
/** Width of the leading row-label column. Default 140 (room for long names). */
|
|
61
92
|
rowLabelWidth?: number;
|
|
@@ -81,6 +112,7 @@ export function Matrix(props: MatrixProps) {
|
|
|
81
112
|
selected = null,
|
|
82
113
|
onSelectCell,
|
|
83
114
|
heatColor = "blue",
|
|
115
|
+
heatScale,
|
|
84
116
|
formatValue = defaultFormat,
|
|
85
117
|
rowLabelWidth = 140,
|
|
86
118
|
children,
|
|
@@ -94,11 +126,31 @@ export function Matrix(props: MatrixProps) {
|
|
|
94
126
|
[children],
|
|
95
127
|
);
|
|
96
128
|
|
|
129
|
+
// The band a wash may spend is decided by what the GRID draws, and the legend
|
|
130
|
+
// is that same scale seen from the side — so the scale is resolved once here,
|
|
131
|
+
// where both bands can read it, rather than in each of them.
|
|
132
|
+
const display = useMemo<Display>(() => {
|
|
133
|
+
const grid = Children.toArray(children).find(
|
|
134
|
+
(c) => isValidElement(c) && typeof c.type === "function" && "isMatrixGrid" in c.type,
|
|
135
|
+
);
|
|
136
|
+
return isValidElement<MatrixGridProps>(grid) ? grid.props.display ?? "both" : "both";
|
|
137
|
+
}, [children]);
|
|
138
|
+
|
|
97
139
|
const { max, rowTotals, colTotals, grandTotal } = useMemo(
|
|
98
140
|
() => matrixTotals(rows, cols, value),
|
|
99
141
|
[rows, cols, value],
|
|
100
142
|
);
|
|
101
143
|
|
|
144
|
+
const heat = useMemo(
|
|
145
|
+
() =>
|
|
146
|
+
matrixHeatScale(
|
|
147
|
+
rows.flatMap((r) => cols.map((c) => value(r.key, c.key))),
|
|
148
|
+
heatScale ?? (display === "heat" ? "linear" : "rank"),
|
|
149
|
+
HEAT_BAND[display],
|
|
150
|
+
),
|
|
151
|
+
[rows, cols, value, heatScale, display],
|
|
152
|
+
);
|
|
153
|
+
|
|
102
154
|
const ctx: MatrixContextValue = {
|
|
103
155
|
rows,
|
|
104
156
|
cols,
|
|
@@ -111,13 +163,26 @@ export function Matrix(props: MatrixProps) {
|
|
|
111
163
|
selected,
|
|
112
164
|
onSelectCell,
|
|
113
165
|
heatBase: colors[heatColor][600],
|
|
166
|
+
heatAlpha: heat.alpha,
|
|
167
|
+
heatLegend: heat.legend,
|
|
114
168
|
formatValue,
|
|
115
169
|
rowLabelWidth,
|
|
116
170
|
};
|
|
117
171
|
|
|
172
|
+
// Bề ngang tối thiểu để lưới còn ĐỌC được, rồi cuộn ngang khi khung hẹp hơn
|
|
173
|
+
// thế. Không có nó, ô co lại theo `flex` cho tới khi cột nhãn — thứ duy nhất
|
|
174
|
+
// nói mỗi hàng là gì — bị bóp còn một chữ cái, và cột Tổng trôi ra ngoài mép:
|
|
175
|
+
// một ma trận 30 cột vẫn "vừa khung" và không còn đọc được gì.
|
|
176
|
+
const minWidth =
|
|
177
|
+
rowLabelWidth +
|
|
178
|
+
cols.length * (MATRIX_CELL_MIN + MATRIX_COL_GAP) +
|
|
179
|
+
(hasTotals ? MATRIX_TOTAL_MIN + MATRIX_COL_GAP : 0);
|
|
180
|
+
|
|
118
181
|
return (
|
|
119
182
|
<MatrixContext.Provider value={ctx}>
|
|
120
|
-
<
|
|
183
|
+
<ScrollView horizontal contentContainerStyle={styles.scrollContent}>
|
|
184
|
+
<View style={[styles.container, { minWidth }]}>{children}</View>
|
|
185
|
+
</ScrollView>
|
|
121
186
|
</MatrixContext.Provider>
|
|
122
187
|
);
|
|
123
188
|
}
|
|
@@ -170,9 +235,15 @@ function MatrixGrid({ display = "both" }: MatrixGridProps) {
|
|
|
170
235
|
<View style={styles.grid}>
|
|
171
236
|
{rows.map((r) => (
|
|
172
237
|
<View key={r.key} style={styles.row}>
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
238
|
+
{/* Mark then label, inside the SAME `rowLabelWidth` — the mark eats the
|
|
239
|
+
label's width rather than widening the column, so a marked matrix
|
|
240
|
+
lines up with an unmarked one and the caller's budget still holds. */}
|
|
241
|
+
<View style={[styles.rowLabel, styles.rowLabelCell, { width: rowLabelWidth }]}>
|
|
242
|
+
{r.leading}
|
|
243
|
+
<Text size="sm" numberOfLines={1} style={styles.rowLabelText}>
|
|
244
|
+
{r.label}
|
|
245
|
+
</Text>
|
|
246
|
+
</View>
|
|
176
247
|
{cols.map((c) => (
|
|
177
248
|
<MatrixCell key={c.key} ctx={ctx} display={display} row={r} col={c} value={value(r.key, c.key)} />
|
|
178
249
|
))}
|
|
@@ -196,16 +267,14 @@ interface MatrixCellProps {
|
|
|
196
267
|
}
|
|
197
268
|
|
|
198
269
|
function MatrixCell({ ctx, display, row, col, value }: MatrixCellProps) {
|
|
199
|
-
const {
|
|
270
|
+
const { selected, onSelectCell, heatBase, heatAlpha, formatValue } = ctx;
|
|
200
271
|
const showHeat = display === "heat" || display === "both";
|
|
201
272
|
const showNumber = display === "number" || display === "both";
|
|
202
273
|
|
|
203
|
-
// A gentler wash behind a number (keeps it legible); the full ramp when the
|
|
204
|
-
// colour IS the value (a pure heatmap).
|
|
205
274
|
const background = showHeat
|
|
206
275
|
? value === 0
|
|
207
276
|
? colors.zinc[100]
|
|
208
|
-
: withAlpha(heatBase,
|
|
277
|
+
: withAlpha(heatBase, heatAlpha(value))
|
|
209
278
|
: colors.background;
|
|
210
279
|
|
|
211
280
|
const isSelected = selected?.row === row.key && selected?.col === col.key;
|
|
@@ -278,15 +347,18 @@ export interface MatrixLegendProps {
|
|
|
278
347
|
}
|
|
279
348
|
|
|
280
349
|
function MatrixLegend({ lessLabel, moreLabel }: MatrixLegendProps) {
|
|
281
|
-
const { heatBase } = useMatrix();
|
|
350
|
+
const { heatBase, heatLegend } = useMatrix();
|
|
282
351
|
const locale = useLoticsLocale();
|
|
352
|
+
// Nothing painted, nothing to explain — a less→more ramp over an empty grid
|
|
353
|
+
// asserts a scale no cell is on.
|
|
354
|
+
if (heatLegend.length === 0) return null;
|
|
283
355
|
return (
|
|
284
356
|
<View style={styles.scale}>
|
|
285
357
|
<Text size="xs" color="muted">
|
|
286
358
|
{lessLabel ?? locale.matrix.less}
|
|
287
359
|
</Text>
|
|
288
|
-
{
|
|
289
|
-
<View key={
|
|
360
|
+
{heatLegend.map((alpha, i) => (
|
|
361
|
+
<View key={i} style={[styles.swatch, { backgroundColor: withAlpha(heatBase, alpha) }]} />
|
|
290
362
|
))}
|
|
291
363
|
<Text size="xs" color="muted">
|
|
292
364
|
{moreLabel ?? locale.matrix.more}
|
|
@@ -296,13 +368,26 @@ function MatrixLegend({ lessLabel, moreLabel }: MatrixLegendProps) {
|
|
|
296
368
|
}
|
|
297
369
|
|
|
298
370
|
Matrix.Header = MatrixHeader;
|
|
299
|
-
Matrix.Grid = MatrixGrid;
|
|
371
|
+
Matrix.Grid = Object.assign(MatrixGrid, { isMatrixGrid: true as const });
|
|
300
372
|
Matrix.Totals = Object.assign(MatrixTotals, { isMatrixTotals: true as const });
|
|
301
373
|
Matrix.Legend = MatrixLegend;
|
|
302
374
|
|
|
303
375
|
const styles = StyleSheet.create({
|
|
376
|
+
scrollContent: {
|
|
377
|
+
// Lưới hẹp hơn khung thì vẫn trải hết khung — không có dòng này nó co về bề
|
|
378
|
+
// ngang nội dung và bỏ lại một khoảng trắng bên phải.
|
|
379
|
+
minWidth: "100%",
|
|
380
|
+
},
|
|
304
381
|
container: {
|
|
305
382
|
gap: MATRIX_COL_GAP,
|
|
383
|
+
// Và `minWidth: "100%"` một mình KHÔNG đủ: khung cuộn ngang xếp con theo
|
|
384
|
+
// HÀNG, nên cái khung nội dung rộng bằng khung ngoài còn cái lưới bên trong
|
|
385
|
+
// vẫn co về bề ngang nội dung của nó. Chỗ trống ấy không hiện ra thành một
|
|
386
|
+
// khoảng trắng ai cũng thấy — nó hiện ra thành những ô xuống dòng: lưới tự
|
|
387
|
+
// tin mình chỉ đáng bấy nhiêu, ô đứng ở sàn 64, còn một phần ba cái thẻ bỏ
|
|
388
|
+
// không ngay bên cạnh. Cho nó nhận phần dư thì ô rộng ra trước, và cuộn
|
|
389
|
+
// ngang chỉ còn xảy ra khi lưới thật sự dài hơn khung.
|
|
390
|
+
flexGrow: 1,
|
|
306
391
|
},
|
|
307
392
|
headRow: {
|
|
308
393
|
flexDirection: "row",
|
|
@@ -320,13 +405,28 @@ const styles = StyleSheet.create({
|
|
|
320
405
|
},
|
|
321
406
|
rowLabel: {
|
|
322
407
|
paddingRight: 8,
|
|
408
|
+
// Cột nhãn không bao giờ nhường chỗ: nó là thứ duy nhất nói mỗi hàng là gì.
|
|
409
|
+
flexShrink: 0,
|
|
410
|
+
},
|
|
411
|
+
// Chỉ hàng dữ liệu: ô nhãn là một hàng ngang mark + chữ. Dải Tổng và góc tiêu
|
|
412
|
+
// đề không mang mark nên giữ nguyên `rowLabel`.
|
|
413
|
+
rowLabelCell: {
|
|
414
|
+
flexDirection: "row",
|
|
415
|
+
alignItems: "center",
|
|
416
|
+
gap: LEADING_GAP,
|
|
417
|
+
},
|
|
418
|
+
rowLabelText: {
|
|
419
|
+
// Chữ nhận phần còn lại sau cái mark; `minWidth: 0` để nó chịu cắt bớt thay
|
|
420
|
+
// vì đẩy mark ra ngoài mép cột.
|
|
421
|
+
flex: 1,
|
|
422
|
+
minWidth: 0,
|
|
323
423
|
},
|
|
324
424
|
cell: {
|
|
325
425
|
flex: 1,
|
|
326
426
|
// 52 + hai lần MATRIX_CELL_PAD: cái đệm mới ăn vào bề ngang, nên sàn tối
|
|
327
427
|
// thiểu phải cộng thêm đúng ngần ấy, không thì ô chật đi và con số dài nhất
|
|
328
428
|
// là con số đầu tiên bị ép.
|
|
329
|
-
minWidth:
|
|
429
|
+
minWidth: MATRIX_CELL_MIN,
|
|
330
430
|
height: 36,
|
|
331
431
|
borderRadius: 4,
|
|
332
432
|
borderWidth: 2,
|
|
@@ -372,7 +472,7 @@ const styles = StyleSheet.create({
|
|
|
372
472
|
// many columns in a narrow frame never gets a total column narrower than it
|
|
373
473
|
// used to have.
|
|
374
474
|
flex: 1,
|
|
375
|
-
minWidth:
|
|
475
|
+
minWidth: MATRIX_TOTAL_MIN,
|
|
376
476
|
paddingLeft: 4,
|
|
377
477
|
},
|
|
378
478
|
totalsRow: {
|
package/src/matrix_totals.ts
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* The data layer for `Matrix` — axis types + the cross-tab aggregation. Kept
|
|
3
|
-
* free of any React-Native import
|
|
4
|
-
*
|
|
5
|
-
*
|
|
5
|
+
* free of any React-Native import (the axis mark is a TYPE-only `ReactNode`, so
|
|
6
|
+
* nothing here runs) and it unit-tests directly: the component in `matrix.tsx`
|
|
7
|
+
* renders these numbers, and a consumer can drive a KPI off the same
|
|
8
|
+
* `matrixTotals` it shows in the grid.
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
export interface MatrixAxisItem {
|
|
9
12
|
key: string;
|
|
10
13
|
label: string;
|
|
14
|
+
/**
|
|
15
|
+
* An identity mark before the label — an `Avatar`, a status dot. ROWS ONLY:
|
|
16
|
+
* a row is a subject and a mark is what makes it recognizable at a glance,
|
|
17
|
+
* while a column is a period or a category and has no identity to carry, so
|
|
18
|
+
* `Matrix.Header` ignores this. The mark is drawn INSIDE `rowLabelWidth`, so
|
|
19
|
+
* it eats the label's width rather than widening the column.
|
|
20
|
+
*/
|
|
21
|
+
leading?: ReactNode;
|
|
11
22
|
}
|
|
12
23
|
|
|
13
24
|
/** The inspected cell — host renders its detail (a filtered list) underneath. */
|
|
@@ -24,6 +35,63 @@ export interface MatrixTotalsResult {
|
|
|
24
35
|
max: number;
|
|
25
36
|
}
|
|
26
37
|
|
|
38
|
+
/** How a cell's value becomes ink. See `matrixHeatScale`. */
|
|
39
|
+
export type MatrixHeatScale = "linear" | "rank";
|
|
40
|
+
|
|
41
|
+
export interface MatrixHeatScaleResult {
|
|
42
|
+
/** The alpha this scale paints a cell with. Zero (and below) paints nothing. */
|
|
43
|
+
alpha: (value: number) => number;
|
|
44
|
+
/** The legend's swatches — the SAME alphas, sampled across the painted set. */
|
|
45
|
+
legend: number[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Value → ink, and the legend that explains it, from ONE expression.
|
|
50
|
+
*
|
|
51
|
+
* `linear` maps a cell onto the band by its share of the largest cell. That is
|
|
52
|
+
* honest only when the values are spread; on the long tail every real dataset
|
|
53
|
+
* has (one big month, eleven ordinary ones) it spends the whole band on the
|
|
54
|
+
* outlier and leaves everything else inside a step the eye cannot resolve.
|
|
55
|
+
* `rank` maps a cell by its POSITION among the DISTINCT values painted, so the
|
|
56
|
+
* band is spent on the differences the reader can see, the smallest cell always
|
|
57
|
+
* lands on `lo` and the largest on `hi`, and equal cells keep equal ink.
|
|
58
|
+
*
|
|
59
|
+
* The legend is SAMPLED from that same function rather than restated, so it can
|
|
60
|
+
* never paint a shade the grid cannot reach: a legend written as its own list of
|
|
61
|
+
* alphas is a second copy of the scale, and it drifts the moment the band moves.
|
|
62
|
+
*/
|
|
63
|
+
export function matrixHeatScale(
|
|
64
|
+
values: number[],
|
|
65
|
+
scale: MatrixHeatScale,
|
|
66
|
+
band: [number, number],
|
|
67
|
+
): MatrixHeatScaleResult {
|
|
68
|
+
const [lo, hi] = band;
|
|
69
|
+
const distinct = [...new Set(values.filter((v) => v > 0))].sort((a, b) => a - b);
|
|
70
|
+
const top = distinct[distinct.length - 1] ?? 1;
|
|
71
|
+
const at = (t: number) => lo + (hi - lo) * t;
|
|
72
|
+
|
|
73
|
+
// One pass to rank: a per-cell scan would be quadratic on a wide sheet.
|
|
74
|
+
const rank = new Map<number, number>();
|
|
75
|
+
distinct.forEach((v, i) => rank.set(v, i));
|
|
76
|
+
|
|
77
|
+
const alpha = (v: number): number => {
|
|
78
|
+
if (v <= 0) return 0;
|
|
79
|
+
// A single distinct value has no order to spend a band on — every cell is
|
|
80
|
+
// the largest one, and the top of the band is what says so.
|
|
81
|
+
if (distinct.length < 2) return hi;
|
|
82
|
+
if (scale === "linear") return at(v / top);
|
|
83
|
+
return at((rank.get(v) ?? distinct.length - 1) / (distinct.length - 1));
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
alpha,
|
|
88
|
+
legend:
|
|
89
|
+
distinct.length === 0
|
|
90
|
+
? []
|
|
91
|
+
: [0, 0.25, 0.5, 0.75, 1].map((q) => alpha(distinct[Math.round(q * (distinct.length - 1))])),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
27
95
|
/**
|
|
28
96
|
* Row sums, column sums, grand total, and the max single cell — computed in one
|
|
29
97
|
* pass. The single source of truth for the matrix's totals.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Month arithmetic on `yyyy-MM-dd` strings, free of React and of the clock.
|
|
2
|
+
// A month is keyed by its first day so a value round-trips through a date
|
|
3
|
+
// field unchanged.
|
|
4
|
+
|
|
5
|
+
/** The first day of the month `iso` falls in. */
|
|
6
|
+
export function monthStart(iso: string): string {
|
|
7
|
+
return `${iso.slice(0, 7)}-01`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** `delta` months from `iso`, as that month's first day. */
|
|
11
|
+
export function shiftMonth(iso: string, delta: number): string {
|
|
12
|
+
const y = Number(iso.slice(0, 4));
|
|
13
|
+
const m = Number(iso.slice(5, 7)) - 1 + delta;
|
|
14
|
+
const d = new Date(y, m, 1);
|
|
15
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-01`;
|
|
16
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { formatDate } from "./format_date";
|
|
3
|
+
import { IconButton } from "./icon_button";
|
|
4
|
+
import { useLoticsLocale } from "./locale";
|
|
5
|
+
import { monthStart, shiftMonth } from "./month_math";
|
|
6
|
+
import { Text } from "./text";
|
|
7
|
+
|
|
8
|
+
export interface MonthStepperLabels {
|
|
9
|
+
previous: string;
|
|
10
|
+
next: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface MonthStepperProps {
|
|
14
|
+
/** The month, as any day in it (`yyyy-MM-dd`); `onChange` always hands back its first day. */
|
|
15
|
+
value: string;
|
|
16
|
+
onChange: (next: string) => void;
|
|
17
|
+
/** The last month that may be stepped INTO — this month, for a surface about what has happened. */
|
|
18
|
+
max?: string;
|
|
19
|
+
min?: string;
|
|
20
|
+
labels?: Partial<MonthStepperLabels>;
|
|
21
|
+
/** BCP-47 for the month name. Defaults to the active locale. */
|
|
22
|
+
locale?: string;
|
|
23
|
+
testID?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* ‹ Tháng 9 2026 › — the period control for a surface keyed by a MONTH: a
|
|
28
|
+
* plan, a roster, a month's attendance. `DateRangeFilterField` is for a
|
|
29
|
+
* surface that takes any range; a month-keyed record cannot take "the 3rd to
|
|
30
|
+
* the 19th", so the control offers only the moves that exist.
|
|
31
|
+
*
|
|
32
|
+
* The bound is the contract. A stepper that walks into next year reads a plan
|
|
33
|
+
* for a month nobody has given one for, and two hand-rolled steppers in one
|
|
34
|
+
* app disagreed on exactly this — one refused the future, one did not.
|
|
35
|
+
*/
|
|
36
|
+
export function MonthStepper(props: MonthStepperProps) {
|
|
37
|
+
const { value, onChange, max, min, labels, locale, testID } = props;
|
|
38
|
+
const pack = useLoticsLocale();
|
|
39
|
+
const l = { ...pack.monthStepper, ...labels };
|
|
40
|
+
const month = monthStart(value);
|
|
41
|
+
const prev = shiftMonth(month, -1);
|
|
42
|
+
const next = shiftMonth(month, 1);
|
|
43
|
+
const atMin = min !== undefined && prev < monthStart(min);
|
|
44
|
+
const atMax = max !== undefined && next > monthStart(max);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<View style={styles.row} testID={testID}>
|
|
48
|
+
<IconButton icon="chevron-left" size="sm" accessibilityLabel={l.previous} disabled={atMin} onPress={() => onChange(prev)} />
|
|
49
|
+
<Text size="sm" weight="medium" tabular align="center" numberOfLines={1} style={styles.label}>
|
|
50
|
+
{formatDate(`${month}T00:00:00`, { format: "monthYear", locale: locale ?? pack.bcp47 })}
|
|
51
|
+
</Text>
|
|
52
|
+
<IconButton icon="chevron-right" size="sm" accessibilityLabel={l.next} disabled={atMax} onPress={() => onChange(next)} />
|
|
53
|
+
</View>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const styles = StyleSheet.create({
|
|
58
|
+
row: { flexDirection: "row", alignItems: "center", gap: 4, alignSelf: "flex-start" },
|
|
59
|
+
// Wide enough for the longest month name in either pack, so the arrows do not
|
|
60
|
+
// shuffle sideways as the reader steps.
|
|
61
|
+
label: { minWidth: 148 },
|
|
62
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An organization's name without the legal form it opens or closes with.
|
|
3
|
+
*
|
|
4
|
+
* A register of companies marks the same letters on every row, because the
|
|
5
|
+
* name opens with "Công ty TNHH" or closes with "Ltd." — the identity mark is
|
|
6
|
+
* derived from the first letters, and the first 60px of the name column are
|
|
7
|
+
* spent on words that tell no two rows apart. composition.md § Identity marks
|
|
8
|
+
* says to strip the form from BOTH ends before the name reaches the mark, and
|
|
9
|
+
* to show the stripped name as the row's title; this is that function, so two
|
|
10
|
+
* screens cannot disagree about what counts as a form.
|
|
11
|
+
*
|
|
12
|
+
* The forms are the ones the kit's two locales meet. A name that IS only a
|
|
13
|
+
* form comes back unchanged — an empty title is worse than a generic one.
|
|
14
|
+
*/
|
|
15
|
+
const LEADING = [
|
|
16
|
+
"công ty cổ phần",
|
|
17
|
+
"công ty cp",
|
|
18
|
+
"cty cp",
|
|
19
|
+
"công ty tnhh một thành viên",
|
|
20
|
+
"công ty tnhh mtv",
|
|
21
|
+
"công ty tnhh",
|
|
22
|
+
"cty tnhh",
|
|
23
|
+
"tổng công ty",
|
|
24
|
+
"công ty",
|
|
25
|
+
"cty",
|
|
26
|
+
"hợp tác xã",
|
|
27
|
+
"htx",
|
|
28
|
+
"hộ kinh doanh",
|
|
29
|
+
"hkd",
|
|
30
|
+
"doanh nghiệp tư nhân",
|
|
31
|
+
"dntn",
|
|
32
|
+
"tập đoàn",
|
|
33
|
+
"chi nhánh",
|
|
34
|
+
"the",
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const TRAILING = [
|
|
38
|
+
"co., ltd.",
|
|
39
|
+
"co., ltd",
|
|
40
|
+
"co. ltd",
|
|
41
|
+
"co ltd",
|
|
42
|
+
"pte. ltd.",
|
|
43
|
+
"pte ltd",
|
|
44
|
+
"ltd.",
|
|
45
|
+
"ltd",
|
|
46
|
+
"limited",
|
|
47
|
+
"llc",
|
|
48
|
+
"inc.",
|
|
49
|
+
"inc",
|
|
50
|
+
"corp.",
|
|
51
|
+
"corp",
|
|
52
|
+
"corporation",
|
|
53
|
+
"company",
|
|
54
|
+
"jsc",
|
|
55
|
+
"plc",
|
|
56
|
+
"gmbh",
|
|
57
|
+
"s.a.",
|
|
58
|
+
"cổ phần",
|
|
59
|
+
"tnhh",
|
|
60
|
+
"cp",
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
/** Longest first, so "công ty tnhh mtv" is tried before "công ty tnhh" before "công ty". */
|
|
64
|
+
const byLength = (a: string, b: string) => b.length - a.length;
|
|
65
|
+
const LEAD = [...LEADING].sort(byLength);
|
|
66
|
+
const TRAIL = [...TRAILING].sort(byLength);
|
|
67
|
+
|
|
68
|
+
const SEP = /^[\s,.\-–—]+|[\s,.\-–—]+$/g;
|
|
69
|
+
|
|
70
|
+
function stripOnce(name: string): string {
|
|
71
|
+
const lower = name.toLowerCase();
|
|
72
|
+
for (const form of LEAD) {
|
|
73
|
+
if (lower.startsWith(form) && (lower.length === form.length || /[\s,.]/.test(lower.charAt(form.length)))) {
|
|
74
|
+
return name.slice(form.length).replace(SEP, "");
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
for (const form of TRAIL) {
|
|
78
|
+
const at = lower.length - form.length;
|
|
79
|
+
if (at > 0 && lower.endsWith(form) && /[\s,.]/.test(lower.charAt(at - 1))) {
|
|
80
|
+
return name.slice(0, at).replace(SEP, "");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return name;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function stripLegalForm(name: string): string {
|
|
87
|
+
let cur = name.trim();
|
|
88
|
+
for (;;) {
|
|
89
|
+
const next = stripOnce(cur);
|
|
90
|
+
if (next === cur) break;
|
|
91
|
+
cur = next;
|
|
92
|
+
}
|
|
93
|
+
return cur === "" ? name.trim() : cur;
|
|
94
|
+
}
|
package/src/search_match.ts
CHANGED
|
@@ -16,3 +16,22 @@ export function normalizeForSearch(s: string): string {
|
|
|
16
16
|
.replace(/\p{Diacritic}/gu, "")
|
|
17
17
|
.replaceAll(String.fromCharCode(0x111), "d");
|
|
18
18
|
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A CODE the way a person types it — a plate, a container number, a voucher
|
|
22
|
+
* number. `14B-031.02` is punctuated three different ways across one yard's
|
|
23
|
+
* paperwork and nobody typing fast reproduces any of them, so both sides drop
|
|
24
|
+
* everything that is not a letter or a digit before matching: `14b031` finds
|
|
25
|
+
* it, and so does `031.02`. Still a substring test, never a subsequence one, or
|
|
26
|
+
* `1402` would match half the register.
|
|
27
|
+
*/
|
|
28
|
+
export function codeKey(s: string): string {
|
|
29
|
+
return normalizeForSearch(s).replace(/[^a-z0-9]/g, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Does any of these fields answer the query, as a code? An empty query matches everything. */
|
|
33
|
+
export function codeMatches(query: string, ...fields: string[]): boolean {
|
|
34
|
+
const q = codeKey(query);
|
|
35
|
+
if (q === "") return true;
|
|
36
|
+
return fields.some((f) => codeKey(f).includes(q));
|
|
37
|
+
}
|