panelui-native 0.75.1 → 0.76.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 (151) hide show
  1. package/lib/module/components/ai-input/ai-input-growth.js +62 -0
  2. package/lib/module/components/ai-input/ai-input-growth.js.map +1 -0
  3. package/lib/module/components/ai-input/index.js +1568 -0
  4. package/lib/module/components/ai-input/index.js.map +1 -0
  5. package/lib/module/components/alert/index.js +1 -1
  6. package/lib/module/components/alert/index.js.map +1 -1
  7. package/lib/module/components/avatar/avatar-source.js +15 -0
  8. package/lib/module/components/avatar/avatar-source.js.map +1 -0
  9. package/lib/module/components/avatar/index.js +32 -31
  10. package/lib/module/components/avatar/index.js.map +1 -1
  11. package/lib/module/components/bottom-sheet/index.js +3 -2
  12. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  13. package/lib/module/components/button-group/index.js +2 -3
  14. package/lib/module/components/button-group/index.js.map +1 -1
  15. package/lib/module/components/carousel/index.js +10 -7
  16. package/lib/module/components/carousel/index.js.map +1 -1
  17. package/lib/module/components/collapsible/index.js +1 -1
  18. package/lib/module/components/collapsible/index.js.map +1 -1
  19. package/lib/module/components/field/index.js +7 -8
  20. package/lib/module/components/field/index.js.map +1 -1
  21. package/lib/module/components/label/index.js +1 -1
  22. package/lib/module/components/label/index.js.map +1 -1
  23. package/lib/module/components/marquee/index.js +36 -11
  24. package/lib/module/components/marquee/index.js.map +1 -1
  25. package/lib/module/components/marquee/marquee-math.js +22 -6
  26. package/lib/module/components/marquee/marquee-math.js.map +1 -1
  27. package/lib/module/components/plan/index.js +2 -1
  28. package/lib/module/components/plan/index.js.map +1 -1
  29. package/lib/module/components/plot/index.js +27 -9
  30. package/lib/module/components/plot/index.js.map +1 -1
  31. package/lib/module/components/search-bar/index.js +11 -3
  32. package/lib/module/components/search-bar/index.js.map +1 -1
  33. package/lib/module/components/split-view/index.js +26 -27
  34. package/lib/module/components/split-view/index.js.map +1 -1
  35. package/lib/module/components/split-view/split-view-lifecycle.js +32 -0
  36. package/lib/module/components/split-view/split-view-lifecycle.js.map +1 -0
  37. package/lib/module/components/splitter/index.js +7 -13
  38. package/lib/module/components/splitter/index.js.map +1 -1
  39. package/lib/module/components/splitter/splitter-math.js +14 -0
  40. package/lib/module/components/splitter/splitter-math.js.map +1 -1
  41. package/lib/module/components/tabs/index.js +1 -1
  42. package/lib/module/components/tabs/index.js.map +1 -1
  43. package/lib/module/components/timeline/index.js +156 -42
  44. package/lib/module/components/timeline/index.js.map +1 -1
  45. package/lib/module/components/timeline/timeline-geometry.js +11 -0
  46. package/lib/module/components/timeline/timeline-geometry.js.map +1 -1
  47. package/lib/module/components/toast/index.js +1 -1
  48. package/lib/module/components/toast/index.js.map +1 -1
  49. package/lib/module/components/tooltip/index.js +2 -2
  50. package/lib/module/components/tooltip/index.js.map +1 -1
  51. package/lib/module/components/tree/index.js +2 -2
  52. package/lib/module/components/tree/index.js.map +1 -1
  53. package/lib/module/components/typography/index.js +2 -1
  54. package/lib/module/components/typography/index.js.map +1 -1
  55. package/lib/module/components/waterfall-chart/index.js +22 -3
  56. package/lib/module/components/waterfall-chart/index.js.map +1 -1
  57. package/lib/module/hooks/use-disclosure.js +10 -8
  58. package/lib/module/hooks/use-disclosure.js.map +1 -1
  59. package/lib/module/icons/index.js +135 -0
  60. package/lib/module/icons/index.js.map +1 -1
  61. package/lib/module/index.js +5 -1
  62. package/lib/module/index.js.map +1 -1
  63. package/lib/module/primitives/controllable-state.js +61 -0
  64. package/lib/module/primitives/controllable-state.js.map +1 -0
  65. package/lib/module/primitives/finite-chart.js +15 -0
  66. package/lib/module/primitives/finite-chart.js.map +1 -0
  67. package/lib/module/primitives/glass.js +136 -0
  68. package/lib/module/primitives/glass.js.map +1 -0
  69. package/lib/module/primitives/scrim.js +8 -1
  70. package/lib/module/primitives/scrim.js.map +1 -1
  71. package/lib/typescript/src/components/ai-input/ai-input-growth.d.ts +59 -0
  72. package/lib/typescript/src/components/ai-input/ai-input-growth.d.ts.map +1 -0
  73. package/lib/typescript/src/components/ai-input/index.d.ts +423 -0
  74. package/lib/typescript/src/components/ai-input/index.d.ts.map +1 -0
  75. package/lib/typescript/src/components/avatar/avatar-source.d.ts +4 -0
  76. package/lib/typescript/src/components/avatar/avatar-source.d.ts.map +1 -0
  77. package/lib/typescript/src/components/avatar/index.d.ts.map +1 -1
  78. package/lib/typescript/src/components/bottom-sheet/index.d.ts +10 -1
  79. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  80. package/lib/typescript/src/components/carousel/index.d.ts.map +1 -1
  81. package/lib/typescript/src/components/marquee/index.d.ts +15 -6
  82. package/lib/typescript/src/components/marquee/index.d.ts.map +1 -1
  83. package/lib/typescript/src/components/marquee/marquee-math.d.ts +6 -0
  84. package/lib/typescript/src/components/marquee/marquee-math.d.ts.map +1 -1
  85. package/lib/typescript/src/components/plan/index.d.ts.map +1 -1
  86. package/lib/typescript/src/components/plot/index.d.ts +2 -1
  87. package/lib/typescript/src/components/plot/index.d.ts.map +1 -1
  88. package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -1
  89. package/lib/typescript/src/components/split-view/index.d.ts.map +1 -1
  90. package/lib/typescript/src/components/split-view/split-view-lifecycle.d.ts +14 -0
  91. package/lib/typescript/src/components/split-view/split-view-lifecycle.d.ts.map +1 -0
  92. package/lib/typescript/src/components/splitter/index.d.ts +2 -2
  93. package/lib/typescript/src/components/splitter/index.d.ts.map +1 -1
  94. package/lib/typescript/src/components/splitter/splitter-math.d.ts +2 -0
  95. package/lib/typescript/src/components/splitter/splitter-math.d.ts.map +1 -1
  96. package/lib/typescript/src/components/timeline/index.d.ts +24 -2
  97. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  98. package/lib/typescript/src/components/timeline/timeline-geometry.d.ts +2 -0
  99. package/lib/typescript/src/components/timeline/timeline-geometry.d.ts.map +1 -1
  100. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  101. package/lib/typescript/src/components/waterfall-chart/index.d.ts +2 -1
  102. package/lib/typescript/src/components/waterfall-chart/index.d.ts.map +1 -1
  103. package/lib/typescript/src/hooks/use-disclosure.d.ts.map +1 -1
  104. package/lib/typescript/src/icons/index.d.ts +18 -0
  105. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  106. package/lib/typescript/src/index.d.ts +3 -1
  107. package/lib/typescript/src/index.d.ts.map +1 -1
  108. package/lib/typescript/src/primitives/controllable-state.d.ts +33 -0
  109. package/lib/typescript/src/primitives/controllable-state.d.ts.map +1 -0
  110. package/lib/typescript/src/primitives/finite-chart.d.ts +5 -0
  111. package/lib/typescript/src/primitives/finite-chart.d.ts.map +1 -0
  112. package/lib/typescript/src/primitives/glass.d.ts +43 -0
  113. package/lib/typescript/src/primitives/glass.d.ts.map +1 -0
  114. package/lib/typescript/src/primitives/scrim.d.ts +7 -0
  115. package/lib/typescript/src/primitives/scrim.d.ts.map +1 -1
  116. package/package.json +7 -2
  117. package/src/components/ai-input/ai-input-growth.ts +65 -0
  118. package/src/components/ai-input/index.tsx +1937 -0
  119. package/src/components/alert/index.tsx +1 -1
  120. package/src/components/avatar/avatar-source.ts +18 -0
  121. package/src/components/avatar/index.tsx +35 -27
  122. package/src/components/bottom-sheet/index.tsx +15 -3
  123. package/src/components/button-group/index.tsx +1 -1
  124. package/src/components/carousel/index.tsx +10 -11
  125. package/src/components/collapsible/index.tsx +1 -1
  126. package/src/components/field/index.tsx +4 -4
  127. package/src/components/label/index.tsx +1 -1
  128. package/src/components/marquee/index.tsx +56 -12
  129. package/src/components/marquee/marquee-math.ts +26 -3
  130. package/src/components/plan/index.tsx +6 -2
  131. package/src/components/plot/index.tsx +37 -11
  132. package/src/components/search-bar/index.tsx +9 -3
  133. package/src/components/split-view/index.tsx +24 -33
  134. package/src/components/split-view/split-view-lifecycle.ts +43 -0
  135. package/src/components/splitter/index.tsx +7 -11
  136. package/src/components/splitter/splitter-math.ts +28 -0
  137. package/src/components/tabs/index.tsx +1 -1
  138. package/src/components/timeline/index.tsx +189 -34
  139. package/src/components/timeline/timeline-geometry.ts +11 -0
  140. package/src/components/toast/index.tsx +6 -1
  141. package/src/components/tooltip/index.tsx +1 -1
  142. package/src/components/tree/index.tsx +2 -2
  143. package/src/components/typography/index.tsx +2 -2
  144. package/src/components/waterfall-chart/index.tsx +37 -4
  145. package/src/hooks/use-disclosure.ts +7 -12
  146. package/src/icons/index.tsx +106 -0
  147. package/src/index.ts +27 -0
  148. package/src/primitives/controllable-state.ts +112 -0
  149. package/src/primitives/finite-chart.ts +14 -0
  150. package/src/primitives/glass.tsx +171 -0
  151. package/src/primitives/scrim.tsx +7 -1
