panelui-native 0.78.0 → 0.79.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/lib/module/components/accordion/index.js +13 -1
  2. package/lib/module/components/accordion/index.js.map +1 -1
  3. package/lib/module/components/map/index.js +63 -39
  4. package/lib/module/components/map/index.js.map +1 -1
  5. package/lib/module/components/map/map-children.js +54 -0
  6. package/lib/module/components/map/map-children.js.map +1 -0
  7. package/lib/module/components/map/maplibre.js +31 -3
  8. package/lib/module/components/map/maplibre.js.map +1 -1
  9. package/lib/module/components/markdown-editor/index.js +288 -34
  10. package/lib/module/components/markdown-editor/index.js.map +1 -1
  11. package/lib/module/components/markdown-editor/markdown-transforms.js +272 -9
  12. package/lib/module/components/markdown-editor/markdown-transforms.js.map +1 -1
  13. package/lib/module/components/marquee/index.js +119 -46
  14. package/lib/module/components/marquee/index.js.map +1 -1
  15. package/lib/module/components/panelside/index.js +162 -34
  16. package/lib/module/components/panelside/index.js.map +1 -1
  17. package/lib/module/components/progress-button/index.js +279 -52
  18. package/lib/module/components/progress-button/index.js.map +1 -1
  19. package/lib/module/components/progress-button/progress-button-hold.js +40 -6
  20. package/lib/module/components/progress-button/progress-button-hold.js.map +1 -1
  21. package/lib/module/components/theme-selector/index.js +239 -0
  22. package/lib/module/components/theme-selector/index.js.map +1 -0
  23. package/lib/module/components/theme-selector/theme-preview.js +320 -0
  24. package/lib/module/components/theme-selector/theme-preview.js.map +1 -0
  25. package/lib/module/index.js +1 -0
  26. package/lib/module/index.js.map +1 -1
  27. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  28. package/lib/typescript/src/components/map/index.d.ts +8 -4
  29. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  30. package/lib/typescript/src/components/map/map-children.d.ts +41 -0
  31. package/lib/typescript/src/components/map/map-children.d.ts.map +1 -0
  32. package/lib/typescript/src/components/map/maplibre.d.ts +8 -4
  33. package/lib/typescript/src/components/map/maplibre.d.ts.map +1 -1
  34. package/lib/typescript/src/components/markdown-editor/index.d.ts +64 -5
  35. package/lib/typescript/src/components/markdown-editor/index.d.ts.map +1 -1
  36. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts +33 -5
  37. package/lib/typescript/src/components/markdown-editor/markdown-transforms.d.ts.map +1 -1
  38. package/lib/typescript/src/components/marquee/index.d.ts.map +1 -1
  39. package/lib/typescript/src/components/panelside/index.d.ts +79 -4
  40. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  41. package/lib/typescript/src/components/progress-button/index.d.ts +29 -13
  42. package/lib/typescript/src/components/progress-button/index.d.ts.map +1 -1
  43. package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts +26 -6
  44. package/lib/typescript/src/components/progress-button/progress-button-hold.d.ts.map +1 -1
  45. package/lib/typescript/src/components/theme-selector/index.d.ts +107 -0
  46. package/lib/typescript/src/components/theme-selector/index.d.ts.map +1 -0
  47. package/lib/typescript/src/components/theme-selector/theme-preview.d.ts +32 -0
  48. package/lib/typescript/src/components/theme-selector/theme-preview.d.ts.map +1 -0
  49. package/lib/typescript/src/index.d.ts +4 -3
  50. package/lib/typescript/src/index.d.ts.map +1 -1
  51. package/package.json +1 -1
  52. package/src/components/accordion/index.tsx +13 -0
  53. package/src/components/map/index.tsx +63 -36
  54. package/src/components/map/map-children.ts +66 -0
  55. package/src/components/map/maplibre.ts +32 -4
  56. package/src/components/markdown-editor/index.tsx +413 -35
  57. package/src/components/markdown-editor/markdown-transforms.ts +274 -10
  58. package/src/components/marquee/index.tsx +128 -55
  59. package/src/components/panelside/index.tsx +179 -40
  60. package/src/components/progress-button/index.tsx +264 -45
  61. package/src/components/progress-button/progress-button-hold.ts +37 -6
  62. package/src/components/theme-selector/index.tsx +281 -0
  63. package/src/components/theme-selector/theme-preview.tsx +205 -0
  64. package/src/index.ts +15 -0
