panelui-native 0.77.0 → 0.79.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 (142) hide show
  1. package/lib/module/components/area-chart/index.js +32 -25
  2. package/lib/module/components/area-chart/index.js.map +1 -1
  3. package/lib/module/components/bottom-sheet/index.js +97 -15
  4. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  5. package/lib/module/components/funnel-chart/index.js +15 -2
  6. package/lib/module/components/funnel-chart/index.js.map +1 -1
  7. package/lib/module/components/heatmap-chart/index.js +19 -27
  8. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  9. package/lib/module/components/hex-chart/index.js +2 -0
  10. package/lib/module/components/hex-chart/index.js.map +1 -1
  11. package/lib/module/components/line-chart/index.js +38 -31
  12. package/lib/module/components/line-chart/index.js.map +1 -1
  13. package/lib/module/components/map/index.js +63 -39
  14. package/lib/module/components/map/index.js.map +1 -1
  15. package/lib/module/components/map/map-children.js +54 -0
  16. package/lib/module/components/map/map-children.js.map +1 -0
  17. package/lib/module/components/map/maplibre.js +31 -3
  18. package/lib/module/components/map/maplibre.js.map +1 -1
  19. package/lib/module/components/markdown-editor/index.js +288 -34
  20. package/lib/module/components/markdown-editor/index.js.map +1 -1
  21. package/lib/module/components/markdown-editor/markdown-transforms.js +272 -9
  22. package/lib/module/components/markdown-editor/markdown-transforms.js.map +1 -1
  23. package/lib/module/components/marquee/index.js +205 -46
  24. package/lib/module/components/marquee/index.js.map +1 -1
  25. package/lib/module/components/panelside/index.js +162 -34
  26. package/lib/module/components/panelside/index.js.map +1 -1
  27. package/lib/module/components/pie-chart/index.js +15 -2
  28. package/lib/module/components/pie-chart/index.js.map +1 -1
  29. package/lib/module/components/plot/index.js +42 -44
  30. package/lib/module/components/plot/index.js.map +1 -1
  31. package/lib/module/components/progress-button/index.js +617 -0
  32. package/lib/module/components/progress-button/index.js.map +1 -0
  33. package/lib/module/components/progress-button/progress-button-hold.js +91 -0
  34. package/lib/module/components/progress-button/progress-button-hold.js.map +1 -0
  35. package/lib/module/components/scatter-chart/index.js +6 -25
  36. package/lib/module/components/scatter-chart/index.js.map +1 -1
  37. package/lib/module/components/section-rail/index.js +40 -17
  38. package/lib/module/components/section-rail/index.js.map +1 -1
  39. package/lib/module/components/split-view/index.js +54 -10
  40. package/lib/module/components/split-view/index.js.map +1 -1
  41. package/lib/module/components/theme-selector/index.js +239 -0
  42. package/lib/module/components/theme-selector/index.js.map +1 -0
  43. package/lib/module/components/theme-selector/theme-preview.js +320 -0
  44. package/lib/module/components/theme-selector/theme-preview.js.map +1 -0
  45. package/lib/module/components/timeline/index.js +145 -14
  46. package/lib/module/components/timeline/index.js.map +1 -1
  47. package/lib/module/components/toast/index.js +45 -11
  48. package/lib/module/components/toast/index.js.map +1 -1
  49. package/lib/module/components/treemap-chart/index.js +63 -6
  50. package/lib/module/components/treemap-chart/index.js.map +1 -1
  51. package/lib/module/components/waterfall-chart/index.js +20 -4
  52. package/lib/module/components/waterfall-chart/index.js.map +1 -1
  53. package/lib/module/hooks/index.js +1 -0
  54. package/lib/module/hooks/index.js.map +1 -1
  55. package/lib/module/hooks/use-scroll-sections.js +44 -2
  56. package/lib/module/hooks/use-scroll-sections.js.map +1 -1
  57. package/lib/module/hooks/use-skeleton-handoff.js +58 -0
  58. package/lib/module/hooks/use-skeleton-handoff.js.map +1 -0
  59. package/lib/module/index.js +2 -0
  60. package/lib/module/index.js.map +1 -1
  61. package/lib/typescript/src/components/area-chart/index.d.ts +1 -1
  62. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
  63. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  64. package/lib/typescript/src/components/funnel-chart/index.d.ts.map +1 -1
  65. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  66. package/lib/typescript/src/components/hex-chart/index.d.ts.map +1 -1
  67. package/lib/typescript/src/components/line-chart/index.d.ts +1 -1
  68. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  69. package/lib/typescript/src/components/map/index.d.ts +8 -4
  70. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  71. package/lib/typescript/src/components/map/map-children.d.ts +41 -0
  72. package/lib/typescript/src/components/map/map-children.d.ts.map +1 -0
  73. package/lib/typescript/src/components/map/maplibre.d.ts +8 -4
  74. package/lib/typescript/src/components/map/maplibre.d.ts.map +1 -1
  75. package/lib/typescript/src/components/markdown-editor/index.d.ts +64 -5
  76. package/lib/typescript/src/components/markdown-editor/index.d.ts.map +1 -1
  77. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts +33 -5
  78. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts.map +1 -1
  79. package/lib/typescript/src/components/marquee/index.d.ts +55 -2
  80. package/lib/typescript/src/components/marquee/index.d.ts.map +1 -1
  81. package/lib/typescript/src/components/panelside/index.d.ts +79 -4
  82. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  83. package/lib/typescript/src/components/pie-chart/index.d.ts.map +1 -1
  84. package/lib/typescript/src/components/plot/index.d.ts +1 -3
  85. package/lib/typescript/src/components/plot/index.d.ts.map +1 -1
  86. package/lib/typescript/src/components/progress-button/index.d.ts +311 -0
  87. package/lib/typescript/src/components/progress-button/index.d.ts.map +1 -0
  88. package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts +60 -0
  89. package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts.map +1 -0
  90. package/lib/typescript/src/components/scatter-chart/index.d.ts +1 -1
  91. package/lib/typescript/src/components/scatter-chart/index.d.ts.map +1 -1
  92. package/lib/typescript/src/components/section-rail/index.d.ts.map +1 -1
  93. package/lib/typescript/src/components/split-view/index.d.ts +10 -1
  94. package/lib/typescript/src/components/split-view/index.d.ts.map +1 -1
  95. package/lib/typescript/src/components/theme-selector/index.d.ts +107 -0
  96. package/lib/typescript/src/components/theme-selector/index.d.ts.map +1 -0
  97. package/lib/typescript/src/components/theme-selector/theme-preview.d.ts +32 -0
  98. package/lib/typescript/src/components/theme-selector/theme-preview.d.ts.map +1 -0
  99. package/lib/typescript/src/components/timeline/index.d.ts +7 -0
  100. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  101. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  102. package/lib/typescript/src/components/treemap-chart/index.d.ts.map +1 -1
  103. package/lib/typescript/src/components/waterfall-chart/index.d.ts.map +1 -1
  104. package/lib/typescript/src/hooks/index.d.ts +1 -0
  105. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  106. package/lib/typescript/src/hooks/use-scroll-sections.d.ts +1 -0
  107. package/lib/typescript/src/hooks/use-scroll-sections.d.ts.map +1 -1
  108. package/lib/typescript/src/hooks/use-skeleton-handoff.d.ts +11 -0
  109. package/lib/typescript/src/hooks/use-skeleton-handoff.d.ts.map +1 -0
  110. package/lib/typescript/src/index.d.ts +6 -4
  111. package/lib/typescript/src/index.d.ts.map +1 -1
  112. package/package.json +1 -1
  113. package/src/components/area-chart/index.tsx +49 -37
  114. package/src/components/bottom-sheet/index.tsx +109 -11
  115. package/src/components/funnel-chart/index.tsx +10 -2
  116. package/src/components/heatmap-chart/index.tsx +27 -22
  117. package/src/components/hex-chart/index.tsx +22 -6
  118. package/src/components/line-chart/index.tsx +57 -43
  119. package/src/components/map/index.tsx +63 -36
  120. package/src/components/map/map-children.ts +66 -0
  121. package/src/components/map/maplibre.ts +32 -4
  122. package/src/components/markdown-editor/index.tsx +413 -35
  123. package/src/components/markdown-editor/markdown-transforms.ts +274 -10
  124. package/src/components/marquee/index.tsx +254 -58
  125. package/src/components/panelside/index.tsx +179 -40
  126. package/src/components/pie-chart/index.tsx +10 -2
  127. package/src/components/plot/index.tsx +66 -50
  128. package/src/components/progress-button/index.tsx +684 -0
  129. package/src/components/progress-button/progress-button-hold.ts +88 -0
  130. package/src/components/scatter-chart/index.tsx +4 -25
  131. package/src/components/section-rail/index.tsx +39 -19
  132. package/src/components/split-view/index.tsx +53 -8
  133. package/src/components/theme-selector/index.tsx +281 -0
  134. package/src/components/theme-selector/theme-preview.tsx +205 -0
  135. package/src/components/timeline/index.tsx +143 -17
  136. package/src/components/toast/index.tsx +60 -11
  137. package/src/components/treemap-chart/index.tsx +65 -8
  138. package/src/components/waterfall-chart/index.tsx +14 -4
  139. package/src/hooks/index.ts +5 -0
  140. package/src/hooks/use-scroll-sections.ts +52 -3
  141. package/src/hooks/use-skeleton-handoff.ts +69 -0
  142. package/src/index.ts +25 -0