@@ -91,9 +91,9 @@ const AlertRoot = forwardRef<View, AlertProps>(
91
91
  <AlertContext.Provider value={variant}>
92
92
  <View
93
93
  ref={ref}
94
+ {...props}
94
95
  accessibilityRole="alert"
95
96
  className={root({ className })}
96
- {...props}
97
97
  >
98
98
  {icon ? (
99
99
  <>
@@ -0,0 +1,18 @@
1
+ import type { ImageSourcePropType } from 'react-native';
2
+
3
+ function stableValue(value: unknown): string {
4
+ if (Array.isArray(value)) return `[${value.map(stableValue).join(',')}]`;
5
+ if (value && typeof value === 'object') {
6
+ return `{${Object.entries(value)
7
+ .filter(([, entry]) => entry !== undefined)
8
+ .sort(([left], [right]) => left.localeCompare(right))
9
+ .map(([key, entry]) => `${JSON.stringify(key)}:${stableValue(entry)}`)
10
+ .join(',')}}`;
11
+ }
12
+ return JSON.stringify(value) ?? String(value);
13
+ }
14
+
15
+ /** Stable across equivalent inline source objects, but changes with request inputs. */
16
+ export function avatarSourceIdentity(source: ImageSourcePropType | undefined) {
17
+ return source === undefined ? undefined : stableValue(source);
18
+ }
@@ -22,6 +22,7 @@ import {
22
22
  avatarGroupOverlap,
23
23
  type AvatarSizeName,
24
24
  } from './avatar-group';
25
+ import { avatarSourceIdentity } from './avatar-source';
25
26
 
26
27
  const avatarVariants = tv({
27
28
  slots: {
@@ -53,16 +54,20 @@ export interface AvatarProps extends ViewProps, VariantProps<typeof avatarVarian
53
54
 
54
55
  const AvatarRoot = forwardRef<View, AvatarProps>(
55
56
  ({ className, size, source, fallback, imageProps, children, ...props }, ref) => {
56
- const [errored, setErrored] = useState(false);
57
+ const [failedSource, setFailedSource] = useState<string>();
57
58
  const { root, image, fallback: fallbackSlot } = avatarVariants({ size });
58
- const showImage = !!source && !errored;
59
+ const sourceIdentity = avatarSourceIdentity(source);
60
+ const showImage = !!source && failedSource !== sourceIdentity;
59
61
 
60
62
  const face = showImage ? (
61
63
  <Image
62
- source={source}
63
- onError={() => setErrored(true)}
64
64
  className={image()}
65
65
  {...imageProps}
66
+ source={source}
67
+ onError={(event) => {
68
+ setFailedSource(sourceIdentity);
69
+ imageProps?.onError?.(event);
70
+ }}
66
71
  />
67
72
  ) : (
68
73
  <Text className={fallbackSlot()}>{fallback ?? '?'}</Text>
@@ -149,11 +154,9 @@ export interface AvatarGroupProps extends ViewProps {
149
154
  * A row of avatars, each overlapping the one after it, with the people who did
150
155
  * not fit counted at the end.
151
156
  *
152
- * The faces are drawn back to front — the first is on top — which is why the
153
- * row lays itself out in reverse: later siblings paint over earlier ones, so
154
- * the stacking order and the reading order pull against each other and one of
155
- * them has to be flipped. Flipping the layout keeps the children in the order
156
- * they were written.
157
+ * The faces stay in logical child order for assistive traversal. Explicit
158
+ * z-indices, rather than a reversed render/layout order, put the first face on
159
+ * top while the overflow count remains the final list item.
157
160
  *
158
161
  * Each face gets a ring in the page background so it separates from the one
159
162
  * underneath whatever surface the stack sits on.
@@ -172,10 +175,21 @@ const AvatarGroup = forwardRef<View, AvatarGroupProps>(
172
175
  const { visible, overflow } = avatarGroupCount(faces.length, max, total);
173
176
  const slide = avatarGroupOverlap(size, overlap);
174
177
 
175
- // Back to front: the count sits under the last face, which sits under the
176
- // one before it, and so on up to the first.
178
+ // Logical reading order: visible people first, then the overflow summary.
179
+ // Painting order is independent and applied to each wrapper below.
177
180
  const stack: { key: string; node: ReactNode }[] = [];
178
181
 
182
+ for (let index = 0; index < visible; index += 1) {
183
+ const face = faces[index]!;
184
+ stack.push({
185
+ key: String(face.key ?? index),
186
+ node: cloneElement(face, {
187
+ size: face.props.size ?? size,
188
+ className: cn('border-2 border-background', face.props.className),
189
+ }),
190
+ });
191
+ }
192
+
179
193
  if (overflow > 0) {
180
194
  stack.push({
181
195
  key: 'overflow',
@@ -190,28 +204,22 @@ const AvatarGroup = forwardRef<View, AvatarGroupProps>(
190
204
  });
191
205
  }
192
206
 
193
- for (let index = visible - 1; index >= 0; index -= 1) {
194
- const face = faces[index]!;
195
- stack.push({
196
- key: String(face.key ?? index),
197
- node: cloneElement(face, {
198
- size: face.props.size ?? size,
199
- className: cn('border-2 border-background', face.props.className),
200
- }),
201
- });
202
- }
203
-
204
207
  return (
205
208
  <View
206
209
  ref={ref}
207
- accessibilityRole="list"
208
- className={cn('flex-row-reverse self-start', className)}
210
+ className={cn('flex-row self-start', className)}
209
211
  {...props}
212
+ accessibilityRole="list"
210
213
  >
211
214
  {stack.map((entry, index) => (
212
- // The first node laid out is the trailing one, and it is the only one
213
- // with nothing to slide under.
214
- <View key={entry.key} style={index === 0 ? undefined : { marginEnd: -slide }}>
215
+ <View
216
+ key={entry.key}
217
+ role="listitem"
218
+ style={{
219
+ zIndex: stack.length - index,
220
+ marginStart: index === 0 ? 0 : -slide,
221
+ }}
222
+ >
215
223
  {entry.node}
216
224
  </View>
217
225
  ))}
@@ -224,6 +224,15 @@ export interface BottomSheetContentProps extends ViewProps {
224
224
  * affordances.
225
225
  */
226
226
  showClose?: boolean;
227
+ /**
228
+ * Show the drag handle at the top of the sheet. On by default, because a
229
+ * sheet that can be dragged should say so.
230
+ *
231
+ * Turn it off when the sheet draws its own — a component wrapping this one
232
+ * to give the surface a material of its own has to put the handle on that
233
+ * material, and a handle floating above it belongs to nothing.
234
+ */
235
+ showGrabber?: boolean;
227
236
  /**
228
237
  * Float the sheet clear of the screen edges instead of docking it to the
229
238
  * bottom, so it reads as a card laid over the app rather than a drawer
@@ -267,6 +276,7 @@ function BottomSheetContent({
267
276
  className,
268
277
  dismissible = true,
269
278
  showClose = true,
279
+ showGrabber = true,
270
280
  detached = false,
271
281
  blur = false,
272
282
  size = 'auto',
@@ -475,9 +485,11 @@ function BottomSheetContent({
475
485
  ]}
476
486
  >
477
487
  <SheetSurfaceContext.Provider value={surface}>
478
- <View className="mb-3 self-center">
479
- <View className="h-1 w-10 rounded-full bg-muted-foreground/30" />
480
- </View>
488
+ {showGrabber ? (
489
+ <View className="mb-3 self-center">
490
+ <View className="h-1 w-10 rounded-full bg-muted-foreground/30" />
491
+ </View>
492
+ ) : null}
481
493
  {textChildren(children)}
482
494
  {/*
483
495
  * Last, and lifted above the content.
@@ -171,11 +171,11 @@ const ButtonGroupRoot = forwardRef<View, ButtonGroupProps>(
171
171
  <ButtonGroupProvider value={context}>
172
172
  <View
173
173
  ref={ref}
174
+ {...props}
174
175
  // A run of related actions is a toolbar; a screen reader announcing
175
176
  // the group is what tells someone the buttons in it belong together.
176
177
  accessibilityRole="toolbar"
177
178
  className={root({ className })}
178
- {...props}
179
179
  >
180
180
  {items.map((child, index) => (
181
181
  // Index keys: these fragments have no identity of their own, and
@@ -74,6 +74,7 @@ import Animated, {
74
74
  type SharedValue,
75
75
  } from 'react-native-reanimated';
76
76
  import { ChevronLeftIcon, ChevronRightIcon } from '../../icons';
77
+ import { useControllableState } from '../../primitives/controllable-state';
77
78
  import { Text, textChildren } from '../../primitives/text';
78
79
  import { cn } from '../../utils/cn';
79
80
  import {
@@ -264,14 +265,20 @@ const CarouselRoot = forwardRef<CarouselHandle, CarouselProps>(function Carousel
264
265
  ref
265
266
  ) {
266
267
  const [size, setSize] = useState({ width: 0, height: 0 });
267
- const [internalIndex, setInternalIndex] = useState(defaultIndex);
268
268
  const [count, setCountValue] = useState(0);
269
269
  const [countKnown, setCountKnown] = useState(false);
270
270
  const [touched, setTouched] = useState(false);
271
271
  const reducedMotion = useReducedMotion();
272
272
 
273
- const isControlled = indexProp !== undefined;
274
- const requestedIndex = isControlled ? indexProp : internalIndex;
273
+ const {
274
+ value: requestedIndex,
275
+ setValue: setIndex,
276
+ isControlled,
277
+ } = useControllableState({
278
+ value: indexProp,
279
+ defaultValue: defaultIndex,
280
+ onChange: onIndexChange,
281
+ });
275
282
 
276
283
  const progress = useSharedValue(indexProp ?? defaultIndex);
277
284
  const engaged = useSharedValue(0);
@@ -287,14 +294,6 @@ const CarouselRoot = forwardRef<CarouselHandle, CarouselProps>(function Carousel
287
294
  setCountKnown(true);
288
295
  }, []);
289
296
 
290
- const setIndex = useCallback(
291
- (next: number) => {
292
- if (!isControlled) setInternalIndex(next);
293
- onIndexChange?.(next);
294
- },
295
- [isControlled, onIndexChange]
296
- );
297
-
298
297
  /*
299
298
  * The slide the run is currently travelling to.
300
299
  *
@@ -180,11 +180,11 @@ const CollapsibleTrigger = forwardRef<View, CollapsibleTriggerProps>(
180
180
  return (
181
181
  <Pressable
182
182
  ref={ref}
183
+ {...props}
183
184
  accessibilityRole="button"
184
185
  accessibilityState={{ expanded: open, disabled: isDisabled }}
185
186
  disabled={isDisabled}
186
187
  className={trigger({ className })}
187
- {...props}
188
188
  onPress={(event) => {
189
189
  onPress?.(event);
190
190
  toggle();
@@ -140,10 +140,10 @@ const FieldRoot = forwardRef<View, FieldProps>(
140
140
  <FieldContext.Provider value={state}>
141
141
  <View
142
142
  ref={ref}
143
+ {...props}
143
144
  role="group"
144
145
  accessibilityState={{ disabled }}
145
146
  className={root({ className })}
146
- {...props}
147
147
  >
148
148
  {textChildren(children)}
149
149
  </View>
@@ -242,13 +242,13 @@ const FieldError = forwardRef<View, FieldErrorProps>(
242
242
  return (
243
243
  <View
244
244
  ref={ref}
245
+ {...props}
245
246
  // Web-aligned `role`, same as `Separator` already uses — RN's older
246
247
  // accessibilityRole list has no alert, the ARIA-aligned one does.
247
248
  role="alert"
248
249
  accessibilityLiveRegion="polite"
249
250
  nativeID={nativeID ?? relationshipIds[3]}
250
251
  className={error({ className })}
251
- {...props}
252
252
  >
253
253
  {messages ? (
254
254
  messages.length === 1 ? (
@@ -278,7 +278,7 @@ export interface FieldSetProps extends ViewProps {
278
278
  const FieldSet = forwardRef<View, FieldSetProps>(
279
279
  ({ className, ...props }, ref) => {
280
280
  const { set } = fieldVariants();
281
- return <View ref={ref} role="group" className={set({ className })} {...props} />;
281
+ return <View ref={ref} {...props} role="group" className={set({ className })} />;
282
282
  }
283
283
  );
284
284
  FieldSet.displayName = 'Field.Set';
@@ -293,7 +293,7 @@ const FieldLegend = forwardRef<RNText, FieldLegendProps>(
293
293
  ({ className, variant = 'legend', ...props }, ref) => {
294
294
  const { legend } = fieldVariants({ legendVariant: variant });
295
295
  return (
296
- <Text ref={ref} role="heading" className={legend({ className })} {...props} />
296
+ <Text ref={ref} {...props} role="heading" className={legend({ className })} />
297
297
  );
298
298
  }
299
299
  );
@@ -81,9 +81,9 @@ const LabelRoot = forwardRef<View, LabelProps>(
81
81
  <LabelContext.Provider value={state}>
82
82
  <View
83
83
  ref={ref}
84
+ {...props}
84
85
  className={root({ className })}
85
86
  accessibilityState={{ disabled: isDisabled }}
86
- {...props}
87
87
  >
88
88
  {/* Bare strings are the common case — wrap them so callers don't have to. */}
89
89
  {textChildren(children, (text) => <LabelText>{text}</LabelText>)}
@@ -15,10 +15,11 @@
15
15
  * ## How it loops
16
16
  *
17
17
  * The content is measured once, then laid out end to end enough times to cover
18
- * the container twice over. One track holds every copy and it is that track,
19
- * not the copies, that moves — a single animated node for any amount of
20
- * content. It travels exactly one copy-and-gap and starts over, so the state it
21
- * ends on is the state it began on and the seam never shows.
18
+ * the container. One track holds every copy and it is that track, not the
19
+ * copies, that moves. The track has a fixed copy budget: exceptionally short
20
+ * content gets extra whitespace instead of multiplying its React subtree
21
+ * hundreds of times. It travels exactly one layout period and starts over, so
22
+ * the state it ends on is the state it began on and the seam never shows.
22
23
  *
23
24
  * The travel is a linear timing driven on the UI thread, so it costs nothing
24
25
  * per frame in JavaScript and keeps running while the thread is busy.
@@ -43,8 +44,10 @@
43
44
  * Screen readers get one copy. The rest are duplicates of content already
44
45
  * announced, and hearing a sponsor list four times over is not thoroughness.
45
46
  */
46
- import { useEffect, useMemo, useState, type ReactNode } from 'react';
47
+ import { useEffect, useMemo, useState, type ComponentType, type ReactNode } from 'react';
47
48
  import {
49
+ Platform,
50
+ Pressable,
48
51
  ScrollView,
49
52
  StyleSheet,
50
53
  View,
@@ -61,6 +64,7 @@ import Animated, {
61
64
  withTiming,
62
65
  } from 'react-native-reanimated';
63
66
  import { useDirectionSign } from '../../hooks/use-direction';
67
+ import { Text } from '../../primitives/text';
64
68
  import { cn } from '../../utils/cn';
65
69
  import {
66
70
  DEFAULT_MARQUEE_SPEED,
@@ -70,6 +74,9 @@ import {
70
74
 
71
75
  export type MarqueeDirection = 'horizontal' | 'vertical';
72
76
 
77
+ type InertViewProps = ViewProps & { inert?: boolean };
78
+ const InertView = View as ComponentType<InertViewProps>;
79
+
73
80
  export interface MarqueeProps extends Omit<ViewProps, 'children'> {
74
81
  className?: string;
75
82
  /** The content to repeat. Measured once, then tiled along the axis. */
@@ -81,7 +88,7 @@ export interface MarqueeProps extends Omit<ViewProps, 'children'> {
81
88
  * rather than moving faster.
82
89
  */
83
90
  speed?: number;
84
- /** Gap between the end of one copy and the start of the next. */
91
+ /** Minimum gap between the end of one copy and the start of the next. */
85
92
  spacing?: number;
86
93
  /** Axis the content travels along. */
87
94
  direction?: MarqueeDirection;
@@ -92,6 +99,14 @@ export interface MarqueeProps extends Omit<ViewProps, 'children'> {
92
99
  reverse?: boolean;
93
100
  /** Set false to hold the content where it is. */
94
101
  playing?: boolean;
102
+ /** Show the built-in user pause/play control while motion is enabled. */
103
+ showPauseControl?: boolean;
104
+ /** Visible and spoken label for the moving state. */
105
+ pauseLabel?: string;
106
+ /** Visible and spoken label for the user-paused state. */
107
+ playLabel?: string;
108
+ /** Reports changes made by the built-in control. */
109
+ onPlayingChange?: (playing: boolean) => void;
95
110
  }
96
111
 
97
112
  export function Marquee({
@@ -102,6 +117,10 @@ export function Marquee({
102
117
  direction = 'horizontal',
103
118
  reverse = false,
104
119
  playing = true,
120
+ showPauseControl = true,
121
+ pauseLabel = 'Pause',
122
+ playLabel = 'Play',
123
+ onPlayingChange,
105
124
  style,
106
125
  onLayout,
107
126
  ...props
@@ -114,7 +133,9 @@ export function Marquee({
114
133
 
115
134
  const [viewport, setViewport] = useState(0);
116
135
  const [content, setContent] = useState(0);
136
+ const [userPaused, setUserPaused] = useState(false);
117
137
  const speed = normalizeMarqueeSpeed(speedProp);
138
+ const moving = playing && !userPaused;
118
139
 
119
140
  // The distance from one copy to the same point on the next, and therefore
120
141
  // both the layout step and the exact loop length. They are the same number
@@ -136,7 +157,7 @@ export function Marquee({
136
157
  useEffect(() => {
137
158
  cancelAnimation(offset);
138
159
  offset.value = 0;
139
- if (reducedMotion || !playing || period <= 0 || speed <= 0) return undefined;
160
+ if (reducedMotion || !moving || period <= 0 || speed <= 0) return undefined;
140
161
  offset.value = withRepeat(
141
162
  withTiming(period, {
142
163
  duration: (period / speed) * 1000,
@@ -146,7 +167,7 @@ export function Marquee({
146
167
  false
147
168
  );
148
169
  return () => cancelAnimation(offset);
149
- }, [offset, period, playing, reducedMotion, speed]);
170
+ }, [offset, period, moving, reducedMotion, speed]);
150
171
 
151
172
  const trackStyle = useAnimatedStyle(() => {
152
173
  const travel = (reverse ? offset.value : -offset.value) * flip;
@@ -200,7 +221,12 @@ export function Marquee({
200
221
  accessibilityElementsHidden
201
222
  importantForAccessibility="no-hide-descendants"
202
223
  >
203
- <View onLayout={onContentLayout}>{children}</View>
224
+ <InertView
225
+ inert={Platform.OS === 'web' ? true : undefined}
226
+ onLayout={onContentLayout}
227
+ >
228
+ {children}
229
+ </InertView>
204
230
  </ScrollView>
205
231
 
206
232
  <Animated.View style={[StyleSheet.absoluteFill, trackStyle]} pointerEvents="box-none">
@@ -210,18 +236,36 @@ export function Marquee({
210
236
  const spoken = index === 1;
211
237
  const at = (index - 1) * period;
212
238
  return (
213
- <View
239
+ <InertView
214
240
  key={index}
215
241
  style={[styles.copy, horizontal ? { left: at } : { top: at }]}
216
- pointerEvents="box-none"
242
+ pointerEvents={spoken ? 'box-none' : 'none'}
243
+ inert={Platform.OS === 'web' && !spoken ? true : undefined}
244
+ aria-hidden={!spoken}
217
245
  accessibilityElementsHidden={!spoken}
218
246
  importantForAccessibility={spoken ? 'auto' : 'no-hide-descendants'}
219
247
  >
220
248
  {children}
221
- </View>
249
+ </InertView>
222
250
  );
223
251
  })}
224
252
  </Animated.View>
253
+ {showPauseControl && playing && period > 0 && speed > 0 ? (
254
+ <Pressable
255
+ accessibilityRole="button"
256
+ 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"
258
+ onPress={() => {
259
+ const next = !userPaused;
260
+ setUserPaused(next);
261
+ onPlayingChange?.(!next);
262
+ }}
263
+ >
264
+ <Text className="text-xs font-medium text-foreground">
265
+ {userPaused ? playLabel : pauseLabel}
266
+ </Text>
267
+ </Pressable>
268
+ ) : null}
225
269
  </View>
226
270
  );
227
271
  }
@@ -14,6 +14,13 @@
14
14
  */
15
15
  export const DEFAULT_MARQUEE_SPEED = 40;
16
16
 
17
+ /**
18
+ * Includes the copy just before and just after the viewport. Keeping this
19
+ * fixed prevents tiny content (for example a one-point separator) from
20
+ * multiplying an arbitrary React subtree hundreds of times.
21
+ */
22
+ export const MAX_MARQUEE_COPIES = 32;
23
+
17
24
  export function normalizeMarqueeSpeed(value: number): number {
18
25
  if (!Number.isFinite(value)) return DEFAULT_MARQUEE_SPEED;
19
26
  return Math.max(value, 0);
@@ -31,7 +38,23 @@ export function marqueeCopyCount(
31
38
  ): { period: number; count: number } {
32
39
  const safeViewport = Number.isFinite(viewport) ? Math.max(viewport, 0) : 0;
33
40
  const safeContent = Number.isFinite(content) ? Math.max(content, 0) : 0;
34
- const period = safeContent > 0 ? safeContent + normalizeMarqueeSpacing(spacing) : 0;
35
- if (period <= 0 || safeViewport <= 0) return { period, count: 0 };
36
- return { period, count: Math.ceil(safeViewport / period) + 2 };
41
+ const naturalPeriod =
42
+ safeContent > 0 ? safeContent + normalizeMarqueeSpacing(spacing) : 0;
43
+ if (naturalPeriod <= 0 || safeViewport <= 0) {
44
+ return { period: naturalPeriod, count: 0 };
45
+ }
46
+
47
+ // Two copies sit outside the viewport to cover one complete loop. If the
48
+ // requested content-and-gap period would need more than the remaining copy
49
+ // budget, widen the layout period (effectively adding whitespace) instead
50
+ // of mounting an unbounded number of identical subtrees.
51
+ const minimumPeriod = safeViewport / (MAX_MARQUEE_COPIES - 2);
52
+ const period = Math.max(naturalPeriod, minimumPeriod);
53
+ return {
54
+ period,
55
+ count: Math.min(
56
+ MAX_MARQUEE_COPIES,
57
+ Math.ceil(safeViewport / period) + 2
58
+ ),
59
+ };
37
60
  }
@@ -296,11 +296,15 @@ function PlanTitle({ className, children, ...props }: PlanTitleProps) {
296
296
  const { title } = planVariants();
297
297
 
298
298
  if (isStreaming && typeof children === 'string') {
299
- return <Shimmer textClassName={cn(title(), className)}>{children}</Shimmer>;
299
+ return (
300
+ <Shimmer accessibilityRole="header" textClassName={cn(title(), className)}>
301
+ {children}
302
+ </Shimmer>
303
+ );
300
304
  }
301
305
 
302
306
  return (
303
- <Text accessibilityRole="header" className={cn(title(), className)} {...props}>
307
+ <Text {...props} accessibilityRole="header" className={cn(title(), className)}>
304
308
  {children}
305
309
  </Text>
306
310
  );
@@ -97,7 +97,9 @@ import Svg, {
97
97
  Rect,
98
98
  } from 'react-native-svg';
99
99
  import { useCSSVariable } from 'uniwind';
100
+ import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility';
100
101
  import { Text } from '../../primitives/text';
102
+ import { finiteChartNumber } from '../../primitives/finite-chart';
101
103
  import {
102
104
  areaPath,
103
105
  bandOf,
@@ -260,7 +262,7 @@ export function usePlotCursor(): { activeIndex: number; activePoint: PlotDatum |
260
262
  };
261
263
  }
262
264
 
263
- export interface PlotProps extends ViewProps {
265
+ export interface PlotProps extends ViewProps, ChartAccessibilityProps<PlotDatum> {
264
266
  className?: string;
265
267
  /** The rows. Each one is a position along the x-axis. */
266
268
  data: PlotDatum[];
@@ -339,6 +341,11 @@ const PlotRoot = forwardRef<PlotHandle, PlotProps>(function PlotRoot(
339
341
  xScale: xScaleProp,
340
342
  curve = 'monotone',
341
343
  onActiveIndexChange,
344
+ accessible,
345
+ accessibilityLabel,
346
+ accessibilityHint,
347
+ accessibilityLabelForDatum,
348
+ onAccessibilityDatumPress,
342
349
  compact = false,
343
350
  children,
344
351
  ...props
@@ -411,9 +418,9 @@ const PlotRoot = forwardRef<PlotHandle, PlotProps>(function PlotRoot(
411
418
  */
412
419
  const seriesKeys = useMemo(() => series.map(([key]) => key), [series]);
413
420
  const extent = useMemo<[number, number]>(() => {
414
- const lowPin = yDomain?.[0];
415
- const highPin = yDomain?.[1];
416
- if (typeof lowPin === 'number' && typeof highPin === 'number') {
421
+ const lowPin = finiteChartNumber(yDomain?.[0]);
422
+ const highPin = finiteChartNumber(yDomain?.[1]);
423
+ if (lowPin !== undefined && highPin !== undefined) {
417
424
  return [lowPin, highPin];
418
425
  }
419
426
 
@@ -421,8 +428,8 @@ const PlotRoot = forwardRef<PlotHandle, PlotProps>(function PlotRoot(
421
428
  let max = -Infinity;
422
429
  for (const row of data) {
423
430
  for (const key of seriesKeys) {
424
- const value = row[key];
425
- if (typeof value !== 'number' || Number.isNaN(value)) continue;
431
+ const value = finiteChartNumber(row[key]);
432
+ if (value === undefined) continue;
426
433
  if (value < min) min = value;
427
434
  if (value > max) max = value;
428
435
  }
@@ -432,8 +439,8 @@ const PlotRoot = forwardRef<PlotHandle, PlotProps>(function PlotRoot(
432
439
  // Nothing to measure. Fall back to whatever was pinned, and to a unit
433
440
  // domain when nothing was — dividing by a span of zero is how a mark
434
441
  // ends up drawn at infinity.
435
- const low = typeof lowPin === 'number' ? lowPin : 0;
436
- const high = typeof highPin === 'number' ? highPin : low + 1;
442
+ const low = lowPin ?? 0;
443
+ const high = highPin ?? low + 1;
437
444
  return [low, high];
438
445
  }
439
446
 
@@ -477,8 +484,8 @@ const PlotRoot = forwardRef<PlotHandle, PlotProps>(function PlotRoot(
477
484
  const derivedHigh = nice ? niceHigh : max + headroom * highRoom;
478
485
 
479
486
  return [
480
- typeof lowPin === 'number' ? lowPin : derivedLow,
481
- typeof highPin === 'number' ? highPin : derivedHigh,
487
+ lowPin ?? derivedLow,
488
+ highPin ?? derivedHigh,
482
489
  ];
483
490
  }, [data, yDomain, seriesKeys, hasBars, nice]);
484
491
 
@@ -619,7 +626,26 @@ const PlotRoot = forwardRef<PlotHandle, PlotProps>(function PlotRoot(
619
626
  <PlotContext.Provider value={context}>
620
627
  <View {...props} style={props.style} className={cn('w-full', className)}>
621
628
  {header}
622
- <View onLayout={onLayout} style={{ aspectRatio }} className="w-full">
629
+ <ChartAccessibilityData
630
+ chart="Plot"
631
+ data={data}
632
+ disabled={accessible === false || status === 'loading'}
633
+ accessibilityLabel={accessibilityLabel}
634
+ accessibilityHint={accessibilityHint}
635
+ accessibilityLabelForDatum={accessibilityLabelForDatum}
636
+ onAccessibilityDatumPress={onAccessibilityDatumPress}
637
+ valueOf={(datum) => [
638
+ [xDataKey, datum[xDataKey]],
639
+ ...seriesKeys.map((key) => [key, datum[key]] as [string, unknown]),
640
+ ]}
641
+ />
642
+ <View
643
+ onLayout={onLayout}
644
+ style={{ aspectRatio }}
645
+ className="w-full"
646
+ accessibilityElementsHidden
647
+ importantForAccessibility="no-hide-descendants"
648
+ >
623
649
  {plot.width > 0 ? (
624
650
  <>
625
651
  <Svg width="100%" height="100%" style={StyleSheet.absoluteFill}>