@lotics/ui 44.4.0 → 44.5.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 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.
@@ -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` in the
591
- `CardFooter`).
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
@@ -419,6 +419,20 @@ is the usual cause.
419
419
  Note the RN-Web trap: once a `Text` contains a nested pressable, `numberOfLines` degrades to bare
420
420
  `overflow: hidden` and loses the ellipsis.
421
421
 
422
+ ### Measure INK, not the box — a stretched label hides the gap it sits in
423
+
424
+ ```js
425
+ // getBoundingClientRect on an RN-Web Text returns the DIV, which stretches to
426
+ // its flex slot. Range over the contents to get where the glyphs actually end.
427
+ (sel) => { const rg = document.createRange(); rg.selectNodeContents(document.querySelector(sel)); return rg.getBoundingClientRect(); }
428
+ ```
429
+
430
+ A label inside a `flex: 1` slot measures as wide as the slot, so a box-to-box gap between it and
431
+ its neighbour reads as the container's `gap` — 12px — while the reader sees hundreds of pixels of
432
+ void between the last glyph and the next element. Any gap RATIO taken across a stretching text is
433
+ wrong in the reassuring direction. Use this whenever one side of a measured gap is text that could
434
+ be filling a flex slot rather than sizing to its content.
435
+
422
436
  ### Grouping — is one `gap` flattening the hierarchy?
423
437
 
424
438
  ```js
@@ -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
- const PAGE_SIZE = 8;
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
- const nextMa = useRef(64);
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
- setPage(0);
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
- setPage(0);
1161
+ resetPaging();
1105
1162
  };
1106
1163
  const doiTimKiem = (s: string) => {
1107
1164
  setSearch(s);
1108
- setPage(0);
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
- setPage(0);
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([]); setPage(0); }}
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); setPage(0); }}
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); setPage(0); }}
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); setPage(0); }}
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); setPage(0); }}
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
- <SummaryLine
1266
- items={[
1267
- { label: "processing", value: dem("dangxuly") },
1268
- { 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." },
1269
- { label: "complete", value: dem("hoantat") },
1270
- { label: "fees collected", value: phiDaThu, format: "currency", compact: true },
1271
- ]}
1272
- />
1273
-
1274
- {/* the register eyebrow band (Table header) list rows → totals + paging */}
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. `count` is what MATCHED, not what this page
1277
- shows the reader's "how many" survives paging and each row's
1278
- `ordinal` continues across pages rather than restarting at 1, so the
1279
- last row of page 2 reads 100 under a header that says 100. */}
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
- count={filtered.length}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "44.4.0",
3
+ "version": "44.5.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
package/src/locale.tsx CHANGED
@@ -265,8 +265,6 @@ export const en: LoticsLocale = {
265
265
  pagination: {
266
266
  rangeWithTotal: (start, end, total) => `${start}–${end} of ${total.toLocaleString()}`,
267
267
  range: (start, end) => `${start}–${end}`,
268
- pageWithTotal: (page, pageCount) => `Page ${page} of ${pageCount}`,
269
- page: (page) => `Page ${page}`,
270
268
  previous: "Previous",
271
269
  next: "Next",
272
270
  },
@@ -445,8 +443,6 @@ export const vi: LoticsLocale = {
445
443
  pagination: {
446
444
  rangeWithTotal: (start, end, total) => `${start}–${end} trên ${total.toLocaleString("vi-VN")}`,
447
445
  range: (start, end) => `${start}–${end}`,
448
- pageWithTotal: (page, pageCount) => `Trang ${page} / ${pageCount}`,
449
- page: (page) => `Trang ${page}`,
450
446
  previous: "Trang trước",
451
447
  next: "Trang sau",
452
448
  },
@@ -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: range summary on the left, page indicator + arrows on the
50
- * right. A pure control — it owns no border, background, or padding. The
51
- * container that frames it (a `CardFooter`, a grid footer, a bordered band)
52
- * supplies the divider and insets, so the same control composes anywhere without
53
- * doubling up a border.
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"