@lotics/ui 46.2.0 → 46.8.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.
Files changed (49) hide show
  1. package/AGENTS.md +38 -1
  2. package/MIGRATION.md +87 -0
  3. package/docs/ai_patterns.md +11 -0
  4. package/docs/catalog.md +217 -21
  5. package/docs/composition.md +74 -6
  6. package/docs/data_entry.md +56 -2
  7. package/docs/reviewing.md +34 -0
  8. package/docs/templates.md +93 -30
  9. package/docs/testing.md +6 -0
  10. package/examples/tpl_board.tsx +257 -0
  11. package/examples/tpl_money.tsx +1027 -0
  12. package/package.json +261 -258
  13. package/src/accordion.tsx +7 -1
  14. package/src/alert.css +0 -1
  15. package/src/alert.tsx +8 -0
  16. package/src/axis_label_indices.ts +84 -0
  17. package/src/bar_chart.tsx +137 -16
  18. package/src/board.tsx +611 -0
  19. package/src/card.tsx +7 -1
  20. package/src/charge_lines.tsx +373 -0
  21. package/src/chip_group.tsx +57 -1
  22. package/src/dialog.tsx +46 -24
  23. package/src/drawer.tsx +21 -2
  24. package/src/file_gallery_modal.tsx +3 -0
  25. package/src/file_row.tsx +98 -5
  26. package/src/icon.tsx +6 -0
  27. package/src/inline_edit.tsx +54 -10
  28. package/src/inline_number_input.tsx +5 -1
  29. package/src/inline_text_input.tsx +1 -1
  30. package/src/line_chart.tsx +2 -2
  31. package/src/locale.tsx +26 -1
  32. package/src/matrix.tsx +23 -8
  33. package/src/modal.tsx +23 -3
  34. package/src/overlay_layer.ts +65 -0
  35. package/src/page_content.tsx +8 -22
  36. package/src/page_header.tsx +60 -11
  37. package/src/popover.tsx +29 -5
  38. package/src/reference_field.tsx +36 -13
  39. package/src/skip_link.tsx +2 -1
  40. package/src/stacked_bar_chart.tsx +31 -1
  41. package/src/table.tsx +6 -1
  42. package/src/tabs.tsx +1 -1
  43. package/src/text.tsx +21 -0
  44. package/src/tooltip.tsx +2 -1
  45. package/src/use_change_set.ts +66 -17
  46. package/src/use_scroll_seam.ts +79 -0
  47. package/examples/tpl_report.tsx +0 -410
  48. package/examples/tpl_statements.tsx +0 -221
  49. package/src/line_chart_labels.ts +0 -32
