@duro-app/ui 5.31.0 → 5.32.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "5.31.0",
3
+ "version": "5.32.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -92,7 +92,7 @@
92
92
  "dependencies": {
93
93
  "@tanstack/react-virtual": "^3.14.13",
94
94
  "qrcode": "^1.5.4",
95
- "@duro-app/tokens": "^5.31.0"
95
+ "@duro-app/tokens": "^5.32.0"
96
96
  },
97
97
  "devDependencies": {
98
98
  "@babel/preset-typescript": "^7.28.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. 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.',
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. onAtEndChange(atEnd) is called once each time the reader comes within 44px of the end or leaves it, so a host can record whether the reader follows the live tail; scrollToEndKey scrolls to the end and resumes anchor="end" following each time it changes to a new non-null value (the host\'s own send), even when the reader had scrolled up.',
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")',
@@ -33,18 +33,39 @@ export function VirtualList<T>({
33
33
  onStartReached,
34
34
  onEndReached,
35
35
  scrollToKey,
36
+ onAtEndChange,
37
+ scrollToEndKey,
36
38
  maxHeight,
37
39
  'aria-label': ariaLabel,
38
40
  }: VirtualListProps<T>) {
39
41
  const listRef = useRef<FlatList<T>>(null)
40
42
  const atBottom = useRef(true)
41
43
 
44
+ // The last state `onAtEndChange` was told, so it only hears a change.
45
+ const reportedAtEnd = useRef(true)
46
+ const atEndCallback = useRef(onAtEndChange)
47
+ atEndCallback.current = onAtEndChange
48
+
42
49
  const onScroll = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
43
50
  const {contentOffset, contentSize, layoutMeasurement} = e.nativeEvent
44
51
  atBottom.current =
45
52
  contentSize.height - contentOffset.y - layoutMeasurement.height <= BOTTOM_SLOP
53
+ if (reportedAtEnd.current !== atBottom.current) {
54
+ reportedAtEnd.current = atBottom.current
55
+ atEndCallback.current?.(atBottom.current)
56
+ }
46
57
  }, [])
47
58
 
59
+ // `scrollToEndKey`: a new non-null key scrolls to the end and resumes following.
60
+ const endKey = useRef(scrollToEndKey)
61
+ useEffect(() => {
62
+ if (endKey.current === scrollToEndKey) return
63
+ endKey.current = scrollToEndKey
64
+ if (scrollToEndKey === null || scrollToEndKey === undefined) return
65
+ atBottom.current = true
66
+ listRef.current?.scrollToEnd({animated: false})
67
+ }, [scrollToEndKey])
68
+
48
69
  // `scrollToKey`: centre that item once per key, as soon as the items hold it.
49
70
  const scrolledTo = useRef<string | number | null | undefined>(undefined)
50
71
  useEffect(() => {
@@ -325,3 +325,90 @@ export const EndReachedFiresOncePerApproach: StoryObj<{onEndReached: () => void}
325
325
  await waitFor(() => expect(args.onEndReached).toHaveBeenCalledTimes(2))
326
326
  },
327
327
  }
328
+
329
+ function Tail({
330
+ onAtEndChange,
331
+ endKey,
332
+ }: {
333
+ onAtEndChange?: (atEnd: boolean) => void
334
+ endKey?: number | null
335
+ }) {
336
+ return (
337
+ <VirtualList
338
+ aria-label="Transcript"
339
+ items={ITEMS.slice(0, 200)}
340
+ getKey={(m) => m.id}
341
+ estimateSize="controlLg"
342
+ anchor="end"
343
+ onAtEndChange={onAtEndChange}
344
+ scrollToEndKey={endKey}
345
+ maxHeight="previewMaxH"
346
+ renderItem={(m) => <Text>{m.text}</Text>}
347
+ />
348
+ )
349
+ }
350
+
351
+ const frames = () =>
352
+ new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
353
+
354
+ export const AtEndChangeFiresOncePerChange: StoryObj<{onAtEndChange: (atEnd: boolean) => void}> = {
355
+ args: {onAtEndChange: fn()},
356
+ render: (args) => <Tail onAtEndChange={args.onAtEndChange} />,
357
+ play: async ({canvas, args}) => {
358
+ const list = canvas.getByRole('list', {name: 'Transcript'})
359
+ await waitFor(() => expect(canvas.getByText('Message 200')).toBeInTheDocument())
360
+ await frames()
361
+ await expect(args.onAtEndChange).not.toHaveBeenCalled()
362
+ list.scrollTop = 0
363
+ await waitFor(() => expect(args.onAtEndChange).toHaveBeenCalledTimes(1))
364
+ await expect(args.onAtEndChange).toHaveBeenLastCalledWith(false)
365
+ list.scrollTop = 20
366
+ await frames()
367
+ await expect(args.onAtEndChange).toHaveBeenCalledTimes(1)
368
+ list.scrollTop = list.scrollHeight
369
+ await waitFor(() => expect(args.onAtEndChange).toHaveBeenCalledTimes(2))
370
+ await expect(args.onAtEndChange).toHaveBeenLastCalledWith(true)
371
+ },
372
+ }
373
+
374
+ function EndKeyHost() {
375
+ const [key, setKey] = useState(0)
376
+ const [, setTick] = useState(0)
377
+ return (
378
+ <>
379
+ <html.button type="button" onClick={() => setKey((k) => k + 1)}>
380
+ Send
381
+ </html.button>
382
+ <html.button type="button" onClick={() => setTick((t) => t + 1)}>
383
+ Rerender
384
+ </html.button>
385
+ <Tail endKey={key} />
386
+ </>
387
+ )
388
+ }
389
+
390
+ export const ScrollToEndKeyScrollsToTheEnd: Story = {
391
+ render: () => <EndKeyHost />,
392
+ play: async ({canvas, userEvent}) => {
393
+ const list = canvas.getByRole('list', {name: 'Transcript'})
394
+ await waitFor(() => expect(canvas.getByText('Message 200')).toBeInTheDocument())
395
+ list.scrollTop = 0
396
+ await waitFor(() => expect(canvas.queryByText('Message 200')).toBeNull())
397
+ await userEvent.click(canvas.getByRole('button', {name: 'Send'}))
398
+ await waitFor(() => expect(canvas.getByText('Message 200')).toBeInTheDocument())
399
+ },
400
+ }
401
+
402
+ export const ScrollToEndKeyUnchangedDoesNotScroll: Story = {
403
+ render: () => <EndKeyHost />,
404
+ play: async ({canvas, userEvent}) => {
405
+ const list = canvas.getByRole('list', {name: 'Transcript'})
406
+ await waitFor(() => expect(canvas.getByText('Message 200')).toBeInTheDocument())
407
+ list.scrollTop = 0
408
+ await waitFor(() => expect(canvas.queryByText('Message 200')).toBeNull())
409
+ await userEvent.click(canvas.getByRole('button', {name: 'Rerender'}))
410
+ await frames()
411
+ await expect(canvas.queryByText('Message 200')).toBeNull()
412
+ await expect(list.scrollTop).toBeLessThan(list.scrollHeight / 2)
413
+ },
414
+ }
@@ -35,6 +35,8 @@ export function VirtualList<T>({
35
35
  onStartReached,
36
36
  onEndReached,
37
37
  scrollToKey,
38
+ onAtEndChange,
39
+ scrollToEndKey,
38
40
  maxHeight,
39
41
  'aria-label': ariaLabel,
40
42
  }: VirtualListProps<T>) {
@@ -57,6 +59,10 @@ export function VirtualList<T>({
57
59
  const endArmed = useRef(true)
58
60
  const endCallback = useRef(onEndReached)
59
61
  endCallback.current = onEndReached
62
+ // The last state `onAtEndChange` was told, so it only hears a change.
63
+ const reportedAtEnd = useRef(true)
64
+ const atEndCallback = useRef(onAtEndChange)
65
+ atEndCallback.current = onAtEndChange
60
66
  // The items a short list last asked more for: a new array with the same
61
67
  // first key and length is the same content, so it does not ask again.
62
68
  const askedFor = useRef<string | undefined>(undefined)
@@ -65,6 +71,10 @@ export function VirtualList<T>({
65
71
  if (!el) return
66
72
  const onScroll = () => {
67
73
  atBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_SLOP
74
+ if (reportedAtEnd.current !== atBottom.current) {
75
+ reportedAtEnd.current = atBottom.current
76
+ atEndCallback.current?.(atBottom.current)
77
+ }
68
78
  if (!atBottom.current) {
69
79
  endArmed.current = true
70
80
  } else if (endArmed.current && el.scrollHeight > el.clientHeight) {
@@ -179,6 +189,16 @@ export function VirtualList<T>({
179
189
  virtualizer.scrollToIndex(at, {align: 'center'})
180
190
  }, [items, scrollToKey, virtualizer])
181
191
 
192
+ // `scrollToEndKey`: a new non-null key scrolls to the end and resumes following.
193
+ const endKey = useRef(scrollToEndKey)
194
+ useLayoutEffect(() => {
195
+ if (endKey.current === scrollToEndKey) return
196
+ endKey.current = scrollToEndKey
197
+ if (scrollToEndKey === null || scrollToEndKey === undefined || items.length === 0) return
198
+ atBottom.current = true
199
+ virtualizer.scrollToIndex(items.length - 1, {align: 'end'})
200
+ }, [items.length, scrollToEndKey, virtualizer])
201
+
182
202
  useLayoutEffect(() => {
183
203
  if (anchor !== 'end' || items.length === 0 || !atBottom.current) return
184
204
  virtualizer.scrollToIndex(items.length - 1, {align: 'end'})
@@ -30,6 +30,19 @@ export interface VirtualListProps<T> {
30
30
  * again only after they move away.
31
31
  */
32
32
  onEndReached?: () => void
33
+ /**
34
+ * Called once each time the reader comes within 44px (touchTarget) of the end
35
+ * or leaves it, with `true` at the end and `false` away from it, so a host can
36
+ * record whether the reader follows the live tail. It never repeats a value.
37
+ */
38
+ onAtEndChange?: (atEnd: boolean) => void
39
+ /**
40
+ * Scroll to the end, and resume `anchor="end"` following, each time the key
41
+ * changes to a new non-null value (a host's own send), even when the reader
42
+ * had scrolled up. The key present at the first render does nothing; `null`
43
+ * does nothing.
44
+ */
45
+ scrollToEndKey?: string | number | null
33
46
  /**
34
47
  * Bring this item into view, centred, each time the key changes to an item in
35
48
  * the list (a link to one message). A key not in the list yet waits for the