@lotics/ui 44.4.0 → 44.6.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/MIGRATION.md +23 -0
- package/docs/catalog.md +4 -1
- package/docs/composition.md +35 -2
- package/docs/reviewing.md +51 -0
- package/examples/tpl_item_list.tsx +125 -47
- package/package.json +1 -1
- package/src/locale.tsx +8 -4
- package/src/pagination.tsx +28 -17
- package/src/text_disclosure.tsx +3 -1
package/MIGRATION.md
CHANGED
|
@@ -4,6 +4,29 @@ Breaking changes, newest first — normally per major, plus the rare minor that
|
|
|
4
4
|
anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
|
|
5
5
|
this file exists only to move an app from one release to the next.
|
|
6
6
|
|
|
7
|
+
## 44.5.0 — `Pagination` drops the page index
|
|
8
|
+
|
|
9
|
+
The pager rendered a range AND a page index — `16–30 of 45` beside `Page 2 of 3`. Both answer
|
|
10
|
+
"where am I", and the second enables no decision the first does not: how much is left is in the
|
|
11
|
+
total, and whether this is the end is in the disabled arrow. The index is gone; the range is the
|
|
12
|
+
whole label.
|
|
13
|
+
|
|
14
|
+
**`PaginationLabels.pageWithTotal` and `.page` are removed.** Delete them from any `labels` object
|
|
15
|
+
— every other field is unchanged, so that is the whole edit.
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
// before
|
|
19
|
+
labels={{ rangeWithTotal: (s, e, t) => `${s}–${e} trên ${t}`,
|
|
20
|
+
pageWithTotal: (p, n) => `Trang ${p} / ${n}`, page: (p) => `Trang ${p}`,
|
|
21
|
+
previous: "Trước", next: "Sau" }}
|
|
22
|
+
// after
|
|
23
|
+
labels={{ rangeWithTotal: (s, e, t) => `${s}–${e} trên ${t}`, previous: "Trước", next: "Sau" }}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Not breaking, but do it in the same pass: the range is now the register's only statement of the
|
|
27
|
+
total, so a `Table` paired with the pager takes **`counted`** instead of `count`. Left as `count`,
|
|
28
|
+
the matched total prints twice a few rows apart.
|
|
29
|
+
|
|
7
30
|
## 44.4.0 — `MemberChip`'s `marker` is gone
|
|
8
31
|
|
|
9
32
|
A minor that broke a type, recorded here under its exact version rather than held
|
package/docs/catalog.md
CHANGED
|
@@ -1366,7 +1366,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1366
1366
|
- **`data_grid`** — `DataGrid` + `gridRowStyle`: the inline-managed grouped table (see
|
|
1367
1367
|
[Tabular data](#tabular-data--pick-by-scale--intent)); `labels` localizes the sort-header
|
|
1368
1368
|
a11y via `SortHeaderLabels`.
|
|
1369
|
-
- **`pagination`** — `Pagination` (+ `PaginationLabels`): the register's pager
|
|
1369
|
+
- **`pagination`** — `Pagination` (+ `PaginationLabels`): the register's pager — a range and two
|
|
1370
|
+
arrows, no page index. ONE per register, ABOVE the rows on the `SummaryLine` row; its range is
|
|
1371
|
+
the register's count (pair with `Table` `counted`), and `onPageChange` owes a scroll reset the
|
|
1372
|
+
control cannot do itself — see [composition.md](./composition.md) §register.
|
|
1370
1373
|
- **`column_filter`** — `ColumnFilter` + `columnFilterToConditions` +
|
|
1371
1374
|
`isColumnFilterActive` + `columnFilterSummary`: the typed per-column filter pill; for a
|
|
1372
1375
|
register filtering on several columns.
|
package/docs/composition.md
CHANGED
|
@@ -587,8 +587,41 @@ hook (`@lotics/ui/use_selection`: `selected` / `has` / `count` / `toggle` / `set
|
|
|
587
587
|
`allSelected` / `indeterminate` / `clear`) — gating which rows CAN be ticked stays with you: pass
|
|
588
588
|
only the selectable ids to `setAll`/`allSelected`/`indeterminate` (a non-selectable row gets a
|
|
589
589
|
disabled checkbox, keeping the gutter aligned). Selection persists across pages, so `count` is the
|
|
590
|
-
running total. `tpl_item_list` is the reference. Paginate OUTSIDE (slice + `Pagination`
|
|
591
|
-
|
|
590
|
+
running total. `tpl_item_list` is the reference. Paginate OUTSIDE (slice + `Pagination`).
|
|
591
|
+
|
|
592
|
+
**A paginated register owes the reader a scroll reset, and it is not the pager's to give.** Putting
|
|
593
|
+
the pager under the rows is inherited from full page loads, where the browser reset the scroll for
|
|
594
|
+
free; an SPA swaps the rows under a viewport that does not move, so paging from the bottom lands on
|
|
595
|
+
the LAST rows of the next page with its first ones already scrolled past. Every control that changes
|
|
596
|
+
WHICH rows are on screen owes the same reset — page, tab, search, sort, each filter — so route them
|
|
597
|
+
all through ONE function that moves the window and scrolls (`goToPage` / `resetPaging` in
|
|
598
|
+
`tpl_item_list`), never a `setPage(0)` at each call site that also has to remember to scroll.
|
|
599
|
+
|
|
600
|
+
**ONE pager, ABOVE the rows.** Once the reset returns the viewport to the top on every press, the
|
|
601
|
+
top is where the reader's cursor already is. A second copy under the rows is the same state said
|
|
602
|
+
twice, and two identical clusters of arrows read as two controls.
|
|
603
|
+
|
|
604
|
+
**Put it on the `SummaryLine` row, not a row of its own** — summary at `flex: 1`, pager sized to its
|
|
605
|
+
content, the row aligned `flex-start` so the arrows stay on the summary's FIRST line when it wraps.
|
|
606
|
+
Both describe the window the toolbar just set (what matched, and which slice is on screen), so they
|
|
607
|
+
are one line. A pager given its own row is a small cluster against a mostly empty band — measure it
|
|
608
|
+
before believing the row is needed; a four-item summary and a full pager leave a quarter of a
|
|
609
|
+
1000px band spare.
|
|
610
|
+
|
|
611
|
+
Never hand `Pagination` the full width with an empty left slot. It gives its range `flex: 1` so a
|
|
612
|
+
band whose left slot HOLDS something pushes the arrows to the far edge; with nothing there, that
|
|
613
|
+
same rule strands the range hundreds of pixels from the arrows it belongs to — two halves of one
|
|
614
|
+
control reading as two unrelated things on a line.
|
|
615
|
+
|
|
616
|
+
**The pager's range IS the register's count** (`16–30 of 45`), so pair it with `Table` `counted`,
|
|
617
|
+
never `count` — the latter prints the matched total again a few rows below, and one number in two
|
|
618
|
+
places is two numbers that can disagree. Row totals beside the pager go the same way when a
|
|
619
|
+
`SummaryLine` above already states them.
|
|
620
|
+
|
|
621
|
+
There is deliberately no page index. `Page 2 of 3` beside `16–30 of 45` answers the same question a
|
|
622
|
+
second way and enables no decision the range does not — how much is left is in the total, whether
|
|
623
|
+
this is the end is in the disabled arrow. A control that is reached for rarely (a register built
|
|
624
|
+
around search and filters is meant to be NARROWED, not paged) earns one label, not two.
|
|
592
625
|
|
|
593
626
|
Right-hand columns align only if every trailing element is FIXED-width — give each trailing action
|
|
594
627
|
a fixed `width`, so amount/status columns don't jitter.
|
package/docs/reviewing.md
CHANGED
|
@@ -236,6 +236,13 @@ A resting register is the cheapest thing to screenshot and the least likely to h
|
|
|
236
236
|
(a feed, a thread) above a bounded one buries it, and the burial deepens with use.
|
|
237
237
|
- **Press every control** and look at the state you land in. Commonest miss: a value rendering as
|
|
238
238
|
a coloured chip in a register and as bare grey text in its own EDITOR.
|
|
239
|
+
- **TAB through it, then diff what a keyboard reaches against what LOOKS pressable.** A pointer
|
|
240
|
+
finds anything with an `onPress`; a keyboard finds only what carries a role and a tab stop, and
|
|
241
|
+
the two lists come apart silently — the screen looks identical either way, and the author
|
|
242
|
+
testing with a mouse never learns. The reliable producer is styled text: underline, a hover
|
|
243
|
+
wash, a "verb" colour applied to a plain container, which reads as a control to the eye while
|
|
244
|
+
announcing as prose. Anything the eye calls a control and the tab order omits is unreachable,
|
|
245
|
+
not merely awkward. Count both lists; a difference is the finding.
|
|
239
246
|
- **Diff a promoted field against where it came from.** Promoting means MOVING; a copy left behind
|
|
240
247
|
gives one field two editors.
|
|
241
248
|
- **Read every string in its SETTLED state**, not the streaming one.
|
|
@@ -419,6 +426,20 @@ is the usual cause.
|
|
|
419
426
|
Note the RN-Web trap: once a `Text` contains a nested pressable, `numberOfLines` degrades to bare
|
|
420
427
|
`overflow: hidden` and loses the ellipsis.
|
|
421
428
|
|
|
429
|
+
### Measure INK, not the box — a stretched label hides the gap it sits in
|
|
430
|
+
|
|
431
|
+
```js
|
|
432
|
+
// getBoundingClientRect on an RN-Web Text returns the DIV, which stretches to
|
|
433
|
+
// its flex slot. Range over the contents to get where the glyphs actually end.
|
|
434
|
+
(sel) => { const rg = document.createRange(); rg.selectNodeContents(document.querySelector(sel)); return rg.getBoundingClientRect(); }
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
A label inside a `flex: 1` slot measures as wide as the slot, so a box-to-box gap between it and
|
|
438
|
+
its neighbour reads as the container's `gap` — 12px — while the reader sees hundreds of pixels of
|
|
439
|
+
void between the last glyph and the next element. Any gap RATIO taken across a stretching text is
|
|
440
|
+
wrong in the reassuring direction. Use this whenever one side of a measured gap is text that could
|
|
441
|
+
be filling a flex slot rather than sizing to its content.
|
|
442
|
+
|
|
422
443
|
### Grouping — is one `gap` flattening the hierarchy?
|
|
423
444
|
|
|
424
445
|
```js
|
|
@@ -459,6 +480,36 @@ receives `mouseenter`, because the pointer is already stationary over it — so
|
|
|
459
480
|
dies at the moment of the swap and reads as a flash. Keep one node across the swap rather than
|
|
460
481
|
restoring the state by hand.
|
|
461
482
|
|
|
483
|
+
### Reachability — what the eye calls a control against what the tab order holds
|
|
484
|
+
|
|
485
|
+
Two lists off one render. The first is what a keyboard can operate; the second is everything that
|
|
486
|
+
LOOKS operable — styled text is the producer, so underline is the cheapest thing to sweep for.
|
|
487
|
+
|
|
488
|
+
```js
|
|
489
|
+
() => {
|
|
490
|
+
const root = document.querySelector('[role="dialog"]') ?? document.body;
|
|
491
|
+
const operable = [...root.querySelectorAll(
|
|
492
|
+
'a[href], button, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"]), input, select, textarea',
|
|
493
|
+
)];
|
|
494
|
+
const looksPressable = [...root.querySelectorAll("*")].filter((e) => {
|
|
495
|
+
if (e.children.length || !(e.textContent || "").trim()) return false;
|
|
496
|
+
const cs = getComputedStyle(e);
|
|
497
|
+
return cs.textDecorationLine.includes("underline") || cs.cursor === "pointer";
|
|
498
|
+
});
|
|
499
|
+
const unreachable = looksPressable.filter((e) => !e.closest(
|
|
500
|
+
'a[href], button, [role="button"], [role="link"], [tabindex]:not([tabindex="-1"])',
|
|
501
|
+
));
|
|
502
|
+
return {
|
|
503
|
+
operable: operable.length,
|
|
504
|
+
unreachable: unreachable.map((e) => (e.textContent || "").trim().slice(0, 40)),
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
`unreachable` must come back empty. A name in it is a control only a pointer can reach — and the
|
|
510
|
+
count beside it is the other half of the reading: a whole pane offering two or three tab stops is
|
|
511
|
+
usually not a minimal screen, it is a screen whose verbs are all prose.
|
|
512
|
+
|
|
462
513
|
Driving the kit's own anatomies — a `PressDoor` row, a portalled overlay, a custom pointer drag —
|
|
463
514
|
has three gotchas of its own: [testing.md](./testing.md).
|
|
464
515
|
|
|
@@ -159,6 +159,34 @@ const HO_SO: HoSo[] = [
|
|
|
159
159
|
{ ma: "RC-2026-0061", binhLuan: 1, khach: "Harbor Industries", dienThoai: "(555) 047-5829", trangThai: "dangxuly", phi: 6_800_000, daThu: true, ngayThu: "11/06", ngayNhan: "10/06", phuTrach: "Maria Lopez" },
|
|
160
160
|
{ ma: "RC-2026-0062", binhLuan: 0, khach: "Grace Holloway", dienThoai: "(555) 826-3050", trangThai: "hoantat", phi: 3_100_000, daThu: true, ngayThu: "05/06", ngayNhan: "31/05", phuTrach: "David Park" },
|
|
161
161
|
{ ma: "RC-2026-0063", binhLuan: 3, khach: "Olivia Manning", dienThoai: "(555) 631-2904", trangThai: "dangxuly", phi: 1_200_000, daThu: false, ngayNhan: "11/06", phuTrach: "Sarah Chen" },
|
|
162
|
+
// The book runs past one SCREEN on purpose, not just past one page. A register
|
|
163
|
+
// whose page fits the viewport cannot exhibit the defect the paging controls
|
|
164
|
+
// below are shaped around — the reader never has to scroll, so it never
|
|
165
|
+
// matters where the viewport is left when the rows change. This example ran on
|
|
166
|
+
// 23 records at 8 a page for exactly that reason, and the reset was missing
|
|
167
|
+
// from the reference the whole time.
|
|
168
|
+
{ ma: "RC-2026-0064", binhLuan: 0, khach: "Marcus Webb", dienThoai: "(555) 318-7742", trangThai: "hoantat", phi: 2_500_000, daThu: true, ngayThu: "07/06", ngayNhan: "01/06", phuTrach: "David Park" },
|
|
169
|
+
{ ma: "RC-2026-0065", binhLuan: 2, khach: "Ironwood Textiles", dienThoai: "(555) 924-6018", trangThai: "dangxuly", phi: 5_600_000, daThu: false, ngayNhan: "12/06", phuTrach: "Maria Lopez" },
|
|
170
|
+
{ ma: "RC-2026-0066", binhLuan: 0, khach: "Helen Vargas", dienThoai: "(555) 673-2295", trangThai: "chobosung", phi: 1_800_000, daThu: true, ngayThu: "11/06", ngayNhan: "10/06", phuTrach: "Sarah Chen" },
|
|
171
|
+
{ ma: "RC-2026-0067", binhLuan: 4, khach: "Peter Nakamura", dienThoai: "(555) 405-8830", trangThai: "dangxuly", phi: 3_100_000, daThu: true, ngayThu: "12/06", ngayNhan: "11/06", phuTrach: "James Walker" },
|
|
172
|
+
{ ma: "RC-2026-0068", binhLuan: 0, khach: "Redstone Millwork", dienThoai: "(555) 217-5546", trangThai: "hoantat", phi: 6_800_000, daThu: true, ngayThu: "04/06", ngayNhan: "30/05", phuTrach: "David Park" },
|
|
173
|
+
{ ma: "RC-2026-0069", binhLuan: 1, khach: "Sandra Piper", dienThoai: "(555) 860-3971", trangThai: "dangxuly", phi: 1_200_000, daThu: false, ngayNhan: "12/06", phuTrach: "Maria Lopez" },
|
|
174
|
+
{ ma: "RC-2026-0070", binhLuan: 0, khach: "Owen Fitzgerald", dienThoai: "(555) 592-1487", trangThai: "chobosung", phi: 2_500_000, daThu: false, ngayNhan: "13/06", phuTrach: "Sarah Chen" },
|
|
175
|
+
{ ma: "RC-2026-0071", binhLuan: 3, khach: "Lakeshore Dairy", dienThoai: "(555) 736-9204", trangThai: "hoantat", phi: 4_200_000, daThu: true, ngayThu: "02/06", ngayNhan: "27/05", phuTrach: "James Walker" },
|
|
176
|
+
{ ma: "RC-2026-0072", binhLuan: 0, khach: "Deborah Quinn", dienThoai: "(555) 148-6673", trangThai: "dangxuly", phi: 1_800_000, daThu: true, ngayThu: "13/06", ngayNhan: "12/06", phuTrach: "David Park" },
|
|
177
|
+
{ ma: "RC-2026-0073", binhLuan: 2, khach: "Vincent Osei", dienThoai: "(555) 389-2015", trangThai: "chobosung", phi: 3_100_000, daThu: false, ngayNhan: "13/06", phuTrach: "Maria Lopez" },
|
|
178
|
+
{ ma: "RC-2026-0074", binhLuan: 0, khach: "Pinehill Ceramics", dienThoai: "(555) 651-4738", trangThai: "hoantat", phi: 5_600_000, daThu: true, ngayThu: "06/06", ngayNhan: "31/05", phuTrach: "Sarah Chen" },
|
|
179
|
+
{ ma: "RC-2026-0075", binhLuan: 1, khach: "Alan Whitcombe", dienThoai: "(555) 902-8564", trangThai: "dangxuly", phi: 2_500_000, daThu: false, ngayNhan: "14/06", phuTrach: "James Walker" },
|
|
180
|
+
{ ma: "RC-2026-0076", binhLuan: 0, khach: "Priya Raghavan", dienThoai: "(555) 473-1290", trangThai: "dangxuly", phi: 6_800_000, daThu: true, ngayThu: "14/06", ngayNhan: "13/06", phuTrach: "David Park" },
|
|
181
|
+
{ ma: "RC-2026-0077", binhLuan: 5, khach: "Fairview Optics", dienThoai: "(555) 260-7451", trangThai: "chobosung", phi: 1_200_000, daThu: false, ngayNhan: "14/06", phuTrach: "Maria Lopez" },
|
|
182
|
+
{ ma: "RC-2026-0078", binhLuan: 0, khach: "Gregory Salazar", dienThoai: "(555) 815-3607", trangThai: "hoantat", phi: 3_100_000, daThu: true, ngayThu: "03/06", ngayNhan: "29/05", phuTrach: "Sarah Chen" },
|
|
183
|
+
{ ma: "RC-2026-0079", binhLuan: 2, khach: "Tessa Lindqvist", dienThoai: "(555) 547-9382", trangThai: "dangxuly", phi: 4_200_000, daThu: true, ngayThu: "15/06", ngayNhan: "14/06", phuTrach: "James Walker" },
|
|
184
|
+
{ ma: "RC-2026-0080", binhLuan: 0, khach: "Copperfield Tools", dienThoai: "(555) 031-6825", trangThai: "chobosung", phi: 1_800_000, daThu: false, ngayNhan: "15/06", phuTrach: "David Park" },
|
|
185
|
+
{ ma: "RC-2026-0081", binhLuan: 1, khach: "Naomi Ferrell", dienThoai: "(555) 794-2158", trangThai: "hoantat", phi: 2_500_000, daThu: true, ngayThu: "05/06", ngayNhan: "01/06", phuTrach: "Maria Lopez" },
|
|
186
|
+
{ ma: "RC-2026-0082", binhLuan: 0, khach: "Bridgeport Marine", dienThoai: "(555) 368-5093", trangThai: "dangxuly", phi: 5_600_000, daThu: false, ngayNhan: "16/06", phuTrach: "Sarah Chen" },
|
|
187
|
+
{ ma: "RC-2026-0083", binhLuan: 3, khach: "Ethan Castellanos", dienThoai: "(555) 620-4716", trangThai: "dangxuly", phi: 1_200_000, daThu: true, ngayThu: "16/06", ngayNhan: "15/06", phuTrach: "James Walker" },
|
|
188
|
+
{ ma: "RC-2026-0084", binhLuan: 0, khach: "Willow Creek Farms", dienThoai: "(555) 187-9340", trangThai: "chobosung", phi: 3_100_000, daThu: false, ngayNhan: "16/06", phuTrach: "David Park" },
|
|
189
|
+
{ ma: "RC-2026-0085", binhLuan: 2, khach: "Simone Beaufort", dienThoai: "(555) 452-8671", trangThai: "hoantat", phi: 6_800_000, daThu: true, ngayThu: "07/06", ngayNhan: "02/06", phuTrach: "Maria Lopez" },
|
|
162
190
|
];
|
|
163
191
|
|
|
164
192
|
const TABS: { value: Tab; label: string }[] = [
|
|
@@ -168,7 +196,9 @@ const TABS: { value: Tab; label: string }[] = [
|
|
|
168
196
|
{ value: "hoantat", label: "Complete" },
|
|
169
197
|
];
|
|
170
198
|
|
|
171
|
-
|
|
199
|
+
// Fifteen rows at the register's rhythm is taller than the viewport, which is
|
|
200
|
+
// the point: paging only has ergonomics once a page is something you SCROLL.
|
|
201
|
+
const PAGE_SIZE = 15;
|
|
172
202
|
|
|
173
203
|
// Distinct case handlers — the secondary filter dimension (a multi-select FilterChip).
|
|
174
204
|
const ASSIGNEES = [...new Set(HO_SO.map((r) => r.phuTrach))].map((n) => ({ label: n, value: n }));
|
|
@@ -1041,7 +1071,34 @@ export function TplItemList() {
|
|
|
1041
1071
|
// The register rows are state: the create dialog PREPENDS a real row and
|
|
1042
1072
|
// opens its drawer — create-then-refine.
|
|
1043
1073
|
const [rows, setRows] = useState<HoSo[]>(HO_SO);
|
|
1044
|
-
|
|
1074
|
+
// Past the LAST seeded code, not past the old one — a created row that reuses
|
|
1075
|
+
// an existing `ma` collides on the register's key and on every lookup by code.
|
|
1076
|
+
const nextMa = useRef(86);
|
|
1077
|
+
|
|
1078
|
+
/** The page's scroll container, so a control that changes the rows can put the
|
|
1079
|
+
* reader back at the first of them. */
|
|
1080
|
+
const scrollRef = useRef<ScrollView>(null);
|
|
1081
|
+
|
|
1082
|
+
/**
|
|
1083
|
+
* Move the window, and return the viewport to the top of it.
|
|
1084
|
+
*
|
|
1085
|
+
* The scroll is HALF the act, not a courtesy on top of it. Bottom pagination
|
|
1086
|
+
* is inherited from full page loads, where the browser reset the scroll for
|
|
1087
|
+
* free; an SPA swaps the rows under a viewport that does not move, so paging
|
|
1088
|
+
* from the bottom lands the reader on the LAST rows of the next page with its
|
|
1089
|
+
* first ones already behind them.
|
|
1090
|
+
*
|
|
1091
|
+
* EVERY control that changes which rows are on screen goes through here —
|
|
1092
|
+
* paging, tabs, search, sort, each filter — rather than each one calling
|
|
1093
|
+
* `setPage(0)` and remembering the scroll. That list is the reason this is one
|
|
1094
|
+
* function: nine call sites each responsible for two things is nine chances to
|
|
1095
|
+
* do one of them, and the one that gets forgotten fails silently.
|
|
1096
|
+
*/
|
|
1097
|
+
const goToPage = (p: number) => {
|
|
1098
|
+
setPage(p);
|
|
1099
|
+
scrollRef.current?.scrollTo({ y: 0, animated: false });
|
|
1100
|
+
};
|
|
1101
|
+
const resetPaging = () => goToPage(0);
|
|
1045
1102
|
const addRow = (khach: string, dienThoai: string, phi: number): string => {
|
|
1046
1103
|
const now = new Date();
|
|
1047
1104
|
const ma = `RC-2026-${String(nextMa.current++).padStart(4, "0")}`;
|
|
@@ -1067,7 +1124,7 @@ export function TplItemList() {
|
|
|
1067
1124
|
setAssignee([]);
|
|
1068
1125
|
setFeeStatus(null);
|
|
1069
1126
|
setSort(null);
|
|
1070
|
-
|
|
1127
|
+
resetPaging();
|
|
1071
1128
|
};
|
|
1072
1129
|
const createRecord = (khach: string, dienThoai: string, phi: number) => {
|
|
1073
1130
|
const ma = addRow(khach, dienThoai, phi);
|
|
@@ -1101,16 +1158,16 @@ export function TplItemList() {
|
|
|
1101
1158
|
// meaningless under the new one.
|
|
1102
1159
|
const chonTab = (t: Tab) => {
|
|
1103
1160
|
setTab(t);
|
|
1104
|
-
|
|
1161
|
+
resetPaging();
|
|
1105
1162
|
};
|
|
1106
1163
|
const doiTimKiem = (s: string) => {
|
|
1107
1164
|
setSearch(s);
|
|
1108
|
-
|
|
1165
|
+
resetPaging();
|
|
1109
1166
|
};
|
|
1110
1167
|
// One sort at a time; the third toggle on a column clears it.
|
|
1111
1168
|
const onSort = (key: string) => {
|
|
1112
1169
|
setSort(cycleSort(sort, key));
|
|
1113
|
-
|
|
1170
|
+
resetPaging();
|
|
1114
1171
|
};
|
|
1115
1172
|
|
|
1116
1173
|
// Search + status tab filter FIRST, then paging over the filtered ordering.
|
|
@@ -1140,7 +1197,6 @@ export function TplItemList() {
|
|
|
1140
1197
|
: key === "ngayNhan" ? r.ngayNhan.split("/").reverse().join("")
|
|
1141
1198
|
: r.ma,
|
|
1142
1199
|
);
|
|
1143
|
-
const tongPhi = filtered.reduce((s, r) => s + r.phi, 0);
|
|
1144
1200
|
const pageRows = sorted.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
|
1145
1201
|
const hasMore = (page + 1) * PAGE_SIZE < sorted.length;
|
|
1146
1202
|
|
|
@@ -1171,7 +1227,7 @@ export function TplItemList() {
|
|
|
1171
1227
|
on top); the accent ring frames the page on drag. A real app opts in
|
|
1172
1228
|
per-app — this page is the reference shape. */}
|
|
1173
1229
|
<FileDropTarget onFiles={openIntakeSeeded} paste style={{ flex: 1 }}>
|
|
1174
|
-
<ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
1230
|
+
<ScrollView ref={scrollRef} style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
|
|
1175
1231
|
<View style={{ width: "100%", maxWidth: 1040, alignSelf: "center", gap: 16 }}>
|
|
1176
1232
|
{/* The page band — the screen's TYPE ANCHOR before it is a label. Without
|
|
1177
1233
|
it this page's largest text is 16px against a 12px floor, and a 1.33x
|
|
@@ -1208,7 +1264,7 @@ export function TplItemList() {
|
|
|
1208
1264
|
<FilterChip
|
|
1209
1265
|
label="Assignee"
|
|
1210
1266
|
summary={assigneeSummary}
|
|
1211
|
-
onClear={() => { setAssignee([]);
|
|
1267
|
+
onClear={() => { setAssignee([]); resetPaging(); }}
|
|
1212
1268
|
clearLabel="Clear assignee filter"
|
|
1213
1269
|
>
|
|
1214
1270
|
<OptionList
|
|
@@ -1216,7 +1272,7 @@ export function TplItemList() {
|
|
|
1216
1272
|
multi
|
|
1217
1273
|
options={ASSIGNEES}
|
|
1218
1274
|
value={assignee}
|
|
1219
|
-
onValueChange={(v) => { setAssignee(v);
|
|
1275
|
+
onValueChange={(v) => { setAssignee(v); resetPaging(); }}
|
|
1220
1276
|
/>
|
|
1221
1277
|
</FilterChip>
|
|
1222
1278
|
{/* Single-select sibling — the render-prop `close` shuts the popover on
|
|
@@ -1224,7 +1280,7 @@ export function TplItemList() {
|
|
|
1224
1280
|
<FilterChip
|
|
1225
1281
|
label="Payment"
|
|
1226
1282
|
summary={feeStatus === "paid" ? "Paid" : feeStatus === "unpaid" ? "Unpaid" : undefined}
|
|
1227
|
-
onClear={() => { setFeeStatus(null);
|
|
1283
|
+
onClear={() => { setFeeStatus(null); resetPaging(); }}
|
|
1228
1284
|
clearLabel="Clear payment filter"
|
|
1229
1285
|
>
|
|
1230
1286
|
{({ close }) => (
|
|
@@ -1232,7 +1288,7 @@ export function TplItemList() {
|
|
|
1232
1288
|
search={{ mode: "none" }}
|
|
1233
1289
|
options={[{ value: "paid", label: "Paid" }, { value: "unpaid", label: "Unpaid" }]}
|
|
1234
1290
|
value={feeStatus}
|
|
1235
|
-
onValueChange={(v) => { setFeeStatus(v);
|
|
1291
|
+
onValueChange={(v) => { setFeeStatus(v); resetPaging(); }}
|
|
1236
1292
|
onRequestClose={close}
|
|
1237
1293
|
/>
|
|
1238
1294
|
)}
|
|
@@ -1243,7 +1299,7 @@ export function TplItemList() {
|
|
|
1243
1299
|
<ColumnFilter
|
|
1244
1300
|
column={{ key: "phi", label: "Fee", type: "number" }}
|
|
1245
1301
|
value={feeAmount}
|
|
1246
|
-
onChange={(v) => { setFeeAmount(v);
|
|
1302
|
+
onChange={(v) => { setFeeAmount(v); resetPaging(); }}
|
|
1247
1303
|
/>
|
|
1248
1304
|
</View>
|
|
1249
1305
|
{/* the intake TRIGGER — the register's whole-surface drop/paste opens
|
|
@@ -1262,25 +1318,66 @@ export function TplItemList() {
|
|
|
1262
1318
|
It is not a page-band subtitle for the same reason. A subtitle
|
|
1263
1319
|
describes the screen; these describe the QUERY, and the query is what
|
|
1264
1320
|
the toolbar above just wrote. */}
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1321
|
+
{/* THE SUMMARY ROW IS THE PAGING ROW. Both describe the window the
|
|
1322
|
+
toolbar above just set — what matched, and which slice of it is on
|
|
1323
|
+
screen — so they are one line, not two.
|
|
1324
|
+
|
|
1325
|
+
PAGING SITS ABOVE THE ROWS, and there is only one of it. Under them is
|
|
1326
|
+
where it has always gone, and that is inherited from full page loads:
|
|
1327
|
+
the browser reset the scroll, so a reader who pressed Next at the
|
|
1328
|
+
bottom arrived at the top of the next page for free. An SPA swaps the
|
|
1329
|
+
rows under a viewport that does not move, so the same press now lands
|
|
1330
|
+
on that page's LAST rows with its first ones already scrolled past.
|
|
1331
|
+
`goToPage` restores the reset — and once the viewport returns here on
|
|
1332
|
+
every press, here is where the control should be: under the reader's
|
|
1333
|
+
cursor, not a page-height below it. A second copy at the bottom would
|
|
1334
|
+
be the same state said twice.
|
|
1335
|
+
|
|
1336
|
+
It carries the RANGE and nothing else. The totals that used to ride
|
|
1337
|
+
the old footer (`45 records, ₫X in fees`) said what this summary
|
|
1338
|
+
already says, and `Table`'s own `count` prints the matched total in
|
|
1339
|
+
the eyebrow below.
|
|
1340
|
+
|
|
1341
|
+
The pager sizes to its CONTENT and the summary takes `flex: 1`.
|
|
1342
|
+
Reversed — the pager handed the full width — its own `flex: 1` range
|
|
1343
|
+
stretches and strands the range hundreds of pixels from the arrows it
|
|
1344
|
+
belongs to, two halves of one control reading as two things. Given a
|
|
1345
|
+
row of its own it read worse still: 236px of control against 778px of
|
|
1346
|
+
nothing. `flex-start` keeps the arrows on the summary's FIRST line
|
|
1347
|
+
when the summary wraps at a narrow width. */}
|
|
1348
|
+
<View style={{ flexDirection: "row", alignItems: "flex-start", gap: 24 }}>
|
|
1349
|
+
<View style={{ flex: 1, minWidth: 0 }}>
|
|
1350
|
+
<SummaryLine
|
|
1351
|
+
items={[
|
|
1352
|
+
{ label: "processing", value: dem("dangxuly") },
|
|
1353
|
+
{ label: "awaiting docs", value: dem("chobosung"), tone: "warning", info: "Records waiting on customer paperwork — they aren't complete, so they can't be ticked for export until the docs arrive." },
|
|
1354
|
+
{ label: "complete", value: dem("hoantat") },
|
|
1355
|
+
{ label: "fees collected", value: phiDaThu, format: "currency", compact: true },
|
|
1356
|
+
]}
|
|
1357
|
+
/>
|
|
1358
|
+
</View>
|
|
1359
|
+
<Pagination
|
|
1360
|
+
page={page}
|
|
1361
|
+
pageSize={PAGE_SIZE}
|
|
1362
|
+
rowCount={pageRows.length}
|
|
1363
|
+
hasMore={hasMore}
|
|
1364
|
+
total={filtered.length}
|
|
1365
|
+
onPageChange={goToPage}
|
|
1366
|
+
/>
|
|
1367
|
+
</View>
|
|
1368
|
+
|
|
1369
|
+
{/* the register — eyebrow band (Table header) → list rows */}
|
|
1275
1370
|
<View>
|
|
1276
|
-
{/* THE COUNTED REGISTER
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1371
|
+
{/* THE COUNTED REGISTER — `counted`, not `count`, because the PAGER
|
|
1372
|
+
states the total now (`16–30 of 45`) and a number printed twice a
|
|
1373
|
+
few rows apart is two numbers that can disagree. `counted` keeps the
|
|
1374
|
+
ordinal gutter reserved and leaves the figure to the row that owns
|
|
1375
|
+
it. Each row's `ordinal` still continues across pages rather than
|
|
1376
|
+
restarting at 1, so the last row of page 2 reads 30, not 15. */}
|
|
1280
1377
|
<Table
|
|
1281
1378
|
columns={COLUMNS}
|
|
1282
1379
|
leading={24}
|
|
1283
|
-
|
|
1380
|
+
counted
|
|
1284
1381
|
trailing={132}
|
|
1285
1382
|
sort={sort}
|
|
1286
1383
|
onSort={onSort}
|
|
@@ -1304,25 +1401,6 @@ export function TplItemList() {
|
|
|
1304
1401
|
<EmptyState message="No matching records" hint="Try another keyword or switch status" />
|
|
1305
1402
|
</>
|
|
1306
1403
|
) : null}
|
|
1307
|
-
{/* NOT `CardFooter` — that is a Card PART, and its 20px is the card's
|
|
1308
|
-
interior gutter. There is no card here, so it put the totals 20px
|
|
1309
|
-
right of the register's own rows and of the toolbar above them.
|
|
1310
|
-
A bare table's footer is a divider and a row; borrowing furniture
|
|
1311
|
-
from a container that is not there is what put it out of true. */}
|
|
1312
|
-
<Divider />
|
|
1313
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 12 }}>
|
|
1314
|
-
<Text size="sm" color="muted" tabular style={{ flex: 1 }}>
|
|
1315
|
-
{`${filtered.length} records, ${formatMoney(tongPhi)} in fees`}
|
|
1316
|
-
</Text>
|
|
1317
|
-
<Pagination
|
|
1318
|
-
page={page}
|
|
1319
|
-
pageSize={PAGE_SIZE}
|
|
1320
|
-
rowCount={pageRows.length}
|
|
1321
|
-
hasMore={hasMore}
|
|
1322
|
-
total={filtered.length}
|
|
1323
|
-
onPageChange={setPage}
|
|
1324
|
-
/>
|
|
1325
|
-
</View>
|
|
1326
1404
|
</View>
|
|
1327
1405
|
</View>
|
|
1328
1406
|
</ScrollView>
|
package/package.json
CHANGED
package/src/locale.tsx
CHANGED
|
@@ -83,6 +83,12 @@ export interface LoticsLocale {
|
|
|
83
83
|
* a phase's rows. The kit owns the wording so one app does not say "Show" while
|
|
84
84
|
* the next says "Expand" for the same gesture. */
|
|
85
85
|
checklist: { expand: string; collapse: string };
|
|
86
|
+
/** `TextDisclosure` supplies the VERB so one app cannot say "Show" where the
|
|
87
|
+
* next says "View" for the same gesture — the same reason `checklist` above
|
|
88
|
+
* owns its pair. It takes the revealed thing as a noun and builds the phrase,
|
|
89
|
+
* because word order is not shared: English puts the verb first, and a
|
|
90
|
+
* language that does not would otherwise be stuck with a prefix. */
|
|
91
|
+
textDisclosure: { show: (label: string) => string; hide: (label: string) => string };
|
|
86
92
|
sectionHeading: { info: string };
|
|
87
93
|
/** `ErrorState`'s retry button. The kit owns the wording so "try again" is
|
|
88
94
|
* phrased identically everywhere instead of hand-written per app. */
|
|
@@ -265,8 +271,6 @@ export const en: LoticsLocale = {
|
|
|
265
271
|
pagination: {
|
|
266
272
|
rangeWithTotal: (start, end, total) => `${start}–${end} of ${total.toLocaleString()}`,
|
|
267
273
|
range: (start, end) => `${start}–${end}`,
|
|
268
|
-
pageWithTotal: (page, pageCount) => `Page ${page} of ${pageCount}`,
|
|
269
|
-
page: (page) => `Page ${page}`,
|
|
270
274
|
previous: "Previous",
|
|
271
275
|
next: "Next",
|
|
272
276
|
},
|
|
@@ -290,6 +294,7 @@ export const en: LoticsLocale = {
|
|
|
290
294
|
ledger: { rowDetails: (label) => `${label} details` },
|
|
291
295
|
stepper: { complete: "Complete step", progress: "Progress" },
|
|
292
296
|
checklist: { expand: "Show", collapse: "Hide" },
|
|
297
|
+
textDisclosure: { show: (label) => `Show ${label}`, hide: (label) => `Hide ${label}` },
|
|
293
298
|
sectionHeading: { info: "About this data" },
|
|
294
299
|
errorState: { retry: "Try again" },
|
|
295
300
|
chip: { remove: "Remove" },
|
|
@@ -445,8 +450,6 @@ export const vi: LoticsLocale = {
|
|
|
445
450
|
pagination: {
|
|
446
451
|
rangeWithTotal: (start, end, total) => `${start}–${end} trên ${total.toLocaleString("vi-VN")}`,
|
|
447
452
|
range: (start, end) => `${start}–${end}`,
|
|
448
|
-
pageWithTotal: (page, pageCount) => `Trang ${page} / ${pageCount}`,
|
|
449
|
-
page: (page) => `Trang ${page}`,
|
|
450
453
|
previous: "Trang trước",
|
|
451
454
|
next: "Trang sau",
|
|
452
455
|
},
|
|
@@ -470,6 +473,7 @@ export const vi: LoticsLocale = {
|
|
|
470
473
|
ledger: { rowDetails: (label) => `Chi tiết ${label}` },
|
|
471
474
|
stepper: { complete: "Hoàn thành bước", progress: "Tiến trình" },
|
|
472
475
|
checklist: { expand: "Mở", collapse: "Thu gọn" },
|
|
476
|
+
textDisclosure: { show: (label) => `Xem ${label}`, hide: (label) => `Ẩn ${label}` },
|
|
473
477
|
sectionHeading: { info: "Giải thích dữ liệu" },
|
|
474
478
|
errorState: { retry: "Thử lại" },
|
|
475
479
|
chip: { remove: "Xóa" },
|
package/src/pagination.tsx
CHANGED
|
@@ -16,10 +16,6 @@ export interface PaginationLabels {
|
|
|
16
16
|
rangeWithTotal?: (start: number, end: number, total: number) => string;
|
|
17
17
|
/** Range summary without a total. Default `${start}–${end}`. */
|
|
18
18
|
range?: (start: number, end: number) => string;
|
|
19
|
-
/** Page indicator with a known total. Default `Page ${page} of ${pageCount}`. */
|
|
20
|
-
pageWithTotal?: (page: number, pageCount: number) => string;
|
|
21
|
-
/** Page indicator without a total. Default `Page ${page}`. */
|
|
22
|
-
page?: (page: number) => string;
|
|
23
19
|
/** Previous-page button tooltip. Default "Previous". */
|
|
24
20
|
previous?: string;
|
|
25
21
|
/** Next-page button tooltip. Default "Next". */
|
|
@@ -46,11 +42,34 @@ export interface PaginationProps {
|
|
|
46
42
|
}
|
|
47
43
|
|
|
48
44
|
/**
|
|
49
|
-
* Pagination control:
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
45
|
+
* Pagination control: which rows are on screen, and arrows to change them. A
|
|
46
|
+
* pure control — it owns no border, background, or padding. The container that
|
|
47
|
+
* frames it (a summary row, a grid footer, a bordered band) supplies the divider
|
|
48
|
+
* and insets, so the same control composes anywhere without doubling up a border.
|
|
49
|
+
*
|
|
50
|
+
* The range is the WHOLE label. A page index beside it ("Page 2 of 3") answers
|
|
51
|
+
* the same question a second way and enables no decision the range does not —
|
|
52
|
+
* how much is left is in the total, and whether this is the end is in the
|
|
53
|
+
* disabled arrow. It was rendered here once and cut.
|
|
54
|
+
*
|
|
55
|
+
* The range is also the register's COUNT. Pass `counted` rather than `count` to
|
|
56
|
+
* a `Table` paired with this, or the matched total is printed twice a few rows
|
|
57
|
+
* apart.
|
|
58
|
+
*
|
|
59
|
+
* **`onPageChange` must also return the viewport to the first row.** This
|
|
60
|
+
* control cannot do it: the scroll container belongs to the page, not to the
|
|
61
|
+
* kit. Bottom pagination is a browser-reload-era convention — a full page load
|
|
62
|
+
* reset the scroll, and that reset is what made it work. An SPA changes the rows
|
|
63
|
+
* underneath a viewport that does not move, so a reader who pages from the
|
|
64
|
+
* bottom lands on the LAST rows of the next page and never sees its first ones.
|
|
65
|
+
* The same is true of every other control that changes which rows are on screen
|
|
66
|
+
* — a filter, a sort, a search — so the reset belongs to "the window changed",
|
|
67
|
+
* not to this component's arrows. `tpl_item_list` is the reference.
|
|
68
|
+
*
|
|
69
|
+
* ONE per register, ABOVE the rows. Once the reset returns the viewport here on
|
|
70
|
+
* every press, this is where the reader's cursor already is; a second copy under
|
|
71
|
+
* the rows is the same state said twice, and two identical clusters of arrows
|
|
72
|
+
* read as two controls rather than one.
|
|
54
73
|
*/
|
|
55
74
|
export function Pagination(props: PaginationProps): React.ReactNode {
|
|
56
75
|
const { page, pageSize, rowCount, hasMore, total, loading, onPageChange, labels } = props;
|
|
@@ -65,11 +84,6 @@ export function Pagination(props: PaginationProps): React.ReactNode {
|
|
|
65
84
|
: (labels?.range ?? loc.range)(start, end)
|
|
66
85
|
: "";
|
|
67
86
|
|
|
68
|
-
const pageLabel =
|
|
69
|
-
total !== undefined
|
|
70
|
-
? (labels?.pageWithTotal ?? loc.pageWithTotal)(page + 1, Math.max(1, Math.ceil(total / pageSize)))
|
|
71
|
-
: (labels?.page ?? loc.page)(page + 1);
|
|
72
|
-
|
|
73
87
|
return (
|
|
74
88
|
<View style={styles.container}>
|
|
75
89
|
<View style={styles.summary}>
|
|
@@ -78,9 +92,6 @@ export function Pagination(props: PaginationProps): React.ReactNode {
|
|
|
78
92
|
</Text>
|
|
79
93
|
</View>
|
|
80
94
|
<View style={styles.controls}>
|
|
81
|
-
<Text size="sm" color="zinc-500">
|
|
82
|
-
{pageLabel}
|
|
83
|
-
</Text>
|
|
84
95
|
<IconButton
|
|
85
96
|
icon="chevron-left"
|
|
86
97
|
color="secondary"
|
package/src/text_disclosure.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Pressable } from "react-native";
|
|
2
2
|
import { TextLink } from "./text_link";
|
|
3
|
+
import { useLoticsLocale } from "./locale";
|
|
3
4
|
import type { TextSize } from "./text";
|
|
4
5
|
|
|
5
6
|
export interface TextDisclosureProps {
|
|
@@ -52,6 +53,7 @@ export interface TextDisclosureProps {
|
|
|
52
53
|
*/
|
|
53
54
|
export function TextDisclosure(props: TextDisclosureProps) {
|
|
54
55
|
const { expanded, onToggle, label, size = "sm", accessibilityLabel, testID } = props;
|
|
56
|
+
const locale = useLoticsLocale();
|
|
55
57
|
return (
|
|
56
58
|
<Pressable
|
|
57
59
|
onPress={() => onToggle(!expanded)}
|
|
@@ -63,7 +65,7 @@ export function TextDisclosure(props: TextDisclosureProps) {
|
|
|
63
65
|
testID={testID}
|
|
64
66
|
>
|
|
65
67
|
<TextLink size={size} color="muted">
|
|
66
|
-
{expanded ?
|
|
68
|
+
{expanded ? locale.textDisclosure.hide(label) : locale.textDisclosure.show(label)}
|
|
67
69
|
</TextLink>
|
|
68
70
|
</Pressable>
|
|
69
71
|
);
|