@lotics/ui 47.10.0 → 47.12.1
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 +1 -1
- package/docs/catalog.md +7 -1
- package/docs/composition.md +54 -0
- package/docs/reviewing.md +37 -2
- package/examples/tpl_dashboard.tsx +20 -9
- package/package.json +2 -1
- package/src/bar_chart.tsx +9 -1
- package/src/pressable_row.tsx +6 -1
- package/src/split_row.tsx +110 -0
- package/src/stacked_progress_bar.tsx +14 -3
- package/src/table.tsx +5 -0
package/AGENTS.md
CHANGED
|
@@ -14,7 +14,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
14
14
|
|
|
15
15
|
| Doc | Read it for |
|
|
16
16
|
|---|---|
|
|
17
|
-
| [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — incl. `just_arrived` (`useJustArrived` + `TableRow`'s `justArrived`: the one-shot wash a row wears when it LANDS, so a realtime push is visible rather than one row of two hundred silently changing) — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
|
|
17
|
+
| [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — incl. `just_arrived` (`useJustArrived` + `TableRow`'s `justArrived`: the one-shot wash a row wears when it LANDS, so a realtime push is visible rather than one row of two hundred silently changing) and `SplitRow`/`SplitPane` (the responsive pane row — a weight that survives into a stacked column divides HEIGHT and clips a pane) — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` + `loticsResolve()` — the pre-bundle list and the whole `resolve` block a custom-code app's `vite.config.ts` imports rather than hand-carries, dev-link included). Read before building any screen; reuse first. |
|
|
18
18
|
| [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, choosing a CHOICE control by option count, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (`InlineFiles` for a record ROW, the `FilesEditor` COMPOUND for a whole section, plus the three-way file INTAKE), stage gates, and the commit-on-blur vs action-press ordering law. |
|
|
19
19
|
| [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split — it turns on WHO supplied the values; findings, provenance, confidence (`Finding` is filed here because AI produces most of them, NOT because it is AI-only — a rules-derived contradiction is as in scope as an extraction's); **after the run** — a stored record that fills up from several writers; the whole run in a dialog, **stopping**; **review surfaces compose from atoms** — `DiffValue`, `DiffMark`, `useChangeSet`. |
|
|
20
20
|
| [docs/composition.md](./docs/composition.md) | The design-language contract — **the form comes before the treatment** (name what the subject IS before reaching for parts), canvas + content column, heading altitude, register vs inset rows, the button ladder, master-detail `Drawer`, the register's rhythm, craft and TRIAGE bands, where the accent goes, theming as an app-level ESCAPE HATCH, color discipline, typography, **one fact one surface** (a chooser over the thing it chooses is a SELECTOR, not a second card grid; a control that only swaps one number for another is replaced by showing both), **a width floor answers can this be READ and never can this be operated** (the operating question passes a row whose every string is already cut), **a status column earns its slot by carrying a REASON rather than a state** (the floor asks for the fact to be visible, not for a badge), **an identity mark's GLYPH is derived from the name's distinguishing part** (an organization's first word is its legal form, so untreated every row marks one letter), **a row subject's supporting line takes a REFERENCE and never a sentence** (an explanation truncates to the clause the reader already knew), **a list stays fast by keeping IDENTITY rather than memoizing** (key by the record not the index; `memo` is the last resort and does nothing against a freshly-built prop), and where a commit sits. |
|
package/docs/catalog.md
CHANGED
|
@@ -425,7 +425,13 @@ component — children are the body), `Subsection` (+ `SubsectionHeading`/
|
|
|
425
425
|
`SectionStack` (the flat page's content column — owns the fixed 56px beat + hairline between
|
|
426
426
|
top-level blocks), `SubsectionStack` (a section's `Subsection` groups — fixed 32px beat,
|
|
427
427
|
space-only while the groups are SHORT, `divided` once they run long), `SectionCard`, `PageHeader` /
|
|
428
|
-
`PageContent`, `Stack`, `
|
|
428
|
+
`PageContent`, `Stack`, `SplitRow`/`SplitPane` (+ `useInSplitPane()`, which a card reads to take `flex: 1` only when it has a sibling to align with — the same card alone in a page column would fill the SCROLLER; two or more panes side by side that STACK when the
|
|
429
|
+
container is phone-narrow — a dashboard's chart pair, a summary beside its detail; the pane's
|
|
430
|
+
`weight` is dropped on the way down, because `flex` divides the MAIN axis and a weight left on a
|
|
431
|
+
column splits HEIGHT instead, clipping the lighter pane and gapping the heavier one. Reach for it
|
|
432
|
+
over a hand-rolled `flexDirection: "row"` on any row of two CARDS. NOT for a pair that is
|
|
433
|
+
meaningless apart — a value and its unit, a control and its field — which is `never`, and not for a
|
|
434
|
+
row of controls, which is `FilterBand`), `Spacer`, `Divider`, `Accordion`, `Tabs`, `PipelineTabs` (Tabs plus order —
|
|
429
435
|
the stage strip of a lifecycle desk, outcomes set apart), `SegmentedControl`,
|
|
430
436
|
`Stepper`, `DangerZone` (the destructive section — delete/archive — set apart at the bottom
|
|
431
437
|
of a record/settings surface).
|
package/docs/composition.md
CHANGED
|
@@ -168,6 +168,15 @@ control is 40. Stack three of those in one column and it reads as three rhythms,
|
|
|
168
168
|
alone. The page picks one beat and the primitives take it (`minHeight` on the row, `spacing` on
|
|
169
169
|
the table).
|
|
170
170
|
|
|
171
|
+
**A two-pane row that STACKS must drop its `flex` weights with the direction.** `flex` distributes
|
|
172
|
+
along the MAIN axis, so the moment a row becomes a column those width shares become HEIGHT shares:
|
|
173
|
+
the pane that asked for less is clipped mid-content and the one that asked for more carries a gap
|
|
174
|
+
under it, and both look like bugs in the panes rather than in the row. Compute the share from the
|
|
175
|
+
same condition that picks the direction — `stacked ? {} : { flex: n }` — never a constant `flex`
|
|
176
|
+
on a direction that varies. And check WHICH condition: `medium` spans 768–1727, so it is an
|
|
177
|
+
ordinary desktop, and stacking on `small || medium` collapses the layout for nearly every reader
|
|
178
|
+
while measuring perfectly at the one width nobody uses.
|
|
179
|
+
|
|
171
180
|
## The heading ramp — one construct per altitude, no drift
|
|
172
181
|
|
|
173
182
|
**The ramp is FIXED — the markdown ladder, no size knobs.** One outline on every surface; never
|
|
@@ -563,6 +572,40 @@ INFORMATIONAL — they never filter or navigate (that's the tabs/chips' job); if
|
|
|
563
572
|
cross-cutting numbers, drop them. Do NOT put a `KPIStrip` on a register page or a `SummaryLine` on
|
|
564
573
|
a dashboard. Card stat rails use `KPICard`.
|
|
565
574
|
|
|
575
|
+
**A card either IS a control or CONTAINS them — never both.** Making a whole card
|
|
576
|
+
pressable is right when its content is inert (a chart, a meter, a read-only
|
|
577
|
+
list): the card is the target, and `PressableHighlight` + the kit ring carries
|
|
578
|
+
the affordance, since a verb parked in the header reads as one more thing on a
|
|
579
|
+
crowded band. It is wrong the moment anything inside is its own door — a row, a
|
|
580
|
+
segment, a legend that filters. The outer press swallows them: the finer target
|
|
581
|
+
still exists, still announces itself, and never fires. Decide per card, and
|
|
582
|
+
where the inner doors are the useful ones, drop the outer press rather than
|
|
583
|
+
nesting.
|
|
584
|
+
|
|
585
|
+
**A container bucket carries SCREEN thresholds — `small` is not "narrow".**
|
|
586
|
+
`useContainerSize()` reports the same three buckets as the screen, so `small`
|
|
587
|
+
means under 768: a perfectly ordinary two-of-five pane on a wide display is
|
|
588
|
+
`small`, and a component keyed on it flips to its phone layout on a desktop.
|
|
589
|
+
Ask the question the component actually has — how much room do six columns
|
|
590
|
+
need — and measure the width with `onLayout` against that number. The bucket
|
|
591
|
+
answers "which device", never "does my content fit".
|
|
592
|
+
|
|
593
|
+
**Cards in a row END ON ONE LINE — fill the pane, and fix the imbalance in the CONTENT.** A card
|
|
594
|
+
in a `SplitPane` takes `flex: 1` so the row closes on a single bottom edge; ragged card bottoms
|
|
595
|
+
read as a broken layout, and no reader has ever mistaken breathing room inside a card for missing
|
|
596
|
+
data. That makes short content a CONTENT problem, and equal heights only move the emptiness, so
|
|
597
|
+
fix it where it is: cap the tall neighbour (a chart's `height` is a prop for this reason), give
|
|
598
|
+
the short card the fact it is missing — a denominator, a comparison, the affordance that opens its
|
|
599
|
+
detail — or pair it with a card of its own weight. A card that still cannot fill its half is
|
|
600
|
+
telling you the row is wrong.
|
|
601
|
+
|
|
602
|
+
**Never total counts over sets that OVERLAP.** A card of checks — stalled, overdue, unassigned,
|
|
603
|
+
past due — is a list of QUESTIONS, and one record answers several of them, so a header summing the
|
|
604
|
+
rows reports more records than exist and the reader cannot tell which figure to believe. Count the
|
|
605
|
+
checks, name the largest set, or carry no total at all. The tell is a total that exceeds the
|
|
606
|
+
population it is drawn from; the fix is never to de-duplicate in the header, because a reader who
|
|
607
|
+
adds the visible rows would then get a different answer from the one printed above them.
|
|
608
|
+
|
|
566
609
|
## One fact, one surface — and no control that only reveals a number
|
|
567
610
|
|
|
568
611
|
Two rules that catch the same defect from opposite ends: a screen saying one thing twice.
|
|
@@ -877,6 +920,17 @@ sized by its HEADER over single-character data. Measure the header row's cells a
|
|
|
877
920
|
content per column; where a column's width is set by its label rather than its values, that
|
|
878
921
|
difference is the identity column's, not its own.
|
|
879
922
|
|
|
923
|
+
**A table in a DRAWER holds three columns, and the one it drops is the LAST
|
|
924
|
+
DECLARED.** A drawer's table area measures ~520px and the identity column claims
|
|
925
|
+
about 220 of it, so an identity plus two figures is the ceiling: a fourth is
|
|
926
|
+
dropped with no error, no warning and no gap where it was. Two things make this
|
|
927
|
+
bite. `priority` does not rescue you — the drop follows declaration order, so
|
|
928
|
+
the column you thought was safest goes first if you happened to write it last.
|
|
929
|
+
And the columns worth dropping are rarely the ones at the end: a drill opened to
|
|
930
|
+
show a value and an age lost exactly those and kept the owner. Put the identity
|
|
931
|
+
and the two figures the drawer was OPENED for in columns, and everything else in
|
|
932
|
+
the identity cell's caption, where it costs no width at all.
|
|
933
|
+
|
|
880
934
|
**Space by the RATIO of within-group to between-group, never by absolutes.** Collect the gap
|
|
881
935
|
inside a composed band and the gap between that band and its neighbours, and divide. Near 1 is
|
|
882
936
|
the defect however sensible each number is alone. Aim for roughly 2 or more, and change the
|
package/docs/reviewing.md
CHANGED
|
@@ -333,8 +333,17 @@ defect, so finding it on one panel is a reason to sweep the siblings.
|
|
|
333
333
|
that drifts across the track means each one was offset by a guess at its own width.
|
|
334
334
|
- **A row beat belongs to the PAGE** — collect every row primitive's height on one screen before
|
|
335
335
|
calling any of them right.
|
|
336
|
-
|
|
337
|
-
|
|
336
|
+
- **Cards laid side by side: collect their BOTTOM edges, not their tops.** Tops align for free —
|
|
337
|
+
the row sets them — so every horizontal check passes while the row ends on a staircase. One
|
|
338
|
+
`getBoundingClientRect().bottom` per card in a row: more than one value is the finding, and the
|
|
339
|
+
spread tells you which card is starved. It has two causes and they need opposite fixes. The card
|
|
340
|
+
did not fill its pane (no `flex`), which is a LAYOUT fix; or it filled it and its content stops
|
|
341
|
+
early, which is a CONTENT one — and equal heights only relocate that emptiness, so cap the tall
|
|
342
|
+
neighbour, give the short card the fact it is missing, or re-pair the row. Screenshots are how
|
|
343
|
+
this is noticed and the rects are how it is aimed: a staircase is obvious on sight and invisible
|
|
344
|
+
to every probe above this one.
|
|
345
|
+
|
|
346
|
+
→ [composition.md](./composition.md) §"Canvas & content column", §"Cards — banded and composable", §"No dead rows", §"The register's own craft".
|
|
338
347
|
|
|
339
348
|
### 7. Surface walk — open it, press it, read it
|
|
340
349
|
A resting register is the cheapest thing to screenshot and the least likely to hold the defect.
|
|
@@ -604,6 +613,32 @@ the data, which is worse than a constant one because the controls no longer form
|
|
|
604
613
|
column. Non-zero-but-constant is the same defect with luckier data.
|
|
605
614
|
|
|
606
615
|
|
|
616
|
+
### 8j. Does every cell sit under the header that names it?
|
|
617
|
+
**Collect** per table: the header cells' boxes, every row's cells' boxes, and where the INK
|
|
618
|
+
starts inside each. Report three sets — cells-per-row against columns-declared, each cell's
|
|
619
|
+
box edges against its header's, each cell's ink edge against its header's.
|
|
620
|
+
|
|
621
|
+
Three failures, and only the first is a defect on sight:
|
|
622
|
+
|
|
623
|
+
- **A row with fewer cells than the table declares columns.** A row's children map to columns
|
|
624
|
+
POSITIONALLY, so a fragment wrapping two cells counts as one and the cells inside it are
|
|
625
|
+
handed no column: they size to content and pack against whatever the flexible column leaves.
|
|
626
|
+
The declared width and alignment are gone with no error, and the result reads as a table
|
|
627
|
+
until you look down a column. Cells are an array, never a fragment.
|
|
628
|
+
- **A box that does not match its header's** means something other than the column style moved
|
|
629
|
+
the cell — the row and the column list disagree about how many columns there are.
|
|
630
|
+
- **Ink starting N px right of its header, by the same N on every row**, is fixed chrome
|
|
631
|
+
leading a flexible cell — an avatar, a dot, a badge. Legitimate where that mark is the row's
|
|
632
|
+
own subject, and then the column owes `lead` those pixels or the fit believes the cell has
|
|
633
|
+
more room for text than it has. Illegitimate in a supporting column, where §Identity marks
|
|
634
|
+
bans the mark outright.
|
|
635
|
+
|
|
636
|
+
*Two traps in the measurement, both of which invent findings.* A pressable row keeps its press
|
|
637
|
+
target in the same wrapper as its cells, absolutely positioned — count it and every register
|
|
638
|
+
reports two cells. And a `Text` wrapping another `Text` keeps its own words in a bare text node,
|
|
639
|
+
so a walk over leaf ELEMENTS measures only the nested span and reports the cell starting a
|
|
640
|
+
hundred pixels in; measure text nodes with a `Range`.
|
|
641
|
+
|
|
607
642
|
### 9. The absence pass — what catches "bland"
|
|
608
643
|
Every probe above reads an element that EXISTS. None fires on a screen where nothing is wrong and
|
|
609
644
|
nothing is right, which is the screen reported as bland. Defect-removal converges on *inoffensive*;
|
|
@@ -33,6 +33,7 @@ import { KPIStrip } from "@lotics/ui/kpi_strip";
|
|
|
33
33
|
import { Peek } from "@lotics/ui/peek";
|
|
34
34
|
import { StackedProgressBar } from "@lotics/ui/stacked_progress_bar";
|
|
35
35
|
import { Funnel } from "@lotics/ui/funnel";
|
|
36
|
+
import { SplitRow, SplitPane } from "@lotics/ui/split_row";
|
|
36
37
|
import { formatMoney } from "@lotics/ui/format_money";
|
|
37
38
|
|
|
38
39
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -330,8 +331,9 @@ export function TplDashboard() {
|
|
|
330
331
|
/>
|
|
331
332
|
|
|
332
333
|
{/* chart row 1: revenue by month (BarChart + TrendFooter), margin trend (LineChart) */}
|
|
333
|
-
<
|
|
334
|
-
<
|
|
334
|
+
<SplitRow>
|
|
335
|
+
<SplitPane weight={3}>
|
|
336
|
+
<Card style={{ padding: 0, flex: 1 }}>
|
|
335
337
|
<CardHeader>
|
|
336
338
|
<CardHeaderTitle info="Revenue recognized by invoice date, before discounts">
|
|
337
339
|
Revenue by month
|
|
@@ -350,8 +352,10 @@ export function TplDashboard() {
|
|
|
350
352
|
<TrendFooter value={11} periodLabel="vs last month" detail="May: ₫1.15B" />
|
|
351
353
|
</View>
|
|
352
354
|
</Card>
|
|
355
|
+
</SplitPane>
|
|
353
356
|
|
|
354
|
-
<
|
|
357
|
+
<SplitPane weight={2}>
|
|
358
|
+
<Card style={{ padding: 0, flex: 1 }}>
|
|
355
359
|
<CardHeader>
|
|
356
360
|
<CardHeaderTitle info="Gross margin per month — revenue minus snapshotted cost of goods">
|
|
357
361
|
Margin trend
|
|
@@ -362,11 +366,13 @@ export function TplDashboard() {
|
|
|
362
366
|
<LineChart points={BIEN_LAI_THANG} height={150} lineColor={solid("blue")} formatNumber={(n) => `${n}%`} />
|
|
363
367
|
</View>
|
|
364
368
|
</Card>
|
|
365
|
-
|
|
369
|
+
</SplitPane>
|
|
370
|
+
</SplitRow>
|
|
366
371
|
|
|
367
372
|
{/* chart row 2: pipeline funnel, revenue mix, on-time delivery */}
|
|
368
|
-
<
|
|
369
|
-
<
|
|
373
|
+
<SplitRow>
|
|
374
|
+
<SplitPane weight={3}>
|
|
375
|
+
<Card style={{ padding: 0, flex: 1 }}>
|
|
370
376
|
<CardHeader>
|
|
371
377
|
<CardHeaderTitle info="Open opportunities at each stage, updated from Quotes">
|
|
372
378
|
Sales pipeline
|
|
@@ -385,8 +391,10 @@ export function TplDashboard() {
|
|
|
385
391
|
<Text size="xs" color="muted">Quote-to-close rate: 32% — up on last quarter</Text>
|
|
386
392
|
</View>
|
|
387
393
|
</Card>
|
|
394
|
+
</SplitPane>
|
|
388
395
|
|
|
389
|
-
<
|
|
396
|
+
<SplitPane weight={3}>
|
|
397
|
+
<Card style={{ padding: 0, flex: 1 }}>
|
|
390
398
|
<CardHeader>
|
|
391
399
|
<CardHeaderTitle info="Period revenue split by customer industry">
|
|
392
400
|
Revenue mix
|
|
@@ -397,8 +405,10 @@ export function TplDashboard() {
|
|
|
397
405
|
<PieChart slices={CO_CAU} size={140} showLegend formatNumber={(n) => n.toLocaleString("en-GB")} />
|
|
398
406
|
</View>
|
|
399
407
|
</Card>
|
|
408
|
+
</SplitPane>
|
|
400
409
|
|
|
401
|
-
<
|
|
410
|
+
<SplitPane weight={2}>
|
|
411
|
+
<Card style={{ padding: 0, flex: 1 }}>
|
|
402
412
|
<CardHeader>
|
|
403
413
|
<CardHeaderTitle info="Deliveries that arrived on or before the needed-by date this period">
|
|
404
414
|
On-time delivery
|
|
@@ -408,7 +418,8 @@ export function TplDashboard() {
|
|
|
408
418
|
<ProgressRing value={87} label="On time" caption="33 of 38 deliveries" color={solid("emerald")} />
|
|
409
419
|
</View>
|
|
410
420
|
</Card>
|
|
411
|
-
|
|
421
|
+
</SplitPane>
|
|
422
|
+
</SplitRow>
|
|
412
423
|
|
|
413
424
|
{/* chart row 3: the SAME pipeline as a CONVERSION funnel — narrowing
|
|
414
425
|
cohorts + step drop-off rates, the subset lens vs the composition bar above */}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "47.
|
|
3
|
+
"version": "47.12.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./accordion": "./src/accordion.tsx",
|
|
@@ -261,6 +261,7 @@
|
|
|
261
261
|
"./spacer": "./src/spacer.tsx",
|
|
262
262
|
"./spacing": "./src/spacing.ts",
|
|
263
263
|
"./sparkline": "./src/sparkline.tsx",
|
|
264
|
+
"./split_row": "./src/split_row.tsx",
|
|
264
265
|
"./stack": "./src/stack.tsx",
|
|
265
266
|
"./stacked_bar_chart": "./src/stacked_bar_chart.tsx",
|
|
266
267
|
"./stacked_progress_bar": "./src/stacked_progress_bar.tsx",
|
package/src/bar_chart.tsx
CHANGED
|
@@ -50,6 +50,14 @@ export interface BarChartProps {
|
|
|
50
50
|
* name runs is the caller's fact, not the chart's.
|
|
51
51
|
*/
|
|
52
52
|
labelWidth?: number;
|
|
53
|
+
/**
|
|
54
|
+
* Plot height for `vertical`, in px (default 200) — the same knob
|
|
55
|
+
* `LineChart` and `WaterfallChart` carry. It is the caller's because height
|
|
56
|
+
* is what balances a chart against the card BESIDE it: a row of cards ends
|
|
57
|
+
* on one bottom edge, so a chart that cannot be capped forces its neighbour
|
|
58
|
+
* to carry the difference as dead space.
|
|
59
|
+
*/
|
|
60
|
+
height?: number;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
function useAxisTicks(maxValue: number) {
|
|
@@ -108,11 +116,11 @@ export function BarChart(props: BarChartProps) {
|
|
|
108
116
|
formatNumber = defaultFormatNumber,
|
|
109
117
|
emptyLabel = locale.chart.noData,
|
|
110
118
|
labelWidth = DEFAULT_LABEL_WIDTH,
|
|
119
|
+
height: barAreaHeight = 200,
|
|
111
120
|
} = props;
|
|
112
121
|
const maxValue = Math.max(...data.map((d) => d.value), 1);
|
|
113
122
|
const axisTicks = useAxisTicks(maxValue);
|
|
114
123
|
const axisMax = axisTicks[axisTicks.length - 1] || maxValue;
|
|
115
|
-
const barAreaHeight = 200;
|
|
116
124
|
|
|
117
125
|
// The vertical axis prints as many labels as the row fits and thins the rest —
|
|
118
126
|
// the same rule `LineChart` runs, because it is the axis's rule and not one
|
package/src/pressable_row.tsx
CHANGED
|
@@ -32,7 +32,12 @@ export interface PressableRowProps {
|
|
|
32
32
|
* `Pressable` with role="button" + "Open …" label. Wrap the row body with it
|
|
33
33
|
* only when the body is non-interactive by construction; a body that can
|
|
34
34
|
* carry its own controls gets an EMPTY absolutely-positioned door as their
|
|
35
|
-
* sibling instead (a button must not contain a button — see `TableRow`).
|
|
35
|
+
* sibling instead (a button must not contain a button — see `TableRow`).
|
|
36
|
+
*
|
|
37
|
+
* The cell that takes the slack says so: `flex: 1, minWidth: 0`. This is a
|
|
38
|
+
* flex row, and a react-native `View` in one sizes to its max-content and
|
|
39
|
+
* never shrinks (`flexShrink: 0`, unlike the web), so a body left unstated
|
|
40
|
+
* lays out at the width of its longest line and overflows the container. */
|
|
36
41
|
children: ReactNode;
|
|
37
42
|
/** Layout only (gap, minHeight overrides). The surface owns its press
|
|
38
43
|
* states — never pass backgroundColor for hover/selected. */
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
import { View, type StyleProp, type ViewStyle } from "react-native";
|
|
3
|
+
import { useScreenSize } from "./use_screen_size";
|
|
4
|
+
import { SizeBoundary } from "./size_boundary";
|
|
5
|
+
|
|
6
|
+
/** Whether the enclosing `SplitRow` has stacked. Private: a pane's only job is
|
|
7
|
+
* to drop its weight, and nothing outside this pair needs to know. */
|
|
8
|
+
const StackedContext = createContext(false);
|
|
9
|
+
|
|
10
|
+
const InPaneContext = createContext(false);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Whether this subtree is inside a `SplitPane` laid out beside a sibling.
|
|
14
|
+
*
|
|
15
|
+
* A card in a pane takes `flex: 1` so the row closes on one bottom edge; the
|
|
16
|
+
* same card standing alone in a page column would then fill the SCROLLER and
|
|
17
|
+
* paint a hundred pixels of nothing under its last row. The two cases need
|
|
18
|
+
* opposite answers and look identical at the call site, which is why the row
|
|
19
|
+
* publishes it rather than every card carrying a `fill` prop somebody has to
|
|
20
|
+
* remember. False when nothing encloses it, and false while the row is stacked
|
|
21
|
+
* — a stacked pane has no sibling to align with.
|
|
22
|
+
*/
|
|
23
|
+
export function useInSplitPane(): boolean {
|
|
24
|
+
return useContext(InPaneContext) && !useContext(StackedContext);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SplitRowProps {
|
|
28
|
+
/** `SplitPane`s. Anything else is laid out as an unweighted child. */
|
|
29
|
+
children: ReactNode;
|
|
30
|
+
/** Gap between panes, in both directions. Default 16 — the dashboard band gap. */
|
|
31
|
+
gap?: number;
|
|
32
|
+
/**
|
|
33
|
+
* Keep the panes on one line no matter how narrow the container gets. For a
|
|
34
|
+
* row whose halves are meaningless apart — a value beside the unit it is in,
|
|
35
|
+
* a control beside the field it edits. Never for two cards.
|
|
36
|
+
*/
|
|
37
|
+
never?: boolean;
|
|
38
|
+
style?: StyleProp<ViewStyle>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface SplitPaneProps {
|
|
42
|
+
children: ReactNode;
|
|
43
|
+
/**
|
|
44
|
+
* Share of the ROW's width, as `flex`. Dropped entirely when the row stacks,
|
|
45
|
+
* which is the whole reason this component exists — see below.
|
|
46
|
+
*/
|
|
47
|
+
weight?: number;
|
|
48
|
+
style?: StyleProp<ViewStyle>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Two or more panes side by side that STACK when the container is phone-narrow.
|
|
53
|
+
*
|
|
54
|
+
* It exists because the hand-rolled version has a failure mode that measures
|
|
55
|
+
* clean and is invisible in review: `flex` distributes along the MAIN axis, so
|
|
56
|
+
* the moment a `flexDirection: "row"` becomes `"column"`, `flex: 3` and
|
|
57
|
+
* `flex: 2` stop dividing the WIDTH and start dividing the HEIGHT. The pane
|
|
58
|
+
* that asked for less is clipped mid-content; the one that asked for more
|
|
59
|
+
* carries a gap under it. Both read as bugs in the panes rather than in the
|
|
60
|
+
* row, so the fix gets aimed at the wrong component.
|
|
61
|
+
*
|
|
62
|
+
* The second trap is the condition. `useScreenSize` reports three buckets and
|
|
63
|
+
* `medium` spans 768–1727 — an ordinary desktop, not a narrow one — so a row
|
|
64
|
+
* that stacks on `small || medium` collapses for nearly every reader while
|
|
65
|
+
* measuring perfectly at the one width nobody uses. This component owns that
|
|
66
|
+
* decision so no caller has to get it right.
|
|
67
|
+
*
|
|
68
|
+
* <SplitRow>
|
|
69
|
+
* <SplitPane weight={3}><Card>…</Card></SplitPane>
|
|
70
|
+
* <SplitPane weight={2}><Card>…</Card></SplitPane>
|
|
71
|
+
* </SplitRow>
|
|
72
|
+
*
|
|
73
|
+
* Panes are `stretch`ed to equal height while side by side, which is what makes
|
|
74
|
+
* a row of cards read as one band. A pane with far less content than its
|
|
75
|
+
* neighbour will show that as white space — the answer is to give it the fact
|
|
76
|
+
* it is missing (a denominator, a comparison), not to unstretch the row.
|
|
77
|
+
*/
|
|
78
|
+
export function SplitRow({ children, gap = 16, never = false, style }: SplitRowProps) {
|
|
79
|
+
const screen = useScreenSize();
|
|
80
|
+
const stacked = !never && screen.small;
|
|
81
|
+
return (
|
|
82
|
+
<StackedContext.Provider value={stacked}>
|
|
83
|
+
<View
|
|
84
|
+
style={[
|
|
85
|
+
{ flexDirection: stacked ? "column" : "row", gap, alignItems: "stretch" },
|
|
86
|
+
style,
|
|
87
|
+
]}
|
|
88
|
+
>
|
|
89
|
+
{children}
|
|
90
|
+
</View>
|
|
91
|
+
</StackedContext.Provider>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* One pane. Also a `SizeBoundary`, because a pane is precisely the box whose
|
|
97
|
+
* width stops tracking the window: a chart inside a 2-of-5 pane on a wide
|
|
98
|
+
* screen is in a narrow box, and `useContainerSize()` is how it finds out. That
|
|
99
|
+
* is why the row's stacked state is NOT published — a descendant asking "did
|
|
100
|
+
* the row stack" is asking the wrong question, and would keep a vertical
|
|
101
|
+
* six-column chart in a 300px pane just because the screen is wide.
|
|
102
|
+
*/
|
|
103
|
+
export function SplitPane({ children, weight = 1, style }: SplitPaneProps) {
|
|
104
|
+
const stacked = useContext(StackedContext);
|
|
105
|
+
return (
|
|
106
|
+
<InPaneContext.Provider value={true}>
|
|
107
|
+
<SizeBoundary style={[stacked ? null : { flex: weight }, style]}>{children}</SizeBoundary>
|
|
108
|
+
</InPaneContext.Provider>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -28,6 +28,7 @@ interface StackedProgressBarProps {
|
|
|
28
28
|
* when the bar IS the section's main visualization. */
|
|
29
29
|
height?: number;
|
|
30
30
|
loading?: boolean;
|
|
31
|
+
testID?: string;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
/**
|
|
@@ -43,17 +44,27 @@ interface StackedProgressBarProps {
|
|
|
43
44
|
* legend, the colored bar is decorative.
|
|
44
45
|
*/
|
|
45
46
|
export function StackedProgressBar(props: StackedProgressBarProps) {
|
|
46
|
-
const { segments, total, title, caption, height = 14, loading } = props;
|
|
47
|
+
const { segments, total, title, caption, height = 14, loading, testID } = props;
|
|
48
|
+
// Segments are measured against the TOTAL, and the shortfall is drawn as the
|
|
49
|
+
// unfilled tail. Sizing them against each OTHER — `flex: seg.value` with no
|
|
50
|
+
// remainder — makes the bar full at every input, so the gap a caller passes a
|
|
51
|
+
// larger `total` to reveal can never appear: a forecast 40% short of its
|
|
52
|
+
// target paints identically to one that beat it.
|
|
53
|
+
const filled = segments.reduce((s, seg) => s + Math.max(seg.value, 0), 0);
|
|
54
|
+
// Over-target is a real state. Keep the segments' proportions and let the row
|
|
55
|
+
// read as full, rather than spilling one past the track.
|
|
56
|
+
const remainder = Math.max(total - filled, 0);
|
|
47
57
|
const bar =
|
|
48
58
|
loading || total === 0 ? (
|
|
49
|
-
<View style={[styles.bar, { height, backgroundColor: colors.zinc[100] }]} />
|
|
59
|
+
<View testID={testID} style={[styles.bar, { height, backgroundColor: colors.zinc[100] }]} />
|
|
50
60
|
) : (
|
|
51
|
-
<View style={[styles.bar, styles.barFilled, { height }]}>
|
|
61
|
+
<View testID={testID} style={[styles.bar, styles.barFilled, { height }]}>
|
|
52
62
|
{segments
|
|
53
63
|
.filter((s) => s.value > 0)
|
|
54
64
|
.map((seg) => (
|
|
55
65
|
<View key={seg.key} style={{ flex: seg.value, backgroundColor: seg.color }} />
|
|
56
66
|
))}
|
|
67
|
+
{remainder > 0 ? <View style={{ flex: remainder }} /> : null}
|
|
57
68
|
</View>
|
|
58
69
|
);
|
|
59
70
|
|
package/src/table.tsx
CHANGED
|
@@ -609,6 +609,11 @@ export function TableRow(props: TableRowProps) {
|
|
|
609
609
|
const rowMinHeight = minHeight ?? ROW_HEIGHT;
|
|
610
610
|
const showDetail = detail != null && expanded === true;
|
|
611
611
|
|
|
612
|
+
// ONE CELL PER COLUMN, POSITIONALLY — so a row's children may never be wrapped
|
|
613
|
+
// in a fragment. `Children.toArray` counts a fragment as ONE child, and the cells
|
|
614
|
+
// inside it are then handed no column at all: they size to content instead of to
|
|
615
|
+
// the declared width, and the register renders plausibly with its columns silently
|
|
616
|
+
// detached from their headers. Build a conditional group as an ARRAY of cells.
|
|
612
617
|
const cells = (Children.toArray(children).filter(isValidElement) as ReactElement<TableCellProps>[])
|
|
613
618
|
.map((cell, i) => ({ cell, column: ctx.columns[i] as TableColumn | undefined }))
|
|
614
619
|
// A dropped column drops its cell (stacked shows everything). Cells beyond
|