panelui-native 0.66.1 → 0.71.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 +31 -1
- package/lib/module/components/accordion/index.js +28 -9
- package/lib/module/components/accordion/index.js.map +1 -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/button/index.js +14 -7
- package/lib/module/components/button/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/collapsible/index.js +280 -0
- package/lib/module/components/collapsible/index.js.map +1 -0
- package/lib/module/components/flow/index.js +66 -3
- package/lib/module/components/flow/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/live-line-chart/index.js +103 -9
- package/lib/module/components/live-line-chart/index.js.map +1 -1
- package/lib/module/components/live-line-chart/live-line-accessibility.js +47 -0
- package/lib/module/components/live-line-chart/live-line-accessibility.js.map +1 -0
- package/lib/module/components/live-line-chart/live-line-lifecycle.js +33 -0
- package/lib/module/components/live-line-chart/live-line-lifecycle.js.map +1 -0
- package/lib/module/components/marquee/index.js +194 -0
- package/lib/module/components/marquee/index.js.map +1 -0
- 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 +268 -0
- package/lib/module/components/meter/index.js.map +1 -0
- package/lib/module/components/meter/meter-scale.js +142 -0
- package/lib/module/components/meter/meter-scale.js.map +1 -0
- package/lib/module/components/planner/index.js +788 -0
- package/lib/module/components/planner/index.js.map +1 -0
- package/lib/module/components/planner/planner-announcement.js +30 -0
- package/lib/module/components/planner/planner-announcement.js.map +1 -0
- package/lib/module/components/planner/planner-entries.js +132 -0
- package/lib/module/components/planner/planner-entries.js.map +1 -0
- package/lib/module/components/planner/planner-grid-navigation.js +33 -0
- package/lib/module/components/planner/planner-grid-navigation.js.map +1 -0
- package/lib/module/components/planner/planner-lifecycle.js +45 -0
- package/lib/module/components/planner/planner-lifecycle.js.map +1 -0
- package/lib/module/components/popover/index.js +2 -2
- package/lib/module/components/popover/index.js.map +1 -1
- 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 +5 -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/module/primitives/focus-restoration-store.js +52 -0
- package/lib/module/primitives/focus-restoration-store.js.map +1 -0
- package/lib/module/primitives/portal.js +36 -2
- package/lib/module/primitives/portal.js.map +1 -1
- package/lib/typescript/src/components/accordion/index.d.ts +4 -0
- package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
- 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/button/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/collapsible/index.d.ts +76 -0
- package/lib/typescript/src/components/collapsible/index.d.ts.map +1 -0
- package/lib/typescript/src/components/flow/index.d.ts +7 -1
- package/lib/typescript/src/components/flow/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/live-line-chart/index.d.ts +21 -7
- package/lib/typescript/src/components/live-line-chart/index.d.ts.map +1 -1
- package/lib/typescript/src/components/live-line-chart/live-line-accessibility.d.ts +22 -0
- package/lib/typescript/src/components/live-line-chart/live-line-accessibility.d.ts.map +1 -0
- package/lib/typescript/src/components/live-line-chart/live-line-lifecycle.d.ts +23 -0
- package/lib/typescript/src/components/live-line-chart/live-line-lifecycle.d.ts.map +1 -0
- package/lib/typescript/src/components/marquee/index.d.ts +73 -0
- package/lib/typescript/src/components/marquee/index.d.ts.map +1 -0
- 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 +273 -0
- package/lib/typescript/src/components/meter/index.d.ts.map +1 -0
- package/lib/typescript/src/components/meter/meter-scale.d.ts +96 -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-announcement.d.ts +15 -0
- package/lib/typescript/src/components/planner/planner-announcement.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/planner/planner-grid-navigation.d.ts +4 -0
- package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts.map +1 -0
- package/lib/typescript/src/components/planner/planner-lifecycle.d.ts +20 -0
- package/lib/typescript/src/components/planner/planner-lifecycle.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 +5 -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/src/primitives/focus-restoration-store.d.ts +24 -0
- package/lib/typescript/src/primitives/focus-restoration-store.d.ts.map +1 -0
- package/lib/typescript/src/primitives/portal.d.ts +4 -0
- package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
- 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 +63 -4
- package/src/components/accordion/index.tsx +31 -8
- package/src/components/area-chart/index.tsx +27 -2
- package/src/components/bar-chart/index.tsx +27 -2
- package/src/components/button/index.tsx +11 -4
- package/src/components/candlestick-chart/index.tsx +30 -2
- package/src/components/collapsible/index.tsx +278 -0
- package/src/components/flow/index.tsx +93 -14
- 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/live-line-chart/index.tsx +130 -13
- package/src/components/live-line-chart/live-line-accessibility.ts +61 -0
- package/src/components/live-line-chart/live-line-lifecycle.ts +63 -0
- package/src/components/marquee/index.tsx +232 -0
- 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 +341 -0
- package/src/components/meter/meter-scale.ts +185 -0
- package/src/components/planner/index.tsx +997 -0
- package/src/components/planner/planner-announcement.ts +37 -0
- package/src/components/planner/planner-entries.ts +155 -0
- package/src/components/planner/planner-grid-navigation.ts +45 -0
- package/src/components/planner/planner-lifecycle.ts +76 -0
- package/src/components/popover/index.tsx +3 -3
- 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 +43 -0
- package/src/primitives/chart-accessibility.ts +102 -0
- package/src/primitives/focus-restoration-store.ts +70 -0
- package/src/primitives/portal.tsx +34 -2
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marquee — content that travels across its container and never runs out.
|
|
3
|
+
*
|
|
4
|
+
* For a strip of logos, a ticker of prices, a row of testimonials: anything
|
|
5
|
+
* whose job is to keep moving past a boundary rather than to be scrolled.
|
|
6
|
+
*
|
|
7
|
+
* ```tsx
|
|
8
|
+
* <Marquee spacing={24} speed={40}>
|
|
9
|
+
* <View className="flex-row gap-6">
|
|
10
|
+
* {sponsors.map((s) => <Logo key={s.id} src={s.logo} />)}
|
|
11
|
+
* </View>
|
|
12
|
+
* </Marquee>
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* ## How it loops
|
|
16
|
+
*
|
|
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.
|
|
22
|
+
*
|
|
23
|
+
* The travel is a linear timing driven on the UI thread, so it costs nothing
|
|
24
|
+
* per frame in JavaScript and keeps running while the thread is busy.
|
|
25
|
+
*
|
|
26
|
+
* ## Measurement needs room
|
|
27
|
+
*
|
|
28
|
+
* The copy that gets measured sits in a hidden scroller, because that is what
|
|
29
|
+
* lets the content report the width it *wants* rather than the width the
|
|
30
|
+
* container would give it. Content with no intrinsic size of its own — a child
|
|
31
|
+
* stretched to `flex-1`, an image with no dimensions — measures as nothing and
|
|
32
|
+
* the marquee will not start.
|
|
33
|
+
*
|
|
34
|
+
* ## Reading direction and reduced motion
|
|
35
|
+
*
|
|
36
|
+
* A horizontal marquee travels toward the end of the line, so it reverses in a
|
|
37
|
+
* right-to-left subtree. `reverse` flips it again from wherever it landed.
|
|
38
|
+
*
|
|
39
|
+
* With the operating system set to reduce motion the content is rendered once
|
|
40
|
+
* and held still. A ticker that never stops is the exact thing that setting is
|
|
41
|
+
* there to turn off, so this is not a shorter animation — it is none.
|
|
42
|
+
*
|
|
43
|
+
* Screen readers get one copy. The rest are duplicates of content already
|
|
44
|
+
* announced, and hearing a sponsor list four times over is not thoroughness.
|
|
45
|
+
*/
|
|
46
|
+
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
|
47
|
+
import {
|
|
48
|
+
ScrollView,
|
|
49
|
+
StyleSheet,
|
|
50
|
+
View,
|
|
51
|
+
type LayoutChangeEvent,
|
|
52
|
+
type ViewProps,
|
|
53
|
+
} from 'react-native';
|
|
54
|
+
import Animated, {
|
|
55
|
+
cancelAnimation,
|
|
56
|
+
Easing,
|
|
57
|
+
useAnimatedStyle,
|
|
58
|
+
useReducedMotion,
|
|
59
|
+
useSharedValue,
|
|
60
|
+
withRepeat,
|
|
61
|
+
withTiming,
|
|
62
|
+
} from 'react-native-reanimated';
|
|
63
|
+
import { useDirectionSign } from '../../hooks/use-direction';
|
|
64
|
+
import { cn } from '../../utils/cn';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Points per second. Slow enough that a word stays readable as it crosses,
|
|
68
|
+
* which is the speed a ticker is actually for.
|
|
69
|
+
*/
|
|
70
|
+
const DEFAULT_SPEED = 40;
|
|
71
|
+
|
|
72
|
+
export type MarqueeDirection = 'horizontal' | 'vertical';
|
|
73
|
+
|
|
74
|
+
export interface MarqueeProps extends Omit<ViewProps, 'children'> {
|
|
75
|
+
className?: string;
|
|
76
|
+
/** The content to repeat. Measured once, then tiled along the axis. */
|
|
77
|
+
children: ReactNode;
|
|
78
|
+
/**
|
|
79
|
+
* Travel speed in points per second. 40 by default, which is slow enough
|
|
80
|
+
* that a word stays readable as it crosses. The cycle time follows from this
|
|
81
|
+
* and the measured content, so longer content takes proportionally longer
|
|
82
|
+
* rather than moving faster.
|
|
83
|
+
*/
|
|
84
|
+
speed?: number;
|
|
85
|
+
/** Gap between the end of one copy and the start of the next. */
|
|
86
|
+
spacing?: number;
|
|
87
|
+
/** Axis the content travels along. */
|
|
88
|
+
direction?: MarqueeDirection;
|
|
89
|
+
/**
|
|
90
|
+
* Send it the other way: toward the start of the line, or upward. Applied
|
|
91
|
+
* after the reading direction, not instead of it.
|
|
92
|
+
*/
|
|
93
|
+
reverse?: boolean;
|
|
94
|
+
/** Set false to hold the content where it is. */
|
|
95
|
+
playing?: boolean;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function Marquee({
|
|
99
|
+
className,
|
|
100
|
+
children,
|
|
101
|
+
speed = DEFAULT_SPEED,
|
|
102
|
+
spacing = 0,
|
|
103
|
+
direction = 'horizontal',
|
|
104
|
+
reverse = false,
|
|
105
|
+
playing = true,
|
|
106
|
+
style,
|
|
107
|
+
onLayout,
|
|
108
|
+
...props
|
|
109
|
+
}: MarqueeProps) {
|
|
110
|
+
const horizontal = direction === 'horizontal';
|
|
111
|
+
const reducedMotion = useReducedMotion();
|
|
112
|
+
// Only the horizontal axis has a reading direction to follow; up is up.
|
|
113
|
+
const sign = useDirectionSign();
|
|
114
|
+
const flip = horizontal ? sign : 1;
|
|
115
|
+
|
|
116
|
+
const [viewport, setViewport] = useState(0);
|
|
117
|
+
const [content, setContent] = useState(0);
|
|
118
|
+
|
|
119
|
+
// The distance from one copy to the same point on the next, and therefore
|
|
120
|
+
// both the layout step and the exact loop length. They are the same number
|
|
121
|
+
// on purpose: taking the gap from anywhere else is how a seam appears.
|
|
122
|
+
const period = content > 0 ? content + spacing : 0;
|
|
123
|
+
|
|
124
|
+
const copies = useMemo(() => {
|
|
125
|
+
if (period <= 0 || viewport <= 0) return [];
|
|
126
|
+
// Enough to span the container, plus one trailing into view and one
|
|
127
|
+
// already past it — the two the travel consumes before the loop restarts.
|
|
128
|
+
const count = Math.ceil(viewport / period) + 2;
|
|
129
|
+
return Array.from({ length: count }, (_, index) => index);
|
|
130
|
+
}, [period, viewport]);
|
|
131
|
+
|
|
132
|
+
const offset = useSharedValue(0);
|
|
133
|
+
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
cancelAnimation(offset);
|
|
136
|
+
offset.value = 0;
|
|
137
|
+
if (reducedMotion || !playing || period <= 0 || speed <= 0) return undefined;
|
|
138
|
+
offset.value = withRepeat(
|
|
139
|
+
withTiming(period, {
|
|
140
|
+
duration: (period / speed) * 1000,
|
|
141
|
+
easing: Easing.linear,
|
|
142
|
+
}),
|
|
143
|
+
-1,
|
|
144
|
+
false
|
|
145
|
+
);
|
|
146
|
+
return () => cancelAnimation(offset);
|
|
147
|
+
}, [offset, period, playing, reducedMotion, speed]);
|
|
148
|
+
|
|
149
|
+
const trackStyle = useAnimatedStyle(() => {
|
|
150
|
+
const travel = (reverse ? offset.value : -offset.value) * flip;
|
|
151
|
+
return {
|
|
152
|
+
transform: [horizontal ? { translateX: travel } : { translateY: travel }],
|
|
153
|
+
};
|
|
154
|
+
}, [horizontal, reverse, flip]);
|
|
155
|
+
|
|
156
|
+
// The container's own measurement is what sizes the loop, so an `onLayout`
|
|
157
|
+
// passed in is called alongside it rather than replacing it.
|
|
158
|
+
const onContainerLayout = (event: LayoutChangeEvent) => {
|
|
159
|
+
const { width, height } = event.nativeEvent.layout;
|
|
160
|
+
setViewport(horizontal ? width : height);
|
|
161
|
+
onLayout?.(event);
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const onContentLayout = (event: LayoutChangeEvent) => {
|
|
165
|
+
const { width, height } = event.nativeEvent.layout;
|
|
166
|
+
setContent(horizontal ? width : height);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Nothing to loop, so nothing to clip or clone: render the content plainly
|
|
170
|
+
// and let it sit where it falls.
|
|
171
|
+
if (reducedMotion) {
|
|
172
|
+
return (
|
|
173
|
+
<View
|
|
174
|
+
className={cn('overflow-hidden', className)}
|
|
175
|
+
style={style}
|
|
176
|
+
onLayout={onLayout}
|
|
177
|
+
{...props}
|
|
178
|
+
>
|
|
179
|
+
{children}
|
|
180
|
+
</View>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<View
|
|
186
|
+
className={cn('overflow-hidden', className)}
|
|
187
|
+
style={style}
|
|
188
|
+
onLayout={onContainerLayout}
|
|
189
|
+
{...props}
|
|
190
|
+
>
|
|
191
|
+
{/* Measured, never seen. A scroller on this axis is what frees the
|
|
192
|
+
content to report its own size instead of the container's. */}
|
|
193
|
+
<ScrollView
|
|
194
|
+
horizontal={horizontal}
|
|
195
|
+
scrollEnabled={false}
|
|
196
|
+
style={styles.measure}
|
|
197
|
+
pointerEvents="none"
|
|
198
|
+
accessibilityElementsHidden
|
|
199
|
+
importantForAccessibility="no-hide-descendants"
|
|
200
|
+
>
|
|
201
|
+
<View onLayout={onContentLayout}>{children}</View>
|
|
202
|
+
</ScrollView>
|
|
203
|
+
|
|
204
|
+
<Animated.View style={[StyleSheet.absoluteFill, trackStyle]} pointerEvents="box-none">
|
|
205
|
+
{copies.map((index) => {
|
|
206
|
+
// Index 1 is the copy that starts flush with the container's edge,
|
|
207
|
+
// and it is the one a screen reader is given.
|
|
208
|
+
const spoken = index === 1;
|
|
209
|
+
const at = (index - 1) * period;
|
|
210
|
+
return (
|
|
211
|
+
<View
|
|
212
|
+
key={index}
|
|
213
|
+
style={[styles.copy, horizontal ? { left: at } : { top: at }]}
|
|
214
|
+
pointerEvents="box-none"
|
|
215
|
+
accessibilityElementsHidden={!spoken}
|
|
216
|
+
importantForAccessibility={spoken ? 'auto' : 'no-hide-descendants'}
|
|
217
|
+
>
|
|
218
|
+
{children}
|
|
219
|
+
</View>
|
|
220
|
+
);
|
|
221
|
+
})}
|
|
222
|
+
</Animated.View>
|
|
223
|
+
</View>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const styles = StyleSheet.create({
|
|
228
|
+
/* Laid out so it measures, hidden so it does not draw, and behind everything
|
|
229
|
+
so it can never intercept anything. */
|
|
230
|
+
measure: { opacity: 0, zIndex: -1 },
|
|
231
|
+
copy: { position: 'absolute' },
|
|
232
|
+
});
|
|
@@ -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
|
+
}
|