@duro-app/ui 5.25.0 → 5.27.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 (54) hide show
  1. package/dist/components/CodeBlock/CodeBlock.d.ts +1 -1
  2. package/dist/components/CodeBlock/CodeBlock.d.ts.map +1 -1
  3. package/dist/components/CodeBlock/CodeBlock.meta.d.ts.map +1 -1
  4. package/dist/components/CodeBlock/CodeBlock.stories.d.ts +8 -0
  5. package/dist/components/CodeBlock/CodeBlock.stories.d.ts.map +1 -1
  6. package/dist/components/CodeBlock/DiffRows.d.ts +15 -0
  7. package/dist/components/CodeBlock/DiffRows.d.ts.map +1 -0
  8. package/dist/components/CodeBlock/diffRoles.d.ts +18 -0
  9. package/dist/components/CodeBlock/diffRoles.d.ts.map +1 -1
  10. package/dist/components/CodeBlock/styles.css.d.ts +43 -0
  11. package/dist/components/CodeBlock/styles.css.d.ts.map +1 -1
  12. package/dist/components/CodeBlock/types.d.ts +22 -0
  13. package/dist/components/CodeBlock/types.d.ts.map +1 -1
  14. package/dist/components/Textarea/Textarea.d.ts +7 -1
  15. package/dist/components/Textarea/Textarea.d.ts.map +1 -1
  16. package/dist/components/Textarea/Textarea.stories.d.ts +6 -0
  17. package/dist/components/Textarea/Textarea.stories.d.ts.map +1 -1
  18. package/dist/components/Textarea/styles.css.d.ts +3 -0
  19. package/dist/components/Textarea/styles.css.d.ts.map +1 -1
  20. package/dist/components/Textarea/useFileDrop.d.ts +9 -0
  21. package/dist/components/Textarea/useFileDrop.d.ts.map +1 -0
  22. package/dist/components/VirtualList/VirtualList.d.ts +1 -1
  23. package/dist/components/VirtualList/VirtualList.d.ts.map +1 -1
  24. package/dist/components/VirtualList/VirtualList.stories.d.ts +4 -0
  25. package/dist/components/VirtualList/VirtualList.stories.d.ts.map +1 -1
  26. package/dist/components/VirtualList/props.d.ts +13 -0
  27. package/dist/components/VirtualList/props.d.ts.map +1 -1
  28. package/dist/index.css +1 -1
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +2632 -2457
  32. package/dist/index.js.map +1 -1
  33. package/package.json +2 -2
  34. package/src/components/CodeBlock/CodeBlock.meta.ts +2 -1
  35. package/src/components/CodeBlock/CodeBlock.native.tsx +12 -1
  36. package/src/components/CodeBlock/CodeBlock.stories.tsx +98 -0
  37. package/src/components/CodeBlock/CodeBlock.tsx +28 -10
  38. package/src/components/CodeBlock/DiffRows.tsx +101 -0
  39. package/src/components/CodeBlock/diffRoles.ts +42 -0
  40. package/src/components/CodeBlock/styles.css.ts +41 -0
  41. package/src/components/CodeBlock/types.ts +24 -0
  42. package/src/components/Textarea/Textarea.meta.ts +1 -1
  43. package/src/components/Textarea/Textarea.stories.tsx +41 -0
  44. package/src/components/Textarea/Textarea.tsx +21 -0
  45. package/src/components/Textarea/styles.css.ts +3 -0
  46. package/src/components/Textarea/useFileDrop.native.ts +6 -0
  47. package/src/components/Textarea/useFileDrop.ts +46 -0
  48. package/src/components/VirtualList/VirtualList.meta.ts +1 -1
  49. package/src/components/VirtualList/VirtualList.native.tsx +24 -1
  50. package/src/components/VirtualList/VirtualList.stories.tsx +62 -0
  51. package/src/components/VirtualList/VirtualList.tsx +23 -0
  52. package/src/components/VirtualList/props.ts +13 -0
  53. package/src/index.native.ts +1 -0
  54. package/src/index.ts +1 -0
@@ -2,7 +2,7 @@ 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. 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). The scroll region is a tab stop with a focus ring, so the keyboard can scroll rows that hold no control.',
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). The scroll region is a tab stop with a focus ring, so the keyboard can scroll rows that hold no control. onEndReached mirrors onStartReached at the end (load newer items in a window opened on an older point); scrollToKey centres one item each time it changes to a key in the list (a link to one message), and anchor="end" stops following the end while it applies.',
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")',
@@ -1,4 +1,4 @@
1
- import {useCallback, useRef} from 'react'
1
+ import {useCallback, useEffect, useRef} from 'react'
2
2
  import {FlatList, type NativeScrollEvent, type NativeSyntheticEvent, StyleSheet} from 'react-native'
3
3
  import {html} from 'react-strict-dom'
4
4
  import {SIZES_PX} from '@duro-app/tokens/keys'
