@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.
Files changed (93) hide show
  1. package/dist/Table-Cw1B14R3.js.map +1 -1
  2. package/dist/components/CodeBlock/CodeBlock.d.ts +3 -21
  3. package/dist/components/CodeBlock/CodeBlock.d.ts.map +1 -1
  4. package/dist/components/CodeBlock/CodeBlock.stories.d.ts +4 -0
  5. package/dist/components/CodeBlock/CodeBlock.stories.d.ts.map +1 -1
  6. package/dist/components/CodeBlock/DiffLines.d.ts +9 -0
  7. package/dist/components/CodeBlock/DiffLines.d.ts.map +1 -0
  8. package/dist/components/CodeBlock/diffRoles.d.ts +14 -0
  9. package/dist/components/CodeBlock/diffRoles.d.ts.map +1 -0
  10. package/dist/components/CodeBlock/styles.css.d.ts +24 -0
  11. package/dist/components/CodeBlock/styles.css.d.ts.map +1 -1
  12. package/dist/components/CodeBlock/types.d.ts +29 -0
  13. package/dist/components/CodeBlock/types.d.ts.map +1 -0
  14. package/dist/components/Drawer/touchInput.d.ts +5 -1
  15. package/dist/components/Drawer/touchInput.d.ts.map +1 -1
  16. package/dist/components/Prose/Prose.d.ts +1 -1
  17. package/dist/components/SplitPane/SplitPane.d.ts +10 -1
  18. package/dist/components/SplitPane/SplitPane.d.ts.map +1 -1
  19. package/dist/components/SplitPane/SplitPane.stories.d.ts +11 -0
  20. package/dist/components/SplitPane/SplitPane.stories.d.ts.map +1 -1
  21. package/dist/components/SplitPane/styles.css.d.ts +22 -0
  22. package/dist/components/SplitPane/styles.css.d.ts.map +1 -1
  23. package/dist/components/Textarea/Textarea.d.ts +14 -2
  24. package/dist/components/Textarea/Textarea.d.ts.map +1 -1
  25. package/dist/components/Textarea/Textarea.meta.d.ts.map +1 -1
  26. package/dist/components/Textarea/Textarea.stories.d.ts +2 -0
  27. package/dist/components/Textarea/Textarea.stories.d.ts.map +1 -1
  28. package/dist/components/Textarea/autoGrow.d.ts +22 -0
  29. package/dist/components/Textarea/autoGrow.d.ts.map +1 -0
  30. package/dist/components/Textarea/grow.css.d.ts +12 -0
  31. package/dist/components/Textarea/grow.css.d.ts.map +1 -0
  32. package/dist/components/Textarea/grow.d.ts +2 -0
  33. package/dist/components/Textarea/grow.d.ts.map +1 -0
  34. package/dist/components/Textarea/native.css.d.ts +8 -0
  35. package/dist/components/Textarea/native.css.d.ts.map +1 -0
  36. package/dist/components/Textarea/useAutoGrowFallback.d.ts +9 -0
  37. package/dist/components/Textarea/useAutoGrowFallback.d.ts.map +1 -0
  38. package/dist/components/Toolbar/Toolbar.d.ts +2 -14
  39. package/dist/components/Toolbar/Toolbar.d.ts.map +1 -1
  40. package/dist/components/Toolbar/types.d.ts +15 -0
  41. package/dist/components/Toolbar/types.d.ts.map +1 -0
  42. package/dist/components/VirtualList/VirtualList.d.ts +5 -26
  43. package/dist/components/VirtualList/VirtualList.d.ts.map +1 -1
  44. package/dist/components/VirtualList/VirtualList.meta.d.ts.map +1 -1
  45. package/dist/components/VirtualList/VirtualList.stories.d.ts +16 -0
  46. package/dist/components/VirtualList/VirtualList.stories.d.ts.map +1 -1
  47. package/dist/components/VirtualList/native.css.d.ts +14 -0
  48. package/dist/components/VirtualList/native.css.d.ts.map +1 -0
  49. package/dist/components/VirtualList/props.d.ts +31 -0
  50. package/dist/components/VirtualList/props.d.ts.map +1 -0
  51. package/dist/index.css +1 -1
  52. package/dist/index.js +3950 -3750
  53. package/dist/index.js.map +1 -1
  54. package/package.json +2 -2
  55. package/src/components/CodeBlock/CodeBlock.meta.ts +1 -1
  56. package/src/components/CodeBlock/CodeBlock.native.tsx +33 -0
  57. package/src/components/CodeBlock/CodeBlock.stories.tsx +54 -0
  58. package/src/components/CodeBlock/CodeBlock.tsx +8 -20
  59. package/src/components/CodeBlock/DiffLines.tsx +32 -0
  60. package/src/components/CodeBlock/diffRoles.ts +40 -0
  61. package/src/components/CodeBlock/styles.css.ts +17 -0
  62. package/src/components/CodeBlock/types.ts +29 -0
  63. package/src/components/Drawer/touchInput.ts +16 -2
  64. package/src/components/Prose/Prose.meta.ts +1 -1
  65. package/src/components/Prose/Prose.native.tsx +23 -0
  66. package/src/components/Prose/Prose.tsx +1 -1
  67. package/src/components/Prose/styles.native.css.ts +15 -0
  68. package/src/components/SplitPane/SplitPane.meta.ts +1 -1
  69. package/src/components/SplitPane/SplitPane.native.tsx +4 -0
  70. package/src/components/SplitPane/SplitPane.stories.tsx +207 -1
  71. package/src/components/SplitPane/SplitPane.tsx +50 -7
  72. package/src/components/SplitPane/styles.css.ts +28 -0
  73. package/src/components/Textarea/Textarea.meta.ts +6 -2
  74. package/src/components/Textarea/Textarea.stories.tsx +136 -1
  75. package/src/components/Textarea/Textarea.tsx +50 -3
  76. package/src/components/Textarea/autoGrow.ts +33 -0
  77. package/src/components/Textarea/grow.css.ts +18 -0
  78. package/src/components/Textarea/grow.native.ts +1 -0
  79. package/src/components/Textarea/grow.ts +1 -0
  80. package/src/components/Textarea/native.css.ts +16 -0
  81. package/src/components/Textarea/useAutoGrowFallback.native.ts +5 -0
  82. package/src/components/Textarea/useAutoGrowFallback.ts +35 -0
  83. package/src/components/Toolbar/Toolbar.meta.ts +1 -1
  84. package/src/components/Toolbar/Toolbar.native.tsx +27 -0
  85. package/src/components/Toolbar/Toolbar.tsx +3 -14
  86. package/src/components/Toolbar/types.ts +15 -0
  87. package/src/components/VirtualList/VirtualList.meta.ts +2 -1
  88. package/src/components/VirtualList/VirtualList.native.tsx +80 -0
  89. package/src/components/VirtualList/VirtualList.stories.tsx +171 -1
  90. package/src/components/VirtualList/VirtualList.tsx +116 -27
  91. package/src/components/VirtualList/native.css.ts +14 -0
  92. package/src/components/VirtualList/props.ts +31 -0
  93. 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 {expect, waitFor} from 'storybook/test'
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 {type ReactNode, useEffect, useLayoutEffect, useRef} from 'react'
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, type SizeToken} from '@duro-app/tokens/keys'
5
- import {resolveLength, type Length} from '../../shared/length'
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 interface VirtualListProps<T> {
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. Web only: on native, host
38
- * FlashList or Legend List behind your own `.native.tsx` leaf.
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 || anchor !== 'end') return
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
- }, [anchor])
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
+ }
@@ -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'