@@ -81,6 +81,23 @@ export function toggleWrap(
81
81
  };
82
82
  }
83
83
 
84
+ /*
85
+ * A selection that crosses a line break is wrapped line by line.
86
+ *
87
+ * `**one\ntwo**` is not bold in any reader — an inline marker cannot span a
88
+ * paragraph break — so wrapping the whole span produces asterisks the writer
89
+ * can see and no emphasis they asked for. Each line gets its own pair, and
90
+ * blank lines are left alone rather than given an empty pair of markers.
91
+ */
92
+ if (selected.includes('\n')) {
93
+ const wrapped = selected
94
+ .split('\n')
95
+ .map((line) => (line.trim() === '' ? line : marker + line + marker))
96
+ .join('\n');
97
+ const next = text.slice(0, start) + wrapped + text.slice(end);
98
+ return { text: next, selection: { start, end: start + wrapped.length } };
99
+ }
100
+
84
101
  const next = text.slice(0, start) + marker + selected + marker + text.slice(end);
85
102
 
86
103
  // Nothing selected: the caret goes between the markers, which is where the
@@ -121,6 +138,60 @@ function replaceBlock(
121
138
  };
122
139
  }
123
140
 
141
+ /**
142
+ * The same replacement, with the caret left where the writer's was.
143
+ *
144
+ * A line-level action applies to whole lines, but the writer's caret was in the
145
+ * middle of one of them, and handing back the whole block selected means the
146
+ * next thing they type replaces the list they just made. So the caret is put
147
+ * back at the same distance from the start of the block, shifted by however
148
+ * much the text before it grew.
149
+ */
150
+ function replaceBlockKeepingCaret(
151
+ text: string,
152
+ range: EditorSelection,
153
+ replacement: string,
154
+ selection: EditorSelection
155
+ ): EditResult {
156
+ const next = text.slice(0, range.start) + replacement + text.slice(range.end);
157
+ const { start, end } = normalise(text, selection);
158
+
159
+ // A real selection is kept as a selection over the same lines; only a caret
160
+ // is followed precisely.
161
+ if (start !== end) {
162
+ return { text: next, selection: { start: range.start, end: range.start + replacement.length } };
163
+ }
164
+
165
+ const before = text.slice(range.start, start);
166
+ const line = before.split('\n').length - 1;
167
+ const column = before.length - (before.lastIndexOf('\n') + 1);
168
+
169
+ const oldLines = text.slice(range.start, range.end).split('\n');
170
+ const newLines = replacement.split('\n');
171
+ const index = Math.min(line, newLines.length - 1);
172
+ const oldLine = oldLines[Math.min(line, oldLines.length - 1)] ?? '';
173
+ const newLine = newLines[index] ?? '';
174
+
175
+ /*
176
+ * The caret keeps its distance from the *end* of its line, not from the
177
+ * start of it.
178
+ *
179
+ * These actions change what is in front of the text: a caret held at the
180
+ * same column ends up inside the bullet it just gained, and after removing
181
+ * one it ends up two characters into the word. The distance to the end of
182
+ * the line is the thing a prefix cannot move.
183
+ */
184
+ const fromEnd = Math.max(0, oldLine.length - column);
185
+ const nextColumn = Math.max(0, newLine.length - fromEnd);
186
+
187
+ const offsetToLine = newLines
188
+ .slice(0, index)
189
+ .reduce((total, each) => total + each.length + 1, 0);
190
+
191
+ const at = range.start + offsetToLine + Math.min(nextColumn, newLine.length);
192
+ return { text: next, selection: { start: at, end: at } };
193
+ }
194
+
124
195
  /**
125
196
  * Puts `prefix` on the front of every line the selection touches, or takes it
126
197
  * off if every one of them already has it.
@@ -140,7 +211,7 @@ export function toggleLinePrefix(
140
211
  ? lines.map((line) => line.slice(prefix.length))
141
212
  : lines.map((line) => (has(line) ? line : prefix + line));
142
213
 
143
- return replaceBlock(text, range, next.join('\n'));
214
+ return replaceBlockKeepingCaret(text, range, next.join('\n'), selection);
144
215
  }
145
216
 
146
217
  /** How an ordered-list line starts, so it can be recognised and removed. */