@@ -31,6 +31,8 @@ export function VirtualList<T>({
31
31
  getKey,
32
32
  anchor = 'start',
33
33
  onStartReached,
34
+ onEndReached,
35
+ scrollToKey,
34
36
  maxHeight,
35
37
  'aria-label': ariaLabel,
36
38
  }: VirtualListProps<T>) {
@@ -43,6 +45,18 @@ export function VirtualList<T>({
43
45
  contentSize.height - contentOffset.y - layoutMeasurement.height <= BOTTOM_SLOP
44
46
  }, [])
45
47
 
48
+ // `scrollToKey`: centre that item once per key, as soon as the items hold it.
49
+ const scrolledTo = useRef<string | number | null | undefined>(undefined)
50
+ useEffect(() => {
51
+ if (scrollToKey === null || scrollToKey === undefined || scrolledTo.current === scrollToKey)
52
+ return
53
+ const at = items.findIndex((item, i) => getKey(item, i) === scrollToKey)
54
+ if (at < 0) return
55
+ scrolledTo.current = scrollToKey
56
+ atBottom.current = false
57
+ listRef.current?.scrollToIndex({index: at, viewPosition: 0.5, animated: false})
58
+ }, [getKey, items, scrollToKey])
59
+
46
60
  const followEnd = useCallback(() => {
47
61
  if (anchor === 'end' && atBottom.current) listRef.current?.scrollToEnd({animated: false})
48
62
  }, [anchor])
@@ -65,6 +79,15 @@ export function VirtualList<T>({
65
79
  maintainVisibleContentPosition={MAINTAIN_POSITION}
66
80
  onStartReached={onStartReached ? () => onStartReached() : undefined}
67
81
  onStartReachedThreshold={0.1}
82
+ onEndReached={onEndReached ? () => onEndReached() : undefined}
83
+ onEndReachedThreshold={0.1}
84
+ // A row not measured yet: jump near it by estimate, then centre it once laid out.
85
+ onScrollToIndexFailed={({index, averageItemLength}) => {
86
+ listRef.current?.scrollToOffset({offset: index * averageItemLength, animated: false})
87
+ requestAnimationFrame(() =>
88
+ listRef.current?.scrollToIndex({index, viewPosition: 0.5, animated: false}),
89
+ )
90
+ }}
68
91
  onScroll={onScroll}
69
92
  scrollEventThrottle={16}
70
93
  onContentSizeChange={followEnd}
@@ -263,3 +263,65 @@ export const ShortListDoesNotLoopOnFreshArrays: StoryObj<{onStartReached: () =>
263
263
  await expect(args.onStartReached).toHaveBeenCalledTimes(1)
264
264
  },
265
265
  }
266
+
267
+ /** A link to one message: the list opens anchored to the end, then centres that message. */
268
+ function JumpTo({target}: {target: number | null}) {
269
+ return (
270
+ <VirtualList
271
+ aria-label="Transcript"
272
+ items={ITEMS}
273
+ getKey={(m) => m.id}
274
+ estimateSize="controlLg"
275
+ anchor="end"
276
+ scrollToKey={target}
277
+ maxHeight="previewMaxH"
278
+ renderItem={(m) => <Text>{m.text}</Text>}
279
+ />
280
+ )
281
+ }
282
+
283
+ export const ScrollToKeyCentresTheItem: Story = {
284
+ render: () => <JumpTo target={4_200} />,
285
+ play: async ({canvas}) => {
286
+ const list = canvas.getByRole('list', {name: 'Transcript'})
287
+ const row = await waitFor(() => canvas.getByText('Message 4201'))
288
+ const listBox = list.getBoundingClientRect()
289
+ const rowBox = row.getBoundingClientRect()
290
+ await expect(rowBox.top).toBeGreaterThanOrEqual(listBox.top)
291
+ await expect(rowBox.bottom).toBeLessThanOrEqual(listBox.bottom)
292
+ // It stays there: anchor="end" does not pull it back to the last message.
293
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
294
+ await expect(canvas.queryByText('Message 10000')).toBeNull()
295
+ },
296
+ }
297
+
298
+ export const EndReachedFiresOncePerApproach: StoryObj<{onEndReached: () => void}> = {
299
+ args: {onEndReached: fn()},
300
+ render: (args) => (
301
+ <VirtualList
302
+ aria-label="Transcript"
303
+ items={ITEMS.slice(0, 200)}
304
+ getKey={(m) => m.id}
305
+ estimateSize="controlLg"
306
+ onEndReached={args.onEndReached}
307
+ maxHeight="previewMaxH"
308
+ renderItem={(m) => <Text>{m.text}</Text>}
309
+ />
310
+ ),
311
+ play: async ({canvas, args}) => {
312
+ const list = canvas.getByRole('list', {name: 'Transcript'})
313
+ await waitFor(() => expect(canvas.getAllByRole('listitem').length).toBeGreaterThan(0))
314
+ const scrollTo = async (top: number) => {
315
+ list.scrollTop = top
316
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
317
+ }
318
+ await expect(args.onEndReached).not.toHaveBeenCalled()
319
+ await scrollTo(list.scrollHeight)
320
+ await waitFor(() => expect(args.onEndReached).toHaveBeenCalledTimes(1))
321
+ await scrollTo(list.scrollHeight)
322
+ await expect(args.onEndReached).toHaveBeenCalledTimes(1)
323
+ await scrollTo(0)
324
+ await scrollTo(list.scrollHeight)
325
+ await waitFor(() => expect(args.onEndReached).toHaveBeenCalledTimes(2))
326
+ },
327
+ }
@@ -33,6 +33,8 @@ export function VirtualList<T>({
33
33
  overscan = 5,
34
34
  anchor = 'start',
35
35
  onStartReached,
36
+ onEndReached,
37
+ scrollToKey,
36
38
  maxHeight,
37
39
  'aria-label': ariaLabel,
38
40
  }: VirtualListProps<T>) {
@@ -52,6 +54,9 @@ export function VirtualList<T>({
52
54
  const startArmed = useRef(true)
53
55
  const startCallback = useRef(onStartReached)
54
56
  startCallback.current = onStartReached
57
+ const endArmed = useRef(true)
58
+ const endCallback = useRef(onEndReached)
59
+ endCallback.current = onEndReached
55
60
  // The items a short list last asked more for: a new array with the same
56
61
  // first key and length is the same content, so it does not ask again.
57
62
  const askedFor = useRef<string | undefined>(undefined)
@@ -60,6 +65,12 @@ export function VirtualList<T>({
60
65
  if (!el) return
61
66
  const onScroll = () => {
62
67
  atBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_SLOP
68
+ if (!atBottom.current) {
69
+ endArmed.current = true
70
+ } else if (endArmed.current && el.scrollHeight > el.clientHeight) {
71
+ endArmed.current = false
72
+ endCallback.current?.()
73
+ }
63
74
  if (el.scrollTop > START_SLOP) {
64
75
  startArmed.current = true
65
76
  } else if (startArmed.current) {
@@ -156,6 +167,18 @@ export function VirtualList<T>({
156
167
  // Runs when `items` changes; scrolling is read by the frame-coalesced listener.
157
168
  }, [items, readSeenRow, virtualizer])
158
169
 
170
+ // `scrollToKey`: centre that item once per key, as soon as the items hold it.
171
+ const scrolledTo = useRef<string | number | null | undefined>(undefined)
172
+ useLayoutEffect(() => {
173
+ if (scrollToKey === null || scrollToKey === undefined || scrolledTo.current === scrollToKey)
174
+ return
175
+ const at = items.findIndex((item, i) => getKeyRef.current(item, i) === scrollToKey)
176
+ if (at < 0) return
177
+ scrolledTo.current = scrollToKey
178
+ atBottom.current = false
179
+ virtualizer.scrollToIndex(at, {align: 'center'})
180
+ }, [items, scrollToKey, virtualizer])
181
+
159
182
  useLayoutEffect(() => {
160
183
  if (anchor !== 'end' || items.length === 0 || !atBottom.current) return
161
184
  virtualizer.scrollToIndex(items.length - 1, {align: 'end'})
@@ -24,6 +24,19 @@ export interface VirtualListProps<T> {
24
24
  * Prepending items keeps the first visible row where it was on screen.
25
25
  */
26
26
  onStartReached?: () => void
27
+ /**
28
+ * Called once each time the reader scrolls to within 44px (touchTarget) of the
29
+ * end (load newer items, as in a window opened on an older point). It fires
30
+ * again only after they move away.
31
+ */
32
+ onEndReached?: () => void
33
+ /**
34
+ * Bring this item into view, centred, each time the key changes to an item in
35
+ * the list (a link to one message). A key not in the list yet waits for the
36
+ * items that hold it; `null` or `undefined` does nothing. While it applies,
37
+ * `anchor="end"` stops following the end.
38
+ */
39
+ scrollToKey?: string | number | null
27
40
  /** Cap the list's height; without it, it fills its parent. */
28
41
  maxHeight?: Length
29
42
  /** The list's accessible name. */
@@ -29,6 +29,7 @@ export {Textarea, type TextareaVariant} from './components/Textarea/Textarea'
29
29
 
30
30
  // Text and layout
31
31
  export {CodeBlock, type CodeBlockProps} from './components/CodeBlock/CodeBlock'
32
+ export type {CodeBlockLineAction, DiffLineInfo} from './components/CodeBlock/types'
32
33
  export {Prose} from './components/Prose/Prose'
33
34
  export {Toolbar, type ToolbarProps} from './components/Toolbar/Toolbar'
34
35
 
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export {Callout, type CalloutVariant} from './components/Callout/Callout'
32
32
  export {Card, type CardVariant, type CardSize} from './components/Card/Card'
33
33
  export {Checkbox} from './components/Checkbox/Checkbox'
34
34
  export {CodeBlock} from './components/CodeBlock/CodeBlock'
35
+ export type {CodeBlockLineAction, DiffLineInfo} from './components/CodeBlock/types'
35
36
  export {CheckboxGroup} from './components/CheckboxGroup/CheckboxGroup'
36
37
  export {ColorInput} from './components/ColorInput/ColorInput'
37
38
  export {ConfirmDialog} from './components/ConfirmDialog/ConfirmDialog'