@duro-app/ui 5.20.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 (106) 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/Heading/Heading.d.ts +10 -2
  17. package/dist/components/Heading/Heading.d.ts.map +1 -1
  18. package/dist/components/Heading/Heading.meta.d.ts.map +1 -1
  19. package/dist/components/Heading/Heading.stories.d.ts +5 -0
  20. package/dist/components/Heading/Heading.stories.d.ts.map +1 -1
  21. package/dist/components/Prose/Prose.d.ts +1 -1
  22. package/dist/components/SplitPane/SplitPane.d.ts +10 -1
  23. package/dist/components/SplitPane/SplitPane.d.ts.map +1 -1
  24. package/dist/components/SplitPane/SplitPane.stories.d.ts +11 -0
  25. package/dist/components/SplitPane/SplitPane.stories.d.ts.map +1 -1
  26. package/dist/components/SplitPane/styles.css.d.ts +22 -0
  27. package/dist/components/SplitPane/styles.css.d.ts.map +1 -1
  28. package/dist/components/Textarea/Textarea.d.ts +14 -2
  29. package/dist/components/Textarea/Textarea.d.ts.map +1 -1
  30. package/dist/components/Textarea/Textarea.meta.d.ts.map +1 -1
  31. package/dist/components/Textarea/Textarea.stories.d.ts +2 -0
  32. package/dist/components/Textarea/Textarea.stories.d.ts.map +1 -1
  33. package/dist/components/Textarea/autoGrow.d.ts +22 -0
  34. package/dist/components/Textarea/autoGrow.d.ts.map +1 -0
  35. package/dist/components/Textarea/grow.css.d.ts +12 -0
  36. package/dist/components/Textarea/grow.css.d.ts.map +1 -0
  37. package/dist/components/Textarea/grow.d.ts +2 -0
  38. package/dist/components/Textarea/grow.d.ts.map +1 -0
  39. package/dist/components/Textarea/native.css.d.ts +8 -0
  40. package/dist/components/Textarea/native.css.d.ts.map +1 -0
  41. package/dist/components/Textarea/useAutoGrowFallback.d.ts +9 -0
  42. package/dist/components/Textarea/useAutoGrowFallback.d.ts.map +1 -0
  43. package/dist/components/Toolbar/Toolbar.d.ts +2 -14
  44. package/dist/components/Toolbar/Toolbar.d.ts.map +1 -1
  45. package/dist/components/Toolbar/types.d.ts +15 -0
  46. package/dist/components/Toolbar/types.d.ts.map +1 -0
  47. package/dist/components/VirtualList/VirtualList.d.ts +5 -26
  48. package/dist/components/VirtualList/VirtualList.d.ts.map +1 -1
  49. package/dist/components/VirtualList/VirtualList.meta.d.ts.map +1 -1
  50. package/dist/components/VirtualList/VirtualList.stories.d.ts +16 -0
  51. package/dist/components/VirtualList/VirtualList.stories.d.ts.map +1 -1
  52. package/dist/components/VirtualList/native.css.d.ts +14 -0
  53. package/dist/components/VirtualList/native.css.d.ts.map +1 -0
  54. package/dist/components/VirtualList/props.d.ts +31 -0
  55. package/dist/components/VirtualList/props.d.ts.map +1 -0
  56. package/dist/index.css +1 -1
  57. package/dist/index.d.ts +1 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +4007 -3767
  60. package/dist/index.js.map +1 -1
  61. package/package.json +2 -2
  62. package/src/components/CodeBlock/CodeBlock.meta.ts +1 -1
  63. package/src/components/CodeBlock/CodeBlock.native.tsx +33 -0
  64. package/src/components/CodeBlock/CodeBlock.stories.tsx +54 -0
  65. package/src/components/CodeBlock/CodeBlock.tsx +8 -20
  66. package/src/components/CodeBlock/DiffLines.tsx +32 -0
  67. package/src/components/CodeBlock/diffRoles.ts +40 -0
  68. package/src/components/CodeBlock/styles.css.ts +17 -0
  69. package/src/components/CodeBlock/types.ts +29 -0
  70. package/src/components/Drawer/touchInput.ts +16 -2
  71. package/src/components/Heading/Heading.meta.ts +6 -3
  72. package/src/components/Heading/Heading.stories.tsx +45 -0
  73. package/src/components/Heading/Heading.tsx +51 -11
  74. package/src/components/Prose/Prose.meta.ts +1 -1
  75. package/src/components/Prose/Prose.native.tsx +23 -0
  76. package/src/components/Prose/Prose.tsx +1 -1
  77. package/src/components/Prose/styles.native.css.ts +15 -0
  78. package/src/components/SplitPane/SplitPane.meta.ts +1 -1
  79. package/src/components/SplitPane/SplitPane.native.tsx +4 -0
  80. package/src/components/SplitPane/SplitPane.stories.tsx +207 -1
  81. package/src/components/SplitPane/SplitPane.tsx +50 -7
  82. package/src/components/SplitPane/styles.css.ts +28 -0
  83. package/src/components/Text/Text.meta.ts +2 -2
  84. package/src/components/Textarea/Textarea.meta.ts +6 -2
  85. package/src/components/Textarea/Textarea.stories.tsx +136 -1
  86. package/src/components/Textarea/Textarea.tsx +50 -3
  87. package/src/components/Textarea/autoGrow.ts +33 -0
  88. package/src/components/Textarea/grow.css.ts +18 -0
  89. package/src/components/Textarea/grow.native.ts +1 -0
  90. package/src/components/Textarea/grow.ts +1 -0
  91. package/src/components/Textarea/native.css.ts +16 -0
  92. package/src/components/Textarea/useAutoGrowFallback.native.ts +5 -0
  93. package/src/components/Textarea/useAutoGrowFallback.ts +35 -0
  94. package/src/components/Toolbar/Toolbar.meta.ts +1 -1
  95. package/src/components/Toolbar/Toolbar.native.tsx +27 -0
  96. package/src/components/Toolbar/Toolbar.tsx +3 -14
  97. package/src/components/Toolbar/types.ts +15 -0
  98. package/src/components/VirtualList/VirtualList.meta.ts +2 -1
  99. package/src/components/VirtualList/VirtualList.native.tsx +80 -0
  100. package/src/components/VirtualList/VirtualList.stories.tsx +171 -1
  101. package/src/components/VirtualList/VirtualList.tsx +116 -27
  102. package/src/components/VirtualList/native.css.ts +14 -0
  103. package/src/components/VirtualList/props.ts +31 -0
  104. package/src/docs/Typography.mdx +19 -15
  105. package/src/index.native.ts +8 -1
  106. package/src/index.ts +1 -1
