@duro-app/ui 5.24.0 → 5.26.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/components/Tree/Tree.d.ts +1 -1
- package/dist/components/Tree/Tree.d.ts.map +1 -1
- package/dist/components/Tree/Tree.stories.d.ts +1 -0
- package/dist/components/Tree/Tree.stories.d.ts.map +1 -1
- package/dist/components/Tree/TreeRows.d.ts +7 -1
- package/dist/components/Tree/TreeRows.d.ts.map +1 -1
- package/dist/components/VirtualList/VirtualList.d.ts +1 -1
- package/dist/components/VirtualList/VirtualList.d.ts.map +1 -1
- package/dist/components/VirtualList/VirtualList.stories.d.ts +4 -0
- package/dist/components/VirtualList/VirtualList.stories.d.ts.map +1 -1
- package/dist/components/VirtualList/props.d.ts +13 -0
- package/dist/components/VirtualList/props.d.ts.map +1 -1
- package/dist/index.js +719 -705
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/Tree/Tree.meta.ts +1 -1
- package/src/components/Tree/Tree.stories.tsx +39 -0
- package/src/components/Tree/Tree.tsx +11 -2
- package/src/components/Tree/TreeRows.ts +18 -3
- package/src/components/VirtualList/VirtualList.meta.ts +1 -1
- package/src/components/VirtualList/VirtualList.native.tsx +24 -1
- package/src/components/VirtualList/VirtualList.stories.tsx +62 -0
- package/src/components/VirtualList/VirtualList.tsx +23 -0
- package/src/components/VirtualList/props.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.26.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.
|
|
95
|
+
"@duro-app/tokens": "^5.26.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
|
-
'Hierarchy of items with single selection and expandable branches (the WAI-ARIA tree pattern): one tab stop, arrow keys, Home/End, typeahead. A click or Enter on a branch row with no href selects it and toggles it open or closed (a folder); a branch with href navigates, and its chevron toggles. An Item with href renders its row as a real link (cmd-click opens a tab) outside the tab order; Enter/Space follows it, through onNavigate(href, event) when given. Compound component — Root is required. On React Native each row is a pressable link (href) or button, and a branch has its own chevron control that expands it (named by the optional Root prop expandLabel(text), default the item text); selection, expansion and aria-level are the same, the keyboard model (arrows, Home/End, typeahead) is web only. On native onNavigate is required for in-app routes: without it a relative href goes to the system (Linking.openURL). On web a tree with more items than Root virtualizeThreshold (default 150, open or not) is windowed: only the rows in view mount, as flat treeitems with aria-level, aria-setsize and aria-posinset; maxHeight caps its scroll viewport. Items must be JSX descendants of Root (fragments and arrays are fine, an Item hidden inside another component is not seen by the window). Native renders every row.',
|
|
5
|
+
'Hierarchy of items with single selection and expandable branches (the WAI-ARIA tree pattern): one tab stop, arrow keys, Home/End, typeahead. A click or Enter on a branch row with no href selects it and toggles it open or closed (a folder); a branch with href navigates, and its chevron toggles. An Item with expandable is a branch before its children exist (a directory listed only when opened): it opens like any branch and onExpandedChange tells the app to load them. An Item with href renders its row as a real link (cmd-click opens a tab) outside the tab order; Enter/Space follows it, through onNavigate(href, event) when given. Compound component — Root is required. On React Native each row is a pressable link (href) or button, and a branch has its own chevron control that expands it (named by the optional Root prop expandLabel(text), default the item text); selection, expansion and aria-level are the same, the keyboard model (arrows, Home/End, typeahead) is web only. On native onNavigate is required for in-app routes: without it a relative href goes to the system (Linking.openURL). On web a tree with more items than Root virtualizeThreshold (default 150, open or not) is windowed: only the rows in view mount, as flat treeitems with aria-level, aria-setsize and aria-posinset; maxHeight caps its scroll viewport. Items must be JSX descendants of Root (fragments and arrays are fine, an Item hidden inside another component is not seen by the window). Native renders every row.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Browsing data that nests to any depth — a file tree, zones › services › capabilities, namespace › resource',
|
|
8
8
|
'Picking one node of a hierarchy to show its details beside the tree',
|
|
@@ -616,3 +616,42 @@ export const BranchRowToggles: Story = {
|
|
|
616
616
|
await expect(branch).toHaveAttribute('aria-expanded', 'false')
|
|
617
617
|
},
|
|
618
618
|
}
|
|
619
|
+
|
|
620
|
+
/** A file tree that lists a directory only when it is opened (`expandable`). */
|
|
621
|
+
function LazyFolders() {
|
|
622
|
+
const [expanded, setExpanded] = useState<string[]>([])
|
|
623
|
+
const [loaded, setLoaded] = useState<ReadonlySet<string>>(() => new Set())
|
|
624
|
+
return (
|
|
625
|
+
<Tree.Root
|
|
626
|
+
aria-label="Folders"
|
|
627
|
+
expanded={expanded}
|
|
628
|
+
onExpandedChange={(next) => {
|
|
629
|
+
setExpanded(next)
|
|
630
|
+
// The app lists a directory the first time it opens.
|
|
631
|
+
setLoaded((prev) => new Set([...prev, ...next]))
|
|
632
|
+
}}
|
|
633
|
+
>
|
|
634
|
+
<Tree.Item value="src" label="src" expandable>
|
|
635
|
+
{loaded.has('src') ? <Tree.Item value="app" label="app.ts" /> : null}
|
|
636
|
+
</Tree.Item>
|
|
637
|
+
<Tree.Item value="readme" label="README.md" />
|
|
638
|
+
</Tree.Root>
|
|
639
|
+
)
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export const ExpandableBranchLoadsOnOpen: Story = {
|
|
643
|
+
name: 'Expandable branch loads on open',
|
|
644
|
+
render: () => <LazyFolders />,
|
|
645
|
+
play: async ({canvas, userEvent}) => {
|
|
646
|
+
const branch = canvas.getByRole('treeitem', {name: 'src'})
|
|
647
|
+
// A branch before its children exist: it can open, a leaf cannot.
|
|
648
|
+
await expect(branch).toHaveAttribute('aria-expanded', 'false')
|
|
649
|
+
await expect(canvas.getByRole('treeitem', {name: 'README.md'})).not.toHaveAttribute(
|
|
650
|
+
'aria-expanded',
|
|
651
|
+
)
|
|
652
|
+
branch.focus()
|
|
653
|
+
await userEvent.keyboard('{ArrowRight}')
|
|
654
|
+
await expect(branch).toHaveAttribute('aria-expanded', 'true')
|
|
655
|
+
await expect(await canvas.findByRole('treeitem', {name: 'app.ts'})).toBeVisible()
|
|
656
|
+
},
|
|
657
|
+
}
|
|
@@ -164,10 +164,19 @@ function Root({
|
|
|
164
164
|
|
|
165
165
|
// --- Item ---
|
|
166
166
|
|
|
167
|
-
function Item({
|
|
167
|
+
function Item({
|
|
168
|
+
value,
|
|
169
|
+
label,
|
|
170
|
+
textValue,
|
|
171
|
+
children,
|
|
172
|
+
meta,
|
|
173
|
+
href,
|
|
174
|
+
onNavigate,
|
|
175
|
+
expandable,
|
|
176
|
+
}: TreeItemProps) {
|
|
168
177
|
const {isExpanded} = useTree()
|
|
169
178
|
const level = useContext(TreeLevelContext)
|
|
170
|
-
const hasChildren = Children.count(children) > 0
|
|
179
|
+
const hasChildren = Children.count(children) > 0 || expandable === true
|
|
171
180
|
|
|
172
181
|
return (
|
|
173
182
|
<TreeItemRow
|
|
@@ -9,8 +9,14 @@ export interface TreeItemProps {
|
|
|
9
9
|
label: ReactNode
|
|
10
10
|
/** plain text for typeahead when `label` isn't a string */
|
|
11
11
|
textValue?: string
|
|
12
|
-
/** nested items: the branch's children (none = a leaf) */
|
|
12
|
+
/** nested items: the branch's children (none = a leaf, unless `expandable`) */
|
|
13
13
|
children?: ReactNode
|
|
14
|
+
/**
|
|
15
|
+
* A branch before its children exist (a directory not listed yet): it shows the
|
|
16
|
+
* chevron and opens like any branch, and `onExpandedChange` tells the app to load
|
|
17
|
+
* them. Pass the children once they are there (or a loading row meanwhile).
|
|
18
|
+
*/
|
|
19
|
+
expandable?: boolean
|
|
14
20
|
/** trailing content on the row (a count, a status) */
|
|
15
21
|
meta?: ReactNode
|
|
16
22
|
/**
|
|
@@ -97,8 +103,17 @@ export function collectRows(
|
|
|
97
103
|
const visit = (nodes: ReactNode, level: number, parent: string | null, visible: boolean) => {
|
|
98
104
|
const items = itemsOf(nodes, isItem)
|
|
99
105
|
items.forEach((item, index) => {
|
|
100
|
-
const {
|
|
101
|
-
|
|
106
|
+
const {
|
|
107
|
+
value,
|
|
108
|
+
label,
|
|
109
|
+
textValue,
|
|
110
|
+
children: nested,
|
|
111
|
+
meta,
|
|
112
|
+
href,
|
|
113
|
+
onNavigate,
|
|
114
|
+
expandable,
|
|
115
|
+
} = item.props
|
|
116
|
+
const hasChildren = Children.count(nested) > 0 || expandable === true
|
|
102
117
|
if (hasChildren && countLeaves(nested) !== itemsOf(nested, isItem).length) complete = false
|
|
103
118
|
const open = hasChildren && isOpen(value)
|
|
104
119
|
total += 1
|
|
@@ -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. */
|