@@ -164,7 +235,7 @@ export function toggleOrderedList(
164
235
  ? lines.map((line) => line.replace(ORDERED, ''))
165
236
  : lines.map((line, i) => `${i + 1}. ${line.replace(ORDERED, '')}`);
166
237
 
167
- return replaceBlock(text, range, next.join('\n'));
238
+ return replaceBlockKeepingCaret(text, range, next.join('\n'), selection);
168
239
  }
169
240
 
170
241
  /* -------------------------------------------------------------------------- *
@@ -208,21 +279,214 @@ export function insertLink(text: string, selection: EditorSelection): EditResult
208
279
  * makes room for itself: a blank line before it when it is not already at the
209
280
  * start of a paragraph, and one after it at the end.
210
281
  */
282
+ /**
283
+ * The whole fenced block the selection is inside, or `null`.
284
+ *
285
+ * Scanned from the top of the document rather than read off the lines the
286
+ * selection touches, because a caret sitting on a line of code touches one
287
+ * line and that line says nothing about the fence around it. Without this,
288
+ * pressing the code button inside a fence wrapped it in a second one.
289
+ */
290
+ function enclosingFence(
291
+ text: string,
292
+ selection: EditorSelection
293
+ ): EditorSelection | null {
294
+ const { start, end } = normalise(text, selection);
295
+ let offset = 0;
296
+ let openedAt: number | null = null;
297
+
298
+ for (const line of text.split('\n')) {
299
+ const lineStart = offset;
300
+ const lineEnd = offset + line.length;
301
+
302
+ if (line.startsWith('```')) {
303
+ if (openedAt === null) openedAt = lineStart;
304
+ else {
305
+ if (start >= openedAt && end <= lineEnd) return { start: openedAt, end: lineEnd };
306
+ openedAt = null;
307
+ }
308
+ }
309
+
310
+ offset = lineEnd + 1;
311
+ }
312
+
313
+ return null;
314
+ }
315
+
211
316
  export function toggleFence(text: string, selection: EditorSelection): EditResult {
317
+ const enclosing = enclosingFence(text, selection);
318
+ if (enclosing) {
319
+ const inner = text
320
+ .slice(enclosing.start, enclosing.end)
321
+ .split('\n')
322
+ .slice(1, -1)
323
+ .join('\n');
324
+ return replaceBlock(text, enclosing, inner);
325
+ }
326
+
212
327
  const range = lineRange(text, selection);
213
328
  const block = text.slice(range.start, range.end);
214
- const lines = block.split('\n');
215
-
216
- if (lines.length >= 2 && lines[0]?.startsWith('```') && lines.at(-1) === '```') {
217
- return replaceBlock(text, range, lines.slice(1, -1).join('\n'));
218
- }
219
329
 
220
330
  const leadIn = range.start > 0 && text[range.start - 1] !== '\n' ? '\n' : '';
221
- const fenced = `${leadIn}\`\`\`\n${block}\n\`\`\``;
331
+ // And a blank line after it, unless the fence already ends the document or
332
+ // the line below it is blank. A paragraph butted up against a closing fence
333
+ // is a paragraph some readers swallow into the code block.
334
+ const rest = text.slice(range.end);
335
+ const leadOut = rest === '' || rest.startsWith('\n\n') ? '' : '\n';
336
+
337
+ const opener = `${leadIn}\`\`\`\n`;
338
+ const fenced = `${opener}${block}\n\`\`\`${leadOut}`;
222
339
  const result = replaceBlock(text, range, fenced);
223
340
 
224
341
  // Inside the fence, on the line the code goes on — not around the whole
225
- // block, which would be selecting the markers along with the content.
226
- const inner = range.start + leadIn.length + 4;
342
+ // block, which would be selecting the markers along with the content. The
343
+ // offset is the opener's own length rather than a written-down number, so it
344
+ // cannot drift from the string above it.
345
+ const inner = range.start + opener.length;
227
346
  return { text: result.text, selection: { start: inner, end: inner + block.length } };
228
347
  }