@@ -0,0 +1,18 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
3
+ import {borders} from '@duro-app/tokens/tokens/borders.css'
4
+
5
+ // Web only: native has no field-sizing or lh unit (see native.css.ts).
6
+ export const growCss = css.create({
7
+ // The height follows the content (CSS field-sizing, or autoGrow.ts where
8
+ // it is missing); the rows attribute is the floor.
9
+ autoGrow: {
10
+ fieldSizing: 'content',
11
+ resize: 'none' as const,
12
+ overflowY: 'auto',
13
+ },
14
+ // `maxRows` lines of text plus the vertical padding and border (border-box).
15
+ maxRows: (rows: number) => ({
16
+ maxHeight: `calc(${rows}lh + ${spacing.sm} + ${spacing.sm} + ${borders.hairline} + ${borders.hairline})`,
17
+ }),
18
+ })
@@ -0,0 +1 @@
1
+ export {nativeStyles as grow} from './native.css'
@@ -0,0 +1 @@
1
+ export {growCss as grow} from './grow.css'
@@ -0,0 +1,16 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {FONT_SIZE_REM, LINE_HEIGHT, ROOT_FONT_PX, SPACING_PX} from '@duro-app/tokens/keys'
3
+
4
+ // One row of text, in px: the Textarea's font size (fontSizeSm, rem) at the
5
+ // root size, times the typography line height. Native has no `lh` unit.
6
+ const ROW_PX = FONT_SIZE_REM.fontSizeSm * ROOT_FONT_PX * LINE_HEIGHT
7
+
8
+ export const nativeStyles = css.create({
9
+ // A multiline field on native grows with its content on its own.
10
+ autoGrow: {},
11
+ // `maxRows` lines plus the vertical padding; a multiline field on native
12
+ // already grows with its content, so only the cap is needed.
13
+ maxRows: (rows: number) => ({
14
+ maxHeight: rows * ROW_PX + 2 * SPACING_PX.sm,
15
+ }),
16
+ })
@@ -0,0 +1,5 @@
1
+ import type {GrowMode} from './autoGrow'
2
+ import type {RefObject} from 'react'
3
+
4
+ /** A multiline field on native already grows with its content. */
5
+ export function useAutoGrowFallback(_ref: RefObject<unknown>, _mode: GrowMode) {}
@@ -0,0 +1,35 @@
1
+ import {useEffect, useLayoutEffect, type RefObject} from 'react'
2
+ import {fitToContent, supportsFieldSizing} from './autoGrow'
3
+
4
+ import type {GrowMode} from './autoGrow'
5
+
6
+ /** Fields this hook gave an inline height, so turning autoGrow off can take it back. */
7
+ const sized = new WeakSet<HTMLElement>()
8
+
9
+ /**
10
+ * Where CSS field-sizing is missing, size the field to its content: after each
11
+ * render (first paint, controlled value changes) and on every `input` event, so
12
+ * an uncontrolled field grows as it is typed into.
13
+ */
14
+ export function useAutoGrowFallback(ref: RefObject<HTMLTextAreaElement | null>, mode: GrowMode) {
15
+ useLayoutEffect(() => {
16
+ const el = ref.current
17
+ if (!el || supportsFieldSizing()) return
18
+ if (mode === 'content') {
19
+ fitToContent(el)
20
+ sized.add(el)
21
+ } else if (sized.delete(el)) {
22
+ el.style.height = ''
23
+ }
24
+ })
25
+ useEffect(() => {
26
+ const el = ref.current
27
+ if (mode !== 'content' || !el || supportsFieldSizing()) return
28
+ const onInput = () => {
29
+ fitToContent(el)
30
+ sized.add(el)
31
+ }
32
+ el.addEventListener('input', onInput)
33
+ return () => el.removeEventListener('input', onInput)
34
+ }, [ref, mode])
35
+ }
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'A row of controls with one tab stop (the WAI-ARIA toolbar pattern): Tab enters at the last focused control, Left/Right move across every control inside — through attached ButtonGroups too — and Home/End jump to the ends. A Select or Menu trigger keeps its own keys, and a control whose popup is open keeps Left/Right.',
5
+ 'A row of controls with one tab stop (the WAI-ARIA toolbar pattern): Tab enters at the last focused control, Left/Right move across every control inside — through attached ButtonGroups too — and Home/End jump to the ends. A Select or Menu trigger keeps its own keys, and a control whose popup is open keeps Left/Right. On native it is the same row of controls without the roving tab stop and arrow keys.',
6
6
  whenToUse: [
7
7
  'An editor toolbar: text style, format toggles, insert menus',
8
8
  'A dense set of related actions that should cost one Tab, not one per control',
@@ -0,0 +1,27 @@
1
+ import {html} from 'react-strict-dom'
2
+ import {styles} from './styles.css'
3
+ import type {ToolbarProps} from './types'
4
+
5
+ export type {ToolbarProps} from './types'
6
+
7
+ /**
8
+ * Native Toolbar: a row (or column) of controls with the web's props. The
9
+ * roving tab stop and arrow keys are web only: a touch screen reaches every
10
+ * control directly, so each control keeps its own press target.
11
+ */
12
+ export function Toolbar({
13
+ 'aria-label': ariaLabel,
14
+ orientation = 'horizontal',
15
+ children,
16
+ }: ToolbarProps) {
17
+ return (
18
+ <html.div
19
+ role="toolbar"
20
+ aria-label={ariaLabel}
21
+ aria-orientation={orientation}
22
+ style={[styles.root, orientation === 'vertical' && styles.vertical]}
23
+ >
24
+ {children}
25
+ </html.div>
26
+ )
27
+ }
@@ -1,22 +1,11 @@
1
- import {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react'
1
+ import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
- import type {Orientation} from '../../shared/types'
4
3
  import {useOrderedRegistry} from '../../shared/useOrderedRegistry'
5
4
  import {ToolbarContext, type ToolbarContextValue} from './ToolbarContext'
6
5
  import {styles} from './styles.css'
6
+ import type {ToolbarProps} from './types'
7
7
 
8
- export interface ToolbarProps {
9
- /** Names the toolbar ("Formatting"). Required: a toolbar is a landmark-like widget. */
10
- 'aria-label': string
11
- /**
12
- * Arrow keys follow the orientation: Left/Right (default) or Up/Down. A
13
- * vertical toolbar cannot hold a Select, whose closed trigger opens on
14
- * ArrowDown/ArrowUp. A Menu can: its closed trigger handles no arrow key,
15
- * so Up/Down move past it and Enter or Space opens it.
16
- */
17
- orientation?: Orientation
18
- children: ReactNode
19
- }
8
+ export type {ToolbarProps} from './types'
20
9
 
21
10
  /**
22
11
  * A row of controls with one tab stop (the WAI-ARIA toolbar pattern). Every
@@ -0,0 +1,15 @@
1
+ import type {ReactNode} from 'react'
2
+ import type {Orientation} from '../../shared/types'
3
+
4
+ export interface ToolbarProps {
5
+ /** Names the toolbar ("Formatting"). Required: a toolbar is a landmark-like widget. */
6
+ 'aria-label': string
7
+ /**
8
+ * Arrow keys follow the orientation: Left/Right (default) or Up/Down. A
9
+ * vertical toolbar cannot hold a Select, whose closed trigger opens on
10
+ * ArrowDown/ArrowUp. A Menu can: its closed trigger handles no arrow key,
11
+ * so Up/Down move past it and Enter or Space opens it.
12
+ */
13
+ orientation?: Orientation
14
+ children: ReactNode
15
+ }
@@ -2,10 +2,11 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'A windowed list on @tanstack/react-virtual: only the rows in view (plus overscan) are mounted, and rows are measured so they may differ in height. role="list" with listitem rows carrying aria-setsize and aria-posinset. anchor="end" opens at the last item and stays pinned to the bottom while new items arrive and the reader is at the bottom (a transcript, a log). It fills its parent, or stops at maxHeight. Web only: on native, host FlashList or Legend List behind your own .native.tsx leaf.',
5
+ 'A windowed list on @tanstack/react-virtual: only the rows in view (plus overscan) are mounted, and rows are measured so they may differ in height. role="list" with listitem rows carrying aria-setsize and aria-posinset. anchor="end" opens at the last item and stays pinned to the bottom while new items arrive and the reader is at the bottom (a transcript, a log). It fills its parent, or stops at maxHeight. onStartReached fires once each time the reader scrolls to the start (load older history), and prepending items keeps the first visible row in place. On native it is a FlatList with the same props (role list; rows carry no listitem role, which React Native lacks) (maintainVisibleContentPosition for prepend).',
6
6
  whenToUse: [
7
7
  'Hundreds to tens of thousands of rows: sessions, a chat transcript, log lines',
8
8
  'A list that grows at the bottom while the reader follows it (anchor="end")',
9
+ 'A transcript that pages older history in at the top (onStartReached, then prepend items)',
9
10
  ],
10
11
  whenNotToUse: [
11
12
  'Tabular data with columns and sorting — use VirtualTable',
@@ -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
+ }