panelui-native 0.66.1 → 0.68.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.
- package/README.md +21 -1
- package/lib/module/components/area-chart/index.js +18 -1
- package/lib/module/components/area-chart/index.js.map +1 -1
- package/lib/module/components/bar-chart/index.js +18 -1
- package/lib/module/components/bar-chart/index.js.map +1 -1
- package/lib/module/components/candlestick-chart/index.js +18 -1
- package/lib/module/components/candlestick-chart/index.js.map +1 -1
- package/lib/module/components/form/index.js +7 -5
- package/lib/module/components/form/index.js.map +1 -1
- package/lib/module/components/form/typed-form.js +11 -0
- package/lib/module/components/form/typed-form.js.map +1 -0
- package/lib/module/components/form/use-form.js +4 -0
- package/lib/module/components/form/use-form.js.map +1 -1
- package/lib/module/components/heatmap-chart/index.js +27 -1
- package/lib/module/components/heatmap-chart/index.js.map +1 -1
- package/lib/module/components/line-chart/index.js +18 -1
- package/lib/module/components/line-chart/index.js.map +1 -1
- package/lib/module/components/message-scroller/index.js +172 -10
- package/lib/module/components/message-scroller/index.js.map +1 -1
- package/lib/module/components/message-scroller/message-scroller-math.js +23 -0
- package/lib/module/components/message-scroller/message-scroller-math.js.map +1 -1
- package/lib/module/components/meter/index.js +250 -0
- package/lib/module/components/meter/index.js.map +1 -0
- package/lib/module/components/meter/meter-scale.js +88 -0
- package/lib/module/components/meter/meter-scale.js.map +1 -0
- package/lib/module/components/planner/index.js +714 -0
- package/lib/module/components/planner/index.js.map +1 -0
- package/lib/module/components/planner/planner-entries.js +124 -0
- package/lib/module/components/planner/planner-entries.js.map +1 -0
- package/lib/module/components/radar-chart/index.js +18 -1
- package/lib/module/components/radar-chart/index.js.map +1 -1
- package/lib/module/components/scatter-chart/index.js +18 -1
- package/lib/module/components/scatter-chart/index.js.map +1 -1
- package/lib/module/components/skeleton/index.js +45 -7
- package/lib/module/components/skeleton/index.js.map +1 -1
- package/lib/module/components/spinner/index.js +63 -14
- package/lib/module/components/spinner/index.js.map +1 -1
- package/lib/module/components/time-picker/index.js +46 -21
- package/lib/module/components/time-picker/index.js.map +1 -1
- package/lib/module/components/time-picker/ruler-window.js +25 -0
- package/lib/module/components/time-picker/ruler-window.js.map +1 -0
- package/lib/module/hooks/breakpoint-contract.js +29 -0
- package/lib/module/hooks/breakpoint-contract.js.map +1 -0
- package/lib/module/hooks/index.js +1 -1
- package/lib/module/hooks/index.js.map +1 -1
- package/lib/module/hooks/use-breakpoint.js +46 -1
- package/lib/module/hooks/use-breakpoint.js.map +1 -1
- package/lib/module/index.js +3 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/primitives/chart-accessibility.js +68 -0
- package/lib/module/primitives/chart-accessibility.js.map +1 -0
- package/lib/typescript/src/components/area-chart/index.d.ts +2 -1
- package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/bar-chart/index.d.ts +2 -1
- package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/candlestick-chart/index.d.ts +2 -1
- package/lib/typescript/src/components/candlestick-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/form/index.d.ts +18 -5
- package/lib/typescript/src/components/form/index.d.ts.map +1 -1
- package/lib/typescript/src/components/form/typed-form.d.ts +7 -0
- package/lib/typescript/src/components/form/typed-form.d.ts.map +1 -0
- package/lib/typescript/src/components/form/use-form.d.ts +4 -0
- package/lib/typescript/src/components/form/use-form.d.ts.map +1 -1
- package/lib/typescript/src/components/heatmap-chart/index.d.ts +2 -1
- package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/line-chart/index.d.ts +2 -1
- package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/message-scroller/index.d.ts +30 -2
- package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -1
- package/lib/typescript/src/components/message-scroller/message-scroller-math.d.ts +10 -0
- package/lib/typescript/src/components/message-scroller/message-scroller-math.d.ts.map +1 -1
- package/lib/typescript/src/components/meter/index.d.ts +270 -0
- package/lib/typescript/src/components/meter/index.d.ts.map +1 -0
- package/lib/typescript/src/components/meter/meter-scale.d.ts +57 -0
- package/lib/typescript/src/components/meter/meter-scale.d.ts.map +1 -0
- package/lib/typescript/src/components/planner/index.d.ts +274 -0
- package/lib/typescript/src/components/planner/index.d.ts.map +1 -0
- package/lib/typescript/src/components/planner/planner-entries.d.ts +75 -0
- package/lib/typescript/src/components/planner/planner-entries.d.ts.map +1 -0
- package/lib/typescript/src/components/radar-chart/index.d.ts +4 -0
- package/lib/typescript/src/components/radar-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/scatter-chart/index.d.ts +2 -1
- package/lib/typescript/src/components/scatter-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/skeleton/index.d.ts +25 -2
- package/lib/typescript/src/components/skeleton/index.d.ts.map +1 -1
- package/lib/typescript/src/components/spinner/index.d.ts +25 -2
- package/lib/typescript/src/components/spinner/index.d.ts.map +1 -1
- package/lib/typescript/src/components/time-picker/index.d.ts +5 -6
- package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
- package/lib/typescript/src/components/time-picker/ruler-window.d.ts +12 -0
- package/lib/typescript/src/components/time-picker/ruler-window.d.ts.map +1 -0
- package/lib/typescript/src/hooks/breakpoint-contract.d.ts +6 -0
- package/lib/typescript/src/hooks/breakpoint-contract.d.ts.map +1 -0
- package/lib/typescript/src/hooks/index.d.ts +1 -1
- package/lib/typescript/src/hooks/index.d.ts.map +1 -1
- package/lib/typescript/src/hooks/use-breakpoint.d.ts +22 -0
- package/lib/typescript/src/hooks/use-breakpoint.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/lib/typescript/src/primitives/chart-accessibility.d.ts +33 -0
- package/lib/typescript/src/primitives/chart-accessibility.d.ts.map +1 -0
- package/lib/typescript/types/form-api.type-test.d.ts +2 -0
- package/lib/typescript/types/form-api.type-test.d.ts.map +1 -0
- package/package.json +28 -2
- package/src/components/area-chart/index.tsx +27 -2
- package/src/components/bar-chart/index.tsx +27 -2
- package/src/components/candlestick-chart/index.tsx +30 -2
- package/src/components/form/index.tsx +32 -5
- package/src/components/form/typed-form.ts +13 -0
- package/src/components/form/use-form.ts +9 -0
- package/src/components/heatmap-chart/index.tsx +49 -3
- package/src/components/line-chart/index.tsx +27 -2
- package/src/components/message-scroller/index.tsx +234 -6
- package/src/components/message-scroller/message-scroller-math.ts +38 -0
- package/src/components/meter/index.tsx +318 -0
- package/src/components/meter/meter-scale.ts +109 -0
- package/src/components/planner/index.tsx +886 -0
- package/src/components/planner/planner-entries.ts +146 -0
- package/src/components/radar-chart/index.tsx +24 -0
- package/src/components/scatter-chart/index.tsx +27 -2
- package/src/components/skeleton/index.tsx +59 -5
- package/src/components/spinner/index.tsx +80 -13
- package/src/components/time-picker/index.tsx +57 -29
- package/src/components/time-picker/ruler-window.ts +24 -0
- package/src/hooks/breakpoint-contract.ts +31 -0
- package/src/hooks/index.ts +4 -0
- package/src/hooks/use-breakpoint.ts +61 -10
- package/src/index.ts +30 -0
- package/src/primitives/chart-accessibility.ts +102 -0
|
@@ -39,9 +39,18 @@ import {
|
|
|
39
39
|
useMemo,
|
|
40
40
|
useRef,
|
|
41
41
|
useState,
|
|
42
|
+
type ReactElement,
|
|
42
43
|
type ReactNode,
|
|
43
44
|
} from 'react';
|
|
44
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
FlatList,
|
|
47
|
+
View,
|
|
48
|
+
type FlatListProps,
|
|
49
|
+
type LayoutChangeEvent,
|
|
50
|
+
type ListRenderItemInfo,
|
|
51
|
+
type ViewProps,
|
|
52
|
+
type ViewToken,
|
|
53
|
+
} from 'react-native';
|
|
45
54
|
import Animated, {
|
|
46
55
|
runOnJS,
|
|
47
56
|
useAnimatedRef,
|
|
@@ -59,7 +68,10 @@ import { cn } from '../../utils/cn';
|
|
|
59
68
|
import { textChildren } from '../../primitives/text';
|
|
60
69
|
import {
|
|
61
70
|
distanceFromMessageScrollerTarget,
|
|
71
|
+
initialMessageScrollerIndex,
|
|
62
72
|
isMessageScrollerTargetVisible,
|
|
73
|
+
messageScrollerAnchorAt,
|
|
74
|
+
messageScrollerIndex,
|
|
63
75
|
MESSAGE_SCROLLER_EDGE_THRESHOLD,
|
|
64
76
|
} from './message-scroller-math';
|
|
65
77
|
|
|
@@ -72,8 +84,15 @@ const ANCHOR_PEEK = 24;
|
|
|
72
84
|
|
|
73
85
|
export type MessageScrollerPosition = 'start' | 'end' | 'last-anchor';
|
|
74
86
|
|
|
87
|
+
interface MessageScrollerDriver {
|
|
88
|
+
scrollToEnd: (animated: boolean) => void;
|
|
89
|
+
scrollToStart: (animated: boolean) => void;
|
|
90
|
+
scrollToMessage: (id: string, animated: boolean) => boolean;
|
|
91
|
+
}
|
|
92
|
+
|
|
75
93
|
interface MessageScrollerContextValue {
|
|
76
94
|
scrollRef: ReturnType<typeof useAnimatedRef<Animated.ScrollView>>;
|
|
95
|
+
registerDriver: (driver: MessageScrollerDriver | null) => void;
|
|
77
96
|
/** 1 while the reader is at the live edge, 0 otherwise. Drives follow state. */
|
|
78
97
|
atEnd: ReturnType<typeof useSharedValue<number>>;
|
|
79
98
|
/** Live distance from each edge, used by target-specific jump controls. */
|
|
@@ -167,6 +186,10 @@ function MessageScrollerRoot({
|
|
|
167
186
|
const itemY = useRef(new Map<string, number>());
|
|
168
187
|
const anchors = useRef<string[]>([]);
|
|
169
188
|
const following = useRef(autoScroll);
|
|
189
|
+
const driver = useRef<MessageScrollerDriver | null>(null);
|
|
190
|
+
const registerDriver = useCallback((next: MessageScrollerDriver | null) => {
|
|
191
|
+
driver.current = next;
|
|
192
|
+
}, []);
|
|
170
193
|
|
|
171
194
|
const registerItem = useCallback((id: string, y: number, anchor: boolean) => {
|
|
172
195
|
itemY.current.set(id, y);
|
|
@@ -183,7 +206,8 @@ function MessageScrollerRoot({
|
|
|
183
206
|
const scrollToEnd = useCallback(
|
|
184
207
|
(animated = true) => {
|
|
185
208
|
following.current = true;
|
|
186
|
-
|
|
209
|
+
if (driver.current) driver.current.scrollToEnd(animated);
|
|
210
|
+
else scrollRef.current?.scrollToEnd({ animated });
|
|
187
211
|
},
|
|
188
212
|
[scrollRef]
|
|
189
213
|
);
|
|
@@ -191,17 +215,23 @@ function MessageScrollerRoot({
|
|
|
191
215
|
const scrollToStart = useCallback(
|
|
192
216
|
(animated = true) => {
|
|
193
217
|
following.current = false;
|
|
194
|
-
|
|
218
|
+
if (driver.current) driver.current.scrollToStart(animated);
|
|
219
|
+
else scrollRef.current?.scrollTo({ y: 0, animated });
|
|
195
220
|
},
|
|
196
221
|
[scrollRef]
|
|
197
222
|
);
|
|
198
223
|
|
|
199
224
|
const scrollToMessage = useCallback(
|
|
200
225
|
(id: string, animated = true) => {
|
|
226
|
+
if (driver.current) {
|
|
227
|
+
if (driver.current.scrollToMessage(id, animated)) following.current = false;
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
201
230
|
const y = itemY.current.get(id);
|
|
202
|
-
if (y
|
|
203
|
-
|
|
204
|
-
|
|
231
|
+
if (y !== undefined) {
|
|
232
|
+
following.current = false;
|
|
233
|
+
scrollRef.current?.scrollTo({ y: Math.max(0, y - ANCHOR_PEEK), animated });
|
|
234
|
+
}
|
|
205
235
|
},
|
|
206
236
|
[scrollRef]
|
|
207
237
|
);
|
|
@@ -209,6 +239,7 @@ function MessageScrollerRoot({
|
|
|
209
239
|
const context = useMemo<MessageScrollerContextValue>(
|
|
210
240
|
() => ({
|
|
211
241
|
scrollRef,
|
|
242
|
+
registerDriver,
|
|
212
243
|
atEnd,
|
|
213
244
|
distanceFromStart,
|
|
214
245
|
distanceFromEnd,
|
|
@@ -230,6 +261,7 @@ function MessageScrollerRoot({
|
|
|
230
261
|
}),
|
|
231
262
|
[
|
|
232
263
|
scrollRef,
|
|
264
|
+
registerDriver,
|
|
233
265
|
atEnd,
|
|
234
266
|
distanceFromStart,
|
|
235
267
|
distanceFromEnd,
|
|
@@ -439,6 +471,201 @@ function MessageScrollerViewport({
|
|
|
439
471
|
}
|
|
440
472
|
MessageScrollerViewport.displayName = 'MessageScroller.Viewport';
|
|
441
473
|
|
|
474
|
+
export interface MessageScrollerListItem {
|
|
475
|
+
/** Stable id used by `scrollToMessage` and as the default React key. */
|
|
476
|
+
messageId: string;
|
|
477
|
+
/** Marks the start of a turn for `last-anchor` and visibility reporting. */
|
|
478
|
+
scrollAnchor?: boolean;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export interface MessageScrollerListProps<T extends MessageScrollerListItem = MessageScrollerListItem>
|
|
482
|
+
extends Omit<
|
|
483
|
+
FlatListProps<T>,
|
|
484
|
+
| 'data'
|
|
485
|
+
| 'renderItem'
|
|
486
|
+
| 'keyExtractor'
|
|
487
|
+
| 'onScroll'
|
|
488
|
+
| 'onContentSizeChange'
|
|
489
|
+
| 'onViewableItemsChanged'
|
|
490
|
+
| 'maintainVisibleContentPosition'
|
|
491
|
+
| 'onScrollToIndexFailed'
|
|
492
|
+
| 'onScrollEndDrag'
|
|
493
|
+
| 'onMomentumScrollEnd'
|
|
494
|
+
| 'CellRendererComponent'
|
|
495
|
+
> {
|
|
496
|
+
className?: string;
|
|
497
|
+
/** Classes on the virtualized list's padded transcript column. */
|
|
498
|
+
contentContainerClassName?: string;
|
|
499
|
+
/**
|
|
500
|
+
* The complete transcript. Rows outside the native render window stay
|
|
501
|
+
* unmounted; each item therefore carries its stable navigation metadata.
|
|
502
|
+
*/
|
|
503
|
+
data: readonly T[];
|
|
504
|
+
/** Draw one turn from the native list window. */
|
|
505
|
+
renderItem: (info: ListRenderItemInfo<T>) => ReactElement | null;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* The real virtualized transcript path. Only its render window mounts; native
|
|
510
|
+
* visible-content maintenance keeps prepends still without measuring offscreen
|
|
511
|
+
* rows in JavaScript.
|
|
512
|
+
*/
|
|
513
|
+
function MessageScrollerList<T extends MessageScrollerListItem>({
|
|
514
|
+
className,
|
|
515
|
+
contentContainerClassName,
|
|
516
|
+
data,
|
|
517
|
+
renderItem,
|
|
518
|
+
initialNumToRender = 12,
|
|
519
|
+
maxToRenderPerBatch = 8,
|
|
520
|
+
windowSize = 7,
|
|
521
|
+
...props
|
|
522
|
+
}: MessageScrollerListProps<T>) {
|
|
523
|
+
const {
|
|
524
|
+
atEnd,
|
|
525
|
+
distanceFromStart,
|
|
526
|
+
distanceFromEnd,
|
|
527
|
+
setAtEndJS,
|
|
528
|
+
autoScroll,
|
|
529
|
+
preserveScrollOnPrepend,
|
|
530
|
+
defaultScrollPosition,
|
|
531
|
+
following,
|
|
532
|
+
registerDriver,
|
|
533
|
+
setCurrentAnchorId,
|
|
534
|
+
} = useScroller('MessageScroller.List');
|
|
535
|
+
const listRef = useRef<FlatList<T>>(null);
|
|
536
|
+
const items = useRef(data);
|
|
537
|
+
items.current = data;
|
|
538
|
+
const opened = useRef(false);
|
|
539
|
+
const firstVisibleIndex = useRef<number | null>(null);
|
|
540
|
+
|
|
541
|
+
const publishAtEnd = useCallback(
|
|
542
|
+
(next: boolean) => {
|
|
543
|
+
following.current = autoScroll && next;
|
|
544
|
+
setAtEndJS(next);
|
|
545
|
+
},
|
|
546
|
+
[autoScroll, following, setAtEndJS]
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
const scrollHandler = useAnimatedScrollHandler({
|
|
550
|
+
onScroll: (event) => {
|
|
551
|
+
const { contentOffset, contentSize, layoutMeasurement } = event;
|
|
552
|
+
distanceFromStart.value = distanceFromMessageScrollerTarget(
|
|
553
|
+
'start',
|
|
554
|
+
contentOffset.y,
|
|
555
|
+
contentSize.height,
|
|
556
|
+
layoutMeasurement.height
|
|
557
|
+
);
|
|
558
|
+
distanceFromEnd.value = distanceFromMessageScrollerTarget(
|
|
559
|
+
'end',
|
|
560
|
+
contentOffset.y,
|
|
561
|
+
contentSize.height,
|
|
562
|
+
layoutMeasurement.height
|
|
563
|
+
);
|
|
564
|
+
const next = isMessageScrollerTargetVisible(distanceFromEnd.value) ? 0 : 1;
|
|
565
|
+
if (next !== atEnd.value) {
|
|
566
|
+
atEnd.value = next;
|
|
567
|
+
runOnJS(publishAtEnd)(next === 1);
|
|
568
|
+
}
|
|
569
|
+
},
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
const scrollToIndex = useCallback((index: number, animated: boolean) => {
|
|
573
|
+
listRef.current?.scrollToIndex({ index, viewOffset: ANCHOR_PEEK, animated });
|
|
574
|
+
}, []);
|
|
575
|
+
|
|
576
|
+
const setAnchorAtIndex = useCallback(
|
|
577
|
+
(index: number) => setCurrentAnchorId(messageScrollerAnchorAt(items.current, index)),
|
|
578
|
+
[setCurrentAnchorId]
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
useEffect(() => {
|
|
582
|
+
registerDriver({
|
|
583
|
+
scrollToEnd: (animated) => {
|
|
584
|
+
listRef.current?.scrollToEnd({ animated });
|
|
585
|
+
setAnchorAtIndex(items.current.length - 1);
|
|
586
|
+
},
|
|
587
|
+
scrollToStart: (animated) => {
|
|
588
|
+
listRef.current?.scrollToOffset({ offset: 0, animated });
|
|
589
|
+
setAnchorAtIndex(0);
|
|
590
|
+
},
|
|
591
|
+
scrollToMessage: (id, animated) => {
|
|
592
|
+
const index = messageScrollerIndex(items.current, id);
|
|
593
|
+
if (index === undefined) return false;
|
|
594
|
+
scrollToIndex(index, animated);
|
|
595
|
+
setAnchorAtIndex(index);
|
|
596
|
+
return true;
|
|
597
|
+
},
|
|
598
|
+
});
|
|
599
|
+
return () => registerDriver(null);
|
|
600
|
+
}, [registerDriver, scrollToIndex, setAnchorAtIndex]);
|
|
601
|
+
|
|
602
|
+
useEffect(() => {
|
|
603
|
+
following.current = autoScroll && atEnd.value === 1;
|
|
604
|
+
}, [atEnd, autoScroll, following]);
|
|
605
|
+
|
|
606
|
+
const onContentSizeChange = () => {
|
|
607
|
+
if (!opened.current) {
|
|
608
|
+
opened.current = true;
|
|
609
|
+
const index = initialMessageScrollerIndex(data, defaultScrollPosition);
|
|
610
|
+
const target = index ?? data.length - 1;
|
|
611
|
+
if (index === undefined) listRef.current?.scrollToEnd({ animated: false });
|
|
612
|
+
else if (index > 0) scrollToIndex(index, false);
|
|
613
|
+
setAnchorAtIndex(target);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
if (following.current) listRef.current?.scrollToEnd({ animated: true });
|
|
617
|
+
};
|
|
618
|
+
|
|
619
|
+
const onViewableItemsChanged = useRef(
|
|
620
|
+
({ viewableItems }: { viewableItems: ViewToken<T>[] }) => {
|
|
621
|
+
const first = viewableItems.reduce(
|
|
622
|
+
(best, token) => (token.index !== null && token.index < best ? token.index : best),
|
|
623
|
+
Infinity
|
|
624
|
+
);
|
|
625
|
+
firstVisibleIndex.current = Number.isFinite(first) ? first : null;
|
|
626
|
+
}
|
|
627
|
+
).current;
|
|
628
|
+
|
|
629
|
+
const settleAnchor = () => {
|
|
630
|
+
if (firstVisibleIndex.current !== null) {
|
|
631
|
+
setCurrentAnchorId(messageScrollerAnchorAt(items.current, firstVisibleIndex.current));
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
|
|
635
|
+
return (
|
|
636
|
+
<Animated.FlatList
|
|
637
|
+
ref={listRef}
|
|
638
|
+
data={data}
|
|
639
|
+
renderItem={renderItem}
|
|
640
|
+
keyExtractor={(item) => item.messageId}
|
|
641
|
+
accessibilityRole="list"
|
|
642
|
+
accessibilityLabel="Messages"
|
|
643
|
+
accessibilityLiveRegion="polite"
|
|
644
|
+
scrollEventThrottle={16}
|
|
645
|
+
showsVerticalScrollIndicator={false}
|
|
646
|
+
initialNumToRender={initialNumToRender}
|
|
647
|
+
maxToRenderPerBatch={maxToRenderPerBatch}
|
|
648
|
+
windowSize={windowSize}
|
|
649
|
+
maintainVisibleContentPosition={
|
|
650
|
+
preserveScrollOnPrepend ? { minIndexForVisible: 0 } : undefined
|
|
651
|
+
}
|
|
652
|
+
className={cn('flex-1', className)}
|
|
653
|
+
contentContainerClassName={cn('gap-3 p-4 pb-16', contentContainerClassName)}
|
|
654
|
+
{...props}
|
|
655
|
+
onScroll={scrollHandler}
|
|
656
|
+
onContentSizeChange={onContentSizeChange}
|
|
657
|
+
onViewableItemsChanged={onViewableItemsChanged}
|
|
658
|
+
onScrollEndDrag={settleAnchor}
|
|
659
|
+
onMomentumScrollEnd={settleAnchor}
|
|
660
|
+
onScrollToIndexFailed={({ index, averageItemLength }) => {
|
|
661
|
+
listRef.current?.scrollToOffset({ offset: index * averageItemLength, animated: false });
|
|
662
|
+
setTimeout(() => scrollToIndex(index, false), 0);
|
|
663
|
+
}}
|
|
664
|
+
/>
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
MessageScrollerList.displayName = 'MessageScroller.List';
|
|
668
|
+
|
|
442
669
|
/**
|
|
443
670
|
* How far the transcript shifted, measured on the message closest to the top
|
|
444
671
|
* that was there before and is still there.
|
|
@@ -601,6 +828,7 @@ MessageScrollerButton.displayName = 'MessageScroller.Button';
|
|
|
601
828
|
|
|
602
829
|
export const MessageScroller = Object.assign(MessageScrollerRoot, {
|
|
603
830
|
Viewport: MessageScrollerViewport,
|
|
831
|
+
List: MessageScrollerList,
|
|
604
832
|
Content: MessageScrollerContent,
|
|
605
833
|
Item: MessageScrollerItem,
|
|
606
834
|
Button: MessageScrollerButton,
|
|
@@ -22,3 +22,41 @@ export function isMessageScrollerTargetVisible(distance: number): boolean {
|
|
|
22
22
|
|
|
23
23
|
return distance > MESSAGE_SCROLLER_EDGE_THRESHOLD;
|
|
24
24
|
}
|
|
25
|
+
|
|
26
|
+
export interface MessageScrollerIndexedItem {
|
|
27
|
+
messageId: string;
|
|
28
|
+
scrollAnchor?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Index used by initial positioning and id-addressed virtualized jumps. */
|
|
32
|
+
export function messageScrollerIndex(
|
|
33
|
+
items: readonly MessageScrollerIndexedItem[],
|
|
34
|
+
id: string
|
|
35
|
+
): number | undefined {
|
|
36
|
+
const index = items.findIndex((item) => item.messageId === id);
|
|
37
|
+
return index < 0 ? undefined : index;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Initial target for a list; `undefined` means use the physical end. */
|
|
41
|
+
export function initialMessageScrollerIndex(
|
|
42
|
+
items: readonly MessageScrollerIndexedItem[],
|
|
43
|
+
position: 'start' | 'end' | 'last-anchor'
|
|
44
|
+
): number | undefined {
|
|
45
|
+
if (position === 'end') return undefined;
|
|
46
|
+
if (position === 'start') return items.length ? 0 : undefined;
|
|
47
|
+
for (let index = items.length - 1; index >= 0; index -= 1) {
|
|
48
|
+
if (items[index]!.scrollAnchor) return index;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Anchor at or before the first visible virtualized row. */
|
|
54
|
+
export function messageScrollerAnchorAt(
|
|
55
|
+
items: readonly MessageScrollerIndexedItem[],
|
|
56
|
+
firstVisibleIndex: number
|
|
57
|
+
): string | null {
|
|
58
|
+
for (let index = Math.min(firstVisibleIndex, items.length - 1); index >= 0; index -= 1) {
|
|
59
|
+
if (items[index]!.scrollAnchor) return items[index]!.messageId;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { forwardRef, useEffect } from 'react';
|
|
2
|
+
import { View, type LayoutChangeEvent, type ViewProps } from 'react-native';
|
|
3
|
+
import Animated, {
|
|
4
|
+
useAnimatedStyle,
|
|
5
|
+
useReducedMotion,
|
|
6
|
+
useSharedValue,
|
|
7
|
+
withSpring,
|
|
8
|
+
withTiming,
|
|
9
|
+
type SharedValue,
|
|
10
|
+
} from 'react-native-reanimated';
|
|
11
|
+
import { tv, type VariantProps } from 'tailwind-variants';
|
|
12
|
+
import { Text } from '../../primitives/text';
|
|
13
|
+
import {
|
|
14
|
+
clamp,
|
|
15
|
+
colorFor,
|
|
16
|
+
fractionOf,
|
|
17
|
+
formatValue,
|
|
18
|
+
litSegments,
|
|
19
|
+
type MeterColor,
|
|
20
|
+
type MeterThreshold,
|
|
21
|
+
} from './meter-scale';
|
|
22
|
+
|
|
23
|
+
const SPRING = { damping: 20, stiffness: 180, mass: 0.6 } as const;
|
|
24
|
+
/** Milliseconds for a segment to light or go out. */
|
|
25
|
+
const SEGMENT_DURATION = 180;
|
|
26
|
+
/** How faint an unlit segment sits. Present, but plainly not counted. */
|
|
27
|
+
const SEGMENT_UNLIT = 0.22;
|
|
28
|
+
|
|
29
|
+
const meterVariants = tv({
|
|
30
|
+
slots: {
|
|
31
|
+
root: 'w-full gap-2',
|
|
32
|
+
header: 'flex-row items-center justify-between gap-2',
|
|
33
|
+
track: 'w-full overflow-hidden rounded-full',
|
|
34
|
+
indicator: 'h-full rounded-full',
|
|
35
|
+
segments: 'w-full flex-row',
|
|
36
|
+
segment: 'flex-1 rounded-full',
|
|
37
|
+
},
|
|
38
|
+
variants: {
|
|
39
|
+
color: {
|
|
40
|
+
primary: { track: 'bg-primary/16', indicator: 'bg-primary', segment: 'bg-primary' },
|
|
41
|
+
success: { track: 'bg-success/16', indicator: 'bg-success', segment: 'bg-success' },
|
|
42
|
+
warning: { track: 'bg-warning/16', indicator: 'bg-warning', segment: 'bg-warning' },
|
|
43
|
+
destructive: {
|
|
44
|
+
track: 'bg-destructive/16',
|
|
45
|
+
indicator: 'bg-destructive',
|
|
46
|
+
segment: 'bg-destructive',
|
|
47
|
+
},
|
|
48
|
+
info: { track: 'bg-info/16', indicator: 'bg-info', segment: 'bg-info' },
|
|
49
|
+
muted: {
|
|
50
|
+
track: 'bg-muted',
|
|
51
|
+
indicator: 'bg-muted-foreground',
|
|
52
|
+
segment: 'bg-muted-foreground',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
size: {
|
|
56
|
+
sm: { track: 'h-1.5', segments: 'h-1.5 gap-1', segment: 'h-1.5' },
|
|
57
|
+
md: { track: 'h-2', segments: 'h-2 gap-1.5', segment: 'h-2' },
|
|
58
|
+
lg: { track: 'h-3', segments: 'h-3 gap-1.5', segment: 'h-3' },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
defaultVariants: {
|
|
62
|
+
color: 'primary',
|
|
63
|
+
size: 'md',
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
type MeterVariantProps = VariantProps<typeof meterVariants>;
|
|
68
|
+
|
|
69
|
+
export interface MeterProps
|
|
70
|
+
extends Omit<ViewProps, 'children'>,
|
|
71
|
+
MeterVariantProps {
|
|
72
|
+
className?: string;
|
|
73
|
+
/** The measurement, somewhere between `minValue` and `maxValue`. */
|
|
74
|
+
value: number;
|
|
75
|
+
/**
|
|
76
|
+
* The bottom of the scale — the value at which the bar reads as empty.
|
|
77
|
+
* Defaults to `0`.
|
|
78
|
+
*/
|
|
79
|
+
minValue?: number;
|
|
80
|
+
/**
|
|
81
|
+
* The top of the scale — the value at which the bar reads as full. Defaults
|
|
82
|
+
* to `100`, so a bare percentage needs neither bound set.
|
|
83
|
+
*/
|
|
84
|
+
maxValue?: number;
|
|
85
|
+
/**
|
|
86
|
+
* What is being measured. Also the accessibility label, and a meter without
|
|
87
|
+
* one announces a number with nothing attached to it — "seventy-five
|
|
88
|
+
* percent" of what is not an answer. Pass `accessibilityLabel` instead only
|
|
89
|
+
* where the caption is drawn some other way.
|
|
90
|
+
*/
|
|
91
|
+
label?: string;
|
|
92
|
+
/** Draw the value above the track, opposite the label. */
|
|
93
|
+
showValueLabel?: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Text for the value label. Overrides the formatted value — use it for a
|
|
96
|
+
* word where a number reads worse: `Strong`, `Almost full`.
|
|
97
|
+
*
|
|
98
|
+
* It is spoken whether or not it is drawn, so it can give a screen reader
|
|
99
|
+
* better words than the caption without changing the caption. Pair it with
|
|
100
|
+
* `showValueLabel` to draw it too.
|
|
101
|
+
*/
|
|
102
|
+
valueLabel?: string;
|
|
103
|
+
/**
|
|
104
|
+
* How to write the value, through `Intl.NumberFormat`. A `percent` style
|
|
105
|
+
* formats how far up the scale the value sits; every other style formats
|
|
106
|
+
* the value itself, so `{ style: 'unit', unit: 'gigabyte' }` against a
|
|
107
|
+
* `maxValue` of `256` reads `64 GB` rather than a percentage of it. Falls
|
|
108
|
+
* back to a rounded percent when omitted.
|
|
109
|
+
*/
|
|
110
|
+
formatOptions?: Intl.NumberFormatOptions;
|
|
111
|
+
/**
|
|
112
|
+
* Points on the scale where the colour changes, each `{ from, color }`. The
|
|
113
|
+
* highest one the reading has reached wins, so the order you list them in
|
|
114
|
+
* does not matter; below all of them the `color` prop applies.
|
|
115
|
+
*
|
|
116
|
+
* This is the difference between a meter and a bar: the colour is a
|
|
117
|
+
* judgement about the reading. Which direction is bad is yours to say —
|
|
118
|
+
* thresholds climbing to `destructive` suit a disk filling up, thresholds
|
|
119
|
+
* falling to it suit a battery running down.
|
|
120
|
+
*/
|
|
121
|
+
thresholds?: MeterThreshold[];
|
|
122
|
+
/**
|
|
123
|
+
* Draw the scale as this many discrete blocks instead of one continuous
|
|
124
|
+
* bar. Blocks are all or nothing, which is the point of them: four blocks
|
|
125
|
+
* say "three out of four" where a bar says "about seventy percent", and a
|
|
126
|
+
* password is not seventy percent strong.
|
|
127
|
+
*
|
|
128
|
+
* Any value above the floor lights at least one block, so a reading that
|
|
129
|
+
* is not empty never looks it.
|
|
130
|
+
*/
|
|
131
|
+
segments?: number;
|
|
132
|
+
/** Extra classes for the fill, or for a lit segment. */
|
|
133
|
+
indicatorClassName?: string;
|
|
134
|
+
/** Extra classes for the label + value row. */
|
|
135
|
+
headerClassName?: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface SegmentProps {
|
|
139
|
+
index: number;
|
|
140
|
+
/** How many blocks are lit, as a shared value so lighting stays off the JS thread. */
|
|
141
|
+
lit: SharedValue<number>;
|
|
142
|
+
className: string;
|
|
143
|
+
reducedMotion: boolean;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* One block of a segmented meter. It reads the lit count rather than a boolean
|
|
148
|
+
* so the whole row animates from one shared value, without a render per block.
|
|
149
|
+
*/
|
|
150
|
+
function Segment({ index, lit, className, reducedMotion }: SegmentProps) {
|
|
151
|
+
const style = useAnimatedStyle(() => {
|
|
152
|
+
const on = lit.value > index ? 1 : SEGMENT_UNLIT;
|
|
153
|
+
return {
|
|
154
|
+
opacity: reducedMotion
|
|
155
|
+
? on
|
|
156
|
+
: withTiming(on, { duration: SEGMENT_DURATION }),
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return <Animated.View style={style} className={className} />;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* A measurement on a fixed scale: disk used, battery left, a score, a password's
|
|
165
|
+
* strength. The fill is driven on the UI thread, so a value change costs one
|
|
166
|
+
* render and nothing per frame.
|
|
167
|
+
*
|
|
168
|
+
* A meter is not a progress bar. Progress is a task moving towards an end that
|
|
169
|
+
* finishing is the point of; a meter is a reading that happens to sit where it
|
|
170
|
+
* sits, and may go back down. Reach for
|
|
171
|
+
* [Progress](/docs/components/progress) when something is completing.
|
|
172
|
+
*
|
|
173
|
+
* `thresholds` paint the bar by where the reading falls, and `segments` draws
|
|
174
|
+
* the scale as discrete blocks for readings that are counted rather than
|
|
175
|
+
* measured.
|
|
176
|
+
*
|
|
177
|
+
* The value is read against `minValue` / `maxValue`, which default to 0 and
|
|
178
|
+
* 100 — so a percentage needs neither. Set them and the meter speaks in
|
|
179
|
+
* whatever is being measured: bytes, degrees, points. Nothing has to be
|
|
180
|
+
* converted to a percent on the way in, and the screen reader is told the real
|
|
181
|
+
* scale rather than a derived one.
|
|
182
|
+
*
|
|
183
|
+
* React Native has no meter role, so this announces as a progress bar with its
|
|
184
|
+
* value, range and unit attached. That is the closest the platform offers, and
|
|
185
|
+
* it is why `label` matters: the role will not distinguish a reading from a
|
|
186
|
+
* task, and the name is what does.
|
|
187
|
+
*/
|
|
188
|
+
export const Meter = forwardRef<View, MeterProps>(
|
|
189
|
+
(
|
|
190
|
+
{
|
|
191
|
+
className,
|
|
192
|
+
indicatorClassName,
|
|
193
|
+
headerClassName,
|
|
194
|
+
value,
|
|
195
|
+
minValue = 0,
|
|
196
|
+
maxValue = 100,
|
|
197
|
+
label,
|
|
198
|
+
showValueLabel = false,
|
|
199
|
+
valueLabel,
|
|
200
|
+
formatOptions,
|
|
201
|
+
thresholds,
|
|
202
|
+
segments,
|
|
203
|
+
color,
|
|
204
|
+
size,
|
|
205
|
+
accessibilityLabel,
|
|
206
|
+
...props
|
|
207
|
+
},
|
|
208
|
+
ref
|
|
209
|
+
) => {
|
|
210
|
+
const held = clamp(value, minValue, maxValue);
|
|
211
|
+
const fraction = fractionOf(value, minValue, maxValue);
|
|
212
|
+
const resolvedColor = colorFor(held, color ?? 'primary', thresholds);
|
|
213
|
+
const slots = meterVariants({ color: resolvedColor, size });
|
|
214
|
+
const reducedMotion = useReducedMotion();
|
|
215
|
+
|
|
216
|
+
const trackWidth = useSharedValue(0);
|
|
217
|
+
const progress = useSharedValue(fraction);
|
|
218
|
+
// Any reading above the floor lights a block, so "a little" never looks
|
|
219
|
+
// like "none". Rounding down would leave the first quarter of a
|
|
220
|
+
// four-block meter dark, which is the reading it is least able to afford.
|
|
221
|
+
const litCount = segments ? litSegments(fraction, segments) : 0;
|
|
222
|
+
const lit = useSharedValue(litCount);
|
|
223
|
+
|
|
224
|
+
// Reduce motion lands on the value instead of springing to it — the
|
|
225
|
+
// number is the information, and the overshoot is decoration.
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
progress.value = reducedMotion ? fraction : withSpring(fraction, SPRING);
|
|
228
|
+
}, [fraction, progress, reducedMotion]);
|
|
229
|
+
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
lit.value = litCount;
|
|
232
|
+
}, [lit, litCount]);
|
|
233
|
+
|
|
234
|
+
const onLayout = (event: LayoutChangeEvent) => {
|
|
235
|
+
trackWidth.value = event.nativeEvent.layout.width;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const fillStyle = useAnimatedStyle(() => ({
|
|
239
|
+
width: trackWidth.value * progress.value,
|
|
240
|
+
}));
|
|
241
|
+
|
|
242
|
+
const spoken = formatValue(held, fraction, valueLabel, formatOptions);
|
|
243
|
+
const showValue = showValueLabel;
|
|
244
|
+
const hasHeader = label != null || showValue;
|
|
245
|
+
|
|
246
|
+
const accessibility = {
|
|
247
|
+
accessibilityRole: 'progressbar' as const,
|
|
248
|
+
accessibilityLabel: accessibilityLabel ?? label,
|
|
249
|
+
accessibilityValue: {
|
|
250
|
+
min: minValue,
|
|
251
|
+
max: maxValue,
|
|
252
|
+
now: held,
|
|
253
|
+
// Without this the scale is read as a bare number. `text` is what
|
|
254
|
+
// carries the unit — the percent sign, the gigabytes, the word.
|
|
255
|
+
text: spoken,
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const bar =
|
|
260
|
+
segments && segments > 0 ? (
|
|
261
|
+
<View
|
|
262
|
+
ref={ref}
|
|
263
|
+
{...accessibility}
|
|
264
|
+
className={slots.segments({ className })}
|
|
265
|
+
{...props}
|
|
266
|
+
>
|
|
267
|
+
{Array.from({ length: segments }, (_, index) => (
|
|
268
|
+
<Segment
|
|
269
|
+
key={index}
|
|
270
|
+
index={index}
|
|
271
|
+
lit={lit}
|
|
272
|
+
reducedMotion={reducedMotion}
|
|
273
|
+
className={slots.segment({ className: indicatorClassName })}
|
|
274
|
+
/>
|
|
275
|
+
))}
|
|
276
|
+
</View>
|
|
277
|
+
) : (
|
|
278
|
+
<View
|
|
279
|
+
ref={ref}
|
|
280
|
+
{...accessibility}
|
|
281
|
+
className={slots.track({ className })}
|
|
282
|
+
onLayout={onLayout}
|
|
283
|
+
{...props}
|
|
284
|
+
>
|
|
285
|
+
<Animated.View
|
|
286
|
+
style={fillStyle}
|
|
287
|
+
className={slots.indicator({ className: indicatorClassName })}
|
|
288
|
+
/>
|
|
289
|
+
</View>
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
if (!hasHeader) return bar;
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<View className={slots.root()}>
|
|
296
|
+
<View className={slots.header({ className: headerClassName })}>
|
|
297
|
+
{label != null ? (
|
|
298
|
+
<Text size="sm" weight="medium" numberOfLines={1}>
|
|
299
|
+
{label}
|
|
300
|
+
</Text>
|
|
301
|
+
) : (
|
|
302
|
+
<View />
|
|
303
|
+
)}
|
|
304
|
+
{showValue ? (
|
|
305
|
+
<Text size="sm" muted numberOfLines={1}>
|
|
306
|
+
{spoken}
|
|
307
|
+
</Text>
|
|
308
|
+
) : null}
|
|
309
|
+
</View>
|
|
310
|
+
{bar}
|
|
311
|
+
</View>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
Meter.displayName = 'Meter';
|
|
317
|
+
|
|
318
|
+
export type { MeterColor, MeterThreshold };
|