348
+
349
+ /* -------------------------------------------------------------------------- *
350
+ * Images
351
+ * -------------------------------------------------------------------------- */
352
+
353
+ /** Placeholder left where an image's description goes. */
354
+ export const IMAGE_PLACEHOLDER = 'description';
355
+
356
+ /**
357
+ * Inserts an image, and selects the part that still needs filling in.
358
+ *
359
+ * The description first, not the URL. An image with no alt text is the failure
360
+ * that actually happens, and the writer is far more likely to have the URL on
361
+ * the clipboard than the sentence describing what is in it — so the sentence is
362
+ * what the caret is left on.
363
+ */
364
+ export function insertImage(text: string, selection: EditorSelection): EditResult {
365
+ const { start, end } = normalise(text, selection);
366
+ const label = text.slice(start, end);
367
+ const alt = label || IMAGE_PLACEHOLDER;
368
+ const inserted = `![${alt}](${LINK_PLACEHOLDER})`;
369
+ const next = text.slice(0, start) + inserted + text.slice(end);
370
+
371
+ // With a description already written, what is missing is the target.
372
+ if (label) {
373
+ const from = start + label.length + 4; // `![` + label + `](`
374
+ return { text: next, selection: { start: from, end: from + LINK_PLACEHOLDER.length } };
375
+ }
376
+
377
+ const from = start + 2; // `![`
378
+ return { text: next, selection: { start: from, end: from + alt.length } };
379
+ }
380
+
381
+ /* -------------------------------------------------------------------------- *
382
+ * Continuing a list
383
+ * -------------------------------------------------------------------------- */
384
+
385
+ /** A bullet line, and how much of it is the marker. */
386
+ const BULLET = /^(\s*)([-*+])(\s+)(.*)$/;
387
+ /** A numbered line, split the same way. */
388
+ const NUMBERED = /^(\s*)(\d+)\.(\s+)(.*)$/;
389
+
390
+ /**
391
+ * What Return should do inside a list.
392
+ *
393
+ * Returns `null` when the caret is not on a list line, which is the signal to
394
+ * let the newline happen normally. Otherwise it either starts the next item or,
395
+ * on an item with nothing in it, ends the list — because a writer pressing
396
+ * Return on an empty bullet is telling you they have run out of items, and the
397
+ * alternative is a trail of empty bullets they have to delete by hand.
398
+ */
399
+ export function continueList(
400
+ text: string,
401
+ selection: EditorSelection
402
+ ): EditResult | null {
403
+ const { start, end } = normalise(text, selection);
404
+ // Only a caret continues a list. With a range selected, Return replaces it,
405
+ // and guessing which end of it the writer meant is worse than not guessing.
406
+ if (start !== end) return null;
407
+
408
+ const lineStart = text.lastIndexOf('\n', start - 1) + 1;
409
+ const line = text.slice(lineStart, start);
410
+
411
+ const bullet = BULLET.exec(line);
412
+ const numbered = bullet ? null : NUMBERED.exec(line);
413
+ if (!bullet && !numbered) return null;
414
+
415
+ const [, indent = '', marker = '', gap = ' ', content = ''] = (bullet ?? numbered)!;
416
+
417
+ // An item with nothing in it ends the list: the line becomes empty and the
418
+ // caret stays on it, ready for an ordinary paragraph.
419
+ if (content.trim() === '') {
420
+ const next = text.slice(0, lineStart) + text.slice(start);
421
+ return { text: next, selection: { start: lineStart, end: lineStart } };
422
+ }
423
+
424
+ const opener = numbered
425
+ ? `${indent}${Number(marker) + 1}.${gap}`
426
+ : `${indent}${marker}${gap}`;
427
+ const inserted = `\n${opener}`;
428
+ const next = text.slice(0, start) + inserted + text.slice(start);
429
+ const at = start + inserted.length;
430
+ return { text: next, selection: { start: at, end: at } };
431
+ }
432
+
433
+ /* -------------------------------------------------------------------------- *
434
+ * What is already applied
435
+ * -------------------------------------------------------------------------- */
436
+
437
+ /**
438
+ * Whether an inline marker is already around the caret or the selection.
439
+ *
440
+ * Both arrangements count, for the same reason `toggleWrap` accepts both: the
441
+ * writer may have selected `**bold**` or selected `bold` inside it, and with
442
+ * nothing selected at all the caret may simply be sitting between the markers.
443
+ */
444
+ export function hasWrap(
445
+ text: string,
446
+ selection: EditorSelection,
447
+ marker: string
448
+ ): boolean {
449
+ const { start, end } = normalise(text, selection);
450
+ const selected = text.slice(start, end);
451
+ const len = marker.length;
452
+
453
+ if (
454
+ selected.length >= len * 2 &&
455
+ selected.startsWith(marker) &&
456
+ selected.endsWith(marker)
457
+ ) {
458
+ return true;
459
+ }
460
+
461
+ return (
462
+ text.slice(Math.max(0, start - len), start) === marker &&
463
+ text.slice(end, end + len) === marker
464
+ );
465
+ }
466
+
467
+ /** Whether every line the selection touches already starts with `prefix`. */
468
+ export function hasLinePrefix(
469
+ text: string,
470
+ selection: EditorSelection,
471
+ prefix: string
472
+ ): boolean {
473
+ const range = lineRange(text, selection);
474
+ return text
475
+ .slice(range.start, range.end)
476
+ .split('\n')
477
+ .every((line) => line.startsWith(prefix));
478
+ }
479
+
480
+ /** Whether every line the selection touches is already numbered. */
481
+ export function hasOrderedList(text: string, selection: EditorSelection): boolean {
482
+ const range = lineRange(text, selection);
483
+ return text
484
+ .slice(range.start, range.end)
485
+ .split('\n')
486
+ .every((line) => ORDERED.test(line));
487
+ }
488
+
489
+ /** Whether the selection is inside a fence. */
490
+ export function hasFence(text: string, selection: EditorSelection): boolean {
491
+ return enclosingFence(text, selection) !== null;
492
+ }
@@ -46,8 +46,10 @@
46
46
  */