@@ -0,0 +1,65 @@
1
+ /**
2
+ * THE PAINT ORDER OF EVERYTHING THE KIT PUTS AT THE TOP OF THE DOCUMENT.
3
+ *
4
+ * Every overlay in the kit (`Dialog`, `Drawer`, `Modal`, `FileGalleryModal`) is
5
+ * a react-native `Modal`, and on web that mounts a bare `<div>` on
6
+ * `document.body`, at the END of it, and removes it again when it unmounts. The
7
+ * layer inside sits at one fixed z-index, the same one for every overlay, so two
8
+ * open overlays tie and the tie is broken by DOM order.
9
+ *
10
+ * So the rule is one line of composition, not a mechanism: **the react-native
11
+ * `Modal` element is rendered only while the overlay is OPEN**. Its body-level
12
+ * div is then appended when it opens and removed when it closes, DOM order is
13
+ * open order, and paint order follows for free — a drawer opened from a dialog
14
+ * covers the dialog, a dialog opened from a drawer covers the drawer, and
15
+ * neither composition has to be picked in advance.
16
+ *
17
+ * Mounting the `Modal` while CLOSED is what breaks this, and it breaks it
18
+ * silently: `ModalPortal` appends its div on FIRST RENDER and never re-orders
19
+ * it, so an always-mounted dialog claims its slot on the app's first paint and a
20
+ * drawer opened later lands after it and covers it. The dialog then renders
21
+ * perfectly — centred, readable, correctly announced — and every control of it
22
+ * under the drawer panel is dead, because `elementFromPoint` there answers the
23
+ * drawer. Nothing is lost by not mounting it: react-native-web renders a closed
24
+ * `Modal`'s children as `null` anyway, so the subtree is already unmounted; only
25
+ * the empty portal div was being held.
26
+ *
27
+ * The rungs BESIDE the overlays are what needs naming, and they are a PUBLISHED
28
+ * CONTRACT — anything outside the kit that must clear a Lotics overlay reads one
29
+ * from here rather than picking a literal:
30
+ *
31
+ * 9999 every overlay, and `Popover` (`OVERLAY_Z`)
32
+ * 10000 `Tooltip`, `Alert` (`OVERLAY_Z_ABOVE`)
33
+ * 10001 a transient notification / toast (`NOTIFICATION_Z`)
34
+ * 10002 the skip link (`SKIP_LINK_Z`)
35
+ */
36
+
37
+ /**
38
+ * Where every overlay sits — react-native-web's own number for a `Modal`, and
39
+ * the one `Popover` writes into its panel.
40
+ *
41
+ * They all share it on purpose. A popover is not an RN `Modal`: it portals into
42
+ * the nearest `PortalHost`, so a popover opened INSIDE an overlay is already
43
+ * inside that overlay's stacking context, and a page-level one is a child of the
44
+ * app root — which every modal's body-level div follows. One number plus DOM
45
+ * order therefore says the same thing for a popover as for a modal: whatever was
46
+ * opened last is on top.
47
+ */
48
+ export const OVERLAY_Z = 9999;
49
+
50
+ /**
51
+ * ABOVE EVERY OVERLAY — a tooltip and an alert are ABOUT the surface under them,
52
+ * so neither can ever be covered by it.
53
+ */
54
+ export const OVERLAY_Z_ABOVE = OVERLAY_Z + 1;
55
+
56
+ /**
57
+ * A transient notification (a toast) reporting the outcome of an action — over
58
+ * the alert on the rung below, because the action that raised it is often the
59
+ * one that alert confirmed, and a report nobody can read is not a report.
60
+ */
61
+ export const NOTIFICATION_Z = OVERLAY_Z_ABOVE + 1;
62
+
63
+ /** The skip link outranks everything: it is the first thing a keyboard reaches.
64
+ * It shared a rung with the toast until this table gave each one a name. */
65
+ export const SKIP_LINK_Z = NOTIFICATION_Z + 1;
@@ -1,7 +1,6 @@
1
1
  import { ScrollView, View } from "react-native";
2
- import { Text } from "@lotics/ui/text";
3
2
  import { colors } from "@lotics/ui/colors";
4
- import { Spacer } from "@lotics/ui/spacer";
3
+ import { PageHeader } from "@lotics/ui/page_header";
5
4
  import { ReactNode } from "react";
6
5
  import { useContainerSize } from "@lotics/ui/size_boundary";
7
6
  import { pagePad } from "@lotics/ui/spacing";
@@ -81,27 +80,14 @@ export function PageContent(props: PageContentProps) {
81
80
  paddingHorizontal: pad,
82
81
  }}
83
82
  >
