@duro-app/ui 5.21.0 → 5.22.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/dist/Table-Cw1B14R3.js.map +1 -1
- package/dist/components/CodeBlock/CodeBlock.d.ts +3 -21
- package/dist/components/CodeBlock/CodeBlock.d.ts.map +1 -1
- package/dist/components/CodeBlock/CodeBlock.stories.d.ts +4 -0
- package/dist/components/CodeBlock/CodeBlock.stories.d.ts.map +1 -1
- package/dist/components/CodeBlock/DiffLines.d.ts +9 -0
- package/dist/components/CodeBlock/DiffLines.d.ts.map +1 -0
- package/dist/components/CodeBlock/diffRoles.d.ts +14 -0
- package/dist/components/CodeBlock/diffRoles.d.ts.map +1 -0
- package/dist/components/CodeBlock/styles.css.d.ts +24 -0
- package/dist/components/CodeBlock/styles.css.d.ts.map +1 -1
- package/dist/components/CodeBlock/types.d.ts +29 -0
- package/dist/components/CodeBlock/types.d.ts.map +1 -0
- package/dist/components/Drawer/touchInput.d.ts +5 -1
- package/dist/components/Drawer/touchInput.d.ts.map +1 -1
- package/dist/components/Prose/Prose.d.ts +1 -1
- package/dist/components/SplitPane/SplitPane.d.ts +10 -1
- package/dist/components/SplitPane/SplitPane.d.ts.map +1 -1
- package/dist/components/SplitPane/SplitPane.stories.d.ts +11 -0
- package/dist/components/SplitPane/SplitPane.stories.d.ts.map +1 -1
- package/dist/components/SplitPane/styles.css.d.ts +22 -0
- package/dist/components/SplitPane/styles.css.d.ts.map +1 -1
- package/dist/components/Textarea/Textarea.d.ts +14 -2
- package/dist/components/Textarea/Textarea.d.ts.map +1 -1
- package/dist/components/Textarea/Textarea.meta.d.ts.map +1 -1
- package/dist/components/Textarea/Textarea.stories.d.ts +2 -0
- package/dist/components/Textarea/Textarea.stories.d.ts.map +1 -1
- package/dist/components/Textarea/autoGrow.d.ts +22 -0
- package/dist/components/Textarea/autoGrow.d.ts.map +1 -0
- package/dist/components/Textarea/grow.css.d.ts +12 -0
- package/dist/components/Textarea/grow.css.d.ts.map +1 -0
- package/dist/components/Textarea/grow.d.ts +2 -0
- package/dist/components/Textarea/grow.d.ts.map +1 -0
- package/dist/components/Textarea/native.css.d.ts +8 -0
- package/dist/components/Textarea/native.css.d.ts.map +1 -0
- package/dist/components/Textarea/useAutoGrowFallback.d.ts +9 -0
- package/dist/components/Textarea/useAutoGrowFallback.d.ts.map +1 -0
- package/dist/components/Toolbar/Toolbar.d.ts +2 -14
- package/dist/components/Toolbar/Toolbar.d.ts.map +1 -1
- package/dist/components/Toolbar/types.d.ts +15 -0
- package/dist/components/Toolbar/types.d.ts.map +1 -0
- package/dist/components/VirtualList/VirtualList.d.ts +5 -26
- package/dist/components/VirtualList/VirtualList.d.ts.map +1 -1
- package/dist/components/VirtualList/VirtualList.meta.d.ts.map +1 -1
- package/dist/components/VirtualList/VirtualList.stories.d.ts +16 -0
- package/dist/components/VirtualList/VirtualList.stories.d.ts.map +1 -1
- package/dist/components/VirtualList/native.css.d.ts +14 -0
- package/dist/components/VirtualList/native.css.d.ts.map +1 -0
- package/dist/components/VirtualList/props.d.ts +31 -0
- package/dist/components/VirtualList/props.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.js +3950 -3750
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/CodeBlock/CodeBlock.meta.ts +1 -1
- package/src/components/CodeBlock/CodeBlock.native.tsx +33 -0
- package/src/components/CodeBlock/CodeBlock.stories.tsx +54 -0
- package/src/components/CodeBlock/CodeBlock.tsx +8 -20
- package/src/components/CodeBlock/DiffLines.tsx +32 -0
- package/src/components/CodeBlock/diffRoles.ts +40 -0
- package/src/components/CodeBlock/styles.css.ts +17 -0
- package/src/components/CodeBlock/types.ts +29 -0
- package/src/components/Drawer/touchInput.ts +16 -2
- package/src/components/Prose/Prose.meta.ts +1 -1
- package/src/components/Prose/Prose.native.tsx +23 -0
- package/src/components/Prose/Prose.tsx +1 -1
- package/src/components/Prose/styles.native.css.ts +15 -0
- package/src/components/SplitPane/SplitPane.meta.ts +1 -1
- package/src/components/SplitPane/SplitPane.native.tsx +4 -0
- package/src/components/SplitPane/SplitPane.stories.tsx +207 -1
- package/src/components/SplitPane/SplitPane.tsx +50 -7
- package/src/components/SplitPane/styles.css.ts +28 -0
- package/src/components/Textarea/Textarea.meta.ts +6 -2
- package/src/components/Textarea/Textarea.stories.tsx +136 -1
- package/src/components/Textarea/Textarea.tsx +50 -3
- package/src/components/Textarea/autoGrow.ts +33 -0
- package/src/components/Textarea/grow.css.ts +18 -0
- package/src/components/Textarea/grow.native.ts +1 -0
- package/src/components/Textarea/grow.ts +1 -0
- package/src/components/Textarea/native.css.ts +16 -0
- package/src/components/Textarea/useAutoGrowFallback.native.ts +5 -0
- package/src/components/Textarea/useAutoGrowFallback.ts +35 -0
- package/src/components/Toolbar/Toolbar.meta.ts +1 -1
- package/src/components/Toolbar/Toolbar.native.tsx +27 -0
- package/src/components/Toolbar/Toolbar.tsx +3 -14
- package/src/components/Toolbar/types.ts +15 -0
- package/src/components/VirtualList/VirtualList.meta.ts +2 -1
- package/src/components/VirtualList/VirtualList.native.tsx +80 -0
- package/src/components/VirtualList/VirtualList.stories.tsx +171 -1
- package/src/components/VirtualList/VirtualList.tsx +116 -27
- package/src/components/VirtualList/native.css.ts +14 -0
- package/src/components/VirtualList/props.ts +31 -0
- package/src/index.native.ts +7 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import {useCallback, useRef} from 'react'
|
|
2
|
+
import {FlatList, type NativeScrollEvent, type NativeSyntheticEvent, StyleSheet} from 'react-native'
|
|
3
|
+
import {html} from 'react-strict-dom'
|
|
4
|
+
import {SIZES_PX} from '@duro-app/tokens/keys'
|
|
5
|
+
import {resolveLength} from '../../shared/length'
|
|
6
|
+
import type {VirtualListProps} from './props'
|
|
7
|
+
import {nativeStyles} from './native.css'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Native VirtualList: a FlatList with the web component's props. Prepending
|
|
11
|
+
* keeps the first visible row in place (`maintainVisibleContentPosition`),
|
|
12
|
+
* `onStartReached` is FlatList's own, and `anchor="end"` follows new items
|
|
13
|
+
* while the reader is at the bottom. FlatList measures rows itself, so
|
|
14
|
+
* `estimateSize` and `overscan` are accepted for one API and not needed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type {VirtualListProps}
|
|
18
|
+
|
|
19
|
+
// React Native has no listitem accessibility role, so rows are not announced as
|
|
20
|
+
// list items (the list itself is role list).
|
|
21
|
+
// holds-until: the device checkpoint. Native prepend (maintainVisibleContentPosition)
|
|
22
|
+
// and onStartReached are verified only on a device, not in CI; FlatList may fire
|
|
23
|
+
// onStartReached on mount for a list shorter than the view.
|
|
24
|
+
|
|
25
|
+
const BOTTOM_SLOP = SIZES_PX.touchTarget
|
|
26
|
+
const MAINTAIN_POSITION = {minIndexForVisible: 0}
|
|
27
|
+
|
|
28
|
+
export function VirtualList<T>({
|
|
29
|
+
items,
|
|
30
|
+
renderItem,
|
|
31
|
+
getKey,
|
|
32
|
+
anchor = 'start',
|
|
33
|
+
onStartReached,
|
|
34
|
+
maxHeight,
|
|
35
|
+
'aria-label': ariaLabel,
|
|
36
|
+
}: VirtualListProps<T>) {
|
|
37
|
+
const listRef = useRef<FlatList<T>>(null)
|
|
38
|
+
const atBottom = useRef(true)
|
|
39
|
+
|
|
40
|
+
const onScroll = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
41
|
+
const {contentOffset, contentSize, layoutMeasurement} = e.nativeEvent
|
|
42
|
+
atBottom.current =
|
|
43
|
+
contentSize.height - contentOffset.y - layoutMeasurement.height <= BOTTOM_SLOP
|
|
44
|
+
}, [])
|
|
45
|
+
|
|
46
|
+
const followEnd = useCallback(() => {
|
|
47
|
+
if (anchor === 'end' && atBottom.current) listRef.current?.scrollToEnd({animated: false})
|
|
48
|
+
}, [anchor])
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<html.div
|
|
52
|
+
style={[
|
|
53
|
+
nativeStyles.root,
|
|
54
|
+
maxHeight !== undefined && nativeStyles.rootMax(resolveLength(maxHeight)),
|
|
55
|
+
]}
|
|
56
|
+
>
|
|
57
|
+
<FlatList
|
|
58
|
+
ref={listRef}
|
|
59
|
+
style={maxHeight === undefined ? layout.fill : layout.capped}
|
|
60
|
+
accessibilityLabel={ariaLabel}
|
|
61
|
+
accessibilityRole="list"
|
|
62
|
+
data={items as T[]}
|
|
63
|
+
keyExtractor={(item, index) => String(getKey(item, index))}
|
|
64
|
+
renderItem={({item, index}) => <>{renderItem(item, index)}</>}
|
|
65
|
+
maintainVisibleContentPosition={MAINTAIN_POSITION}
|
|
66
|
+
onStartReached={onStartReached ? () => onStartReached() : undefined}
|
|
67
|
+
onStartReachedThreshold={0.1}
|
|
68
|
+
onScroll={onScroll}
|
|
69
|
+
scrollEventThrottle={16}
|
|
70
|
+
onContentSizeChange={followEnd}
|
|
71
|
+
/>
|
|
72
|
+
</html.div>
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Fill the parent unless capped; with a cap the list shrinks to its content.
|
|
77
|
+
const layout = StyleSheet.create({
|
|
78
|
+
fill: {flexGrow: 1, flexShrink: 1},
|
|
79
|
+
capped: {flexGrow: 0, flexShrink: 1},
|
|
80
|
+
})
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
-
import {
|
|
2
|
+
import {useState} from 'react'
|
|
3
|
+
import {html} from 'react-strict-dom'
|
|
4
|
+
import {expect, fn, waitFor} from 'storybook/test'
|
|
3
5
|
import type {SizeToken} from '@duro-app/tokens/keys'
|
|
4
6
|
import {VirtualList} from './VirtualList'
|
|
5
7
|
import {Text} from '../Text/Text'
|
|
@@ -93,3 +95,171 @@ export const AnchoredToTheEnd: Story = {
|
|
|
93
95
|
await expect(last).toBeDefined()
|
|
94
96
|
},
|
|
95
97
|
}
|
|
98
|
+
|
|
99
|
+
function firstVisible(list: HTMLElement) {
|
|
100
|
+
const top = list.getBoundingClientRect().top
|
|
101
|
+
const row = Array.from(list.querySelectorAll<HTMLElement>('[role="listitem"]')).find(
|
|
102
|
+
(r) => r.getBoundingClientRect().bottom > top,
|
|
103
|
+
)
|
|
104
|
+
if (!row) throw new Error('no visible row')
|
|
105
|
+
return {text: row.textContent, offset: row.getBoundingClientRect().top - top}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const HISTORY = Array.from({length: 200}, (_, i) => ({id: i, text: `Message ${i + 1}`}))
|
|
109
|
+
|
|
110
|
+
function Pager({onStart}: {onStart?: () => void}) {
|
|
111
|
+
const [items, setItems] = useState(HISTORY.slice(100))
|
|
112
|
+
return (
|
|
113
|
+
<>
|
|
114
|
+
<html.button
|
|
115
|
+
type="button"
|
|
116
|
+
onClick={() => setItems((prev) => [...HISTORY.slice(prev[0].id - 20, prev[0].id), ...prev])}
|
|
117
|
+
>
|
|
118
|
+
Load older
|
|
119
|
+
</html.button>
|
|
120
|
+
<VirtualList
|
|
121
|
+
aria-label="Transcript"
|
|
122
|
+
items={items}
|
|
123
|
+
getKey={(m) => m.id}
|
|
124
|
+
estimateSize="controlLg"
|
|
125
|
+
maxHeight="previewMaxH"
|
|
126
|
+
onStartReached={onStart}
|
|
127
|
+
renderItem={(m) => <Text>{m.text}</Text>}
|
|
128
|
+
/>
|
|
129
|
+
</>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const PrependKeepsPosition: Story = {
|
|
134
|
+
render: () => <Pager />,
|
|
135
|
+
play: async ({canvas, userEvent}) => {
|
|
136
|
+
const list = canvas.getByRole('list', {name: 'Transcript'})
|
|
137
|
+
await waitFor(() => expect(canvas.getAllByRole('listitem').length).toBeGreaterThan(0))
|
|
138
|
+
list.scrollTop = 600
|
|
139
|
+
// scrolled and re-rendered: the first visible row is no longer the first item
|
|
140
|
+
await waitFor(() => {
|
|
141
|
+
expect(list.scrollTop).toBe(600)
|
|
142
|
+
expect(firstVisible(list).text).not.toBe('Message 101')
|
|
143
|
+
})
|
|
144
|
+
const before = firstVisible(list)
|
|
145
|
+
await userEvent.click(canvas.getByRole('button', {name: 'Load older'}))
|
|
146
|
+
await waitFor(() =>
|
|
147
|
+
expect(canvas.getAllByRole('listitem')[0]).toHaveAttribute('aria-setsize', '120'),
|
|
148
|
+
)
|
|
149
|
+
await waitFor(() => {
|
|
150
|
+
const after = firstVisible(list)
|
|
151
|
+
expect(after.text).toBe(before.text)
|
|
152
|
+
expect(Math.abs(after.offset - before.offset)).toBeLessThan(2)
|
|
153
|
+
})
|
|
154
|
+
},
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const StartReachedFiresOncePerApproach: StoryObj<{onStartReached: () => void}> = {
|
|
158
|
+
args: {onStartReached: fn()},
|
|
159
|
+
render: (args) => <Pager onStart={args.onStartReached} />,
|
|
160
|
+
play: async ({canvas, args}) => {
|
|
161
|
+
const onStart = args.onStartReached
|
|
162
|
+
const list = canvas.getByRole('list', {name: 'Transcript'})
|
|
163
|
+
await waitFor(() => expect(canvas.getAllByRole('listitem').length).toBeGreaterThan(0))
|
|
164
|
+
const scrollTo = async (top: number) => {
|
|
165
|
+
list.scrollTop = top
|
|
166
|
+
await waitFor(() => expect(list.scrollTop).toBe(top))
|
|
167
|
+
// The scroll listener reads rows once per animation frame, so two scrollTop
|
|
168
|
+
// writes in one frame would lose the first offset: wait two frames.
|
|
169
|
+
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
|
170
|
+
}
|
|
171
|
+
await scrollTo(600)
|
|
172
|
+
await expect(onStart).not.toHaveBeenCalled()
|
|
173
|
+
await scrollTo(0)
|
|
174
|
+
await waitFor(() => expect(onStart).toHaveBeenCalledTimes(1))
|
|
175
|
+
await scrollTo(1)
|
|
176
|
+
await expect(onStart).toHaveBeenCalledTimes(1)
|
|
177
|
+
await scrollTo(600)
|
|
178
|
+
await scrollTo(0)
|
|
179
|
+
await waitFor(() => expect(onStart).toHaveBeenCalledTimes(2))
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A list too short to scroll asks for older history at once, since no scroll can reach its start. */
|
|
184
|
+
export const ShortListAsksForMore: StoryObj<{onStartReached: () => void}> = {
|
|
185
|
+
tags: ['!dev', '!autodocs'],
|
|
186
|
+
args: {onStartReached: fn()},
|
|
187
|
+
render: (args) => (
|
|
188
|
+
<VirtualList
|
|
189
|
+
aria-label="Transcript"
|
|
190
|
+
items={HISTORY.slice(0, 2)}
|
|
191
|
+
getKey={(m) => m.id}
|
|
192
|
+
estimateSize="controlLg"
|
|
193
|
+
maxHeight="previewMaxH"
|
|
194
|
+
onStartReached={args.onStartReached}
|
|
195
|
+
renderItem={(m) => <Text>{m.text}</Text>}
|
|
196
|
+
/>
|
|
197
|
+
),
|
|
198
|
+
play: async ({canvas, args}) => {
|
|
199
|
+
await waitFor(() => expect(canvas.getAllByRole('listitem').length).toBe(2))
|
|
200
|
+
await waitFor(() => expect(args.onStartReached).toHaveBeenCalledTimes(1))
|
|
201
|
+
},
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function ShortPager({onStart}: {onStart: () => void}) {
|
|
205
|
+
const [from, setFrom] = useState(198)
|
|
206
|
+
return (
|
|
207
|
+
<VirtualList
|
|
208
|
+
aria-label="Transcript"
|
|
209
|
+
items={HISTORY.slice(from)}
|
|
210
|
+
getKey={(m) => m.id}
|
|
211
|
+
estimateSize="controlLg"
|
|
212
|
+
maxHeight="previewMaxH"
|
|
213
|
+
onStartReached={() => {
|
|
214
|
+
onStart()
|
|
215
|
+
setFrom((f) => Math.max(f - 1, 196))
|
|
216
|
+
}}
|
|
217
|
+
renderItem={(m) => <Text>{m.text}</Text>}
|
|
218
|
+
/>
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** A prepend that leaves the list short asks again; when no more comes, it stops. */
|
|
223
|
+
export const ShortListKeepsAskingUntilNoMore: StoryObj<{onStartReached: () => void}> = {
|
|
224
|
+
tags: ['!dev', '!autodocs'],
|
|
225
|
+
args: {onStartReached: fn()},
|
|
226
|
+
render: (args) => <ShortPager onStart={args.onStartReached} />,
|
|
227
|
+
play: async ({canvas, args}) => {
|
|
228
|
+
// 2 items, then 3, then 4 (the last prepend): one call for each, then no more
|
|
229
|
+
await waitFor(() => expect(canvas.getAllByRole('listitem').length).toBe(4))
|
|
230
|
+
await waitFor(() => expect(args.onStartReached).toHaveBeenCalledTimes(3))
|
|
231
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
232
|
+
await expect(args.onStartReached).toHaveBeenCalledTimes(3)
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function FreshArray({onStart}: {onStart: () => void}) {
|
|
237
|
+
const [calls, setCalls] = useState(0)
|
|
238
|
+
return (
|
|
239
|
+
<VirtualList
|
|
240
|
+
aria-label={`Transcript ${calls}`}
|
|
241
|
+
// a new array on every render, with the same content
|
|
242
|
+
items={HISTORY.slice(0, 2)}
|
|
243
|
+
getKey={(m) => m.id}
|
|
244
|
+
estimateSize="controlLg"
|
|
245
|
+
maxHeight="previewMaxH"
|
|
246
|
+
onStartReached={() => {
|
|
247
|
+
onStart()
|
|
248
|
+
setCalls((c) => c + 1)
|
|
249
|
+
}}
|
|
250
|
+
renderItem={(m) => <Text>{m.text}</Text>}
|
|
251
|
+
/>
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** A new array with the same content does not ask again, even when the call re-renders. */
|
|
256
|
+
export const ShortListDoesNotLoopOnFreshArrays: StoryObj<{onStartReached: () => void}> = {
|
|
257
|
+
tags: ['!dev', '!autodocs'],
|
|
258
|
+
args: {onStartReached: fn()},
|
|
259
|
+
render: (args) => <FreshArray onStart={args.onStartReached} />,
|
|
260
|
+
play: async ({canvas, args}) => {
|
|
261
|
+
await waitFor(() => expect(canvas.getByRole('list', {name: 'Transcript 1'})).toBeVisible())
|
|
262
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
263
|
+
await expect(args.onStartReached).toHaveBeenCalledTimes(1)
|
|
264
|
+
},
|
|
265
|
+
}
|
|
@@ -1,41 +1,29 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {useCallback, useEffect, useLayoutEffect, useRef} from 'react'
|
|
2
2
|
import {html} from 'react-strict-dom'
|
|
3
3
|
import {useVirtualizer} from '@tanstack/react-virtual'
|
|
4
|
-
import {SIZES_PX
|
|
5
|
-
import {resolveLength
|
|
4
|
+
import {SIZES_PX} from '@duro-app/tokens/keys'
|
|
5
|
+
import {resolveLength} from '../../shared/length'
|
|
6
6
|
import {styles} from './styles.css'
|
|
7
|
+
import type {VirtualListProps} from './props'
|
|
7
8
|
|
|
8
|
-
export
|
|
9
|
-
items: readonly T[]
|
|
10
|
-
/** Each row's starting height estimate, a size token; rows are then measured. */
|
|
11
|
-
estimateSize: SizeToken
|
|
12
|
-
renderItem: (item: T, index: number) => ReactNode
|
|
13
|
-
/** A stable key per item (an id), so rows keep their measurement as items change. */
|
|
14
|
-
getKey: (item: T, index: number) => string | number
|
|
15
|
-
/** Rows rendered beyond each edge of the view. Default 5 */
|
|
16
|
-
overscan?: number
|
|
17
|
-
/**
|
|
18
|
-
* `end` starts at the last item and stays pinned to the bottom while new
|
|
19
|
-
* items arrive, as long as the reader is at the bottom (a transcript, a log).
|
|
20
|
-
* Default `start`.
|
|
21
|
-
*/
|
|
22
|
-
anchor?: 'start' | 'end'
|
|
23
|
-
/** Cap the list's height; without it, it fills its parent. */
|
|
24
|
-
maxHeight?: Length
|
|
25
|
-
/** The list's accessible name. */
|
|
26
|
-
'aria-label': string
|
|
27
|
-
}
|
|
9
|
+
export type {VirtualListProps}
|
|
28
10
|
|
|
29
11
|
/** Within this many px of the end counts as "at the bottom" for `anchor="end"`. */
|
|
30
12
|
const BOTTOM_SLOP = SIZES_PX.touchTarget
|
|
13
|
+
/** Frames a prepend keeps re-pinning its anchor row while new rows are measured. */
|
|
14
|
+
// holds-until: a prepend whose rows take more than 6 frames to measure (images,
|
|
15
|
+
// late fonts, mermaid) and visibly jumps; then settle on a ResizeObserver instead.
|
|
16
|
+
const PIN_FRAMES = 6
|
|
17
|
+
/** Within this many px of the start counts as "reached" for `onStartReached`. */
|
|
18
|
+
const START_SLOP = SIZES_PX.touchTarget
|
|
31
19
|
|
|
32
20
|
/**
|
|
33
21
|
* A windowed list: only the rows in view (plus `overscan`) are mounted, so a
|
|
34
22
|
* list of tens of thousands stays fast. Rows are measured as they render, so
|
|
35
23
|
* they may differ in height. It is a `role="list"`; each row is a listitem
|
|
36
24
|
* with `aria-setsize` and `aria-posinset`, so assistive tech knows the full
|
|
37
|
-
* length while most rows are not in the DOM.
|
|
38
|
-
*
|
|
25
|
+
* length while most rows are not in the DOM. On native it is
|
|
26
|
+
* a FlatList with the same props.
|
|
39
27
|
*/
|
|
40
28
|
export function VirtualList<T>({
|
|
41
29
|
items,
|
|
@@ -44,6 +32,7 @@ export function VirtualList<T>({
|
|
|
44
32
|
getKey,
|
|
45
33
|
overscan = 5,
|
|
46
34
|
anchor = 'start',
|
|
35
|
+
onStartReached,
|
|
47
36
|
maxHeight,
|
|
48
37
|
'aria-label': ariaLabel,
|
|
49
38
|
}: VirtualListProps<T>) {
|
|
@@ -57,16 +46,116 @@ export function VirtualList<T>({
|
|
|
57
46
|
})
|
|
58
47
|
|
|
59
48
|
// `end`: open at the last item, and follow new items while at the bottom.
|
|
49
|
+
// `onStartReached`: fire once per approach to the start, re-arming when the
|
|
50
|
+
// reader moves away (a prepend that moves the start away re-arms it too).
|
|
60
51
|
const atBottom = useRef(true)
|
|
52
|
+
const startArmed = useRef(true)
|
|
53
|
+
const startCallback = useRef(onStartReached)
|
|
54
|
+
startCallback.current = onStartReached
|
|
55
|
+
// The items a short list last asked more for: a new array with the same
|
|
56
|
+
// first key and length is the same content, so it does not ask again.
|
|
57
|
+
const askedFor = useRef<string | undefined>(undefined)
|
|
61
58
|
useEffect(() => {
|
|
62
59
|
const el = scrollRef.current
|
|
63
|
-
if (!el
|
|
60
|
+
if (!el) return
|
|
64
61
|
const onScroll = () => {
|
|
65
62
|
atBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_SLOP
|
|
63
|
+
if (el.scrollTop > START_SLOP) {
|
|
64
|
+
startArmed.current = true
|
|
65
|
+
} else if (startArmed.current) {
|
|
66
|
+
startArmed.current = false
|
|
67
|
+
startCallback.current?.()
|
|
68
|
+
}
|
|
66
69
|
}
|
|
67
70
|
el.addEventListener('scroll', onScroll, {passive: true})
|
|
68
71
|
return () => el.removeEventListener('scroll', onScroll)
|
|
69
|
-
}, [
|
|
72
|
+
}, [])
|
|
73
|
+
|
|
74
|
+
// Prepend: the rows above are new and only estimated until measured, so
|
|
75
|
+
// pin the first visible row (by key, read from the DOM while the list is
|
|
76
|
+
// still as the reader last saw it) at its on-screen offset, and repeat for a
|
|
77
|
+
// few frames while the new rows settle to their real heights.
|
|
78
|
+
const firstKey = useRef<string | number | undefined>(undefined)
|
|
79
|
+
const seenRow = useRef<{key: string | number; offset: number} | null>(null)
|
|
80
|
+
const itemsRef = useRef(items)
|
|
81
|
+
// Latest getKey, so the effects below need not re-run when its identity changes.
|
|
82
|
+
const getKeyRef = useRef(getKey)
|
|
83
|
+
getKeyRef.current = getKey
|
|
84
|
+
const readSeenRow = useCallback(() => {
|
|
85
|
+
const el = scrollRef.current
|
|
86
|
+
if (!el) return
|
|
87
|
+
const top = el.getBoundingClientRect().top
|
|
88
|
+
for (const row of el.querySelectorAll<HTMLElement>('[role="listitem"]')) {
|
|
89
|
+
const rect = row.getBoundingClientRect()
|
|
90
|
+
const index = Number(row.dataset.index)
|
|
91
|
+
const item = itemsRef.current[index]
|
|
92
|
+
if (rect.bottom > top && item !== undefined) {
|
|
93
|
+
seenRow.current = {key: getKeyRef.current(item, index), offset: rect.top - top}
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}, [])
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
const el = scrollRef.current
|
|
100
|
+
if (!el) return
|
|
101
|
+
// Coalesce: at most one read per frame, however many scroll events fire.
|
|
102
|
+
let frame = 0
|
|
103
|
+
const onScroll = () => {
|
|
104
|
+
if (frame) return
|
|
105
|
+
frame = requestAnimationFrame(() => {
|
|
106
|
+
frame = 0
|
|
107
|
+
readSeenRow()
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
el.addEventListener('scroll', onScroll, {passive: true})
|
|
111
|
+
return () => {
|
|
112
|
+
el.removeEventListener('scroll', onScroll)
|
|
113
|
+
if (frame) cancelAnimationFrame(frame)
|
|
114
|
+
}
|
|
115
|
+
}, [readSeenRow])
|
|
116
|
+
useLayoutEffect(() => {
|
|
117
|
+
const el = scrollRef.current
|
|
118
|
+
const previous = firstKey.current
|
|
119
|
+
firstKey.current = items.length > 0 ? getKeyRef.current(items[0], 0) : undefined
|
|
120
|
+
itemsRef.current = items
|
|
121
|
+
// A list too short to scroll cannot be scrolled to its start: ask for more
|
|
122
|
+
// now, once per distinct content, until it fills.
|
|
123
|
+
const content = `${String(firstKey.current)}:${items.length}`
|
|
124
|
+
if (
|
|
125
|
+
el &&
|
|
126
|
+
items.length > 0 &&
|
|
127
|
+
el.scrollHeight <= el.clientHeight &&
|
|
128
|
+
askedFor.current !== content
|
|
129
|
+
) {
|
|
130
|
+
askedFor.current = content
|
|
131
|
+
startCallback.current?.()
|
|
132
|
+
}
|
|
133
|
+
const kept = seenRow.current
|
|
134
|
+
let settleFrame = 0
|
|
135
|
+
if (el && kept && previous !== undefined && previous !== firstKey.current) {
|
|
136
|
+
const at = items.findIndex((item, i) => getKeyRef.current(item, i) === kept.key)
|
|
137
|
+
if (at >= 0) {
|
|
138
|
+
const pin = () => {
|
|
139
|
+
const start = virtualizer.measurementsCache[at]?.start
|
|
140
|
+
if (start !== undefined) el.scrollTop = start - kept.offset
|
|
141
|
+
}
|
|
142
|
+
pin()
|
|
143
|
+
if (el.scrollTop > START_SLOP) startArmed.current = true
|
|
144
|
+
let frames = 0
|
|
145
|
+
const settle = () => {
|
|
146
|
+
pin()
|
|
147
|
+
if (frames++ < PIN_FRAMES) settleFrame = requestAnimationFrame(settle)
|
|
148
|
+
else readSeenRow()
|
|
149
|
+
}
|
|
150
|
+
settleFrame = requestAnimationFrame(settle)
|
|
151
|
+
// A later prepend (or unmount) stops this loop, so it cannot re-pin a stale row.
|
|
152
|
+
return () => cancelAnimationFrame(settleFrame)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
readSeenRow()
|
|
156
|
+
// Runs when `items` changes; scrolling is read by the frame-coalesced listener.
|
|
157
|
+
}, [items, readSeenRow, virtualizer])
|
|
158
|
+
|
|
70
159
|
useLayoutEffect(() => {
|
|
71
160
|
if (anchor !== 'end' || items.length === 0 || !atBottom.current) return
|
|
72
161
|
virtualizer.scrollToIndex(items.length - 1, {align: 'end'})
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
|
|
3
|
+
/** The native list sits in a view that fills its parent or stops at a cap. */
|
|
4
|
+
export const nativeStyles = css.create({
|
|
5
|
+
root: {
|
|
6
|
+
width: '100%',
|
|
7
|
+
height: '100%',
|
|
8
|
+
minHeight: 0,
|
|
9
|
+
},
|
|
10
|
+
rootMax: (maxHeight: number | string) => ({
|
|
11
|
+
height: 'auto',
|
|
12
|
+
maxHeight,
|
|
13
|
+
}),
|
|
14
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type {ReactNode} from 'react'
|
|
2
|
+
import type {SizeToken} from '@duro-app/tokens/keys'
|
|
3
|
+
import type {Length} from '../../shared/length'
|
|
4
|
+
|
|
5
|
+
export interface VirtualListProps<T> {
|
|
6
|
+
items: readonly T[]
|
|
7
|
+
/** Each row's starting height estimate, a size token; rows are then measured. */
|
|
8
|
+
estimateSize: SizeToken
|
|
9
|
+
renderItem: (item: T, index: number) => ReactNode
|
|
10
|
+
/** A stable key per item (an id), so rows keep their measurement as items change. */
|
|
11
|
+
getKey: (item: T, index: number) => string | number
|
|
12
|
+
/** Rows rendered beyond each edge of the view. Default 5 */
|
|
13
|
+
overscan?: number
|
|
14
|
+
/**
|
|
15
|
+
* `end` starts at the last item and stays pinned to the bottom while new
|
|
16
|
+
* items arrive, as long as the reader is at the bottom (a transcript, a log).
|
|
17
|
+
* Default `start`.
|
|
18
|
+
*/
|
|
19
|
+
anchor?: 'start' | 'end'
|
|
20
|
+
/**
|
|
21
|
+
* Called once each time the reader scrolls to within 44px (touchTarget) of the
|
|
22
|
+
* start (load older history). It fires again only after they move away.
|
|
23
|
+
* A list too short to scroll calls it once for each new first item or length, until it fills.
|
|
24
|
+
* Prepending items keeps the first visible row where it was on screen.
|
|
25
|
+
*/
|
|
26
|
+
onStartReached?: () => void
|
|
27
|
+
/** Cap the list's height; without it, it fills its parent. */
|
|
28
|
+
maxHeight?: Length
|
|
29
|
+
/** The list's accessible name. */
|
|
30
|
+
'aria-label': string
|
|
31
|
+
}
|
package/src/index.native.ts
CHANGED
|
@@ -27,6 +27,11 @@ export {Text, type TextVariant, type TextColor} from './components/Text/Text'
|
|
|
27
27
|
export {TextLink, type TextLinkVariant} from './components/TextLink/TextLink'
|
|
28
28
|
export {Textarea, type TextareaVariant} from './components/Textarea/Textarea'
|
|
29
29
|
|
|
30
|
+
// Text and layout
|
|
31
|
+
export {CodeBlock, type CodeBlockProps} from './components/CodeBlock/CodeBlock'
|
|
32
|
+
export {Prose} from './components/Prose/Prose'
|
|
33
|
+
export {Toolbar, type ToolbarProps} from './components/Toolbar/Toolbar'
|
|
34
|
+
|
|
30
35
|
// Form field wrappers (RN-safe — used by Input/Textarea/Checkbox)
|
|
31
36
|
export {Field} from './components/Field/Field'
|
|
32
37
|
export {InputGroup} from './components/InputGroup/InputGroup'
|
|
@@ -57,6 +62,8 @@ export {Tree} from './components/Tree/Tree'
|
|
|
57
62
|
export {Select} from './components/Select/Select'
|
|
58
63
|
export {Combobox} from './components/Combobox/Combobox'
|
|
59
64
|
export {Listbox} from './components/Listbox/Listbox'
|
|
65
|
+
// A FlatList with the web props; prepend keeps position.
|
|
66
|
+
export {VirtualList, type VirtualListProps} from './components/VirtualList/VirtualList'
|
|
60
67
|
// The platform's own scroll indicator: Scrollbar and Thumb draw nothing.
|
|
61
68
|
export {ScrollArea} from './components/ScrollArea/ScrollArea'
|
|
62
69
|
export type {ListboxAnchorProps, ListboxMaxHeight} from './components/Listbox/Listbox'
|