@lotics/ui 11.5.0 → 11.7.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 +3 -0
- package/docs/catalog.md +23 -3
- package/docs/composition.md +74 -1
- package/examples/tpl_item_list.tsx +2 -6
- package/examples/tpl_record.tsx +4 -4
- package/package.json +1 -1
- package/src/detail_row.tsx +40 -7
- package/src/section_heading.tsx +11 -3
- package/src/table.test.ts +90 -0
- package/src/table.tsx +200 -43
- package/src/table_fit.ts +88 -0
package/AGENTS.md
CHANGED
|
@@ -39,6 +39,9 @@ doc before building any screen, **never from memory**. Exact props are the shipp
|
|
|
39
39
|
- **Pure primitives only** — no i18n, analytics, or domain types in `src/` (pass `labels`,
|
|
40
40
|
callbacks); Lotics-coupled UI belongs in `@lotics/ui-internal`.
|
|
41
41
|
- **Every state designed** — skeleton (mirroring layout), empty, error; no layout shift.
|
|
42
|
+
- **Copy states content, not mechanics** — a `description` names the section's content or one
|
|
43
|
+
domain rule; widget behavior ("edits in place, saves on its own") and selling points never
|
|
44
|
+
appear. Instructions live in empty states / form-field hints. → [composition.md §Microcopy](./docs/composition.md).
|
|
42
45
|
|
|
43
46
|
## Keeping this reference current
|
|
44
47
|
|
package/docs/catalog.md
CHANGED
|
@@ -121,7 +121,11 @@ Two columnar shapes, and the choice is about data size:
|
|
|
121
121
|
headers via `SortHeader`; paired with `Pagination`) + read-only rows that open a `Drawer`
|
|
122
122
|
to edit. It scales by PAGING — renders one page, never the whole set — so you FILTER +
|
|
123
123
|
search, you don't group. Worked example: [`tpl_item_list`](../examples/tpl_item_list.tsx).
|
|
124
|
-
Never an HTML `<table>` or a `.map` of rows.
|
|
124
|
+
Never an HTML `<table>` or a `.map` of rows. The register adapts to its container on its
|
|
125
|
+
own — hides columns by `priority`, stacks rows below the two-column floor (see the `table`
|
|
126
|
+
inventory entry) — so a narrow screen or panel needs NO branching; compose a hand-designed
|
|
127
|
+
card pile (`PressableRow` + your own hierarchy) over `useScreenSize` only when a screen
|
|
128
|
+
deserves a better mobile shape than the automatic stack.
|
|
125
129
|
- **Inline-managed grouped table** (MODERATE — hundreds, low-thousands — you MANAGE in view)
|
|
126
130
|
— the `DataGrid` primitive: a grouped, sortable grid whose cells are LIVE inline editors
|
|
127
131
|
(ANY field — a column is `{ key, label, width?, sortable?, cell: (item) => ReactNode }`,
|
|
@@ -331,7 +335,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
331
335
|
(12, fixed), no body component. `SectionHeadingTitle` is ALWAYS `##` (xl semibold;
|
|
332
336
|
`weight="medium"` opt-down only) + `info` for an ⓘ provenance popover after the title,
|
|
333
337
|
same as `CardHeaderTitle.info`. `SubsectionHeadingTitle` is the `###` lg-semibold level-3
|
|
334
|
-
title of a named group inside a section
|
|
338
|
+
title of a named group inside a section (same `info` ⓘ affordance as the section title) —
|
|
339
|
+
heading-row siblings ride its right edge; the
|
|
335
340
|
heading ramp is FIXED: `#` xxl / `##` xl / `###` lg, no size props.
|
|
336
341
|
- **`section`** — a SECOND, standalone `Section`: a self-contained titled block
|
|
337
342
|
(`title`/`description`/`collapsible`/`icon`/`titleRight`, imperative
|
|
@@ -547,7 +552,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
547
552
|
label+value row for drawer/peek detail; in FORM mode (`labelWidth` set) the value column
|
|
548
553
|
FILLS the row so a stack of inline editors all span the same width + none jumps wider on
|
|
549
554
|
edit; optional `trailing` slot renders a right-side action/badge after the value (units
|
|
550
|
-
belong IN the value via `InlineNumberInput format`)
|
|
555
|
+
belong IN the value via `InlineNumberInput format`); optional `info` renders an ⓘ popover
|
|
556
|
+
hugging the label — the row-label member of the one `info` vocabulary (`CardHeaderTitle` /
|
|
557
|
+
`SectionHeadingTitle` / `SubsectionHeadingTitle`); pass `undefined` to hide it (callers may
|
|
558
|
+
gate it on their own state). `DetailTable`: the compound parent of
|
|
551
559
|
a row STACK — `labelWidth` (default 130) / `trailingWidth` / `minHeight` (default 40, the
|
|
552
560
|
inline-control grid) declared ONCE + the 6px row gap; with `trailingWidth` every row
|
|
553
561
|
reserves the trailing column so value cells share one width and trailing items align at
|
|
@@ -582,6 +590,18 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
582
590
|
- **`list_item`** — `ListItem`: the plain list row.
|
|
583
591
|
- **`table`** — `Table` + `TableRow` + `TableCell`: the paginated high-volume register
|
|
584
592
|
(columns defined once, `sortLabels` localizable; rows are `PressableRow`-based).
|
|
593
|
+
CONTAINER-RESPONSIVE with no prop (measures itself, like `Breakdown`/`DetailTable`): when
|
|
594
|
+
the width can't fit every column it hides columns by `TableColumn.priority` (higher drops
|
|
595
|
+
first; default = column order, rightmost first; column 0 — the identity — never drops), and
|
|
596
|
+
below the two-column floor every row STACKS: each cell renders as a `DetailRow`-spread
|
|
597
|
+
line (muted label left, value at the right edge — one vocabulary with the drawer's detail
|
|
598
|
+
rows), the lines sitting on the identity column's text edge, never under the checkbox;
|
|
599
|
+
the header band gives way, so sorting is a wide-container affordance. Rows carry TWO right-side slots: `action`
|
|
600
|
+
(the primary CTA `Button` — register: in the trailing gutter; stacked: closes the content,
|
|
601
|
+
right-aligned) and `trailing` (the ⋯ overflow — stays beside the identity on the top line).
|
|
602
|
+
On pressable rows dropped values stay one tap away — the row opens the record; a READ-ONLY
|
|
603
|
+
register (rows without `onPress`) has no door, so give the columns it can't afford to lose
|
|
604
|
+
a low `priority` (e.g. `priority: 1` — outlives its right-side neighbours).
|
|
585
605
|
- **`sort_header`** — `SortHeader` + `SortState`/`SortDir` + `cycleSort` + `sortBy` +
|
|
586
606
|
`SortHeaderLabels`: the sortable column header and the sort-state helpers `Table`/
|
|
587
607
|
`DataGrid` consumers drive.
|
package/docs/composition.md
CHANGED
|
@@ -60,7 +60,8 @@ restyle a heading level per-page.
|
|
|
60
60
|
`Section` with a `title` string prop — a different component. Import from `section_heading` for
|
|
61
61
|
the grammar described here.
|
|
62
62
|
- **Subsection title** — the level BELOW a section on a long record surface: `Subsection` ›
|
|
63
|
-
`SubsectionHeading` › `SubsectionHeadingTitle` (`###` — lg semibold
|
|
63
|
+
`SubsectionHeading` › `SubsectionHeadingTitle` (`###` — lg semibold, with the same `info` ⓘ
|
|
64
|
+
affordance as `SectionHeadingTitle`; siblings — a `Badge`, a
|
|
64
65
|
`SectionHeadingMeta`, an action — ride the heading row's right edge). Sibling subsections stack
|
|
65
66
|
in a **`SubsectionStack`** (the same hairline law one step tighter: a fixed 24px beat + hairline
|
|
66
67
|
between groups — no margins, no hand-rolled dividers); a headingless `Subsection` is the
|
|
@@ -437,3 +438,75 @@ disabled ink).
|
|
|
437
438
|
Anything pressable is ≥ 40px tall (8px minimum gap between pressables). 16px between cards, 24–28
|
|
438
439
|
canvas padding, 16–20 inside bands, 10–12 between content lines. Density comes from alignment +
|
|
439
440
|
hierarchy, not cramming.
|
|
441
|
+
|
|
442
|
+
## Microcopy — content, not mechanics
|
|
443
|
+
|
|
444
|
+
UI copy states WHAT something is — domain content or one real rule — never how the widget
|
|
445
|
+
behaves, and never a selling point. The control demonstrates its own behavior; copy that
|
|
446
|
+
narrates it ("edits in place, saves on its own", "sửa tại chỗ, lưu ngay") is noise, and copy
|
|
447
|
+
that praises it is marketing.
|
|
448
|
+
|
|
449
|
+
- **Heading `description`s** name the section's content ("Các bên và điều khoản thương mại
|
|
450
|
+
của lô.") or one domain invariant ("Each invoice owns its charge lines — a charge never
|
|
451
|
+
lives apart from the document it bills on."). Never widget mechanics, never usage narration
|
|
452
|
+
("drop to add, click to preview"), never feature-brag adjectives.
|
|
453
|
+
- **Instructions live where action is needed** — empty states, callouts, form-field
|
|
454
|
+
descriptions: imperative, one concrete step ("Drop the CI + PL here."). A heading never
|
|
455
|
+
instructs.
|
|
456
|
+
- **Restatement is the second noise mode.** A description that repeats what the screen already
|
|
457
|
+
shows — the title, the subsection names below it, the table's columns — carries zero
|
|
458
|
+
information and fails even though it "states content". Useful copy states an **invisible
|
|
459
|
+
truth**: an invariant of the data, a boundary ("P&L lives in the accounting app, not here"),
|
|
460
|
+
a linkage ("the HS code here feeds the customs declaration"), or a consequence ("past the
|
|
461
|
+
cut-off, the box misses the sailing").
|
|
462
|
+
- **The deletion test**: if removing the line changes nothing for the reader, remove the line —
|
|
463
|
+
`description` is optional for a reason; when no invisible truth is worth stating, omit it.
|
|
464
|
+
- **Plain language over jargon; effect over mechanism** ("Record visibility", not "Row-Level
|
|
465
|
+
Privacy") — if a term needs explaining, replace it. **One concept = one word everywhere** —
|
|
466
|
+
pick the term once and reuse it on every surface.
|
|
467
|
+
- **Native language**: match the workspace language with native vocabulary — a Vietnamese ops
|
|
468
|
+
app reads like a Vietnamese ops tool, not a translated brochure. Sentence case; no
|
|
469
|
+
exclamation marks.
|
|
470
|
+
|
|
471
|
+
### Error messages — what happened → what it means → what to do
|
|
472
|
+
|
|
473
|
+
The consensus anatomy (GOV.UK · NN/g · Microsoft): state what happened, what it means for the
|
|
474
|
+
user's data, and the one next step. Field errors go INLINE (`FormField error=`); a page-level
|
|
475
|
+
failure is a `Callout tone="error"` (title = what happened, text = what to do, an action when
|
|
476
|
+
one exists).
|
|
477
|
+
|
|
478
|
+
- Never blame ("invalid", "you forgot"), never bare "An error occurred", never an error code
|
|
479
|
+
as the headline (put codes at the end, for support). No ALL-CAPS, no exclamation.
|
|
480
|
+
- Say whether the user's input survived — and PRESERVE it (re-entering data is the real cost).
|
|
481
|
+
- Empty required field → instruct ("Enter the hours you work a week"); constraint violation →
|
|
482
|
+
describe the constraint ("First name must be 35 characters or less").
|
|
483
|
+
- Apology is reserved for genuine system failure — never for validation.
|
|
484
|
+
- BAD: `Error: invalid input (0x643).` · GOOD: `Không tải được chứng từ lên — tệp vẫn còn trên
|
|
485
|
+
máy bạn. Thử lại; nếu vẫn lỗi, kiểm tra kết nối mạng.`
|
|
486
|
+
|
|
487
|
+
### Empty states — confirm, orient, one action
|
|
488
|
+
|
|
489
|
+
An empty region has three jobs (NN/g): confirm this is EMPTY (not loading, not broken), say
|
|
490
|
+
what belongs here, and offer ONE next action — the `EmptyState` props are this anatomy
|
|
491
|
+
(`message` = what's empty, `hint` = what to do about it, `action` = the one CTA). The three
|
|
492
|
+
empties get DIFFERENT copy:
|
|
493
|
+
|
|
494
|
+
- **First use** → teach + invite: message names what will live here, `action` creates the first
|
|
495
|
+
one ("Chưa có khoản phí" + "Thêm khoản thu/chi cho lô" + a Thêm phí button).
|
|
496
|
+
- **User cleared it** → stay quiet: the user knows why it's empty; confirmation only, no tutorial.
|
|
497
|
+
- **No search/filter results** → help recover: restate the scope ("Không có lô nào khớp
|
|
498
|
+
'ABC'"), hint the fix ("Thử từ khóa khác / xóa bộ lọc"). Never a blank panel or bare "No data".
|
|
499
|
+
|
|
500
|
+
### Buttons & action labels — the outcome, verb-first
|
|
501
|
+
|
|
502
|
+
A label names the OUTCOME of pressing — verb + object, 2–4 words (GOV.UK: "Save and continue",
|
|
503
|
+
"Add another address"). Universal across every source; only casing forks by platform (this kit:
|
|
504
|
+
sentence case, always).
|
|
505
|
+
|
|
506
|
+
- Never "OK" / "Yes" / "Submit" / "Click here" — a `Button title` says what happens ("Tạo lô
|
|
507
|
+
hàng", "Xóa 3 tệp").
|
|
508
|
+
- A dialog's confirm button RESTATES the action: "Xóa lô?" → `Xóa` / `Hủy` — never `OK` / `Hủy`
|
|
509
|
+
(the pair must answer the question without reading the body).
|
|
510
|
+
- Destructive buttons name the object (`color="danger"` + "Xóa lô", not "Xóa") — the label is
|
|
511
|
+
the last safeguard.
|
|
512
|
+
|
|
@@ -390,12 +390,8 @@ function HoSoRow({ hs, daThu, tasks, onTasksChange, suggestions, onDismissSugges
|
|
|
390
390
|
minHeight={56}
|
|
391
391
|
accessibilityLabel={`Open record ${hs.ma} — ${hs.khach}`}
|
|
392
392
|
leading={<CheckboxInput accessibilityLabel={selectable ? `Select ${hs.ma}` : `${hs.ma} — awaiting docs, not ready to export`} checked={marked} disabled={!selectable} onChange={onToggle} />}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
<Button title="Print" icon={docFor(hs, daThu).icon} color="secondary" onPress={() => {}} />
|
|
396
|
-
<ActionMenu items={menuFor(hs, daThu)} accessibilityLabel={`Actions for ${hs.ma}`} />
|
|
397
|
-
</View>
|
|
398
|
-
}
|
|
393
|
+
action={<Button title="Print" icon={docFor(hs, daThu).icon} color="secondary" onPress={() => {}} />}
|
|
394
|
+
trailing={<ActionMenu items={menuFor(hs, daThu)} accessibilityLabel={`Actions for ${hs.ma}`} />}
|
|
399
395
|
>
|
|
400
396
|
<TableCell>
|
|
401
397
|
<Text size="sm" weight="semibold" tabular>{hs.ma}</Text>
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -707,7 +707,7 @@ export function TplRecord() {
|
|
|
707
707
|
<View onLayout={nav.register("details")}>
|
|
708
708
|
<Section>
|
|
709
709
|
<SectionHeading>
|
|
710
|
-
<SectionHeadingTitle description="
|
|
710
|
+
<SectionHeadingTitle description="The order's core facts — reference, dates, and the amounts every desk reads first.">Details</SectionHeadingTitle>
|
|
711
711
|
<OwnerTag stage="sales" />
|
|
712
712
|
</SectionHeading>
|
|
713
713
|
<DetailTable labelWidth={150} trailingWidth={88}>
|
|
@@ -757,7 +757,7 @@ export function TplRecord() {
|
|
|
757
757
|
<View onLayout={nav.register("customer")}>
|
|
758
758
|
<Section>
|
|
759
759
|
<SectionHeading>
|
|
760
|
-
<SectionHeadingTitle description="The bill-to
|
|
760
|
+
<SectionHeadingTitle description="The bill-to party — its tax ID gates invoicing below.">Customer</SectionHeadingTitle>
|
|
761
761
|
<OwnerTag stage="sales" />
|
|
762
762
|
{customer && !editingCustomer ? (
|
|
763
763
|
<Button title="Edit" color="secondary" icon="pencil" onPress={() => setEditingCustomer(true)} />
|
|
@@ -964,7 +964,7 @@ export function TplRecord() {
|
|
|
964
964
|
<View onLayout={nav.register("files")}>
|
|
965
965
|
<Section>
|
|
966
966
|
<SectionHeading>
|
|
967
|
-
<SectionHeadingTitle description="The signed quote, the PO, photos —
|
|
967
|
+
<SectionHeadingTitle description="The signed quote, the PO, photos — everything filed on the order.">Files</SectionHeadingTitle>
|
|
968
968
|
<OwnerTag stage="operations" />
|
|
969
969
|
{files.length > 0 ? <SectionHeadingMeta>{`${files.length} ${files.length === 1 ? "file" : "files"}`}</SectionHeadingMeta> : null}
|
|
970
970
|
</SectionHeading>
|
|
@@ -1194,7 +1194,7 @@ export function TplRecord() {
|
|
|
1194
1194
|
<View onLayout={nav.register("preferences")}>
|
|
1195
1195
|
<Section>
|
|
1196
1196
|
<SectionHeading>
|
|
1197
|
-
<SectionHeadingTitle description="
|
|
1197
|
+
<SectionHeadingTitle description="Per-order handling and notification preferences.">Preferences</SectionHeadingTitle>
|
|
1198
1198
|
</SectionHeading>
|
|
1199
1199
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, minHeight: 40 }}>
|
|
1200
1200
|
<View style={{ flex: 1, gap: 1 }}>
|
package/package.json
CHANGED
package/src/detail_row.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createContext, ReactNode, useContext, useState } from "react";
|
|
2
2
|
import { StyleProp, StyleSheet, View, ViewStyle } from "react-native";
|
|
3
3
|
import { INLINE_CONTROL_HEIGHT } from "./inline_edit";
|
|
4
|
+
import { InfoPopover } from "./info_popover";
|
|
5
|
+
import { useLoticsLocale } from "./locale";
|
|
4
6
|
import { Text } from "./text";
|
|
5
7
|
|
|
6
8
|
interface DetailTableColumns {
|
|
@@ -103,6 +105,11 @@ export interface DetailRowProps {
|
|
|
103
105
|
labelSize?: "xs" | "sm";
|
|
104
106
|
/** Min row height. Default 28 (or the `DetailTable`'s `minHeight`). */
|
|
105
107
|
minHeight?: number;
|
|
108
|
+
/** An ⓘ popover hugging the label — a short "what this field means" gloss,
|
|
109
|
+
* the row-label member of the one `info` vocabulary
|
|
110
|
+
* (`CardHeaderTitle` / `SectionHeadingTitle` / `SubsectionHeadingTitle`).
|
|
111
|
+
* Pass `undefined` to hide the ⓘ (a caller can gate it on its own state). */
|
|
112
|
+
info?: string;
|
|
106
113
|
}
|
|
107
114
|
|
|
108
115
|
/**
|
|
@@ -116,10 +123,21 @@ export interface DetailRowProps {
|
|
|
116
123
|
*/
|
|
117
124
|
export function DetailRow(props: DetailRowProps) {
|
|
118
125
|
const table = useContext(DetailTableContext);
|
|
119
|
-
const { label, children, trailing, labelSize = "sm" } = props;
|
|
126
|
+
const { label, children, trailing, labelSize = "sm", info } = props;
|
|
120
127
|
const labelWidth = props.labelWidth ?? table?.labelWidth;
|
|
121
128
|
const minHeight = props.minHeight ?? table?.minHeight ?? 28;
|
|
122
129
|
const form = labelWidth != null;
|
|
130
|
+
const words = useLoticsLocale();
|
|
131
|
+
// The ⓘ hugs the label text (flexShrink keeps long labels from pushing it
|
|
132
|
+
// out of the cell) — the label cell keeps its exact width either way.
|
|
133
|
+
const infoLabel = (cellStyle: StyleProp<ViewStyle>) => (
|
|
134
|
+
<View style={[styles.labelWithInfo, cellStyle]}>
|
|
135
|
+
<Text size={labelSize} color="muted" numberOfLines={1} style={styles.labelShrink}>
|
|
136
|
+
{label}
|
|
137
|
+
</Text>
|
|
138
|
+
<InfoPopover text={info ?? ""} accessibilityLabel={words.sectionHeading.info} />
|
|
139
|
+
</View>
|
|
140
|
+
);
|
|
123
141
|
if (table?.stacked) {
|
|
124
142
|
// Stacked mode wears the FORM grammar: the label renders exactly like a
|
|
125
143
|
// `FormField` label (medium, default ink), so a narrow record surface
|
|
@@ -128,9 +146,18 @@ export function DetailRow(props: DetailRowProps) {
|
|
|
128
146
|
// inline editor self-persists — only the look converges.
|
|
129
147
|
return (
|
|
130
148
|
<View style={styles.stackedRow}>
|
|
131
|
-
|
|
132
|
-
{
|
|
133
|
-
|
|
149
|
+
{info != null ? (
|
|
150
|
+
<View style={styles.labelWithInfo}>
|
|
151
|
+
<Text weight="medium" numberOfLines={1} style={styles.labelShrink}>
|
|
152
|
+
{label}
|
|
153
|
+
</Text>
|
|
154
|
+
<InfoPopover text={info} accessibilityLabel={words.sectionHeading.info} />
|
|
155
|
+
</View>
|
|
156
|
+
) : (
|
|
157
|
+
<Text weight="medium" numberOfLines={1}>
|
|
158
|
+
{label}
|
|
159
|
+
</Text>
|
|
160
|
+
)}
|
|
134
161
|
<View style={[styles.stackedValueRow, { minHeight }]}>
|
|
135
162
|
<View style={styles.value}>{children}</View>
|
|
136
163
|
{trailing != null ? trailing : null}
|
|
@@ -140,9 +167,13 @@ export function DetailRow(props: DetailRowProps) {
|
|
|
140
167
|
}
|
|
141
168
|
return (
|
|
142
169
|
<View style={[styles.row, { minHeight }]}>
|
|
143
|
-
|
|
144
|
-
{
|
|
145
|
-
|
|
170
|
+
{info != null ? (
|
|
171
|
+
infoLabel(form ? { width: labelWidth } : styles.flexLabel)
|
|
172
|
+
) : (
|
|
173
|
+
<Text size={labelSize} color="muted" style={form ? { width: labelWidth } : styles.flexLabel}>
|
|
174
|
+
{label}
|
|
175
|
+
</Text>
|
|
176
|
+
)}
|
|
146
177
|
{form ? <View style={styles.value}>{children}</View> : children}
|
|
147
178
|
{table?.trailingWidth != null ? (
|
|
148
179
|
<View style={[styles.trailingCell, { width: table.trailingWidth }]}>{trailing}</View>
|
|
@@ -171,6 +202,8 @@ const styles = StyleSheet.create({
|
|
|
171
202
|
gap: 12,
|
|
172
203
|
},
|
|
173
204
|
flexLabel: { flex: 1 },
|
|
205
|
+
labelWithInfo: { flexDirection: "row", alignItems: "center", gap: 4 },
|
|
206
|
+
labelShrink: { flexShrink: 1 },
|
|
174
207
|
// Form mode (labelWidth set): the value column FILLS the row so every inline
|
|
175
208
|
// editor spans the SAME width — and none jumps wider when it swaps to the
|
|
176
209
|
// (flex:1) edit control. A stretch column, so an InlineEditView / InlineStatic
|
package/src/section_heading.tsx
CHANGED
|
@@ -88,18 +88,23 @@ export interface SubsectionHeadingTitleProps {
|
|
|
88
88
|
children: React.ReactNode;
|
|
89
89
|
/** Heading rank. Defaults to 3 — one level under `SectionHeadingTitle`'s 2. */
|
|
90
90
|
level?: HeadingLevel;
|
|
91
|
+
/** An ⓘ popover after the title — a short "what this is / where it came from"
|
|
92
|
+
* gloss, mirroring `SectionHeadingTitle`'s `info`. */
|
|
93
|
+
info?: string;
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
/** The subsection title — ALWAYS `###` (lg semibold), the fixed step under the
|
|
94
97
|
* xl section title and above the md/sm body. Grows to push siblings (a
|
|
95
98
|
* `SectionHeadingMeta`, a `Badge`, an action) to the right edge. */
|
|
96
99
|
export function SubsectionHeadingTitle(props: SubsectionHeadingTitleProps) {
|
|
97
|
-
const { children, level = 3 } = props;
|
|
100
|
+
const { children, level = 3, info } = props;
|
|
101
|
+
const words = useLoticsLocale();
|
|
98
102
|
return (
|
|
99
|
-
<View style={{ flex: 1 }}>
|
|
103
|
+
<View style={{ flex: 1, flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
100
104
|
<Text level={level} size="lg" weight="semibold">
|
|
101
105
|
{children}
|
|
102
106
|
</Text>
|
|
107
|
+
{info ? <InfoPopover text={info} accessibilityLabel={words.sectionHeading.info} /> : null}
|
|
103
108
|
</View>
|
|
104
109
|
);
|
|
105
110
|
}
|
|
@@ -139,6 +144,9 @@ export interface SectionHeadingTitleProps {
|
|
|
139
144
|
* push any sibling actions to the right edge. */
|
|
140
145
|
export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
|
|
141
146
|
const { children, description, icon, level = 2, weight = "semibold", info } = props;
|
|
147
|
+
// Hoisted — a hook inside the `info ?` ternary breaks hook ordering the
|
|
148
|
+
// render `info` appears or disappears.
|
|
149
|
+
const words = useLoticsLocale();
|
|
142
150
|
return (
|
|
143
151
|
<View style={{ flex: 1, gap: 2 }}>
|
|
144
152
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
@@ -146,7 +154,7 @@ export function SectionHeadingTitle(props: SectionHeadingTitleProps) {
|
|
|
146
154
|
<Text level={level} weight={weight} size="xl">
|
|
147
155
|
{children}
|
|
148
156
|
</Text>
|
|
149
|
-
{info ? <InfoPopover text={info} accessibilityLabel={
|
|
157
|
+
{info ? <InfoPopover text={info} accessibilityLabel={words.sectionHeading.info} /> : null}
|
|
150
158
|
</View>
|
|
151
159
|
{description ? (
|
|
152
160
|
<Text color="zinc-500" size="sm">
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { computeTableFit, type TableFitColumn } from "./table_fit";
|
|
3
|
+
|
|
4
|
+
// The canonical register's columns (tpl_item_list): a fixed identity column,
|
|
5
|
+
// a flexible name column, then fixed data columns — plus a 24px leading
|
|
6
|
+
// checkbox gutter and a 132px trailing actions gutter.
|
|
7
|
+
const REGISTER: TableFitColumn[] = [
|
|
8
|
+
{ key: "ma", width: 112 },
|
|
9
|
+
{ key: "khach" }, // flex
|
|
10
|
+
{ key: "trangThai", width: 112 },
|
|
11
|
+
{ key: "viec", width: 82 },
|
|
12
|
+
{ key: "phi", width: 116 },
|
|
13
|
+
{ key: "ngayNhan", width: 72 },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function visible(fit: ReturnType<typeof computeTableFit>): string[] {
|
|
17
|
+
return REGISTER.filter((c) => fit.visibleKeys.has(c.key)).map((c) => c.key);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("computeTableFit", () => {
|
|
21
|
+
it("keeps every column when the container fits them all", () => {
|
|
22
|
+
const fit = computeTableFit(REGISTER, 24, 132, 1040);
|
|
23
|
+
expect(fit.stacked).toBe(false);
|
|
24
|
+
expect(visible(fit)).toEqual(["ma", "khach", "trangThai", "viec", "phi", "ngayNhan"]);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("drops columns right-to-left by default, one at a time, until the rest fit", () => {
|
|
28
|
+
// 700px: the full set (908px) and the set minus "ngayNhan" (822px) overflow;
|
|
29
|
+
// minus "phi" too (692px) fits.
|
|
30
|
+
const fit = computeTableFit(REGISTER, 24, 132, 700);
|
|
31
|
+
expect(fit.stacked).toBe(false);
|
|
32
|
+
expect(visible(fit)).toEqual(["ma", "khach", "trangThai", "viec"]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("keeps a high-priority column alive while lower-priority ones drop", () => {
|
|
36
|
+
const withPriority = REGISTER.map((c) => (c.key === "phi" ? { ...c, priority: 1 } : c));
|
|
37
|
+
const fit = computeTableFit(withPriority, 24, 132, 700);
|
|
38
|
+
expect(fit.stacked).toBe(false);
|
|
39
|
+
// "phi" (priority 1) outlives "trangThai"/"viec"/"ngayNhan" (default = index).
|
|
40
|
+
expect(fit.visibleKeys.has("phi")).toBe(true);
|
|
41
|
+
expect(fit.visibleKeys.has("trangThai")).toBe(false);
|
|
42
|
+
expect(fit.visibleKeys.has("viec")).toBe(false);
|
|
43
|
+
expect(fit.visibleKeys.has("ngayNhan")).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("never drops the first column", () => {
|
|
47
|
+
// Wide enough for exactly the identity + flex pair, no more.
|
|
48
|
+
const fit = computeTableFit(REGISTER, 0, 0, 320);
|
|
49
|
+
expect(fit.stacked).toBe(false);
|
|
50
|
+
expect(fit.visibleKeys.has("ma")).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("never drops the first column even when it carries the worst priority", () => {
|
|
54
|
+
const hostile = REGISTER.map((c) => (c.key === "ma" ? { ...c, priority: 99 } : c));
|
|
55
|
+
const fit = computeTableFit(hostile, 24, 132, 700);
|
|
56
|
+
expect(fit.stacked).toBe(false);
|
|
57
|
+
expect(fit.visibleKeys.has("ma")).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("stacks when even the minimum column set overflows (the phone case)", () => {
|
|
61
|
+
// A 390px phone minus the page padding: the canonical register's identity +
|
|
62
|
+
// customer + gutters still need ~470px.
|
|
63
|
+
const fit = computeTableFit(REGISTER, 24, 132, 334);
|
|
64
|
+
expect(fit.stacked).toBe(true);
|
|
65
|
+
// Stacked mode shows every column — vertical space is free.
|
|
66
|
+
expect(visible(fit)).toEqual(["ma", "khach", "trangThai", "viec", "phi", "ngayNhan"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("counts the leading/trailing gutters and gaps in the fit math", () => {
|
|
70
|
+
// Two fixed columns: 40 padding + 100 + 100 + 1 gap (14) = 254 exactly.
|
|
71
|
+
const two: TableFitColumn[] = [
|
|
72
|
+
{ key: "a", width: 100 },
|
|
73
|
+
{ key: "b", width: 100 },
|
|
74
|
+
];
|
|
75
|
+
expect(computeTableFit(two, 0, 0, 254).stacked).toBe(false);
|
|
76
|
+
expect(computeTableFit(two, 0, 0, 253).stacked).toBe(true);
|
|
77
|
+
// Adding a 24px leading gutter adds the gutter and one more gap.
|
|
78
|
+
expect(computeTableFit(two, 24, 0, 292).stacked).toBe(false);
|
|
79
|
+
expect(computeTableFit(two, 24, 0, 291).stacked).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("a single-column table fits until the gutters overflow, then stacks", () => {
|
|
83
|
+
const one: TableFitColumn[] = [{ key: "a" }];
|
|
84
|
+
// 40 padding + 120 flex minimum = 160.
|
|
85
|
+
expect(computeTableFit(one, 0, 0, 200).stacked).toBe(false);
|
|
86
|
+
const tiny = computeTableFit(one, 0, 0, 100);
|
|
87
|
+
expect(tiny.stacked).toBe(true);
|
|
88
|
+
expect(tiny.visibleKeys.has("a")).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
});
|
package/src/table.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createContext,
|
|
3
3
|
useContext,
|
|
4
|
+
useState,
|
|
4
5
|
Children,
|
|
5
6
|
cloneElement,
|
|
6
7
|
isValidElement,
|
|
@@ -12,24 +13,26 @@ import { Text } from "./text";
|
|
|
12
13
|
import { colors } from "./colors";
|
|
13
14
|
import { PressableRow } from "./pressable_row";
|
|
14
15
|
import { Divider } from "./divider";
|
|
16
|
+
import { DetailRow } from "./detail_row";
|
|
15
17
|
import { SortHeader, type SortState, type SortHeaderLabels } from "./sort_header";
|
|
16
18
|
import { FOCUS_RING } from "./control_surface";
|
|
17
19
|
import { useFocusRing } from "./use_focus_ring";
|
|
20
|
+
import { COLUMN_GAP, ROW_GUTTER, computeTableFit, type TableFit, type TableFitColumn } from "./table_fit";
|
|
18
21
|
|
|
19
22
|
/**
|
|
20
23
|
* One column of a register — its width/flex/align/label/sortability defined ONCE,
|
|
21
|
-
* here, instead of being re-typed in the header band AND every row.
|
|
24
|
+
* here, instead of being re-typed in the header band AND every row. The fit
|
|
25
|
+
* fields (`key`/`width`/`priority`) live on `TableFitColumn` — the pure fit
|
|
26
|
+
* layer in `table_fit.ts` that decides which columns survive a narrow container.
|
|
22
27
|
*/
|
|
23
|
-
export interface TableColumn {
|
|
24
|
-
/**
|
|
25
|
-
|
|
26
|
-
/** Header label (uppercase eyebrow). Omit for a control column (a trailing ⋯). */
|
|
28
|
+
export interface TableColumn extends TableFitColumn {
|
|
29
|
+
/** Header label (uppercase eyebrow). Omit for a control column (a trailing ⋯).
|
|
30
|
+
* In stacked mode it renders as the eyebrow over the cell's value. */
|
|
27
31
|
label?: string;
|
|
28
|
-
/** Fixed width in px; omit for a flexible column. */
|
|
29
|
-
width?: number;
|
|
30
32
|
/** Flex grow when no `width` (default 1). */
|
|
31
33
|
flex?: number;
|
|
32
34
|
align?: "left" | "right";
|
|
35
|
+
/** The `key` doubles as the `sortKey`. */
|
|
33
36
|
sortable?: boolean;
|
|
34
37
|
}
|
|
35
38
|
|
|
@@ -37,6 +40,8 @@ interface TableCtx {
|
|
|
37
40
|
columns: TableColumn[];
|
|
38
41
|
leading: number;
|
|
39
42
|
trailing: number;
|
|
43
|
+
visibleKeys: ReadonlySet<string>;
|
|
44
|
+
stacked: boolean;
|
|
40
45
|
}
|
|
41
46
|
const TableContext = createContext<TableCtx | null>(null);
|
|
42
47
|
|
|
@@ -73,34 +78,64 @@ export interface TableProps {
|
|
|
73
78
|
* sortable column becomes a `SortHeader`) and its `TableRow` children,
|
|
74
79
|
* `Divider`-separated. Compose `TableRow` / `TableCell` for the body. For a
|
|
75
80
|
* non-columnar list (entity piles, card stacks) use `PressableRow` directly.
|
|
81
|
+
*
|
|
82
|
+
* The register is container-responsive with no prop: when the measured width
|
|
83
|
+
* can't fit every column it drops droppable columns by `priority`, and below
|
|
84
|
+
* the register floor rows stack (label over value, from the column `label`s) —
|
|
85
|
+
* the header band gives way (sorting rides the columns; a stacked register
|
|
86
|
+
* keeps its current order). See `computeTableFit`.
|
|
76
87
|
*/
|
|
77
88
|
export function Table(props: TableProps) {
|
|
78
89
|
const { columns, sort, onSort, sortLabels, selectAll, leading = 0, trailing = 0, children } = props;
|
|
79
90
|
const rows = Children.toArray(children).filter(isValidElement);
|
|
80
91
|
|
|
92
|
+
// Measure-then-REVEAL (the `DetailTable` contract): the unmeasured first
|
|
93
|
+
// frame renders invisible, so the first PAINT is already in the right mode —
|
|
94
|
+
// no register→stacked reshuffle as the screen mounts.
|
|
95
|
+
const [width, setWidth] = useState<number | null>(null);
|
|
96
|
+
const fit: TableFit =
|
|
97
|
+
width == null
|
|
98
|
+
? { visibleKeys: new Set(columns.map((c) => c.key)), stacked: false }
|
|
99
|
+
: computeTableFit(columns, leading, trailing, width);
|
|
100
|
+
const visibleColumns = columns.filter((c) => fit.visibleKeys.has(c.key));
|
|
101
|
+
|
|
81
102
|
return (
|
|
82
|
-
<TableContext.Provider value={{ columns, leading, trailing }}>
|
|
103
|
+
<TableContext.Provider value={{ columns, leading, trailing, visibleKeys: fit.visibleKeys, stacked: fit.stacked }}>
|
|
83
104
|
{/* ONE layout node: without this wrapper the header band + body land as two
|
|
84
105
|
direct flex children of the app's container, and a parent column `gap`
|
|
85
106
|
(the standard section spacing) opens a hole between the header and rows. */}
|
|
86
|
-
<View
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
</Text>
|
|
98
|
-
)
|
|
99
|
-
) : null}
|
|
107
|
+
<View
|
|
108
|
+
onLayout={(e) => setWidth(e.nativeEvent.layout.width)}
|
|
109
|
+
style={width == null ? styles.unmeasured : null}
|
|
110
|
+
>
|
|
111
|
+
{fit.stacked ? (
|
|
112
|
+
// Stacked mode has no column band — labels moved into the rows. The
|
|
113
|
+
// select-all checkbox keeps its band: it's the bulk-select entry point,
|
|
114
|
+
// aligned over the rows' leading checkboxes.
|
|
115
|
+
selectAll != null && leading > 0 ? (
|
|
116
|
+
<View style={styles.headerBand}>
|
|
117
|
+
<View style={{ width: leading }}>{selectAll}</View>
|
|
100
118
|
</View>
|
|
101
|
-
)
|
|
102
|
-
|
|
103
|
-
|
|
119
|
+
) : null
|
|
120
|
+
) : (
|
|
121
|
+
<View style={styles.headerBand}>
|
|
122
|
+
{leading > 0 ? <View style={{ width: leading }}>{selectAll}</View> : null}
|
|
123
|
+
{visibleColumns.map((col) => (
|
|
124
|
+
<View key={col.key} style={colStyle(col)}>
|
|
125
|
+
{col.label ? (
|
|
126
|
+
col.sortable && onSort ? (
|
|
127
|
+
<SortHeader label={col.label} sortKey={col.key} sort={sort ?? null} onSort={onSort} align={col.align} labels={sortLabels} />
|
|
128
|
+
) : (
|
|
129
|
+
<Text size="xs" color="muted" transform="uppercase" numberOfLines={1}>
|
|
130
|
+
{col.label}
|
|
131
|
+
</Text>
|
|
132
|
+
)
|
|
133
|
+
) : null}
|
|
134
|
+
</View>
|
|
135
|
+
))}
|
|
136
|
+
{trailing > 0 ? <View style={{ width: trailing }} /> : null}
|
|
137
|
+
</View>
|
|
138
|
+
)}
|
|
104
139
|
<View style={styles.body}>
|
|
105
140
|
{rows.map((row, i) => (
|
|
106
141
|
<View key={i}>
|
|
@@ -120,9 +155,15 @@ export type TableRowProps = {
|
|
|
120
155
|
marked?: boolean;
|
|
121
156
|
/** Outside the door, BEFORE the cells (a selection checkbox) — width = Table `leading`. */
|
|
122
157
|
leading?: ReactNode;
|
|
123
|
-
/** Outside the door, AFTER the cells
|
|
158
|
+
/** Outside the door, AFTER the cells: the row's OVERFLOW chrome (a ⋯ `ActionMenu`).
|
|
159
|
+
* Register mode renders it in the trailing gutter beside `action`; stacked mode keeps
|
|
160
|
+
* it on the top line. Width = Table `trailing` (shared with `action`). */
|
|
124
161
|
trailing?: ReactNode;
|
|
125
|
-
/**
|
|
162
|
+
/** The row's primary action (a CTA `Button`). Register mode: in the trailing gutter,
|
|
163
|
+
* before `trailing`; stacked mode: the content's bottom line, right-aligned — an action
|
|
164
|
+
* reads at content scale, not as row chrome. Reserve Table `trailing` for it. */
|
|
165
|
+
action?: ReactNode;
|
|
166
|
+
/** Min row height (register mode; stacked rows size to their content). Default 52. */
|
|
126
167
|
minHeight?: number;
|
|
127
168
|
/** The `TableCell`s, one per column, in column order. */
|
|
128
169
|
children: ReactNode;
|
|
@@ -151,34 +192,68 @@ export type TableRowProps = {
|
|
|
151
192
|
* row, rounded to the register wash); mouse presses ride the `PressableRow`
|
|
152
193
|
* surface, and nested controls claim their own. `leading`/`trailing` slots stay
|
|
153
194
|
* independently pressable. Cells map to `Table`'s columns by position.
|
|
195
|
+
*
|
|
196
|
+
* In the Table's STACKED mode (container below the register floor) the same
|
|
197
|
+
* row renders as a pile: a top line of [leading] [first cell] [trailing], then
|
|
198
|
+
* the remaining cells as label-over-value blocks — same door, same wash, same
|
|
199
|
+
* slots, different geometry.
|
|
154
200
|
*/
|
|
155
201
|
export function TableRow(props: TableRowProps) {
|
|
156
|
-
const { onPress, selected, marked, accessibilityLabel, leading, trailing, minHeight = 52, children } = props;
|
|
202
|
+
const { onPress, selected, marked, accessibilityLabel, leading, trailing, action, minHeight = 52, children } = props;
|
|
157
203
|
const ctx = useContext(TableContext);
|
|
158
204
|
if (!ctx) throw new Error("TableRow must be used within a Table");
|
|
159
205
|
|
|
160
|
-
const cells = (Children.toArray(children).filter(isValidElement) as ReactElement<TableCellProps>[])
|
|
161
|
-
(cell, i) =>
|
|
162
|
-
|
|
206
|
+
const cells = (Children.toArray(children).filter(isValidElement) as ReactElement<TableCellProps>[])
|
|
207
|
+
.map((cell, i) => ({ cell, column: ctx.columns[i] as TableColumn | undefined }))
|
|
208
|
+
// A dropped column drops its cell (stacked shows everything). Cells beyond
|
|
209
|
+
// the declared columns stay — same tolerance as the width mapping.
|
|
210
|
+
.filter(({ column }) => ctx.stacked || column == null || ctx.visibleKeys.has(column.key))
|
|
211
|
+
.map(({ cell, column }) => cloneElement(cell, { _column: column, _stacked: ctx.stacked }));
|
|
163
212
|
const { focusVisible, focusProps } = useFocusRing();
|
|
164
213
|
|
|
214
|
+
const body = ctx.stacked ? (
|
|
215
|
+
<View style={styles.stackedBody}>
|
|
216
|
+
<View style={styles.stackedTopLine}>
|
|
217
|
+
{ctx.leading > 0 ? <View style={[styles.slot, { width: ctx.leading }]}>{leading}</View> : null}
|
|
218
|
+
<View style={styles.stackedPrimary}>{cells[0]}</View>
|
|
219
|
+
{trailing != null ? <View style={styles.slot}>{trailing}</View> : null}
|
|
220
|
+
</View>
|
|
221
|
+
{cells.length > 1 ? (
|
|
222
|
+
// The field lines sit on the identity column's text edge — indented
|
|
223
|
+
// past the leading gutter, never wrapping under the checkbox.
|
|
224
|
+
<View style={[styles.stackedFields, ctx.leading > 0 ? { paddingLeft: ctx.leading + COLUMN_GAP } : null]}>
|
|
225
|
+
{cells.slice(1)}
|
|
226
|
+
</View>
|
|
227
|
+
) : null}
|
|
228
|
+
{action != null ? <View style={styles.stackedActionLine}>{action}</View> : null}
|
|
229
|
+
</View>
|
|
230
|
+
) : null;
|
|
231
|
+
|
|
165
232
|
// Read-only row (no `onPress`): a static, non-interactive row — no hover wash,
|
|
166
233
|
// no pointer cursor, no focusable door — so a `Table` can present read-only
|
|
167
234
|
// tabular data (a fee breakdown, a spec sheet) without implying the rows open
|
|
168
235
|
// something. An interactive row (with `onPress`) keeps the full register surface.
|
|
169
236
|
if (!onPress) {
|
|
237
|
+
if (ctx.stacked) {
|
|
238
|
+
return <View style={styles.staticStackedRow}>{body}</View>;
|
|
239
|
+
}
|
|
170
240
|
return (
|
|
171
241
|
<View style={styles.staticRow}>
|
|
172
242
|
{ctx.leading > 0 ? <View style={{ width: ctx.leading }}>{leading}</View> : null}
|
|
173
243
|
<View style={[styles.cells, { minHeight }]}>{cells}</View>
|
|
174
|
-
{ctx.trailing > 0 ?
|
|
244
|
+
{ctx.trailing > 0 ? (
|
|
245
|
+
<View style={[styles.trailingSlot, { width: ctx.trailing }]}>
|
|
246
|
+
{action}
|
|
247
|
+
{trailing}
|
|
248
|
+
</View>
|
|
249
|
+
) : null}
|
|
175
250
|
</View>
|
|
176
251
|
);
|
|
177
252
|
}
|
|
178
253
|
|
|
179
254
|
return (
|
|
180
255
|
<PressableRow onPress={onPress} selected={selected} marked={marked} style={styles.row}>
|
|
181
|
-
{ctx.leading > 0 ? <View style={[styles.slot, { width: ctx.leading }]}>{leading}</View> : null}
|
|
256
|
+
{!ctx.stacked && ctx.leading > 0 ? <View style={[styles.slot, { width: ctx.leading }]}>{leading}</View> : null}
|
|
182
257
|
{/* An absolutely-positioned sibling paints — and hit-tests — above in-flow
|
|
183
258
|
content, which would swallow every cell control's press; the slots lift
|
|
184
259
|
above it via zIndex 1 (z-index applies to flex items), so the door gets
|
|
@@ -191,8 +266,19 @@ export function TableRow(props: TableRowProps) {
|
|
|
191
266
|
{...focusProps}
|
|
192
267
|
style={[styles.door, focusVisible && { boxShadow: FOCUS_RING }]}
|
|
193
268
|
/>
|
|
194
|
-
|
|
195
|
-
|
|
269
|
+
{ctx.stacked ? (
|
|
270
|
+
body
|
|
271
|
+
) : (
|
|
272
|
+
<>
|
|
273
|
+
<View style={[styles.cells, { minHeight }]}>{cells}</View>
|
|
274
|
+
{ctx.trailing > 0 ? (
|
|
275
|
+
<View style={[styles.slot, styles.trailingSlot, { width: ctx.trailing }]}>
|
|
276
|
+
{action}
|
|
277
|
+
{trailing}
|
|
278
|
+
</View>
|
|
279
|
+
) : null}
|
|
280
|
+
</>
|
|
281
|
+
)}
|
|
196
282
|
</PressableRow>
|
|
197
283
|
);
|
|
198
284
|
}
|
|
@@ -201,11 +287,26 @@ export interface TableCellProps {
|
|
|
201
287
|
children: ReactNode;
|
|
202
288
|
/** @internal — injected by `TableRow` from the column at this cell's position. */
|
|
203
289
|
_column?: TableColumn;
|
|
290
|
+
/** @internal — injected by `TableRow`; the Table is in stacked mode. */
|
|
291
|
+
_stacked?: boolean;
|
|
204
292
|
}
|
|
205
293
|
|
|
206
|
-
/** One cell — its width/align come from the column `TableRow` injects by position.
|
|
294
|
+
/** One cell — its width/align come from the column `TableRow` injects by position.
|
|
295
|
+
* In stacked mode it IS a `DetailRow` (spread: muted label left, value at the
|
|
296
|
+
* right edge) — the drawer's detail-row component, not a lookalike, so a
|
|
297
|
+
* stacked register and the record workspace behind its door share one
|
|
298
|
+
* vocabulary by construction. `DetailRow` values are arbitrary nodes (the
|
|
299
|
+
* drawer renders badges, money stacks, editors, popover triggers in them), so
|
|
300
|
+
* register cell content needs no adaptation. A label-less control column has
|
|
301
|
+
* no line to spread — its content pins to the right edge. */
|
|
207
302
|
export function TableCell(props: TableCellProps) {
|
|
208
|
-
const { children, _column } = props;
|
|
303
|
+
const { children, _column, _stacked } = props;
|
|
304
|
+
if (_stacked) {
|
|
305
|
+
if (!_column?.label) {
|
|
306
|
+
return <View style={styles.stackedBareCell}>{children}</View>;
|
|
307
|
+
}
|
|
308
|
+
return <DetailRow label={_column.label}>{children}</DetailRow>;
|
|
309
|
+
}
|
|
209
310
|
return <View style={_column ? colStyle(_column) : undefined}>{children}</View>;
|
|
210
311
|
}
|
|
211
312
|
|
|
@@ -213,11 +314,11 @@ const styles = StyleSheet.create({
|
|
|
213
314
|
// A hairline under the column header anchors the columns; the rows below it are
|
|
214
315
|
// Divider-separated.
|
|
215
316
|
headerBand: {
|
|
216
|
-
paddingHorizontal:
|
|
317
|
+
paddingHorizontal: ROW_GUTTER,
|
|
217
318
|
paddingVertical: 10,
|
|
218
319
|
flexDirection: "row",
|
|
219
320
|
alignItems: "center",
|
|
220
|
-
gap:
|
|
321
|
+
gap: COLUMN_GAP,
|
|
221
322
|
borderBottomWidth: 1,
|
|
222
323
|
borderColor: colors.border,
|
|
223
324
|
},
|
|
@@ -226,15 +327,22 @@ const styles = StyleSheet.create({
|
|
|
226
327
|
gap: 0,
|
|
227
328
|
},
|
|
228
329
|
row: {
|
|
229
|
-
gap:
|
|
330
|
+
gap: COLUMN_GAP,
|
|
230
331
|
},
|
|
231
332
|
// Read-only row (a `TableRow` with no `onPress`): the register row's gutter +
|
|
232
333
|
// layout so cells still align with the header, minus the hover/press surface.
|
|
233
334
|
staticRow: {
|
|
234
335
|
flexDirection: "row",
|
|
235
336
|
alignItems: "center",
|
|
236
|
-
gap:
|
|
237
|
-
paddingHorizontal:
|
|
337
|
+
gap: COLUMN_GAP,
|
|
338
|
+
paddingHorizontal: ROW_GUTTER,
|
|
339
|
+
},
|
|
340
|
+
// Row-direction like the pressable path — the body fills the width via
|
|
341
|
+
// `flex: 1` on the horizontal axis and sizes its own height (a `flex: 1`
|
|
342
|
+
// child of an auto-height COLUMN parent risks collapsing to zero).
|
|
343
|
+
staticStackedRow: {
|
|
344
|
+
flexDirection: "row",
|
|
345
|
+
paddingHorizontal: ROW_GUTTER,
|
|
238
346
|
},
|
|
239
347
|
// The keyboard door: an empty overlay spanning the row — the tab stop + focus
|
|
240
348
|
// ring live here, the cells are its SIBLINGS (see TableRow's doc). Radius
|
|
@@ -251,7 +359,7 @@ const styles = StyleSheet.create({
|
|
|
251
359
|
flex: 1,
|
|
252
360
|
flexDirection: "row",
|
|
253
361
|
alignItems: "center",
|
|
254
|
-
gap:
|
|
362
|
+
gap: COLUMN_GAP,
|
|
255
363
|
zIndex: 1,
|
|
256
364
|
},
|
|
257
365
|
// Leading/trailing gutters carry their own controls — lifted above the door
|
|
@@ -259,4 +367,53 @@ const styles = StyleSheet.create({
|
|
|
259
367
|
slot: {
|
|
260
368
|
zIndex: 1,
|
|
261
369
|
},
|
|
370
|
+
// Stacked (below the register floor): the row is a pile — top line keeps the
|
|
371
|
+
// leading/trailing slots on the first cell, the rest are label-over-value
|
|
372
|
+
// blocks. Vertical padding replaces the register's minHeight centering.
|
|
373
|
+
stackedBody: {
|
|
374
|
+
flex: 1,
|
|
375
|
+
gap: 10,
|
|
376
|
+
paddingVertical: 12,
|
|
377
|
+
zIndex: 1,
|
|
378
|
+
},
|
|
379
|
+
stackedFields: {
|
|
380
|
+
gap: 8,
|
|
381
|
+
},
|
|
382
|
+
// The register's trailing gutter composes [action, overflow] on one row —
|
|
383
|
+
// the gap matches the composite the templates previously hand-rolled.
|
|
384
|
+
trailingSlot: {
|
|
385
|
+
flexDirection: "row",
|
|
386
|
+
alignItems: "center",
|
|
387
|
+
justifyContent: "flex-end",
|
|
388
|
+
gap: 8,
|
|
389
|
+
},
|
|
390
|
+
// Stacked mode: the CTA closes the content, right-aligned — content scale,
|
|
391
|
+
// not row chrome (the overflow ⋯ stays on the top line).
|
|
392
|
+
stackedActionLine: {
|
|
393
|
+
flexDirection: "row",
|
|
394
|
+
justifyContent: "flex-end",
|
|
395
|
+
},
|
|
396
|
+
stackedTopLine: {
|
|
397
|
+
flexDirection: "row",
|
|
398
|
+
alignItems: "center",
|
|
399
|
+
gap: COLUMN_GAP,
|
|
400
|
+
minHeight: 40,
|
|
401
|
+
},
|
|
402
|
+
stackedPrimary: {
|
|
403
|
+
flex: 1,
|
|
404
|
+
minWidth: 0,
|
|
405
|
+
},
|
|
406
|
+
// A label-less cell in stacked mode: no label to spread against — content
|
|
407
|
+
// pins right, on the `DetailRow` line rhythm.
|
|
408
|
+
stackedBareCell: {
|
|
409
|
+
flexDirection: "row",
|
|
410
|
+
justifyContent: "flex-end",
|
|
411
|
+
alignItems: "center",
|
|
412
|
+
minHeight: 28,
|
|
413
|
+
},
|
|
414
|
+
// Measure-then-reveal: invisible until the container width is known, so the
|
|
415
|
+
// first visible frame is already register OR stacked — never a reshuffle.
|
|
416
|
+
unmeasured: {
|
|
417
|
+
opacity: 0,
|
|
418
|
+
},
|
|
262
419
|
});
|
package/src/table_fit.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fit layer for `Table` — the pure decision of which columns fit a measured
|
|
3
|
+
* container width, kept free of any React-Native import so it unit-tests
|
|
4
|
+
* directly (the register in `table.tsx` renders this result; see `matrix_totals`
|
|
5
|
+
* for the same split).
|
|
6
|
+
*
|
|
7
|
+
* The register degrades in two deterministic tiers instead of overflowing.
|
|
8
|
+
* Tier 1: everything fits → the columnar register, unchanged. Tier 2: drop
|
|
9
|
+
* droppable columns by `priority` until the rest fit (the row is a scannable
|
|
10
|
+
* index — the door opens the record where dropped values live). Tier 3: even
|
|
11
|
+
* the minimum column set can't fit (a phone) → every row STACKS, label above
|
|
12
|
+
* value. All of it derives from the container width (onLayout, not the
|
|
13
|
+
* viewport) so a register inside a half-width panel adapts exactly like one on
|
|
14
|
+
* a small screen — the same contract as `DetailTable` / `Breakdown`.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The column fields the fit decision reads — `TableColumn` extends this. */
|
|
18
|
+
export interface TableFitColumn {
|
|
19
|
+
/** Stable id. */
|
|
20
|
+
key: string;
|
|
21
|
+
/** Fixed width in px; omit for a flexible column. */
|
|
22
|
+
width?: number;
|
|
23
|
+
/** Drop precedence when the container can't fit every column: HIGHER numbers
|
|
24
|
+
* drop first, ties drop right-to-left. Default = the column's index (so an
|
|
25
|
+
* unannotated register sheds from the right). The FIRST column is the row's
|
|
26
|
+
* identity — it never drops. */
|
|
27
|
+
priority?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Gap between the header band's / a row's children — `table.tsx` styles consume
|
|
31
|
+
* this, so the fit math and the rendered geometry can't drift. */
|
|
32
|
+
export const COLUMN_GAP = 14;
|
|
33
|
+
/** The register's horizontal gutter — mirrors `PressableRow`'s register variant
|
|
34
|
+
* (`paddingHorizontal: 20`), the one geometry this file models but doesn't own.
|
|
35
|
+
* `table.tsx` consumes it for the header band and static rows. */
|
|
36
|
+
export const ROW_GUTTER = 20;
|
|
37
|
+
const ROW_H_PADDING = ROW_GUTTER * 2;
|
|
38
|
+
/** Fit-math width a flexible column needs to stay usable — below this the flex
|
|
39
|
+
* column is crushed to ellipsis soup, so it counts as this wide when deciding
|
|
40
|
+
* what fits. Layout still lets it grow (`flex`) or shrink (`minWidth: 0`). */
|
|
41
|
+
const FLEX_MIN_WIDTH = 120;
|
|
42
|
+
/** Fewer side-by-side columns than this stops being a register — stack instead. */
|
|
43
|
+
const MIN_VISIBLE_COLUMNS = 2;
|
|
44
|
+
|
|
45
|
+
export interface TableFit {
|
|
46
|
+
/** Keys of the columns that render side-by-side. All keys when `stacked`. */
|
|
47
|
+
visibleKeys: ReadonlySet<string>;
|
|
48
|
+
/** The minimum column set can't fit — rows render as label-over-value stacks. */
|
|
49
|
+
stacked: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pure fit decision for a measured container width: which columns stay
|
|
54
|
+
* side-by-side, or whether the register stacks.
|
|
55
|
+
*/
|
|
56
|
+
export function computeTableFit(
|
|
57
|
+
columns: TableFitColumn[],
|
|
58
|
+
leading: number,
|
|
59
|
+
trailing: number,
|
|
60
|
+
width: number,
|
|
61
|
+
): TableFit {
|
|
62
|
+
const requiredWidth = (cols: TableFitColumn[]): number => {
|
|
63
|
+
const slots = cols.length + (leading > 0 ? 1 : 0) + (trailing > 0 ? 1 : 0);
|
|
64
|
+
const gaps = Math.max(0, slots - 1) * COLUMN_GAP;
|
|
65
|
+
const colsWidth = cols.reduce((sum, c) => sum + (c.width ?? FLEX_MIN_WIDTH), 0);
|
|
66
|
+
return ROW_H_PADDING + (leading > 0 ? leading : 0) + (trailing > 0 ? trailing : 0) + colsWidth + gaps;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const allKeys = new Set(columns.map((c) => c.key));
|
|
70
|
+
const priorityOf = (c: TableFitColumn) => c.priority ?? columns.indexOf(c);
|
|
71
|
+
// Column 0 never enters the drop order — it's the row's identity.
|
|
72
|
+
const dropOrder = columns
|
|
73
|
+
.slice(1)
|
|
74
|
+
.sort((a, b) => priorityOf(b) - priorityOf(a) || columns.indexOf(b) - columns.indexOf(a));
|
|
75
|
+
|
|
76
|
+
const kept = new Set(allKeys);
|
|
77
|
+
const floor = Math.min(MIN_VISIBLE_COLUMNS, columns.length);
|
|
78
|
+
for (const col of dropOrder) {
|
|
79
|
+
if (kept.size <= floor) break;
|
|
80
|
+
if (requiredWidth(columns.filter((c) => kept.has(c.key))) <= width) break;
|
|
81
|
+
kept.delete(col.key);
|
|
82
|
+
}
|
|
83
|
+
if (requiredWidth(columns.filter((c) => kept.has(c.key))) > width) {
|
|
84
|
+
// Below the register floor: stack every column (vertical space is free).
|
|
85
|
+
return { visibleKeys: allKeys, stacked: true };
|
|
86
|
+
}
|
|
87
|
+
return { visibleKeys: kept, stacked: false };
|
|
88
|
+
}
|