84
- <View
85
- style={{
86
- flexDirection: "row",
87
- alignItems: "center",
88
- justifyContent: "space-between",
89
- }}
90
- >
91
- {!!title && (
92
- <Text size="xxl" weight="semibold">
93
- {title}
94
- </Text>
95
- )}
96
- {titleRight && <View>{titleRight}</View>}
97
- </View>
98
- {!!description && (
99
- <>
100
- {title && <Spacer size={8} />}
101
- <Text color="zinc-500">{description}</Text>
102
- </>
83
+ {/* The title band IS a `PageHeader` — the same optional xxl title, the
84
+ same right-hand slot, the same zinc-500 description, so it is that
85
+ component and not a second copy of it. Written out here it drifted
86
+ immediately: two spellings of one row law and two rhythms around
87
+ it, with `titleRight` and `actions` naming the same slot. */}
88
+ {(!!title || !!description || !!titleRight) && (
89
+ <PageHeader title={title} description={description} actions={titleRight} />
103
90
  )}
104
- {(title || description) && <Spacer size={24} />}
105
91
  <View style={{ flex: 1 }}>{children}</View>
106
92
  </View>
107
93
  </ScrollView>
@@ -1,9 +1,12 @@
1
1
  import { View } from "react-native";
2
2
  import { Text } from "@lotics/ui/text";
3
+ import { Spacer } from "@lotics/ui/spacer";
3
4
  import { ReactNode } from "react";
4
5
 
5
6
  interface PageHeaderProps {
6
- title: string;
7
+ /** The page's name. Optional only so the SHELL can delegate to this component
8
+ * for a band that is description-only; a page header normally has one. */
9
+ title?: string;
7
10
  description?: string | null;
8
11
  /** The nav row ABOVE the title — breadcrumbs, a back control, row-level status. */
9
12
  left?: ReactNode;
@@ -60,7 +63,6 @@ export function PageHeader(props: PageHeaderProps) {
60
63
  style={{
61
64
  flexDirection: "row",
62
65
  alignItems: "center",
63
- justifyContent: "space-between",
64
66
  flexWrap: "wrap",
65
67
  gap: 12,
66
68
  }}
@@ -74,9 +76,11 @@ export function PageHeader(props: PageHeaderProps) {
74
76
  minWidth: 0,
75
77
  }}
76
78
  >
77
- <Text size="xxl" weight="semibold" style={{ flexShrink: 1, minWidth: 0 }}>
78
- {title}
79
- </Text>
79
+ {!!title && (
80
+ <Text size="xxl" weight="semibold" style={{ flexShrink: 1, minWidth: 0 }}>
81
+ {title}
82
+ </Text>
83
+ )}
80
84
  {/* Never shrinks: the title is what gives way, and a control squeezed
81
85
  below its own icon is not a smaller control, it is a broken one. */}
82
86
  {trailing !== undefined && <View style={{ flexShrink: 0 }}>{trailing}</View>}
@@ -84,8 +88,14 @@ export function PageHeader(props: PageHeaderProps) {
84
88
  {/* Wrapped so which side gives way is STATED rather than left to whatever
85
89
  the caller happened to pass. A bare `{actions}` inherited its own
86
90
  shrink behaviour, so the row's bargain held or broke depending on the
87
- CTA — and a bargain that depends on the other party is not one. */}
88
- {actions !== undefined && <View style={{ flexShrink: 0 }}>{actions}</View>}
91
+ CTA — and a bargain that depends on the other party is not one.
92
+ `marginLeft: auto` rather than the row's `justifyContent`, because
93
+ justification is applied PER LINE: once the row wraps, the second line
94
+ holds only the actions and `space-between` packs that one item at the
95
+ START, dropping the CTA to the left edge under the title on exactly the
96
+ narrow frame the wrap exists for. An auto margin right-aligns it on the
97
+ shared line and on a line of its own alike. */}
98
+ {actions !== undefined && <View style={{ flexShrink: 0, marginLeft: "auto" }}>{actions}</View>}
89
99
  </View>
90
100
  );
91
101
 
@@ -93,9 +103,45 @@ export function PageHeader(props: PageHeaderProps) {
93
103
  <View style={{ paddingBottom: 16 }}>
94
104
  {hasNav ? (
95
105
  <>
96
- <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", height: 64, paddingBottom: 16 }}>
97
- {left}
98
- {right}
106
+ {/* The SAME three facts as the title row, for the same reason — this
107
+ row is a pair of intrinsically-sized children too, and a
108
+ breadcrumb trail is exactly the kind of thing that outgrows a
109
+ narrow frame. `minHeight` rather than `height`: a fixed height
110
+ cannot absorb a wrap, so the row that most needs to grow was the
111
+ one forbidden to. */}
112
+ <View
113
+ style={{
114
+ flexDirection: "row",
115
+ alignItems: "center",
116
+ flexWrap: "wrap",
117
+ gap: 12,
118
+ minHeight: 64,
119
+ paddingBottom: 16,
120
+ }}
121
+ >
122
+ {/* `flexDirection: "row"` is not decoration: these wrappers RE-PARENT
123
+ what used to be a direct child of the row, and a react-native
124
+ `View` defaults to `column`. Both props are documented as taking
125
+ a nav row's worth of content — "breadcrumbs, a back control,
126
+ row-level status" — so a fragment of several nodes is the shape
127
+ they invite, and without the axis restated it lays out
128
+ top-to-bottom. A wrapper added to state a shrink rule must not
129
+ also silently state a direction. */}
130
+ <View style={{ flexDirection: "row", alignItems: "center", flexShrink: 1, minWidth: 0 }}>
131
+ {left}
132
+ </View>
133
+ {right !== undefined && (
134
+ <View
135
+ style={{
136
+ flexDirection: "row",
137
+ alignItems: "center",
138
+ flexShrink: 0,
139
+ marginLeft: "auto",
140
+ }}
141
+ >
142
+ {right}
143
+ </View>
144
+ )}
99
145
  </View>
100
146
  {titleRow}
101
147
  </>
@@ -103,7 +149,10 @@ export function PageHeader(props: PageHeaderProps) {
103
149
  titleRow
104
150
  )}
105
151
  {!!description && (
106
- <Text color="zinc-500">{description}</Text>
152
+ <>
153
+ {!!title && <Spacer size={8} />}
154
+ <Text color="zinc-500">{description}</Text>
155
+ </>
107
156
  )}
108
157
  </View>
109
158
  );
package/src/popover.tsx CHANGED
@@ -23,6 +23,8 @@ import {
23
23
  } from "./popover_layers";
24
24
  import { PopoverNavContext, type PopoverNavContextValue } from "./popover_nav";
25
25
  import { useLoticsLocale } from "./locale";
26
+ import { OVERLAY_Z } from "./overlay_layer";
27
+ import { useScrollSeam } from "./use_scroll_seam";
26
28
  import { HeadingAltitudeContext } from "./heading_altitude";
27
29
 
28
30
  export type PopoverSide = "top" | "right" | "bottom" | "left";
@@ -269,6 +271,22 @@ export function PopoverContent(props: PopoverContentProps) {
269
271
  // inside it must not dismiss this popover. See popover_layers.ts.
270
272
  const modalsAtOpenRef = useRef<ReadonlySet<Element> | null>(null);
271
273
 
274
+ // THE SEAM EVERY SCROLLER THAT SWAPS ITS BODY IN PLACE TAKES.
275
+ //
276
+ // A routed popover is one: `PopoverScreen` renders `null` for every route but
277
+ // the active one, and the screens are children of THIS scroller — so
278
+ // navigating from a long root list to a sub-screen leaves the container at the
279
+ // list's offset and opens the sub-screen part-way down itself, its
280
+ // `PopoverNavHeader` and back chevron scrolled off the top. Same failure as a
281
+ // master-detail drawer's, in the kit's own navigation primitive.
282
+ //
283
+ // The route IS the content's identity, so it is read here rather than asked
284
+ // for: a popover with no sub-screens sits on the root route forever, its key
285
+ // never changes, and the seam is inert — which is exactly the opt-out
286
+ // `use_scroll_seam` defines.
287
+ const currentRoute = useContext(PopoverNavContext)?.currentRoute;
288
+ const bodySeam = useScrollSeam(currentRoute);
289
+
272
290
  const handleClose = useCallback(() => {
273
291
  if (!open) return;
274
292
  onOpenChange(false);
@@ -603,9 +621,6 @@ export function PopoverContent(props: PopoverContentProps) {
603
621
  if (!open) return null;
604
622
 
605
623
  const nestingLevel = getNestingLevel();
606
- const baseZIndex = 9999;
607
- const overlayZIndex = baseZIndex + nestingLevel * 2;
608
- const contentZIndex = baseZIndex + nestingLevel * 2 + 1;
609
624
 
610
625
  return (
611
626
  // A popover is dialog-scale — a few hundred px with its own chrome — so a
@@ -630,7 +645,7 @@ export function PopoverContent(props: PopoverContentProps) {
630
645
  backgroundColor: "rgba(0, 0, 0, 0.5)",
631
646
  opacity: isBottomSheetShown ? 1 : 0,
632
647
  transition: "opacity 0.3s ease",
633
- zIndex: overlayZIndex,
648
+ zIndex: OVERLAY_Z,
634
649
  pointerEvents: "auto",
635
650
  }}
636
651
  onClick={handleOverlayClick}
@@ -656,7 +671,15 @@ export function PopoverContent(props: PopoverContentProps) {
656
671
  backgroundColor: colors.background,
657
672
  boxShadow: colors.shadow,
658
673
  boxSizing: "border-box",
659
- zIndex: contentZIndex,
674
+ // The kit's ONE overlay rung (`overlay_layer.ts`), shared with every
675
+ // react-native `Modal` — DOM order settles the tie, and DOM order is
676
+ // open order for all of them. A nested popover portals into the same
677
+ // host AFTER its parent; a page-level one is inside the app root,
678
+ // which every modal's body-level div follows; and one opened INSIDE
679
+ // an overlay portals into that overlay's own host. The scrim and the
680
+ // panel share the rung too: siblings in one container, panel written
681
+ // second.
682
+ zIndex: OVERLAY_Z,
660
683
  transition: small ? "transform 0.3s ease" : undefined,
661
684
  ...(small
662
685
  ? {
@@ -752,6 +775,7 @@ export function PopoverContent(props: PopoverContentProps) {
752
775
  // Horizontal only: the vertical padding is the gap to the header and
753
776
  // footer, which is a gap the reader wants.
754
777
  <ScrollView
778
+ {...bodySeam}
755
779
  style={[SCROLL_BODY, style]}
756
780
  contentContainerStyle={[SCROLL_BODY_CONTENT, contentContainerStyle]}
757
781
  >
@@ -113,15 +113,15 @@ export interface ReferenceFieldProps {
113
113
  onOpen?: () => void;
114
114
  openLabel?: string;
115
115
  /**
116
- * Point this field at a DIFFERENT record. REQUIRED.
116
+ * Point this field at a DIFFERENT record.
117
117
  *
118
118
  * Unsets the reference so the call site's picker returns, and the caller
119
119
  * should hand that picker focus: the press said "wrong one", so the next move
120
120
  * is choosing the right one and the correction should cost one gesture.
121
121
  */
122
- onChange: () => void;
122
+ onChange?: () => void;
123
123
  /**
124
- * Leave the reference EMPTY — the plain detach. REQUIRED.
124
+ * Leave the reference EMPTY — the plain detach.
125
125
  *
126
126
  * The SAME write as `onChange`; they differ only in FOLLOW-THROUGH, and
127
127
  * nothing focuses after this one — auto-opening a picker would argue with the
@@ -131,14 +131,21 @@ export interface ReferenceFieldProps {
131
131
  * either steals the caret from someone who wanted an empty field or sends
132
132
  * someone who wanted a swap hunting for the input.
133
133
  *
134
- * BOTH are required, including on a reference the record cannot do without.
135
- * Hiding Clear there enforces nothing Change, then decline to pick, lands on
136
- * the same empty state — so it only removes the direct route to a place the
137
- * reader can already reach, and makes the footer's shape vary for no gain.
138
- * Whether empty is VALID is the row's business (its `warning`, its
139
- * validation), never the peek's.
134
+ * They come as a PAIR both, or neither. Supplying one alone enforces
135
+ * nothing: Change, then decline to pick, lands on the same empty state Clear
136
+ * reaches, so hiding Clear only removes the direct route to a place the reader
137
+ * can already get to, and makes the footer's shape vary for no gain. Whether
138
+ * empty is VALID is the row's business (its `warning`, its validation), never
139
+ * the peek's.
140
+ *
141
+ * **Omitting BOTH is different, and it is the read-only reference.** On a
142
+ * record that is closed — departed, posted, archived — nothing about the link
143
+ * may change, and a Change button that renders and then declines to act is the
144
+ * same broken promise as a disabled field still wearing its chevron. Without
145
+ * them the peek keeps its facts and its Open and simply carries no link verbs,
146
+ * which is what a reference you can read but not re-point looks like.
140
147
  */
141
- onClear: () => void;
148
+ onClear?: () => void;
142
149
  /**
143
150
  * Commit the draft. Receives ONLY the facts whose value CHANGED, keyed by
144
151
  * `name` — never a full snapshot, so a lock or a `before_update` hook sees the
@@ -163,6 +170,10 @@ export function ReferenceField(props: ReferenceFieldProps) {
163
170
  const [error, setError] = useState<string | null>(null);
164
171
  const editing = draft !== null;
165
172
  const editable = onSave != null && facts.some((f) => f.name != null);
173
+ /** Chân trang chỉ có lý do tồn tại khi nó chở một động từ. Không có gì để
174
+ * đổi, để bỏ, để sửa hay để mở thì cái peek chỉ còn là các dữ kiện — và một
175
+ * đường kẻ dưới chúng chỉ nói rằng có thêm thứ gì đó, mà không có. */
176
+ const coVerb = editing || onChange != null || onClear != null || editable || onOpen != null;
166
177
 
167
178
  const openDraft = () => {
168
179
  const seed: Record<string, string> = {};
@@ -239,6 +250,12 @@ export function ReferenceField(props: ReferenceFieldProps) {
239
250
  <InlineEditView
240
251
  anchorRef={anchor}
241
252
  accessibilityLabel={accessibilityLabel}
253
+ /* Nothing here can be changed — no re-point, no detach, no fact edit —
254
+ so the field stops advertising one and rests like the read-only values
255
+ beside it. The peek still opens: reading who a closed record points at
256
+ is ordinary. Derived rather than a prop, so a caller cannot hand out
257
+ an editable surface over verbs it never passed. */
258
+ inert={onChange == null && onClear == null && onSave == null}
242
259
  onPress={() => setPeekOpen(true)}
243
260
  active={peekOpen}
244
261
  /* THE MARKER — what says this value is a RECORD and not typed text.
@@ -395,6 +412,7 @@ export function ReferenceField(props: ReferenceFieldProps) {
395
412
  record being edited, one navigates off it — so offering either here
396
413
  would be offering to lose the typing. Cancel and Save are the only
397
414
  two exits, which is also what the pinned popover promised. */}
415
+ {coVerb ? (
398
416
  <PopoverFooter align={editing ? "end" : "space-between"}>
399
417
  {editing ? (
400
418
  <>
@@ -415,16 +433,18 @@ export function ReferenceField(props: ReferenceFieldProps) {
415
433
  </>
416
434
  ) : (
417
435
  <>
418
- {/* LEFT acts on the LINK — which record this points at. Both are
419
- unconditional: the peek always offers "point it elsewhere" and
420
- "leave it empty", so its footer has ONE shape everywhere. */}
436
+ {/* LEFT acts on the LINK — which record this points at. The two
437
+ travel together, so the footer has ONE shape wherever the link
438
+ can be re-pointed, and no left group at all where it cannot. */}
421
439
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
440
+ {onChange ? (
422
441
  <Button
423
442
  title={t.change}
424
443
  color="secondary"
425
444
  accessibilityLabel={`${t.change} — ${name}`}
426
445
  onPress={() => { setPeekOpen(false); onChange(); }}
427
446
  />
447
+ ) : null}
428
448
  {/* Same resting surface as `change` beside it. It had NO
429
449
  color — which renders transparent, borderless and
430
450
  undecorated, i.e. the hover-only affordance composition.md
@@ -433,12 +453,14 @@ export function ReferenceField(props: ReferenceFieldProps) {
433
453
  before they read any label. Being the least-reached verb is
434
454
  said by PLACEMENT — second in the left group, far from the
435
455
  primary — not by drawing nothing. */}
456
+ {onClear ? (
436
457
  <Button
437
458
  title={t.clear}
438
459
  color="secondary"
439
460
  accessibilityLabel={`${t.clear} — ${name}`}
440
461
  onPress={() => { setPeekOpen(false); onClear(); }}
441
462
  />
463
+ ) : null}
442
464
  </View>
443
465
  {/* RIGHT acts on the RECORD the link points at. Edit takes the ONE
444
466
  filled-dark rung because it is the only verb here that leads to
@@ -459,6 +481,7 @@ export function ReferenceField(props: ReferenceFieldProps) {
459
481
  </>
460
482
  )}
461
483
  </PopoverFooter>
484
+ ) : null}
462
485
  </PopoverContent>
463
486
  </Popover>
464
487
  );
package/src/skip_link.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Platform } from "react-native";
2
2
  import { colors } from "./colors";
3
+ import { SKIP_LINK_Z } from "./overlay_layer";
3
4
 
4
5
  export interface SkipLinkProps {
5
6
  /** DOM id of the main region to jump to, e.g. `"main-content"`. */
@@ -29,7 +30,7 @@ export function SkipLink(props: SkipLinkProps) {
29
30
  color: colors.white,
30
31
  textDecoration: "none",
31
32
  borderRadius: 4,
32
- zIndex: 10001,
33
+ zIndex: SKIP_LINK_Z,
33
34
  transform: "translateY(-200%)",
34
35
  transition: "transform 0.15s ease",
35
36
  }}
@@ -1,10 +1,11 @@
1
- import { useMemo } from "react";
1
+ import { useMemo, type ReactNode } from "react";
2
2
  import { StyleSheet, View } from "react-native";
3
3
  import { colors } from "./colors";
4
4
  import { LegendItem } from "./legend_item";
5
5
  import { useLoticsLocale } from "./locale";
6
6
  import { SPACE } from "./spacing";
7
7
  import { Text } from "./text";
8
+ import { TYPE_LEADING_TIGHT_DESKTOP, TYPE_LEADING_TIGHT_MOBILE } from "./type_ramp";
8
9
  import type { TextColor } from "./text_utils";
9
10
 
10
11
  export interface StackedBarSeries {
@@ -17,6 +18,20 @@ export interface StackedBarRow {
17
18
  key: string;
18
19
  /** The entity this row is about — a campaign, a depot, a month. */
19
20
  label: string;
21
+ /**
22
+ * That entity's own mark, before its name — a `BrandMark`, an `Avatar`, a
23
+ * status dot. A chart and a table over the SAME entities inside one card have
24
+ * to draw them the same way; without a slot the chart can only say the name in
25
+ * text, and one value ends up with two renderings a few hundred pixels apart.
26
+ *
27
+ * A slot rather than a widened `label`, following `Table`/`FileRow`: `label`
28
+ * is also this row's accessible name and the target of the two-line clamp, and
29
+ * a `ReactNode` there would lose both.
30
+ *
31
+ * The row's SERIES colours belong to the measure, so unlike a legend row this
32
+ * one carries no identity of its own until you give it one.
33
+ */
34
+ leading?: ReactNode;
20
35
  /** A neutral qualifier beside the label ("Đang chạy", "12 đơn"). */
21
36
  meta?: string;
22
37
  /** Per-series magnitudes, keyed by `StackedBarSeries.key`. Negatives are dropped. */
@@ -110,6 +125,9 @@ export function StackedBarChart(props: StackedBarChartProps) {
110
125
  that also has to fit a status and a figure spends a narrow
111
126
  container's width truncating exactly that. */}
112
127
  <View style={styles.head}>
128
+ {row.leading !== undefined ? (
129
+ <View style={styles.leading}>{row.leading}</View>
130
+ ) : null}
113
131
  <View style={styles.headText}>
114
132
  <Text size="sm" weight="medium" numberOfLines={2} leading="tight">
115
133
  {row.label}
@@ -181,6 +199,18 @@ const styles = StyleSheet.create({
181
199
  minWidth: 0,
182
200
  gap: 1,
183
201
  },
202
+ // The mark centres on the LABEL'S OWN LINE BOX, not on the head — a row that
203
+ // also carries `meta` is two lines tall, and centring on the head would drift
204
+ // the mark down between them for that row only. Giving the slot the line box's
205
+ // height and centring inside it lands any mark on the first line whatever the
206
+ // mark's size, so a dot and a 24px avatar both sit right without either being
207
+ // measured. `sm` is the same box at both breakpoints; the max is what keeps
208
+ // that from being an assumption.
209
+ leading: {
210
+ height: Math.max(TYPE_LEADING_TIGHT_MOBILE.sm, TYPE_LEADING_TIGHT_DESKTOP.sm),
211
+ justifyContent: "center",
212
+ flexShrink: 0,
213
+ },
184
214
  // The track is the SHARED ruler; the fill is this row's share of it, and the
185
215
  // segments split the fill. Three boxes, because collapsing the middle one is
186
216
  // exactly how a stacked bar loses its scale.
package/src/table.tsx CHANGED
@@ -331,7 +331,12 @@ export function TableGroup(props: TableGroupProps) {
331
331
  return (
332
332
  <View>
333
333
  <View style={styles.groupHeading}>
334
- {color ? <View style={[styles.groupDot, { backgroundColor: solid(color) }]} /> : null}
334
+ {/* Ô này giữ chỗ kể cả khi băng không có màu: bỏ hẳn nó đi thì nhãn của
335
+ băng trung tính bắt đầu sớm hơn nhãn băng có màu đúng một chấm cộng
336
+ khoảng cách, và hai băng nằm ngay cạnh nhau trong cùng một bảng. */}
337
+ <View
338
+ style={[styles.groupDot, color ? { backgroundColor: solid(color) } : { opacity: 0 }]}
339
+ />
335
340
  <Text size="sm" weight="semibold">
336
341
  {label}
337
342
  </Text>
package/src/tabs.tsx CHANGED
@@ -77,7 +77,7 @@ export function Tabs<T extends string>(props: TabsProps<T>) {
77
77
 
78
78
  return (
79
79
  <View
80
- style={{ flexDirection: "row", gap: 4 }}
80
+ style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 4, rowGap: 4 }}
81
81
  accessibilityRole="tablist"
82
82
  accessibilityLabel={accessibilityLabel}
83
83
  >
package/src/text.tsx CHANGED
@@ -162,6 +162,21 @@ export function Text(props: TextProps) {
162
162
  decoration && styles[decoration],
163
163
  tabular && styles.tabular,
164
164
  transform && styles[transform],
165
+ // A CLAMP THAT CANNOT SHRINK CANNOT CLAMP. `numberOfLines` says "cut this
166
+ // to fit", and on a flex row neither platform lets it: on web a clamped
167
+ // Text is `white-space: nowrap`, so its min-content width is the WHOLE
168
+ // string and `min-width: auto` floors it there; on native a `Text` is
169
+ // `flexShrink: 0` (unlike the web, where 1 is the CSS default), so it
170
+ // holds its full width outright. Either way two labelled values on one
171
+ // row lay out at intrinsic width and run off the frame — measured at
172
+ // x=790 in a 390px frame, with no page scroll to reach it.
173
+ //
174
+ // Both halves are needed because each fixes one platform, and both are
175
+ // NO-OPS unless the row actually overflows: flex-shrink only acts on
176
+ // negative free space. It sits before the caller's `style`, so a value
177
+ // that must never give way still says so — `flexShrink: 0` on the site
178
+ // that means it, which is how a figure keeps winning over its label.
179
+ numberOfLines != null && styles.clamped,
165
180
  style,
166
181
  ]}
167
182
  numberOfLines={numberOfLines}
@@ -259,6 +274,12 @@ const styles = StyleSheet.create({
259
274
  fontVariant: ["tabular-nums"],
260
275
  },
261
276
 
277
+ /** The pair a `numberOfLines` clamp needs to mean anything on a flex row. */
278
+ clamped: {
279
+ minWidth: 0,
280
+ flexShrink: 1,
281
+ },
282
+
262
283
  // Text transform styles
263
284
  uppercase: {
264
285
  textTransform: "uppercase",
package/src/tooltip.tsx CHANGED
@@ -1,5 +1,6 @@
1
1
  import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
2
2
  import { createPortal } from "react-dom";
3
+ import { OVERLAY_Z_ABOVE } from "./overlay_layer";
3
4
  import { colors } from "./colors";
4
5
  import { Text } from "./text";
5
6
 
@@ -51,7 +52,7 @@ export function TooltipProvider({ children }: { children: React.ReactNode }) {
51
52
  container.style.width = "100%";
52
53
  container.style.height = "100%";
53
54
  container.style.pointerEvents = "none";
54
- container.style.zIndex = "10000";
55
+ container.style.zIndex = String(OVERLAY_Z_ABOVE);
55
56
  document.body.appendChild(container);
56
57
 
57
58
  setPortalContainer(container);