@lotics/ui 46.2.0 → 46.3.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 +24 -0
- package/MIGRATION.md +87 -0
- package/docs/ai_patterns.md +11 -0
- package/docs/catalog.md +99 -15
- package/docs/composition.md +48 -6
- package/docs/templates.md +3 -1
- package/docs/testing.md +6 -0
- package/package.json +3 -2
- package/src/alert.css +0 -1
- package/src/alert.tsx +8 -0
- package/src/axis_label_indices.ts +84 -0
- package/src/bar_chart.tsx +137 -16
- package/src/dialog.tsx +46 -24
- package/src/drawer.tsx +21 -2
- package/src/file_gallery_modal.tsx +3 -0
- package/src/line_chart.tsx +2 -2
- package/src/modal.tsx +23 -3
- package/src/overlay_layer.ts +65 -0
- package/src/page_content.tsx +8 -22
- package/src/page_header.tsx +60 -11
- package/src/popover.tsx +29 -5
- package/src/skip_link.tsx +2 -1
- package/src/stacked_bar_chart.tsx +31 -1
- package/src/text.tsx +21 -0
- package/src/tooltip.tsx +2 -1
- package/src/use_change_set.ts +66 -17
- package/src/use_scroll_seam.ts +79 -0
- package/src/line_chart_labels.ts +0 -32
package/AGENTS.md
CHANGED
|
@@ -75,6 +75,30 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
|
|
|
75
75
|
- **The kit's fonts/colors/icons ARE the design system** — never a custom font, icon set, or
|
|
76
76
|
hand-picked palette shade; color is `solid`/`tint`/`ramp` with ONE accent per screen.
|
|
77
77
|
→ [composition.md §"Color discipline"](./docs/composition.md).
|
|
78
|
+
- **An overlay paints in the order it was OPENED, and a scroller that swaps content opens the new
|
|
79
|
+
content at the top.** Compose overlays wherever you like — a `Dialog` written beside the
|
|
80
|
+
`Drawer` whose row opens it works, both ways round, and a `Dialog` opened from an open `Popover`
|
|
81
|
+
paints over its panel. An overlay of your OWN gets that for free by rendering its react-native
|
|
82
|
+
`Modal` **only while open** (`{open && <Modal visible …>}`); mounted while closed it takes its
|
|
83
|
+
body-level slot on the app's first paint and is then covered by anything opened later —
|
|
84
|
+
rendering and announcing perfectly, dead to every press. The z-index rungs beside the overlays
|
|
85
|
+
are published in `overlay_layer`, so never hand-pick a number to clear a Lotics overlay.
|
|
86
|
+
`DrawerScrollArea` / `DialogScrollArea` / `ModalBody` take a `scrollKey` and the routed
|
|
87
|
+
`Popover` takes the same seam off its route, so a swapped-in record opens at the top and the
|
|
88
|
+
reader returns to their place on the way back.
|
|
89
|
+
→ [composition.md §"Canvas & content column"](./docs/composition.md)
|
|
90
|
+
→ [catalog.md §"Overlays & navigation"](./docs/catalog.md)
|
|
91
|
+
- **ONE VALUE, ONE RENDERING, inside one card — the chart draws the entity its table draws.**
|
|
92
|
+
A value's filter renders it the way its cell does, and the same rule reaches the chart beside
|
|
93
|
+
the table: `StackedBarRow` and `BarChartItem` take a `leading` slot for the entity's own mark.
|
|
94
|
+
A LEGEND row is the exception — its subject is a category, and the swatch is that identity.
|
|
95
|
+
→ [composition.md §"A register that TRIAGES"](./docs/composition.md).
|
|
96
|
+
- **A row says which side HOLDS; `numberOfLines` handles the side that gives way.** The clamp
|
|
97
|
+
brings its own `minWidth: 0` + `flexShrink: 1` (a clamp that cannot shrink cannot clamp), so
|
|
98
|
+
what a call site still has to state is `flexShrink: 0` on the figure or control that must stay
|
|
99
|
+
whole. Same law on a categorical axis: the LABELS thin to fit, the bars never do.
|
|
100
|
+
→ [composition.md §"Typography"](./docs/composition.md),
|
|
101
|
+
[catalog.md §"Numbers & charts"](./docs/catalog.md).
|
|
78
102
|
- **Hand-typed type is off-system — and it always lands too small.** Every run of language is
|
|
79
103
|
`<Text size= weight= color=>` on the fixed rungs. **Grep the diff: `fontSize:` / `lineHeight:` /
|
|
80
104
|
`letterSpacing:` outside `src/` is a bug.** → [composition.md §"Typography"](./docs/composition.md).
|
package/MIGRATION.md
CHANGED
|
@@ -4,6 +4,93 @@ 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
|
+
## 46.3.0
|
|
8
|
+
|
|
9
|
+
**A `Text` with `numberOfLines` now declares `flexShrink: 1` + `minWidth: 0` for you.**
|
|
10
|
+
A clamp that cannot shrink cannot clamp: on web a clamped `Text` is `white-space: nowrap`, so
|
|
11
|
+
`min-width: auto` floors it at the WHOLE string; on native a `Text` is `flexShrink: 0`. Either way
|
|
12
|
+
two clamped values on one row laid out at intrinsic width and ran off the frame. Both declarations
|
|
13
|
+
now come with the clamp, and both are no-ops until a row actually overflows.
|
|
14
|
+
|
|
15
|
+
Nothing's API changed, but **a layout default moved under every clamped `Text` in your app** — this
|
|
16
|
+
is the one 46.3.0 change to re-check by looking. Two shapes are worth a pass:
|
|
17
|
+
|
|
18
|
+
- A flex ROW where a clamped `Text` was the side that HELD and an unclamped sibling gave way. Both
|
|
19
|
+
can shrink now, so the wrong one may truncate.
|
|
20
|
+
- A flex COLUMN with a bounded height (a fixed-height card, a `maxHeight` panel). `flexShrink` acts
|
|
21
|
+
on the MAIN axis, so a clamped `Text` that used to hold its height can now be compressed.
|
|
22
|
+
|
|
23
|
+
The pair sits BEFORE your own `style`, so the site that must stay whole says so:
|
|
24
|
+
|
|
25
|
+
```diff
|
|
26
|
+
-<Text numberOfLines={1}>{amount}</Text>
|
|
27
|
+
+<Text numberOfLines={1} style={{ flexShrink: 0 }}>{amount}</Text>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
That is how a figure keeps winning over its label. `minWidth` is overridden the same way
|
|
31
|
+
(`style={{ minWidth: "auto" }}`) on the rare surface that wants the old min-content floor back.
|
|
32
|
+
|
|
33
|
+
**An overlay of your OWN must render its react-native `Modal` only while it is OPEN.**
|
|
34
|
+
`Dialog`, `Drawer`, `Modal` and `FileGalleryModal` now do — nothing in their API changed — and a
|
|
35
|
+
hand-rolled overlay outside the kit has to do the same, or it loses to them:
|
|
36
|
+
|
|
37
|
+
```diff
|
|
38
|
+
-<Modal visible={open} onRequestClose={close} transparent>
|
|
39
|
+
- …
|
|
40
|
+
-</Modal>
|
|
41
|
+
+{open && (
|
|
42
|
+
+ <Modal visible onRequestClose={close} transparent>
|
|
43
|
+
+ …
|
|
44
|
+
+ </Modal>
|
|
45
|
+
+)}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
react-native-web appends a `Modal`'s body-level `<div>` on its FIRST render and never re-orders
|
|
49
|
+
it, so an overlay mounted while closed takes its slot on the app's first paint and is then covered
|
|
50
|
+
by any overlay opened later — while rendering, reading and announcing perfectly, with only its
|
|
51
|
+
presses dead. Nothing is lost by the change: react-native-web renders a closed `Modal`'s children
|
|
52
|
+
as `null` already, so only the empty portal div was being held. A takeover with
|
|
53
|
+
`animationType="fade"` keeps its fade IN and now closes immediately, as `FileGalleryModal` always
|
|
54
|
+
has; state that must survive a close belongs outside the `Modal` element either way.
|
|
55
|
+
|
|
56
|
+
**The overlay z-index rungs are now published constants.** The numbers are unchanged for `Tooltip`
|
|
57
|
+
and `Alert` (`10000`) and for a toast (`10001`); **the skip link moved `10001` → `10002`**, off the
|
|
58
|
+
rung it was sharing with a toast. Read the rung instead of naming a number:
|
|
59
|
+
|
|
60
|
+
```diff
|
|
61
|
+
-import { View } from "react-native";
|
|
62
|
+
+import { NOTIFICATION_Z } from "@lotics/ui/overlay_layer";
|
|
63
|
+
…
|
|
64
|
+
- zIndex: 10001,
|
|
65
|
+
+ zIndex: NOTIFICATION_Z,
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The full table is in `docs/catalog.md` § Overlays & navigation: `OVERLAY_Z` (every overlay, and
|
|
69
|
+
`Popover`), `OVERLAY_Z_ABOVE` (tooltip/alert), `NOTIFICATION_Z` (a toast), `SKIP_LINK_Z`.
|
|
70
|
+
|
|
71
|
+
**`PageContent`'s title band is now a `PageHeader`**, so the two stop drifting. `title`,
|
|
72
|
+
`titleRight` and `description` are unchanged, and `PageHeader`'s `title` became optional — but the
|
|
73
|
+
band's rhythm is now `PageHeader`'s everywhere: a description sits **8px** under the title (it was
|
|
74
|
+
flush in `PageHeader`) and the band clears **16px** before the content (it was 24 in
|
|
75
|
+
`PageContent`). Nothing to change; worth one look at a page shell you care about.
|
|
76
|
+
|
|
77
|
+
**`useChangeSet`'s per-proposal default is a MAP, not a predicate.** `initial: "accepted"` /
|
|
78
|
+
`"pending"` are unchanged.
|
|
79
|
+
|
|
80
|
+
```diff
|
|
81
|
+
-useChangeSet(ids, { initial: (id) => (overwrites.has(id) ? "rejected" : "accepted") })
|
|
82
|
+
+const initial = useMemo(
|
|
83
|
+
+ () => new Map(rows.filter((r) => r.overwrites).map((r) => [r.id, "rejected" as const])),
|
|
84
|
+
+ [rows],
|
|
85
|
+
+);
|
|
86
|
+
+useChangeSet(ids, { initial })
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
An id the map does not name arrives `accepted`, as before. A value rather than a function is what
|
|
90
|
+
lets the default be an ordinary dependency: `status` and the group arrays derive from it together,
|
|
91
|
+
so `status` changes identity whenever a decision does — which a screen that memoizes its rows on
|
|
92
|
+
`review.status` needs, and which a predicate held out of the dependency lists could not give.
|
|
93
|
+
|
|
7
94
|
## 46.0.0
|
|
8
95
|
|
|
9
96
|
**`AgentRun` / `AgentProgress` drop `stepsLabel`, and the `agentRun` locale slice drops `steps`.**
|
package/docs/ai_patterns.md
CHANGED
|
@@ -379,6 +379,17 @@ alignment and rhythm.
|
|
|
379
379
|
`keptCount`/`total`/`settled`. `initial` defaults to **`accepted`** so an operator drops
|
|
380
380
|
exceptions instead of approving eight identical lines; pass `pending` when each change
|
|
381
381
|
genuinely deserves its own verdict, and gate the commit on `settled`.
|
|
382
|
+
- **A set that mixes KINDS defaults per kind: `initial` also takes a `Map<id, decision>`.** A
|
|
383
|
+
review usually holds both — filling a blank should arrive `accepted`, overwriting a value a
|
|
384
|
+
human already set should arrive `rejected`, so the destructive half is opt-in — and one
|
|
385
|
+
global default puts one of the two on the wrong footing. An id the map does not name arrives
|
|
386
|
+
`accepted`. Build it with a `useMemo` over the proposals your screen already holds, which is
|
|
387
|
+
what keeps the hook ignorant of what a proposal IS; a VALUE (rather than a predicate written
|
|
388
|
+
inline) is what lets `status` and the group arrays derive from it together, so `status`
|
|
389
|
+
changes identity the moment a decision moves and a screen memoizing its rows on it re-runs.
|
|
390
|
+
Deciding those rows by calling `reject()` from the result handler instead writes overrides
|
|
391
|
+
nobody made, and `undo` then hands the row back **accepted** — the destructive change
|
|
392
|
+
silently in, from the control that means "undo".
|
|
382
393
|
|
|
383
394
|
### The shapes, and what each one is
|
|
384
395
|
|
package/docs/catalog.md
CHANGED
|
@@ -443,6 +443,10 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
443
443
|
numbers), `weight`, `color`, alignment, tabular numerals. **`leading="tight"`** sets the line
|
|
444
444
|
box for a STACKED PAIR rather than for prose — a subject over its supporting line, a value over
|
|
445
445
|
its annotation; see composition.md, which also covers the `gap` that goes with it.
|
|
446
|
+
**`numberOfLines`** clamps, and brings the `minWidth: 0` + `flexShrink: 1` that makes clamping
|
|
447
|
+
possible on a flex row — a clamp that cannot shrink cannot clamp. Both are no-ops until the row
|
|
448
|
+
overflows, and a `style` on the call site still wins, so a value that must never give way says
|
|
449
|
+
`flexShrink: 0` and keeps holding over its own label.
|
|
446
450
|
- **`eyebrow`** — `Eyebrow`: the small quiet label above or beside a VALUE (`xs` muted medium) —
|
|
447
451
|
a metric caption, a field name in a cell, an artifact tag. Takes `color` (for a VERDICT word
|
|
448
452
|
like "Mismatch") and `align`, and deliberately takes no `size`, `weight` or `transform`:
|
|
@@ -702,16 +706,21 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
702
706
|
useSeparator` inserts and what menus/popovers put between option groups.
|
|
703
707
|
- **`container`** — `Container`: centers content at a max width (`ContainerSize` sm|md|lg,
|
|
704
708
|
`CONTAINER_SIZES`).
|
|
705
|
-
- **`page_header`** — `PageHeader`: the page's title band
|
|
709
|
+
- **`page_header`** — `PageHeader`: the page's title band, and the ONE place that band is
|
|
710
|
+
defined — `PageContent`'s `title`/`titleRight`/`description` render one of these rather than a
|
|
711
|
+
second copy of the row. `actions` puts page-level CTAs on
|
|
706
712
|
the title row (right-aligned) and `trailing` puts a control immediately AFTER the title;
|
|
707
713
|
`left`/`right` form a separate nav row above. Split `trailing` from `actions` by what the
|
|
708
714
|
control acts on: `actions` do something to the page's CONTENT (create, sort, export),
|
|
709
715
|
`trailing` changes what is AROUND it (a side-panel toggle, a view switch). A panel toggle
|
|
710
716
|
filed under `actions` reads as a peer of "create one of these". Under a title too long for
|
|
711
717
|
the row the TITLE gives way and wraps while `trailing`
|
|
712
|
-
and `actions` keep their width.
|
|
718
|
+
and `actions` keep their width. **The band owns the space under itself** — 16px, whether it
|
|
719
|
+
is rendered on a hand-rolled screen or by `PageContent`; a shell adds nothing after it, or
|
|
720
|
+
the same band closes on two different rhythms depending on which shell a screen picked.
|
|
713
721
|
- **`page_content`** — `PageContent` + `PAGE_SIZES`: the page's padded, width-capped content
|
|
714
|
-
region — a centred column with optional `title`/`titleRight`/`description
|
|
722
|
+
region — a centred column with optional `title`/`titleRight`/`description` (which render a
|
|
723
|
+
`PageHeader`, so the band's row law and rhythm have one home), `header`/`footer`
|
|
715
724
|
slots and `fullscreen`. **Reach for it before hand-rolling a screen shell.** Its side
|
|
716
725
|
padding is `pagePad` (`@lotics/ui/spacing`), so a screen that
|
|
717
726
|
genuinely cannot use it — one with a rail, a side panel, or a scroller it must hold a ref to —
|
|
@@ -1586,15 +1595,56 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1586
1595
|
|
|
1587
1596
|
### Overlays & navigation
|
|
1588
1597
|
|
|
1598
|
+
**Every overlay paints in the order it was OPENED**, so mount them wherever composition wants — a
|
|
1599
|
+
`Dialog` written beside the `Drawer` whose row opens it is the ordinary shape and it works; a
|
|
1600
|
+
drawer opened from a dialog covers the dialog, which is the same rule the other way round. There
|
|
1601
|
+
is no layer prop and nothing at the call site has to know the tree order. It covers every
|
|
1602
|
+
body-level surface the kit has, `Popover` included: a `Dialog` or a file-preview `Modal` opened
|
|
1603
|
+
from an open popover paints OVER the panel that opened it. `Alert` and `Tooltip` stay above every
|
|
1604
|
+
overlay at any depth.
|
|
1605
|
+
|
|
1606
|
+
**The one rule an overlay of your OWN has to follow: render the react-native `Modal` element only
|
|
1607
|
+
while it is open** (`{open && <Modal visible …>}`, or an early `return null`). react-native-web
|
|
1608
|
+
appends a `Modal`'s body-level `<div>` on its FIRST render and never re-orders it, so an overlay
|
|
1609
|
+
mounted while closed has already taken its slot before anything opened — and once two overlays tie
|
|
1610
|
+
at the same z-index, that slot is what decides. The symptom is nasty precisely because it is not
|
|
1611
|
+
visual: the covered surface renders, reads and announces perfectly, and only its presses do
|
|
1612
|
+
nothing. Nothing is lost by not mounting it — react-native-web renders a closed `Modal`'s children
|
|
1613
|
+
as `null` anyway, so only the empty portal div was ever being held, and anything that must survive
|
|
1614
|
+
a close (a router, a context, form state) belongs outside the `Modal` element regardless.
|
|
1615
|
+
|
|
1616
|
+
**The rungs beside the overlays are a published contract** (`overlay_layer`), because a surface
|
|
1617
|
+
OUTSIDE the kit that has to clear a Lotics overlay cannot do it with a hand-picked literal:
|
|
1618
|
+
|
|
1619
|
+
| z-index | what sits there |
|
|
1620
|
+
|---|---|
|
|
1621
|
+
| `9999` | every overlay, and `Popover` — `OVERLAY_Z` |
|
|
1622
|
+
| `10000` | `Tooltip`, `Alert` — `OVERLAY_Z_ABOVE` |
|
|
1623
|
+
| `10001` | a transient notification / toast — `NOTIFICATION_Z` |
|
|
1624
|
+
| `10002` | the skip link — `SKIP_LINK_Z` |
|
|
1625
|
+
|
|
1626
|
+
Overlays all share one rung on purpose: DOM order settles the tie and DOM order is open order.
|
|
1627
|
+
`Popover` is on it too — it is no `Modal`, but it portals into the nearest `PortalHost`, so one
|
|
1628
|
+
opened inside an overlay is already inside that overlay's stacking context and a page-level one is
|
|
1629
|
+
a child of the app root, which every overlay's body-level box follows.
|
|
1630
|
+
|
|
1589
1631
|
- **`portal`** — `PortalHost` + `Portal`: the floating-content mount point; the app root
|
|
1590
1632
|
needs ONE `PortalHost` or `Dialog`/`Popover`/`Tooltip`/`Alert`/`OptionList` cannot render.
|
|
1591
1633
|
- **`dialog`** — `Dialog` (+ `DialogHeader`/`DialogHeaderTitle`/`DialogFooter`, `useDialog`,
|
|
1592
1634
|
`useDialogNavigation`): the centered card over a scrim; BAKES a screen router in
|
|
1593
|
-
(`<Dialog><Screen route="">…` — see `screen_router`).
|
|
1635
|
+
(`<Dialog><Screen route="">…` — see `screen_router`). `DialogScrollArea` takes the same
|
|
1636
|
+
**`scrollKey`** seam as `DrawerScrollArea` for a pane that swaps its body in place; a dialog
|
|
1637
|
+
navigating between `Screen`s does not need it, because a stacked screen stays mounted and keeps
|
|
1638
|
+
its own offset.
|
|
1594
1639
|
- **`drawer`** — `Drawer` + `DrawerScrollArea` + `DrawerFooter`: the docked side panel with scrim; the
|
|
1595
1640
|
register row's edit surface. ONE standard width (600px) — pass `width` only for a genuinely
|
|
1596
1641
|
exceptional panel; full-width on small screens. `DrawerScrollArea` is the guttered content
|
|
1597
|
-
region — `DialogScrollArea`'s counterpart — so never hand-pad a drawer body.
|
|
1642
|
+
region — `DialogScrollArea`'s counterpart — so never hand-pad a drawer body. Give it a
|
|
1643
|
+
**`scrollKey`** (the open content's identity — `scrollKey={openChild?.id}`, where the undefined
|
|
1644
|
+
leg names the root content rather than opting out) on a drawer that SWAPS its body in place, so
|
|
1645
|
+
the new content opens at the top and the previous one's offset returns on the way back — the seam
|
|
1646
|
+
`DialogScrollArea` and `ModalBody` take too, and the one thing a React `key` on the scroller
|
|
1647
|
+
cannot buy, since that resets the parent's place along with the child's. It always
|
|
1598
1648
|
scrolls: a padded box around a `flex:1` scroller insets that scroller's viewport, ending the
|
|
1599
1649
|
list short of the panel with the last row clipped. Two bodies skip it and take the `Drawer`'s
|
|
1600
1650
|
bare slot: a FULL-BLEED one (a band spanning the panel, a record screen with its own gutters),
|
|
@@ -1605,7 +1655,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1605
1655
|
edge-to-edge takeover: an OPAQUE surface that COVERS THE WHOLE SCREEN, so unlike Dialog
|
|
1606
1656
|
(centered card WITH scrim) and Drawer (docked panel WITH scrim) there is nothing behind it
|
|
1607
1657
|
to dim — NO scrim, NO backdrop. Lays children as a flex column: a pinned ModalHeader
|
|
1608
|
-
(eyebrow/title + an actions slot + close), a flex:1 scrolling ModalBody
|
|
1658
|
+
(eyebrow/title + an actions slot + close), a flex:1 scrolling ModalBody (which takes the
|
|
1659
|
+
**`scrollKey`** seam — a wizard stepping between steps swaps its body in place), a pinned
|
|
1609
1660
|
ModalFooter (the commit bar, same chrome as DialogFooter/DrawerFooter). Reach for it for a
|
|
1610
1661
|
focused capture / multi-step wizard / a console the user steps INTO; pick Dialog when the
|
|
1611
1662
|
surface is a card the user can see context around.
|
|
@@ -1631,11 +1682,16 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1631
1682
|
The built-in scroller is **full-bleed horizontally** and re-insets its content by the same
|
|
1632
1683
|
amount, so a scrolling body reaches the panel's real edges while the text stays on the
|
|
1633
1684
|
same column the header and footer use. Nothing to opt into; `disableBodyScroll` consumers
|
|
1634
|
-
(which own their scroll) are untouched.
|
|
1685
|
+
(which own their scroll) are untouched. It also carries the **scroll seam** every scroller
|
|
1686
|
+
that swaps its body in place takes — keyed on the routed popover's current route, with no prop
|
|
1687
|
+
at call site, and inert in a popover that has no sub-screens.
|
|
1635
1688
|
- **`popover_nav`** — `usePopoverNav` + `PopoverScreen` + `PopoverNavHeader` — the popover's
|
|
1636
1689
|
built-in mini-router: EVERY `Popover` provides the nav context (`navigate(route)` pushes,
|
|
1637
1690
|
`goBack`, `currentRoute`, `canGoBack`; resets on close), `PopoverScreen route=""` is the
|
|
1638
|
-
root and screens render conditionally (unmounted when inactive
|
|
1691
|
+
root and screens render conditionally (unmounted when inactive, so a screen's own state does
|
|
1692
|
+
not survive the round trip — the panel's SCROLL does: the screens share `PopoverContent`'s one
|
|
1693
|
+
scroller, which takes the ROUTE as its seam key, so a sub-screen opens at the top with its
|
|
1694
|
+
`PopoverNavHeader` in view and the root list's offset returns on the way back),
|
|
1639
1695
|
`PopoverNavHeader` is the title row whose back chevron auto-appears while `canGoBack`
|
|
1640
1696
|
(`right` slot, `backLabel`). For a multi-screen menu inside ONE popover; route PATTERNS,
|
|
1641
1697
|
`params`, and stacked-alive screens are `screen_router`'s job.
|
|
@@ -1661,6 +1717,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
|
|
|
1661
1717
|
content) — it is what `SectionHeadingTitle.info`/`CardHeaderTitle.info` render.
|
|
1662
1718
|
- **`alert`** — `Alert` (+ `AlertButton`/`AlertOptions`): the blocking confirm —
|
|
1663
1719
|
`Alert.alert(title, message, [{cancel}, {destructive}])`.
|
|
1720
|
+
- **`overlay_layer`** — `OVERLAY_Z` / `OVERLAY_Z_ABOVE` / `NOTIFICATION_Z` / `SKIP_LINK_Z`: the
|
|
1721
|
+
ONE paint-order table for everything that reaches `document.body` (the rungs are in the table
|
|
1722
|
+
above), and the file that states why open order works. Constants only — there is no layer to
|
|
1723
|
+
render and nothing to claim; an overlay of your own gets its place by mounting its react-native
|
|
1724
|
+
`Modal` only while it is open. Never write one of these numbers as a literal.
|
|
1664
1725
|
- **`overlay_scope`** — `isOverlayScopeActive` / `pushOverlayScope` / `useOverlayScope`: the
|
|
1665
1726
|
module-level open-overlay counter every overlay primitive reports into; the host's
|
|
1666
1727
|
shortcut registry reads it synchronously to floor page-level shortcuts while any overlay
|
|
@@ -1715,11 +1776,24 @@ component rather than showing it at zero.
|
|
|
1715
1776
|
it carries SHAPE (rising, spiky, flat) and no readable values, which is exactly what a
|
|
1716
1777
|
register wants. Reach for a real chart the moment someone needs to read a value off it.
|
|
1717
1778
|
- **`bar_chart`** / **`line_chart`** / **`pie_chart`** — `BarChart` / `LineChart` /
|
|
1718
|
-
`PieChart`: the canonical SVG chart set (no recharts).
|
|
1719
|
-
as the track fits and thins the rest
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1779
|
+
`PieChart`: the canonical SVG chart set (no recharts). **A categorical axis prints as many
|
|
1780
|
+
labels as the track fits and thins the rest** — `LineChart` and `BarChart`'s vertical
|
|
1781
|
+
orientation both, because it is the axis's rule and not one chart's. It is anchored on the LAST
|
|
1782
|
+
position: the newest reading is the one a reader looks up, and anchoring there is what keeps the
|
|
1783
|
+
spacing uniform; the first position is labelled only when it clears the same distance, so a
|
|
1784
|
+
series whose length does not divide evenly drops its opening label rather than crowding the one
|
|
1785
|
+
beside it. **The BARS never thin** — how many bars a card carries is a question about the data,
|
|
1786
|
+
never about how wide a label happens to be, and an unlabelled bar keeps its slot so the row
|
|
1787
|
+
above stays in line. **What thins is the whole annotation, label AND value together**, because a
|
|
1788
|
+
bar's name and its figure are one reading: a number under an unnamed bar is a quantity of nothing
|
|
1789
|
+
a reader can name, and the surviving annotation is widened across the room its dropped neighbours
|
|
1790
|
+
vacated — room the figure needs as much as the name does. So a DENSE vertical `BarChart` (more
|
|
1791
|
+
bars than `track ÷ 50px`: a 30-day daily series in a 600px card keeps roughly ten) shows the
|
|
1792
|
+
figures only for the bars it labels. When every bar's number has to be readable, that is a
|
|
1793
|
+
`Table` or the horizontal orientation, which gives each entity its own row and its own value
|
|
1794
|
+
column. `BarChart`'s horizontal orientation is one row per entity: **`labelWidth`**
|
|
1795
|
+
sizes the fixed label column (default 80, which fits a date and not a company name), and each
|
|
1796
|
+
item's **`leading`** slot carries that entity's own mark ahead of its name.
|
|
1723
1797
|
- **`progress_bar`** — `ProgressBar`: the determinate meter; `compact` = ONE row, track + a
|
|
1724
1798
|
plain sm tabular count beside it.
|
|
1725
1799
|
- **`progress_ring`** — `ProgressRing`: the same meter in a circle, and the ONLY circular one. Same API shape as the bar — a real `value`/`max` rather than a
|
|
@@ -1741,7 +1815,10 @@ component rather than showing it at zero.
|
|
|
1741
1815
|
cost make-up per product line. `series` (key/label/colour, one hue family per dimension)
|
|
1742
1816
|
drives a legend that is on by default; each `StackedBarRow` takes `label` + optional `meta`,
|
|
1743
1817
|
a pre-formatted headline `value` with its `valueTone`, and a `caption` sentence, so colour is
|
|
1744
|
-
never the only channel.
|
|
1818
|
+
never the only channel. **`leading`** is the entity's own mark (`BrandMark`, `Avatar`, a status
|
|
1819
|
+
dot), centred on the label's line box — a row's segment colours belong to the MEASURE, so unlike
|
|
1820
|
+
a legend row this one carries no identity until you give it one, and a chart beside a table over
|
|
1821
|
+
the same entities has to draw them the same way (composition.md § the register's own craft). Between `stacked_progress_bar` (one whole, own track) and `breakdown`
|
|
1745
1822
|
(one whole + ranked share rows beneath it): reach here the moment there are SEVERAL wholes to
|
|
1746
1823
|
compare.
|
|
1747
1824
|
- **`waterfall_chart`** — `WaterfallChart`: the BRIDGE — an opening level, the signed steps
|
|
@@ -2205,7 +2282,14 @@ component rather than showing it at zero.
|
|
|
2205
2282
|
`ids` is normally a memo over query rows — a new array every render, which is the
|
|
2206
2283
|
`Maximum update depth exceeded` loop). `initial` defaults to `accepted` so the operator drops
|
|
2207
2284
|
exceptions instead of approving eight identical lines; pass `pending` when each change deserves
|
|
2208
|
-
its own verdict and gate the commit on `settled`.
|
|
2285
|
+
its own verdict and gate the commit on `settled`. **`initial` also takes a
|
|
2286
|
+
`ReadonlyMap<id, decision>`**, for a set that mixes kinds with different safe defaults —
|
|
2287
|
+
filling a blank arrives `accepted`, overwriting a value a human already set arrives `rejected`,
|
|
2288
|
+
so the destructive half is opt-in; an id the map does not name arrives `accepted`. Build it in a
|
|
2289
|
+
`useMemo` over your own rows: it is a VALUE, so `status` and the groups derive from it together
|
|
2290
|
+
and `status` changes identity whenever a decision does — which is what a screen memoizing its
|
|
2291
|
+
rows on `status` needs. Do not reach for `reject()` in the result handler instead: that writes
|
|
2292
|
+
overrides the operator never made, so `undo` hands the row back `accepted`.
|
|
2209
2293
|
- **`clarify`** — `Clarify` + `ClarifyOption`: the agent asks back — a borderless block
|
|
2210
2294
|
(the question text + a `ChoiceList`, no card wrapper; an optional muted `eyebrow` sits tight above
|
|
2211
2295
|
the question — e.g. a wizard's "1 / 3"). `ClarifyOption` requires
|
package/docs/composition.md
CHANGED
|
@@ -69,6 +69,28 @@ the subject into rows.
|
|
|
69
69
|
the same left edge as the title above it and the actions below it. Never hand-pad inside one,
|
|
70
70
|
and never hand-roll one: the three surfaces disagree on the number (24 / responsive / 20) and
|
|
71
71
|
only the surface knows which it is.
|
|
72
|
+
- **ANY scroller whose body is SWAPPED opens the new content at the top.** Stated as a rule and
|
|
73
|
+
not as a count of the surfaces that happen to have one today: `ModalBody`,
|
|
74
|
+
`DialogScrollArea` and `DrawerScrollArea` each take a `scrollKey` — the content's identity (a
|
|
75
|
+
record id, a step name) — and the kit's routed `Popover` takes the same seam with no prop at
|
|
76
|
+
all, because its ROUTE already is that identity. A scroller you add that swaps its body joins
|
|
77
|
+
the rule rather than being added to a list. `scrollKey={openChild?.id}` is the right shape:
|
|
78
|
+
an ABSENT key names the root content, not an opt-out, so the offset comes back when the child
|
|
79
|
+
closes. Opting out needs no signal — a surface whose key never changes never scrolls. Without it the container keeps the offset it
|
|
80
|
+
was holding for content that no longer exists, so a swapped-in record opens part-way down
|
|
81
|
+
itself with its heading off-screen above — invisible until the first list long enough to
|
|
82
|
+
scroll, which is the same list that made the swap worth having. The key opens unseen content
|
|
83
|
+
at the top and restores the offset when it comes BACK, which is what a React `key` on the
|
|
84
|
+
scroll area cannot do: that rebuilds the container, resetting the parent's place along with
|
|
85
|
+
the child's. Omit it where the body is one thing, and inside a `Screen`-routed dialog, where
|
|
86
|
+
a stacked screen stays mounted and already keeps its own offset.
|
|
87
|
+
- **Overlays paint in the order they were OPENED, and no call site has to know it.** Mount them
|
|
88
|
+
wherever composition wants — a `Dialog` sitting always-mounted beside the `Drawer` whose row
|
|
89
|
+
opens it is the ordinary shape, and it is the one that used to fail: the dialog claimed its DOM
|
|
90
|
+
slot on the app's first paint, the drawer landed after it, and the dialog then rendered
|
|
91
|
+
perfectly and read correctly with every control under the panel DEAD. Whatever opens last is on
|
|
92
|
+
top, both ways round, so a drawer opened from a dialog covers it too. `Alert` and `Tooltip` sit
|
|
93
|
+
above every overlay at any depth — they are ABOUT the surface under them.
|
|
72
94
|
- **The responsive one is READ, never re-derived — `useDialogGutter()`, no argument.** Inside
|
|
73
95
|
a `Dialog` that is what the header, the scroll area, the footer and any pane a caller drops
|
|
74
96
|
in all call. The `Dialog` resolves `small ? 16 : 24` ONCE, off the SCREEN, and publishes it.
|
|
@@ -829,7 +851,11 @@ unconditionally and let an empty `value` disable it, rather than revealing it on
|
|
|
829
851
|
|
|
830
852
|
Pressing a PRIMARY entity row opens the record workspace in a `Drawer` with `onPrev`/`onNext`/
|
|
831
853
|
`position` ("3/24") over the visible ordering (←/→ arrow keys are built in). Key the drawer body by
|
|
832
|
-
record id so per-record state resets on step.
|
|
854
|
+
record id so per-record state resets on step. A drawer that instead REPLACES its body with a child
|
|
855
|
+
record — the shape where a row inside the panel opens a second record rather than stacking a
|
|
856
|
+
second drawer — passes that id as `DrawerScrollArea`'s `scrollKey` (`scrollKey={openChild?.id}` —
|
|
857
|
+
the undefined leg is the list's own identity), so the child opens at the top and the list keeps its
|
|
858
|
+
place on the way back. Facts are `DetailRow`s; the commit bar is a
|
|
833
859
|
`DrawerFooter` (a hairline-topped band pinned to the panel bottom — render it as the LAST child,
|
|
834
860
|
after the scroll body; actions sit right, a leading `<Text style={{ flex: 1 }}>` hint pushes them
|
|
835
861
|
there). The open row shows a `selected` highlight (and `marked` for a bulk-ticked row). `Peek` is
|
|
@@ -1318,6 +1344,15 @@ colours, same shapes. Re-rendering it as plain text makes the reader translate b
|
|
|
1318
1344
|
presentations of one field, and the two drift the first time either side gains a value. This holds
|
|
1319
1345
|
for every custom-rendered value, not just status.
|
|
1320
1346
|
|
|
1347
|
+
**The same rule reaches the CHART beside the table.** One entity, one rendering, everywhere inside
|
|
1348
|
+
one card: if the table's first cell carries the entity's mark (`BrandMark`, `Avatar`, a status
|
|
1349
|
+
dot), the chart's rows carry it too — `StackedBarRow` and `BarChartItem` each take a `leading`
|
|
1350
|
+
slot for exactly that, laid out on the label's own line box. It is worst where the mark carries
|
|
1351
|
+
most (a channel, a platform, a person), and the two ways out that are not the slot are both
|
|
1352
|
+
losses: dropping the mark from the table to match makes *both* halves anonymous, and hand-drawing
|
|
1353
|
+
the chart to get one slot loses the component. A LEGEND row is the exception and needs no slot —
|
|
1354
|
+
its subject is a category, and the colour swatch already is that category's identity.
|
|
1355
|
+
|
|
1321
1356
|
**Group by what implies a different ACTION, never by a category the reader can already see.**
|
|
1322
1357
|
Bands like *Waiting on you* / *Gone quiet* / *Open* / *Closed* each name a different response, so
|
|
1323
1358
|
the grouping tells the reader something no column does. Grouping by a value that is already a
|
|
@@ -1780,13 +1815,20 @@ complaint it was meant to fix, because a reader who cannot read a label reports
|
|
|
1780
1815
|
cluttered rather than as small. Recede with INK, at the same rung. `xs` is for genuine meta (a
|
|
1781
1816
|
timestamp, a count), not for a label that a person actually has to read.
|
|
1782
1817
|
|
|
1783
|
-
**A LABEL sharing a row with a flexible VALUE must be told which one
|
|
1818
|
+
**A LABEL sharing a row with a flexible VALUE must be told which one HOLDS.** Two items in one
|
|
1784
1819
|
flex row, and whichever cannot shrink forces the other to. It is invisible wherever the values
|
|
1785
1820
|
happen to fit, which is usually the width it was built at, and it appears the first time a real
|
|
1786
|
-
record carries a long one.
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1821
|
+
record carries a long one. Say which one stays whole — `flexShrink: 0` on a fixed-length key, on a
|
|
1822
|
+
figure, on a control — and pick by which is still USEFUL truncated: `RC-2026-0…` is unusable,
|
|
1823
|
+
`(555) 384-7…` is still recognisably a phone number.
|
|
1824
|
+
|
|
1825
|
+
The clipping half needs only **`numberOfLines`**, which now brings its own `minWidth: 0` +
|
|
1826
|
+
`flexShrink: 1`: a clamp that cannot shrink cannot clamp, so the prop that says "cut this to fit"
|
|
1827
|
+
is what makes it possible. (Neither platform gave it for free — on web a clamped `Text` is
|
|
1828
|
+
`white-space: nowrap`, so `min-width: auto` floors it at the WHOLE string; on native a `Text` is
|
|
1829
|
+
`flexShrink: 0`. A row of two clamped values laid out at intrinsic width and ran off the frame.)
|
|
1830
|
+
Both are no-ops until a row actually overflows, and a `style` on the call site still wins — which
|
|
1831
|
+
is how a figure keeps holding over its own label.
|
|
1790
1832
|
|
|
1791
1833
|
|
|
1792
1834
|
|
package/docs/templates.md
CHANGED
|
@@ -450,7 +450,9 @@ The corollaries, each of which a register is routinely missing:
|
|
|
450
450
|
DRAFTS; it does not write. Every drafted record then renders in full — all four fields as
|
|
451
451
|
they will be stored, each editable in place, a `DiffMark` per card, Keep/Drop via
|
|
452
452
|
`useChangeSet` (`initial: "accepted"` — the operator drops the exceptions rather than
|
|
453
|
-
approving six identical records
|
|
453
|
+
approving six identical records; a set that also OVERWRITES values a human set passes a
|
|
454
|
+
`Map<id, decision>` instead, so that half arrives dropped) — and ONE commit named for its outcome
|
|
455
|
+
creates them. The
|
|
454
456
|
manual form beside it skips all of that and saves direct. Only the DOCUMENT path needs the
|
|
455
457
|
gate. The receipt still follows the write — it states the outcome, ROUTES, and carries the
|
|
456
458
|
deterministic checks (a checksum, a count reconciliation).
|
package/docs/testing.md
CHANGED
|
@@ -46,6 +46,12 @@ Assert two things, not one: that the content appeared, and that it **dismisses**
|
|
|
46
46
|
backdrop, so clicking the trigger a second time is often intercepted; click the
|
|
47
47
|
backdrop or press `Escape`.
|
|
48
48
|
|
|
49
|
+
Two overlays open at once stack by the order they were OPENED, so the newest one
|
|
50
|
+
takes the press. If a driver reports `subtree intercepts pointer events` on a
|
|
51
|
+
control of the overlay you just opened, that is a real defect and not this
|
|
52
|
+
anatomy: check with `document.elementFromPoint` at the control's centre, which
|
|
53
|
+
names whatever is actually on top.
|
|
54
|
+
|
|
49
55
|
## Custom pointer drag is not `dragTo`
|
|
50
56
|
|
|
51
57
|
The calendar and gantt drags are built on `use_pointer_drag`, which listens for
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "46.
|
|
3
|
+
"version": "46.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./vite": {
|
|
@@ -150,6 +150,7 @@
|
|
|
150
150
|
"./popover_nav": "./src/popover_nav.tsx",
|
|
151
151
|
"./popover": "./src/popover.tsx",
|
|
152
152
|
"./overlay_scope": "./src/overlay_scope.ts",
|
|
153
|
+
"./overlay_layer": "./src/overlay_layer.ts",
|
|
153
154
|
"./switcher": "./src/switcher.tsx",
|
|
154
155
|
"./menu_button": "./src/menu_button.tsx",
|
|
155
156
|
"./menu_list_item": "./src/menu_list_item.tsx",
|
|
@@ -334,7 +335,7 @@
|
|
|
334
335
|
"dependencies": {
|
|
335
336
|
"@lotics/docx": "^0.3.0",
|
|
336
337
|
"@lotics/markdown-editor": "^0.1.0",
|
|
337
|
-
"@lotics/xlsx": "^0.
|
|
338
|
+
"@lotics/xlsx": "^0.3.0",
|
|
338
339
|
"ai": "^7.0.30",
|
|
339
340
|
"mdast-util-from-markdown": "^2.0.3",
|
|
340
341
|
"mdast-util-gfm-footnote": "^2.1.0",
|
package/src/alert.css
CHANGED
package/src/alert.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import "./alert.css";
|
|
|
4
4
|
import { Text } from "./text";
|
|
5
5
|
import { Button, ButtonColor } from "./button";
|
|
6
6
|
import { useOverlayScope } from "./overlay_scope";
|
|
7
|
+
import { OVERLAY_Z_ABOVE } from "./overlay_layer";
|
|
7
8
|
|
|
8
9
|
export type AlertButtonStyle = "default" | "cancel" | "destructive";
|
|
9
10
|
|
|
@@ -27,6 +28,13 @@ class Alert {
|
|
|
27
28
|
if (!this.container) {
|
|
28
29
|
this.container = document.createElement("div");
|
|
29
30
|
this.container.id = "alert-container";
|
|
31
|
+
// The rung lives here rather than on the overlay's CSS class, because it
|
|
32
|
+
// is a fact about where an alert sits among the OTHER body-level layers —
|
|
33
|
+
// one table answers that (`overlay_layer.tsx`), and a stylesheet cannot
|
|
34
|
+
// read it. The box is zero-height, so making it a stacking context is
|
|
35
|
+
// free; the fixed overlay inside paints in it.
|
|
36
|
+
this.container.style.position = "relative";
|
|
37
|
+
this.container.style.zIndex = String(OVERLAY_Z_ABOVE);
|
|
30
38
|
document.body.appendChild(this.container);
|
|
31
39
|
}
|
|
32
40
|
if (!this.root) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which of a CATEGORICAL AXIS's positions get a printed label.
|
|
3
|
+
*
|
|
4
|
+
* The rule belongs to the axis, not to one chart: a line's x labels and a bar
|
|
5
|
+
* chart's per-bar labels have the same failure — a label is roughly
|
|
6
|
+
* `minLabelWidth` wide, so only so many fit across the track, and past that the
|
|
7
|
+
* axis either overlaps or ellipsises its own labels. Neither is a typography
|
|
8
|
+
* problem to solve by shortening the data; the labels are what thin out.
|
|
9
|
+
*
|
|
10
|
+
* ONE model serves both geometries. How many labels fit is a function of the
|
|
11
|
+
* TRACK and the label width — `floor(track / minLabelWidth)` — and that is the
|
|
12
|
+
* same question whether the labels are points on the track (a line's, at
|
|
13
|
+
* `i/(count-1)`) or slot centres (a bar's, at `(i+0.5)·track/count`). The two
|
|
14
|
+
* geometries differ only in the distance from position 0 to the first kept
|
|
15
|
+
* index, and by less than one slot; the count and the step are shared.
|
|
16
|
+
*
|
|
17
|
+
* What the caller must NOT assume is that thinning alone widens a label. On a
|
|
18
|
+
* SLOT geometry the survivor still sits in its own slot, so the space the
|
|
19
|
+
* dropped neighbours freed has to be handed to it explicitly — see
|
|
20
|
+
* {@link axisLabelStep} and `BarChart`'s label box.
|
|
21
|
+
*
|
|
22
|
+
* The rule that matters is WHERE the thinning is anchored: stepping forwards
|
|
23
|
+
* from the first position and then forcing the last one in collides whenever the
|
|
24
|
+
* count minus one is not a multiple of the step — the newest label lands a few
|
|
25
|
+
* pixels from the one before it while every other pair is a full step apart.
|
|
26
|
+
* Anchoring on the LAST position makes the spacing uniform by construction, and
|
|
27
|
+
* the last position is the one a reader looks up first. The first position is
|
|
28
|
+
* then kept only when it clears the same distance.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* How many positions the axis skips between printed labels — 1 when every label
|
|
32
|
+
* fits, `count` when the track has room for only one. Exported because a SLOT
|
|
33
|
+
* geometry needs it twice: once to pick the labels, and once to give a survivor
|
|
34
|
+
* the width of the span its dropped neighbours vacated. Deriving it a second
|
|
35
|
+
* time at the call site is how the two drift.
|
|
36
|
+
*
|
|
37
|
+
* The floor is ONE label, not two. Forcing a second onto a track that fits one
|
|
38
|
+
* breaks this module's own rule — the two then land closer together than a label
|
|
39
|
+
* is wide, which is the overlap the thinning exists to prevent — and it is the
|
|
40
|
+
* narrow end that needs the rule most, because that is where a track can go to
|
|
41
|
+
* zero or below.
|
|
42
|
+
*/
|
|
43
|
+
export function axisLabelStep(count: number, chartWidth: number, minLabelWidth = 50): number {
|
|
44
|
+
if (count <= 1) return 1;
|
|
45
|
+
const maxLabels = Math.max(1, Math.floor(chartWidth / minLabelWidth));
|
|
46
|
+
return Math.max(1, Math.ceil(count / maxLabels));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The positions to print, thinned to what the track fits.
|
|
51
|
+
*
|
|
52
|
+
* THE CONTRACT: the result is empty ONLY when there is nothing to label
|
|
53
|
+
* (`count <= 0`). An axis that HAS positions always prints at least one of them
|
|
54
|
+
* — the last, the one a reader looks up — however little room it has, so a card
|
|
55
|
+
* too narrow to fit a second label shows one rather than none.
|
|
56
|
+
*
|
|
57
|
+
* Whether the track has been MEASURED yet is the caller's fact, not this
|
|
58
|
+
* function's: a host with no layout pass reports 0 exactly as a 0px track does,
|
|
59
|
+
* and only the caller knows which it is holding. It answers that itself (show
|
|
60
|
+
* every label until measured) before asking here. Returning `[]` for both a
|
|
61
|
+
* zero width and a zero count made one value mean two things, and the two call
|
|
62
|
+
* sites duly read it two ways — the line chart as "not measured", the bar chart
|
|
63
|
+
* as "kept nothing", which is how a narrow card came to erase its whole label
|
|
64
|
+
* row.
|
|
65
|
+
*/
|
|
66
|
+
export function axisLabelIndices(
|
|
67
|
+
count: number,
|
|
68
|
+
chartWidth: number,
|
|
69
|
+
minLabelWidth = 50,
|
|
70
|
+
): number[] {
|
|
71
|
+
if (count <= 0) return [];
|
|
72
|
+
if (count === 1) return [0];
|
|
73
|
+
|
|
74
|
+
const last = count - 1;
|
|
75
|
+
const step = axisLabelStep(count, chartWidth, minLabelWidth);
|
|
76
|
+
|
|
77
|
+
const kept: number[] = [];
|
|
78
|
+
for (let i = last; i >= 0; i -= step) kept.unshift(i);
|
|
79
|
+
|
|
80
|
+
const firstKept = kept[0] ?? 0;
|
|
81
|
+
if (firstKept > 0 && (firstKept / last) * chartWidth >= minLabelWidth) kept.unshift(0);
|
|
82
|
+
|
|
83
|
+
return kept;
|
|
84
|
+
}
|