@@ -44,7 +44,17 @@
44
44
  * Screen readers get one copy. The rest are duplicates of content already
45
45
  * announced, and hearing a sponsor list four times over is not thoroughness.
46
46
  */
47
- import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react';
47
+ import {
48
+ createContext,
49
+ useCallback,
50
+ useContext,
51
+ useEffect,
52
+ useId,
53
+ useMemo,
54
+ useState,
55
+ type ComponentType,
56
+ type ReactNode,
57
+ } from 'react';
48
58
  import {
49
59
  Platform,
50
60
  Pressable,
@@ -61,6 +71,7 @@ import Animated, {
61
71
  useReducedMotion,
62
72
  useSharedValue,
63
73
  withRepeat,
74
+ withSequence,
64
75
  withTiming,
65
76
  } from 'react-native-reanimated';
66
77
  import { useDirectionSign } from '../../hooks/use-direction';
@@ -99,7 +110,13 @@ export interface MarqueeProps extends Omit<ViewProps, 'children'> {
99
110
  reverse?: boolean;
100
111
  /** Set false to hold the content where it is. */
101
112
  playing?: boolean;
102
- /** Show the built-in user pause/play control while motion is enabled. */
113
+ /**
114
+ * Show the built-in user pause/play control while motion is enabled.
115
+ *
116
+ * Defaults to `true` on its own, and to `false` inside a `Marquee.Group` —
117
+ * the group draws one control for everything in it, and a control per row is
118
+ * how two of them end up stacked on top of each other.
119
+ */
103
120
  showPauseControl?: boolean;
104
121
  /** Visible and spoken label for the moving state. */
105
122
  pauseLabel?: string;
@@ -109,7 +126,27 @@ export interface MarqueeProps extends Omit<ViewProps, 'children'> {
109
126
  onPlayingChange?: (playing: boolean) => void;
110
127
  }
111
128
 
112
- export function Marquee({
129
+ /**
130
+ * Set by `Marquee.Group` so the marquees inside it stop drawing a pause control
131
+ * each and take the group's instead.
132
+ */
133
+ interface MarqueeGroupContextValue {
134
+ /** True while the group's control is holding everything in it still. */
135
+ paused: boolean;
136
+ /**
137
+ * How a marquee inside the group reports whether it has a loop to stop.
138
+ *
139
+ * The group draws one control for everything in it, and it should not offer
140
+ * to pause content that never measured — the failure mode a marquee already
141
+ * has, where a child with no intrinsic size never starts. Only the marquees
142
+ * themselves know that, so they say so.
143
+ */
144
+ report: (id: string, live: boolean) => void;
145
+ }
146
+
147
+ const MarqueeGroupContext = createContext<MarqueeGroupContextValue | null>(null);
148
+
149
+ function MarqueeRoot({
113
150
  className,
114
151
  children,
115
152
  speed: speedProp = DEFAULT_MARQUEE_SPEED,
@@ -117,7 +154,7 @@ export function Marquee({
117
154
  direction = 'horizontal',
118
155
  reverse = false,
119
156
  playing = true,
120
- showPauseControl = true,
157
+ showPauseControl,
121
158
  pauseLabel = 'Pause',
122
159
  playLabel = 'Play',
123
160
  onPlayingChange,
@@ -135,7 +172,13 @@ export function Marquee({
135
172
  const [content, setContent] = useState(0);
136
173
  const [userPaused, setUserPaused] = useState(false);
137
174
  const speed = normalizeMarqueeSpeed(speedProp);
138
- const moving = playing && !userPaused;
175
+
176
+ // A group's pause is the same instruction as this one's, so it is read here
177
+ // rather than pushed down as a `playing` prop — a caller who set `playing`
178
+ // themselves should not have it overwritten by the container.
179
+ const group = useContext(MarqueeGroupContext);
180
+ const showControl = showPauseControl ?? group === null;
181
+ const moving = playing && !userPaused && !(group?.paused ?? false);
139
182
 
140
183
  // The distance from one copy to the same point on the next, and therefore
141
184
  // both the layout step and the exact loop length. They are the same number
@@ -154,18 +197,43 @@ export function Marquee({
154
197
 
155
198
  const offset = useSharedValue(0);
156
199
 
200
+ /*
201
+ * A re-measure is the only thing allowed to move the content back to the
202
+ * start. The loop length has changed, so an offset taken against the old one
203
+ * no longer means anything.
204
+ */
157
205
  useEffect(() => {
158
206
  cancelAnimation(offset);
159
207
  offset.value = 0;
208
+ }, [offset, period]);
209
+
210
+ /*
211
+ * Pausing freezes where it is. `cancelAnimation` already leaves the shared
212
+ * value at whatever it had reached, so stopping is simply not starting again
213
+ * — the content stays exactly where the reader stopped it, which is the
214
+ * whole point of a pause control on something they are trying to read.
215
+ *
216
+ * Resuming picks up from there. The first leg is shortened to the distance
217
+ * actually left, or the lap after a pause would run at a fraction of the
218
+ * speed every other lap runs at.
219
+ */
220
+ useEffect(() => {
221
+ cancelAnimation(offset);
160
222
  if (reducedMotion || !moving || period <= 0 || speed <= 0) return undefined;
161
- offset.value = withRepeat(
162
- withTiming(period, {
163
- duration: (period / speed) * 1000,
164
- easing: Easing.linear,
165
- }),
166
- -1,
167
- false
223
+
224
+ const cycle = (period / speed) * 1000;
225
+ const from = ((offset.value % period) + period) % period;
226
+ const remaining = period - from;
227
+
228
+ offset.value = from;
229
+ offset.value = withSequence(
230
+ withTiming(period, { duration: cycle * (remaining / period), easing: Easing.linear }),
231
+ // Back to the seam in no time at all. The state at `period` is the state
232
+ // at `0`, so this is a bookkeeping step rather than a visible one.
233
+ withTiming(0, { duration: 0 }),
234
+ withRepeat(withTiming(period, { duration: cycle, easing: Easing.linear }), -1, false)
168
235
  );
236
+
169
237
  return () => cancelAnimation(offset);
170
238
  }, [offset, period, moving, reducedMotion, speed]);
171
239
 
@@ -189,6 +257,24 @@ export function Marquee({
189
257
  setContent(horizontal ? width : height);
190
258
  };
191
259
 
260
+ /*
261
+ * Whether there is motion here at all: content that measured, a speed to
262
+ * travel it at, and a `playing` that has not already stopped it from
263
+ * outside. A control offering to pause a still marquee is the same lie as a
264
+ * disabled button with no reason on it.
265
+ */
266
+ const live = playing && !reducedMotion && period > 0 && speed > 0;
267
+
268
+ // Reported to the group, if there is one, so its single control can be
269
+ // decided the same way this one's is.
270
+ const id = useId();
271
+ const report = group?.report;
272
+ useEffect(() => {
273
+ if (!report) return undefined;
274
+ report(id, live);
275
+ return () => report(id, false);
276
+ }, [report, id, live]);
277
+
192
278
  // Nothing to loop, so nothing to clip or clone: render the content plainly
193
279
  // and let it sit where it falls.
194
280
  if (reducedMotion) {
@@ -205,56 +291,61 @@ export function Marquee({
205
291
  }
206
292
 
207
293
  return (
208
- <View
209
- className={cn('overflow-hidden', className)}
210
- style={style}
211
- onLayout={onContainerLayout}
212
- {...props}
213
- >
214
- {/* Measured, never seen. A scroller on this axis is what frees the
215
- content to report its own size instead of the container's. */}
216
- <ScrollView
217
- horizontal={horizontal}
218
- scrollEnabled={false}
219
- style={styles.measure}
220
- pointerEvents="none"
221
- accessibilityElementsHidden
222
- importantForAccessibility="no-hide-descendants"
223
- >
224
- <InertView
225
- inert={Platform.OS === 'web' ? true : undefined}
226
- onLayout={onContentLayout}
294
+ <View className={cn('gap-3', className)} style={style} onLayout={onLayout} {...props}>
295
+ {/* The track, and only the track. The control below it is not clipped by
296
+ this and does not travel with it. */}
297
+ <View className="flex-1 overflow-hidden" onLayout={onContainerLayout}>
298
+ {/* Measured, never seen. A scroller on this axis is what frees the
299
+ content to report its own size instead of the container's. */}
300
+ <ScrollView
301
+ horizontal={horizontal}
302
+ scrollEnabled={false}
303
+ style={styles.measure}
304
+ pointerEvents="none"
305
+ accessibilityElementsHidden
306
+ importantForAccessibility="no-hide-descendants"
227
307
  >
228
- {children}
229
- </InertView>
230
- </ScrollView>
231
-
232
- <Animated.View style={[StyleSheet.absoluteFill, trackStyle]} pointerEvents="box-none">
233
- {copies.map((index) => {
234
- // Index 1 is the copy that starts flush with the container's edge,
235
- // and it is the one a screen reader is given.
236
- const spoken = index === 1;
237
- const at = (index - 1) * period;
238
- return (
239
- <InertView
240
- key={index}
241
- style={[styles.copy, horizontal ? { left: at } : { top: at }]}
242
- pointerEvents={spoken ? 'box-none' : 'none'}
243
- inert={Platform.OS === 'web' && !spoken ? true : undefined}
244
- aria-hidden={!spoken}
245
- accessibilityElementsHidden={!spoken}
246
- importantForAccessibility={spoken ? 'auto' : 'no-hide-descendants'}
247
- >
248
- {children}
249
- </InertView>
250
- );
251
- })}
252
- </Animated.View>
253
- {showPauseControl && playing && period > 0 && speed > 0 ? (
308
+ <InertView
309
+ inert={Platform.OS === 'web' ? true : undefined}
310
+ onLayout={onContentLayout}
311
+ >
312
+ {children}
313
+ </InertView>
314
+ </ScrollView>
315
+
316
+ <Animated.View style={[StyleSheet.absoluteFill, trackStyle]} pointerEvents="box-none">
317
+ {copies.map((index) => {
318
+ // Index 1 is the copy that starts flush with the container's edge,
319
+ // and it is the one a screen reader is given.
320
+ const spoken = index === 1;
321
+ const at = (index - 1) * period;
322
+ return (
323
+ <InertView
324
+ key={index}
325
+ style={[styles.copy, horizontal ? { left: at } : { top: at }]}
326
+ pointerEvents={spoken ? 'box-none' : 'none'}
327
+ inert={Platform.OS === 'web' && !spoken ? true : undefined}
328
+ aria-hidden={!spoken}
329
+ accessibilityElementsHidden={!spoken}
330
+ importantForAccessibility={spoken ? 'auto' : 'no-hide-descendants'}
331
+ >
332
+ {children}
333
+ </InertView>
334
+ );
335
+ })}
336
+ </Animated.View>
337
+ </View>
338
+ {showControl && live ? (
254
339
  <Pressable
255
340
  accessibilityRole="button"
256
341
  accessibilityLabel={userPaused ? playLabel : pauseLabel}
257
- className="absolute bottom-2 end-2 min-h-12 min-w-12 items-center justify-center rounded-full border border-border bg-background/95 px-3"
342
+ /*
343
+ * Below the content, in flow, the way a group's control already is.
344
+ * Floating over the track it was clipped by the track's own
345
+ * `overflow-hidden` — a 48pt target on a strip of badges half that
346
+ * tall — and it covered the content it was there to let you read.
347
+ */
348
+ className="min-h-12 self-end items-center justify-center rounded-full border border-border bg-background px-4"
258
349
  onPress={() => {
259
350
  const next = !userPaused;
260
351
  setUserPaused(next);
@@ -270,6 +361,111 @@ export function Marquee({
270
361
  );
271
362
  }
272
363
 
364
+ export interface MarqueeGroupProps extends ViewProps {
365
+ className?: string;
366
+ /** Set false to hold every marquee in the group where it is. */
367
+ playing?: boolean;
368
+ /** Show the group's pause/play control. */
369
+ showPauseControl?: boolean;
370
+ /** Visible and spoken label for the moving state. */
371
+ pauseLabel?: string;
372
+ /** Visible and spoken label for the user-paused state. */
373
+ playLabel?: string;
374
+ /** Reports changes made by the group's control. */
375
+ onPlayingChange?: (playing: boolean) => void;
376
+ children?: ReactNode;
377
+ }
378
+
379
+ /**
380
+ * Rows of travelling content that stop and start together, under one control.
381
+ *
382
+ * Moving content needs a way to stop it, so a marquee draws its own pause
383
+ * button. Stacked — two rows of logos travelling against each other — that is
384
+ * one button per row, each pinned to its own bottom corner, and the upper one
385
+ * lands on top of the row beneath it. Two buttons for one piece of motion, and
386
+ * one of them covering the content it belongs to.
387
+ *
388
+ * A group is the answer to both: the marquees inside it stop drawing their own
389
+ * control, and the group draws a single one below them rather than over them.
390
+ *
391
+ * ```tsx
392
+ * <Marquee.Group>
393
+ * <Marquee>{topRow}</Marquee>
394
+ * <Marquee reverse>{bottomRow}</Marquee>
395
+ * </Marquee.Group>
396
+ * ```
397
+ *
398
+ * Each marquee keeps its own `playing` prop. The group's pause is an additional
399
+ * hold, not a replacement — a row already held still stays still.
400
+ */
401
+ function MarqueeGroup({
402
+ className,
403
+ children,
404
+ playing = true,
405
+ showPauseControl = true,
406
+ pauseLabel = 'Pause',
407
+ playLabel = 'Play',
408
+ onPlayingChange,
409
+ ...props
410
+ }: MarqueeGroupProps) {
411
+ const [userPaused, setUserPaused] = useState(false);
412
+ const [liveRows, setLiveRows] = useState<ReadonlySet<string>>(() => new Set());
413
+ const reducedMotion = useReducedMotion();
414
+
415
+ // Each marquee inside says whether it has a loop to stop, and the group's
416
+ // control is drawn only if at least one of them does.
417
+ const report = useCallback((id: string, live: boolean) => {
418
+ setLiveRows((current) => {
419
+ if (current.has(id) === live) return current;
420
+ const next = new Set(current);
421
+ if (live) next.add(id);
422
+ else next.delete(id);
423
+ return next;
424
+ });
425
+ }, []);
426
+
427
+ const value = useMemo(() => ({ paused: userPaused, report }), [userPaused, report]);
428
+
429
+ // Nothing is moving, so there is nothing to stop. The control is not hidden
430
+ // to save space — offering to pause content that is already still is the
431
+ // same lie as a disabled button with no reason on it.
432
+ const showControl = showPauseControl && playing && !reducedMotion && liveRows.size > 0;
433
+
434
+ return (
435
+ <MarqueeGroupContext.Provider value={value}>
436
+ <View {...props} className={cn('w-full gap-3', className)}>
437
+ {children}
438
+ {showControl ? (
439
+ <Pressable
440
+ accessibilityRole="button"
441
+ accessibilityLabel={userPaused ? playLabel : pauseLabel}
442
+ // In flow and at the end, not floating over the last row. The
443
+ // control belongs to the group, and a group's control that covers
444
+ // one of its members is describing itself as part of that member.
445
+ className="min-h-12 self-end items-center justify-center rounded-full border border-border bg-background px-4"
446
+ onPress={() => {
447
+ const next = !userPaused;
448
+ setUserPaused(next);
449
+ onPlayingChange?.(!next);
450
+ }}
451
+ >
452
+ <Text className="text-xs font-medium text-foreground">
453
+ {userPaused ? playLabel : pauseLabel}
454
+ </Text>
455
+ </Pressable>
456
+ ) : null}
457
+ </View>
458
+ </MarqueeGroupContext.Provider>
459
+ );
460
+ }
461
+ MarqueeGroup.displayName = 'Marquee.Group';
462
+
463
+ MarqueeRoot.displayName = 'Marquee';
464
+
465
+ export const Marquee = Object.assign(MarqueeRoot, {
466
+ Group: MarqueeGroup,
467
+ });
468
+
273
469
  const styles = StyleSheet.create({
274
470
  /* Laid out so it measures, hidden so it does not draw, and behind everything
275
471
  so it can never intercept anything. */
@@ -119,7 +119,7 @@ import { LinearGradient } from 'expo-linear-gradient';
119
119
  import { EllipsisIcon, IconColorProvider, MenuIcon, SearchIcon } from '../../icons';
120
120
  import { Button } from '../button';
121
121
  import { AnimatedPressable } from '../../primitives/animated-pressable';
122
- import { Text, textChildren } from '../../primitives/text';
122
+ import { Text, textChildren, type TextProps } from '../../primitives/text';
123
123
  import { useBackHandler } from '../../hooks/use-back-handler';
124
124
  import { useDirectionSign } from '../../hooks/use-direction';
125
125
  import { cn } from '../../utils/cn';
@@ -888,17 +888,47 @@ function PanelsideGroupLabel({ className, children, ...props }: PanelsideGroupLa
888
888
  );
889
889
  }
890
890
 
891
+ /**
892
+ * What a row's parts read off the row, rather than being handed it.
893
+ *
894
+ * A label goes medium and un-muted when the row is the current destination,
895
+ * and an icon takes the matching tint. Passing that to every part would mean
896
+ * `<Panelside.ItemLabel active={active}>` at every call site, with an `active`
897
+ * that has to be kept in step with the row's own.
898
+ */
899
+ interface PanelsideItemContextValue {
900
+ active: boolean;
901
+ disabled: boolean;
902
+ size: PanelsideItemSize;
903
+ /** The colour an icon in this row should take, resolved once by the row. */
904
+ tint: string | undefined;
905
+ }
906
+
907
+ const PanelsideItemContext = createContext<PanelsideItemContextValue | null>(null);
908
+
909
+ function usePanelsideItem(part: string): PanelsideItemContextValue {
910
+ const value = useContext(PanelsideItemContext);
911
+ if (!value) throw new Error(`${part} must be used inside a <Panelside.Item>.`);
912
+ return value;
913
+ }
914
+
891
915
  export interface PanelsideItemProps extends Omit<PressableProps, 'children'> {
892
916
  className?: string;
893
- /** Leading element — an icon, an avatar, a coloured dot. */
917
+ /**
918
+ * Leading element — an icon, an avatar, a coloured dot. The shorthand for
919
+ * `Panelside.ItemIcon`.
920
+ */
894
921
  icon?: ReactNode;
895
- /** The row's text. Truncated to one line, since chat titles run long. */
922
+ /**
923
+ * The row's text, truncated to one line since chat titles run long. The
924
+ * shorthand for `Panelside.ItemLabel`.
925
+ */
896
926
  label?: string;
897
927
  /** Marks the row as the current destination. */
898
928
  active?: boolean;
899
929
  /**
900
930
  * Trailing count or status. A number or string renders as a pill; anything
901
- * else renders as given.
931
+ * else renders as given. The shorthand for `Panelside.ItemBadge`.
902
932
  */
903
933
  badge?: ReactNode;
904
934
  disabled?: boolean;
@@ -908,10 +938,37 @@ export interface PanelsideItemProps extends Omit<PressableProps, 'children'> {
908
938
  * worth more than two extra rows.
909
939
  */
910
940
  size?: PanelsideItemSize;
911
- /** Trailing content — usually a `Panelside.Action`. */
941
+ /**
942
+ * The row's contents, written out: `Panelside.ItemIcon`,
943
+ * `Panelside.ItemLabel`, `Panelside.ItemBadge` and `Panelside.Action`, in
944
+ * whatever order the row wants them. Anything else you draw works too.
945
+ *
946
+ * Children and the shorthand props compose — a row can take its label from
947
+ * `label` and still write a trailing `Panelside.Action` as a child.
948
+ */
912
949
  children?: ReactNode;
913
950
  }
914
951
 
952
+ /**
953
+ * One destination, or one conversation.
954
+ *
955
+ * There are two ways to fill it, and they are the same row. The shorthand —
956
+ * `icon`, `label`, `badge` — covers the row every navigation panel has, and is
957
+ * what most call sites should use. The parts cover everything else: two lines
958
+ * of text, a label that is not a string, a badge before the label rather than
959
+ * after it, a trailing control that is not an overflow menu.
960
+ *
961
+ * ```tsx
962
+ * <Panelside.Item icon={<InboxIcon />} label="Inbox" badge={12} />
963
+ *
964
+ * <Panelside.Item active>
965
+ * <Panelside.ItemIcon><Avatar size="xs" src={author.avatar} /></Panelside.ItemIcon>
966
+ * <Panelside.ItemLabel>{thread.title}</Panelside.ItemLabel>
967
+ * <Panelside.ItemBadge>{thread.unread}</Panelside.ItemBadge>
968
+ * <Panelside.Action onPress={rename} />
969
+ * </Panelside.Item>
970
+ * ```
971
+ */
915
972
  function PanelsideItem({
916
973
  className,
917
974
  icon,
@@ -934,46 +991,122 @@ function PanelsideItem({
934
991
  ? restTint
935
992
  : undefined;
936
993
 
994
+ const context = useMemo<PanelsideItemContextValue>(
995
+ () => ({ active, disabled, size, tint }),
996
+ [active, disabled, size, tint]
997
+ );
998
+
999
+ /*
1000
+ * The row needs something flexible in the middle or its trailing content
1001
+ * floats next to the icon instead of sitting at the end. A label supplies
1002
+ * that, and a written-out `Panelside.ItemLabel` supplies it too — so the
1003
+ * spacer is only for the row that has neither, which is a row of nothing but
1004
+ * an icon and a badge.
1005
+ */
1006
+ const filled = label !== undefined || children !== undefined;
1007
+
937
1008
  return (
938
- <AnimatedPressable
939
- className={itemVariants({ active, disabled, size, className })}
940
- disabled={disabled}
941
- accessibilityRole="button"
942
- accessibilityState={{ selected: active, disabled }}
943
- accessibilityLabel={label}
944
- pressScale={0.985}
1009
+ <PanelsideItemContext.Provider value={context}>
1010
+ <AnimatedPressable
1011
+ className={itemVariants({ active, disabled, size, className })}
1012
+ disabled={disabled}
1013
+ accessibilityRole="button"
1014
+ accessibilityState={{ selected: active, disabled }}
1015
+ accessibilityLabel={label}
1016
+ pressScale={0.985}
1017
+ {...props}
1018
+ >
1019
+ {icon ? <PanelsideItemIcon>{icon}</PanelsideItemIcon> : null}
1020
+
1021
+ {label !== undefined ? <PanelsideItemLabel>{label}</PanelsideItemLabel> : null}
1022
+
1023
+ {filled ? null : <View className="flex-1" />}
1024
+
1025
+ {badge !== undefined && badge !== null ? (
1026
+ <PanelsideItemBadge>{badge}</PanelsideItemBadge>
1027
+ ) : null}
1028
+
1029
+ {children}
1030
+ </AnimatedPressable>
1031
+ </PanelsideItemContext.Provider>
1032
+ );
1033
+ }
1034
+
1035
+ export interface PanelsideItemIconProps extends ViewProps {
1036
+ className?: string;
1037
+ children?: ReactNode;
1038
+ }
1039
+
1040
+ /**
1041
+ * The leading slot on a row.
1042
+ *
1043
+ * Whatever is inside it inherits the row's own tint rather than each call site
1044
+ * passing a colour that stops being right the moment the row goes active.
1045
+ */
1046
+ function PanelsideItemIcon({ className, children, ...props }: PanelsideItemIconProps) {
1047
+ const { tint } = usePanelsideItem('Panelside.ItemIcon');
1048
+
1049
+ // No wrapper unless one was asked for: an icon is already the right size,
1050
+ // and a `View` around it is a layout node between the row and its glyph.
1051
+ if (className === undefined && Object.keys(props).length === 0) {
1052
+ return <IconColorProvider color={tint}>{children}</IconColorProvider>;
1053
+ }
1054
+
1055
+ return (
1056
+ <View className={className} {...props}>
1057
+ <IconColorProvider color={tint}>{children}</IconColorProvider>
1058
+ </View>
1059
+ );
1060
+ }
1061
+
1062
+ export interface PanelsideItemLabelProps extends TextProps {
1063
+ className?: string;
1064
+ children?: ReactNode;
1065
+ }
1066
+
1067
+ /**
1068
+ * The row's text. It takes the flexible middle, so a long title truncates
1069
+ * rather than pushing the badge and the action off the end of the panel.
1070
+ */
1071
+ function PanelsideItemLabel({ className, children, ...props }: PanelsideItemLabelProps) {
1072
+ const { active } = usePanelsideItem('Panelside.ItemLabel');
1073
+
1074
+ return (
1075
+ <Text
1076
+ size="base"
1077
+ weight={active ? 'medium' : 'normal'}
1078
+ muted={!active}
1079
+ numberOfLines={1}
1080
+ className={cn('flex-1', className)}
945
1081
  {...props}
946
1082
  >
947
- {/* Icons inherit the row's state rather than each caller passing a
948
- colour that stops being right the moment the row goes active. */}
949
- {icon ? <IconColorProvider color={tint}>{icon}</IconColorProvider> : null}
950
-
951
- {label ? (
952
- <Text
953
- size="base"
954
- weight={active ? 'medium' : 'normal'}
955
- muted={!active}
956
- numberOfLines={1}
957
- className="flex-1"
958
- >
959
- {label}
960
- </Text>
961
- ) : (
962
- <View className="flex-1" />
963
- )}
1083
+ {children}
1084
+ </Text>
1085
+ );
1086
+ }
964
1087
 
965
- {typeof badge === 'string' || typeof badge === 'number' ? (
966
- <View className="rounded-full bg-secondary px-2 py-0.5">
967
- <Text size="xs" muted>
968
- {badge}
969
- </Text>
970
- </View>
971
- ) : (
972
- badge
973
- )}
1088
+ export interface PanelsideItemBadgeProps extends ViewProps {
1089
+ className?: string;
1090
+ children?: ReactNode;
1091
+ }
974
1092
 
975
- {children}
976
- </AnimatedPressable>
1093
+ /**
1094
+ * The trailing count or status on a row. Text becomes a pill; anything else is
1095
+ * drawn as given, so a dot or a chip needs no opting out of the pill.
1096
+ */
1097
+ function PanelsideItemBadge({ className, children, ...props }: PanelsideItemBadgeProps) {
1098
+ usePanelsideItem('Panelside.ItemBadge');
1099
+
1100
+ if (typeof children !== 'string' && typeof children !== 'number') {
1101
+ return <>{children}</>;
1102
+ }
1103
+
1104
+ return (
1105
+ <View className={cn('rounded-full bg-secondary px-2 py-0.5', className)} {...props}>
1106
+ <Text size="xs" muted>
1107
+ {children}
1108
+ </Text>
1109
+ </View>
977
1110
  );
978
1111
  }
979
1112
 
@@ -1456,6 +1589,9 @@ PanelsideContent.displayName = 'Panelside.Content';
1456
1589
  PanelsideGroup.displayName = 'Panelside.Group';
1457
1590
  PanelsideGroupLabel.displayName = 'Panelside.GroupLabel';
1458
1591
  PanelsideItem.displayName = 'Panelside.Item';
1592
+ PanelsideItemIcon.displayName = 'Panelside.ItemIcon';
1593
+ PanelsideItemLabel.displayName = 'Panelside.ItemLabel';
1594
+ PanelsideItemBadge.displayName = 'Panelside.ItemBadge';
1459
1595
  PanelsideAction.displayName = 'Panelside.Action';
1460
1596
  PanelsideFooter.displayName = 'Panelside.Footer';
1461
1597
  PanelsideCta.displayName = 'Panelside.Cta';
@@ -1470,6 +1606,9 @@ export const Panelside = Object.assign(PanelsideRoot, {
1470
1606
  Group: PanelsideGroup,
1471
1607
  GroupLabel: PanelsideGroupLabel,
1472
1608
  Item: PanelsideItem,
1609
+ ItemIcon: PanelsideItemIcon,
1610
+ ItemLabel: PanelsideItemLabel,
1611
+ ItemBadge: PanelsideItemBadge,
1473
1612
  Action: PanelsideAction,
1474
1613
  Footer: PanelsideFooter,
1475
1614
  Cta: PanelsideCta,