@duro-app/ui 5.21.0 → 5.23.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/AppShell/AppShell.d.ts +10 -1
- package/dist/components/AppShell/AppShell.d.ts.map +1 -1
- package/dist/components/AppShell/AppShell.stories.d.ts +8 -0
- package/dist/components/AppShell/AppShell.stories.d.ts.map +1 -1
- package/dist/components/AppShell/styles.css.d.ts +7 -0
- package/dist/components/AppShell/styles.css.d.ts.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/components/VirtualList/styles.css.d.ts +4 -0
- package/dist/components/VirtualList/styles.css.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4442 -4215
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/AppShell/AppShell.meta.ts +1 -1
- package/src/components/AppShell/AppShell.native.tsx +3 -0
- package/src/components/AppShell/AppShell.stories.tsx +68 -0
- package/src/components/AppShell/AppShell.tsx +16 -5
- package/src/components/AppShell/styles.css.ts +10 -0
- 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 +118 -27
- package/src/components/VirtualList/native.css.ts +14 -0
- package/src/components/VirtualList/props.ts +31 -0
- package/src/components/VirtualList/styles.css.ts +7 -0
- package/src/index.native.ts +8 -0
- package/src/index.ts +1 -0
|
@@ -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,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 {
|
|
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
|
|
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.
|
|
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.',
|
|
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 {
|
|
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'})
|
|
@@ -78,6 +167,8 @@ export function VirtualList<T>({
|
|
|
78
167
|
ref={scrollRef}
|
|
79
168
|
role="list"
|
|
80
169
|
aria-label={ariaLabel}
|
|
170
|
+
// A scroll region the keyboard reaches even when no row holds a control.
|
|
171
|
+
tabIndex={0}
|
|
81
172
|
style={[
|
|
82
173
|
styles.scroller,
|
|
83
174
|
maxHeight !== undefined && styles.scrollMax(resolveLength(maxHeight)),
|
|
@@ -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
|
+
})
|