47
47
  import {
48
48
  createContext,
49
+ useCallback,
49
50
  useContext,
50
51
  useEffect,
52
+ useId,
51
53
  useMemo,
52
54
  useState,
53
55
  type ComponentType,
@@ -69,6 +71,7 @@ import Animated, {
69
71
  useReducedMotion,
70
72
  useSharedValue,
71
73
  withRepeat,
74
+ withSequence,
72
75
  withTiming,
73
76
  } from 'react-native-reanimated';
74
77
  import { useDirectionSign } from '../../hooks/use-direction';
@@ -130,6 +133,15 @@ export interface MarqueeProps extends Omit<ViewProps, 'children'> {
130
133
  interface MarqueeGroupContextValue {
131
134
  /** True while the group's control is holding everything in it still. */
132
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;
133
145
  }
134
146
 
135
147
  const MarqueeGroupContext = createContext<MarqueeGroupContextValue | null>(null);
@@ -185,18 +197,43 @@ function MarqueeRoot({
185
197
 
186
198
  const offset = useSharedValue(0);
187
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
+ */
188
205
  useEffect(() => {
189
206
  cancelAnimation(offset);
190
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);
191
222
  if (reducedMotion || !moving || period <= 0 || speed <= 0) return undefined;
192
- offset.value = withRepeat(
193
- withTiming(period, {
194
- duration: (period / speed) * 1000,
195
- easing: Easing.linear,
196
- }),
197
- -1,
198
- 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)
199
235
  );
236
+
200
237
  return () => cancelAnimation(offset);
201
238
  }, [offset, period, moving, reducedMotion, speed]);
202
239
 
@@ -220,6 +257,24 @@ function MarqueeRoot({
220
257
  setContent(horizontal ? width : height);
221
258
  };
222
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
+
223
278
  // Nothing to loop, so nothing to clip or clone: render the content plainly
224
279
  // and let it sit where it falls.
225
280
  if (reducedMotion) {
@@ -236,56 +291,61 @@ function MarqueeRoot({
236
291
  }
237
292
 
238
293
  return (
239
- <View
240
- className={cn('overflow-hidden', className)}
241
- style={style}
242
- onLayout={onContainerLayout}
243
- {...props}
244
- >
245
- {/* Measured, never seen. A scroller on this axis is what frees the
246
- content to report its own size instead of the container's. */}
247
- <ScrollView
248
- horizontal={horizontal}
249
- scrollEnabled={false}
250
- style={styles.measure}
251
- pointerEvents="none"
252
- accessibilityElementsHidden
253
- importantForAccessibility="no-hide-descendants"
254
- >
255
- <InertView
256
- inert={Platform.OS === 'web' ? true : undefined}
257
- 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"
258
307
  >
259
- {children}
260
- </InertView>
261
- </ScrollView>
262
-
263
- <Animated.View style={[StyleSheet.absoluteFill, trackStyle]} pointerEvents="box-none">
264
- {copies.map((index) => {
265
- // Index 1 is the copy that starts flush with the container's edge,
266
- // and it is the one a screen reader is given.
267
- const spoken = index === 1;
268
- const at = (index - 1) * period;
269
- return (
270
- <InertView
271
- key={index}
272
- style={[styles.copy, horizontal ? { left: at } : { top: at }]}
273
- pointerEvents={spoken ? 'box-none' : 'none'}
274
- inert={Platform.OS === 'web' && !spoken ? true : undefined}
275
- aria-hidden={!spoken}
276
- accessibilityElementsHidden={!spoken}
277
- importantForAccessibility={spoken ? 'auto' : 'no-hide-descendants'}
278
- >
279
- {children}
280
- </InertView>
281
- );
282
- })}
283
- </Animated.View>
284
- {showControl && 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 ? (
285
339
  <Pressable
286
340
  accessibilityRole="button"
287
341
  accessibilityLabel={userPaused ? playLabel : pauseLabel}
288
- 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"
289
349
  onPress={() => {
290
350
  const next = !userPaused;
291
351
  setUserPaused(next);
@@ -349,14 +409,27 @@ function MarqueeGroup({
349
409
  ...props
350
410
  }: MarqueeGroupProps) {
351
411
  const [userPaused, setUserPaused] = useState(false);
412
+ const [liveRows, setLiveRows] = useState<ReadonlySet<string>>(() => new Set());
352
413
  const reducedMotion = useReducedMotion();
353
414
 
354
- const value = useMemo(() => ({ paused: userPaused }), [userPaused]);
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]);
355
428
 
356
429
  // Nothing is moving, so there is nothing to stop. The control is not hidden
357
430
  // to save space — offering to pause content that is already still is the
358
431
  // same lie as a disabled button with no reason on it.
359
- const showControl = showPauseControl && playing && !reducedMotion;
432
+ const showControl = showPauseControl && playing && !reducedMotion && liveRows.size > 0;
360
433
 
361
434
  return (
362
435
  <MarqueeGroupContext.Provider value={value}>