@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.
@@ -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
  >
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/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);
@@ -39,22 +39,52 @@ export interface ChangeSet<Id extends string = string> {
39
39
  settled: boolean;
40
40
  }
41
41
 
42
- export interface UseChangeSetOptions {
42
+ export interface UseChangeSetOptions<Id extends string = string> {
43
43
  /**
44
44
  * What an untouched proposal counts as. Default `accepted`: the operator
45
45
  * drops the exceptions rather than approving each of eight identical lines,
46
46
  * which is the difference between a review and a second round of data entry.
47
47
  * Use `pending` when each change genuinely deserves its own verdict — and
48
48
  * gate the commit on `settled`.
49
+ *
50
+ * **A MAP when one set mixes kinds with different safe defaults** — filling a
51
+ * blank arrives `accepted` while overwriting a value a human already set
52
+ * arrives `rejected`, so the destructive half is opt-in. An id the map does
53
+ * not name arrives `accepted`. Deciding those rows by calling `reject()` from
54
+ * the result handler instead writes overrides the operator never made, and
55
+ * `undo` on such a row then returns it to `accepted` rather than to the safe
56
+ * default it was supposed to arrive at.
57
+ *
58
+ * A VALUE and not a predicate, so the default is a dependency like any other:
59
+ * `status` and the group arrays are derived from it together and change
60
+ * identity together when it changes. The caller closes over its own rows to
61
+ * build it (`useMemo` over the same rows it already has), which is what keeps
62
+ * this hook ignorant of what a proposal IS. A predicate written inline is a
63
+ * new function every render, so it can only be honoured by hiding it from the
64
+ * dependency lists — and a `status` that never changes identity is a `status`
65
+ * a memoizing screen reads once and then never again, which is the row
66
+ * rendering as kept while the commit bar counts it as dropped.
49
67
  */
50
- initial?: ChangeDecision;
68
+ initial?: ChangeDecision | ReadonlyMap<Id, ChangeDecision>;
69
+ }
70
+
71
+ /** The default for one id: the whole set's decision, or the map's entry for it,
72
+ * or `accepted` for an id nobody named. */
73
+ function defaultFor<Id extends string>(
74
+ initial: ChangeDecision | ReadonlyMap<Id, ChangeDecision> | undefined,
75
+ id: Id,
76
+ ): ChangeDecision {
77
+ if (initial === undefined) return "accepted";
78
+ if (typeof initial === "string") return initial;
79
+ return initial.get(id) ?? "accepted";
51
80
  }
52
81
 
53
82
  export function useChangeSet<Id extends string = string>(
54
83
  ids: readonly Id[],
55
- options?: UseChangeSetOptions,
84
+ options?: UseChangeSetOptions<Id>,
56
85
  ): ChangeSet<Id> {
57
- const initial = options?.initial ?? "accepted";
86
+ const initial = options?.initial;
87
+
58
88
  const [overrides, setOverrides] = useState<ReadonlyMap<Id, ChangeDecision>>(new Map());
59
89
 
60
90
  const set = useCallback((id: Id, decision: ChangeDecision) => {
@@ -79,21 +109,40 @@ export function useChangeSet<Id extends string = string>(
79
109
  [ids],
80
110
  );
81
111
 
82
- const status = useCallback((id: Id) => overrides.get(id) ?? initial, [overrides, initial]);
83
-
84
- const groups = useMemo(() => {
112
+ // ONE derivation, feeding BOTH readers of the same fact.
113
+ //
114
+ // Deriving the groups and `status` separately is what split them: they read
115
+ // the same three inputs twice, and the moment one of those inputs was kept out
116
+ // of a dependency list the two answered differently — `status(id)` said
117
+ // "rejected" while `accepted`, `keptCount` and the commit still carried the
118
+ // row. One memo over the same deps cannot do that. There is nothing expensive
119
+ // here either way: it is one pass over ids, the same pass `status` used to
120
+ // make per call.
121
+ const derived = useMemo(() => {
122
+ const decisions = new Map<Id, ChangeDecision>();
85
123
  const accepted: Id[] = [];
86
124
  const rejected: Id[] = [];
87
125
  const pending: Id[] = [];
88
126
  for (const id of ids) {
89
- const s = overrides.get(id) ?? initial;
90
- if (s === "accepted") accepted.push(id);
91
- else if (s === "rejected") rejected.push(id);
127
+ const decision = overrides.get(id) ?? defaultFor(initial, id);
128
+ decisions.set(id, decision);
129
+ if (decision === "accepted") accepted.push(id);
130
+ else if (decision === "rejected") rejected.push(id);
92
131
  else pending.push(id);
93
132
  }
94
- return { accepted, rejected, pending };
133
+ return { decisions, accepted, rejected, pending };
95
134
  }, [ids, overrides, initial]);
96
135
 
136
+ // Reads the SAME map the groups were built from, so the two cannot disagree,
137
+ // and changes identity whenever an answer does — a screen that memoizes its
138
+ // rows on `status` re-runs when a decision moves. An id outside `ids` still
139
+ // answers (its own override, else the default): a row can leave the set while
140
+ // a handler still holds its id.
141
+ const status = useCallback(
142
+ (id: Id) => derived.decisions.get(id) ?? overrides.get(id) ?? defaultFor(initial, id),
143
+ [derived, overrides, initial],
144
+ );
145
+
97
146
  return useMemo(
98
147
  () => ({
99
148
  status,
@@ -103,13 +152,13 @@ export function useChangeSet<Id extends string = string>(
103
152
  acceptAll: () => all("accepted"),
104
153
  rejectAll: () => all("rejected"),
105
154
  reset: () => setOverrides(new Map()),
106
- accepted: groups.accepted,
107
- rejected: groups.rejected,
108
- pending: groups.pending,
109
- keptCount: groups.accepted.length,
155
+ accepted: derived.accepted,
156
+ rejected: derived.rejected,
157
+ pending: derived.pending,
158
+ keptCount: derived.accepted.length,
110
159
  total: ids.length,
111
- settled: groups.pending.length === 0,
160
+ settled: derived.pending.length === 0,
112
161
  }),
113
- [status, set, undo, all, groups, ids.length],
162
+ [status, set, undo, all, derived, ids.length],
114
163
  );
115
164
  }
@@ -0,0 +1,79 @@
1
+ import { useCallback, useLayoutEffect, useRef } from "react";
2
+ import type { NativeScrollEvent, NativeSyntheticEvent, ScrollView } from "react-native";
3
+
4
+ /** The three props a seam needs on the `ScrollView` it manages. Spread, so a
5
+ * surface cannot wire half of it. */
6
+ export interface ScrollSeam {
7
+ ref: React.RefObject<ScrollView | null>;
8
+ onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
9
+ scrollEventThrottle: number;
10
+ }
11
+
12
+ /**
13
+ * The identity of the content while nothing is swapped in.
14
+ *
15
+ * `scrollKey` is optional, and the natural call site — `scrollKey={openChild?.id}`,
16
+ * which is what the master-detail recipe prescribes — passes `undefined` for the
17
+ * MASTER leg. Reading that as "this surface opts out" broke exactly half of the
18
+ * seam: the forward leg worked (undefined → "child" is a change, so the child
19
+ * opened at 0) while the return leg restored nothing, because the master's
20
+ * offset had never been recorded under any key. The reader came back to the
21
+ * CHILD's offset inside the parent list — worse than either 0 or the remembered
22
+ * place.
23
+ *
24
+ * So an absent key is a KEY, not an opt-out. Opting out needs no signal of its
25
+ * own: a surface that never changes its key never scrolls, because the seam
26
+ * fires on the CHANGE and there is none.
27
+ */
28
+ const ROOT_KEY = "\u0000root";
29
+
30
+ /**
31
+ * REMEMBERED SCROLL OFFSETS ACROSS A CONTENT SWAP.
32
+ *
33
+ * A scroll container keeps its offset when its children change, because nothing
34
+ * tells it the content it was holding no longer exists. Swap a drawer's body for
35
+ * a child record and the new record opens part-way down itself, with its own
36
+ * heading off-screen above — invisible until the first list long enough to
37
+ * scroll, which is the same list that makes the swap worth having.
38
+ *
39
+ * The seam is the content's IDENTITY: while `scrollKey` holds, the container is
40
+ * left alone; when it changes, the outgoing key's offset is already recorded and
41
+ * the incoming one is restored — 0 for a key never seen, so a swap FORWARD opens
42
+ * at the top, and the remembered offset on the way BACK, so the reader keeps
43
+ * their place in the list they came from.
44
+ *
45
+ * That return leg is the whole reason this is not a React `key` on the scroll
46
+ * area. A key throws the container away and rebuilds it, which resets the child
47
+ * correctly and resets the PARENT just as thoroughly.
48
+ *
49
+ * No key CHANGE ⇒ no scrolling: a surface that does not swap content behaves
50
+ * exactly as it did before, whether it names its content or not. An ABSENT key
51
+ * is the root content's identity (see {@link ROOT_KEY}), not a request to skip
52
+ * the seam — which is what `scrollKey={openChild?.id}` needs on the leg where
53
+ * no child is open.
54
+ */
55
+ export function useScrollSeam(scrollKey: string | undefined): ScrollSeam {
56
+ const key = scrollKey ?? ROOT_KEY;
57
+ const ref = useRef<ScrollView | null>(null);
58
+ const offsets = useRef(new Map<string, number>());
59
+ // Which key the live offset belongs to. Read by `onScroll`, which fires long
60
+ // after the render that changed the prop.
61
+ const liveKey = useRef(key);
62
+
63
+ const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
64
+ offsets.current.set(liveKey.current, event.nativeEvent.contentOffset.y);
65
+ }, []);
66
+
67
+ useLayoutEffect(() => {
68
+ const previous = liveKey.current;
69
+ liveKey.current = key;
70
+ // On mount `previous` IS the key, so nothing is scrolled — a fresh container
71
+ // is already at the top and moving it would be a visible jump on open.
72
+ if (previous === key) return;
73
+ ref.current?.scrollTo({ y: offsets.current.get(key) ?? 0, animated: false });
74
+ }, [key]);
75
+
76
+ // Native emits one scroll event per gesture without this, so the offset the
77
+ // seam remembers would be wherever the finger first landed.
78
+ return { ref, onScroll, scrollEventThrottle: 16 };
79
+ }
@@ -1,32 +0,0 @@
1
- /**
2
- * Which of a line chart's x positions get a printed label.
3
- *
4
- * A label is roughly `minLabelWidth` wide, so only so many fit across the
5
- * track; the rest are thinned out. The rule that matters is WHERE the thinning
6
- * is anchored: stepping forwards from the first point and then forcing the last
7
- * one in collides whenever the series length minus one is not a multiple of the
8
- * step — the newest label lands a few pixels from the one before it while every
9
- * other pair is a full step apart. Anchoring on the LAST point makes the spacing
10
- * uniform by construction, and the last point is the one a reader looks up
11
- * first. The first point is then kept only when it clears the same distance.
12
- */
13
- export function lineChartLabelIndices(
14
- count: number,
15
- chartWidth: number,
16
- minLabelWidth = 50,
17
- ): number[] {
18
- if (count <= 0 || chartWidth <= 0) return [];
19
- if (count === 1) return [0];
20
-
21
- const last = count - 1;
22
- const maxLabels = Math.max(2, Math.floor(chartWidth / minLabelWidth));
23
- const step = Math.max(1, Math.ceil(count / maxLabels));
24
-
25
- const kept: number[] = [];
26
- for (let i = last; i >= 0; i -= step) kept.unshift(i);
27
-
28
- const firstKept = kept[0] ?? 0;
29
- if (firstKept > 0 && (firstKept / last) * chartWidth >= minLabelWidth) kept.unshift(0);
30
-
31
- return kept;
32
- }