@duro-app/ui 5.30.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/dist/components/Select/Select.d.ts +3 -1
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.stories.d.ts +5 -0
- package/dist/components/Select/Select.stories.d.ts.map +1 -1
- package/dist/components/Select/SelectContext.d.ts +2 -0
- package/dist/components/Select/SelectContext.d.ts.map +1 -1
- package/dist/components/Select/styles.css.d.ts +5 -0
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
- package/dist/components/Video/Video.d.ts +22 -0
- package/dist/components/Video/Video.d.ts.map +1 -0
- package/dist/components/Video/Video.meta.d.ts +3 -0
- package/dist/components/Video/Video.meta.d.ts.map +1 -0
- package/dist/components/Video/Video.stories.d.ts +8 -0
- package/dist/components/Video/Video.stories.d.ts.map +1 -0
- 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 +5 -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.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1944 -1899
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/Select/Select.meta.ts +1 -1
- package/src/components/Select/Select.native.tsx +14 -3
- package/src/components/Select/Select.stories.tsx +31 -0
- package/src/components/Select/Select.tsx +23 -4
- package/src/components/Select/SelectContext.ts +2 -0
- package/src/components/Select/styles.css.ts +6 -0
- package/src/components/Select/useSelectRoot.ts +1 -0
- package/src/components/Video/Video.meta.ts +15 -0
- package/src/components/Video/Video.stories.tsx +28 -0
- package/src/components/Video/Video.tsx +31 -0
- package/src/components/VirtualList/VirtualList.meta.ts +1 -1
- package/src/components/VirtualList/VirtualList.native.tsx +21 -0
- package/src/components/VirtualList/VirtualList.stories.tsx +87 -0
- package/src/components/VirtualList/VirtualList.tsx +20 -0
- package/src/components/VirtualList/props.ts +13 -0
- package/src/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "5.
|
|
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.
|
|
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
|
-
'Dropdown select for choosing one value from a list. Compound component — Root is required (throws without it). Select.Trigger takes size="small" to match small Toggles and Buttons in a toolbar. Select.Popup takes label (the list\'s accessible name) and empty (shown in place of the list when it has no items). On the web the list opens below the trigger, or above it when it would overflow the viewport below and fits above. On React Native the trigger opens a bottom sheet (a Drawer at the medium and large detents) titled by label, with a Cancel text button (cancelLabel); the chosen option is filled with a trailing check, and a press picks an option and closes the sheet. Native has no arrow keys: each option is its own screen-reader stop. An Item can be disabled (shown, skipped by the keys, inert to a press, announced as disabled), and onOpenChange on Root reports the list opening and closing, so work such as checking which options are available starts only once someone is choosing.',
|
|
5
|
+
'Dropdown select for choosing one value from a list. Compound component — Root is required (throws without it). Select.Trigger takes size="small" to match small Toggles and Buttons in a toolbar. Select.Popup takes label (the list\'s accessible name) and empty (shown in place of the list when it has no items). On the web the list opens below the trigger, or above it when it would overflow the viewport below and fits above. On React Native the trigger opens a bottom sheet (a Drawer at the medium and large detents) titled by label, with a Cancel text button (cancelLabel); the chosen option is filled with a trailing check, and a press picks an option and closes the sheet. Native has no arrow keys: each option is its own screen-reader stop. An Item can be disabled (shown, skipped by the keys, inert to a press, announced as disabled), and onOpenChange on Root reports the list opening and closing, so work such as checking which options are available starts only once someone is choosing. Root takes disabled for the whole control: the trigger is a disabled button (announced so, on native too) and does not open.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'User must pick one option from a predefined list',
|
|
8
8
|
'Form field for country, category, role selection',
|
|
@@ -28,6 +28,7 @@ const SNAP_POINTS = ['medium', 'large'] as const
|
|
|
28
28
|
interface NativeSelectContextValue {
|
|
29
29
|
value: string | null
|
|
30
30
|
open: boolean
|
|
31
|
+
disabled: boolean
|
|
31
32
|
setOpen: (open: boolean) => void
|
|
32
33
|
choose: (value: string) => void
|
|
33
34
|
labels: Record<string, string>
|
|
@@ -59,6 +60,8 @@ interface RootProps {
|
|
|
59
60
|
initialLabels?: Record<string, string>
|
|
60
61
|
/** Called when the sheet opens or closes. */
|
|
61
62
|
onOpenChange?: (open: boolean) => void
|
|
63
|
+
/** The whole control is disabled: the trigger does not open the sheet. */
|
|
64
|
+
disabled?: boolean
|
|
62
65
|
children: ReactNode
|
|
63
66
|
}
|
|
64
67
|
|
|
@@ -74,6 +77,7 @@ function Root({
|
|
|
74
77
|
onValueChange,
|
|
75
78
|
initialLabels,
|
|
76
79
|
onOpenChange,
|
|
80
|
+
disabled = false,
|
|
77
81
|
children,
|
|
78
82
|
}: RootProps) {
|
|
79
83
|
const state = useSelectState({defaultValue, value, onValueChange, initialLabels, onOpenChange})
|
|
@@ -90,6 +94,7 @@ function Root({
|
|
|
90
94
|
const ctx: NativeSelectContextValue = {
|
|
91
95
|
value: state.value,
|
|
92
96
|
open,
|
|
97
|
+
disabled,
|
|
93
98
|
setOpen,
|
|
94
99
|
choose: (v) => {
|
|
95
100
|
setValue(v)
|
|
@@ -123,19 +128,25 @@ function Trigger({
|
|
|
123
128
|
'aria-label'?: string
|
|
124
129
|
size?: ControlSize
|
|
125
130
|
}) {
|
|
126
|
-
const {open, setOpen, triggerRef} = useSelect()
|
|
131
|
+
const {open, disabled, setOpen, triggerRef} = useSelect()
|
|
127
132
|
const hint = useTooltipHint()
|
|
128
133
|
return (
|
|
129
134
|
<Pressable
|
|
130
135
|
ref={triggerRef as RefObject<never>}
|
|
136
|
+
disabled={disabled}
|
|
131
137
|
onPress={() => setOpen(true)}
|
|
132
138
|
accessibilityRole="combobox"
|
|
133
139
|
accessibilityLabel={ariaLabel}
|
|
134
140
|
accessibilityHint={hint}
|
|
135
|
-
accessibilityState={{expanded: open}}
|
|
141
|
+
accessibilityState={{expanded: open, disabled}}
|
|
136
142
|
>
|
|
137
143
|
<html.div
|
|
138
|
-
style={[
|
|
144
|
+
style={[
|
|
145
|
+
styles.trigger,
|
|
146
|
+
nativeStyles.nativeFlex,
|
|
147
|
+
size === 'small' && styles.triggerSmall,
|
|
148
|
+
disabled && styles.triggerDisabled,
|
|
149
|
+
]}
|
|
139
150
|
>
|
|
140
151
|
{children}
|
|
141
152
|
</html.div>
|
|
@@ -349,3 +349,34 @@ export const DisabledOptionAndOpenChange: Story = {
|
|
|
349
349
|
await waitFor(() => expect(onAgentOpenChange).toHaveBeenLastCalledWith(false))
|
|
350
350
|
},
|
|
351
351
|
}
|
|
352
|
+
|
|
353
|
+
const onDisabledOpenChange = fn()
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The whole control can be `disabled` (a choice that does not apply right now: a write
|
|
357
|
+
* that is not allowed): the trigger is a disabled button, announced so, and does not open.
|
|
358
|
+
*/
|
|
359
|
+
export const DisabledControl: Story = {
|
|
360
|
+
render: () => (
|
|
361
|
+
<Select.Root defaultValue="private" disabled onOpenChange={onDisabledOpenChange}>
|
|
362
|
+
<Select.Trigger aria-label="Visibility">
|
|
363
|
+
<Select.Value />
|
|
364
|
+
<Select.Icon />
|
|
365
|
+
</Select.Trigger>
|
|
366
|
+
<Select.Popup label="Visibility">
|
|
367
|
+
<Select.Item value="private">Private</Select.Item>
|
|
368
|
+
<Select.Item value="public">Public</Select.Item>
|
|
369
|
+
</Select.Popup>
|
|
370
|
+
</Select.Root>
|
|
371
|
+
),
|
|
372
|
+
play: async ({canvas, userEvent}) => {
|
|
373
|
+
onDisabledOpenChange.mockClear()
|
|
374
|
+
const trigger = canvas.getByRole('combobox', {name: 'Visibility'})
|
|
375
|
+
await expect(trigger).toBeDisabled()
|
|
376
|
+
await expect(trigger).toHaveTextContent(/Private/)
|
|
377
|
+
await userEvent.click(trigger)
|
|
378
|
+
await expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
|
379
|
+
await expect(screen.queryByRole('listbox')).toBeNull()
|
|
380
|
+
await expect(onDisabledOpenChange).not.toHaveBeenCalled()
|
|
381
|
+
},
|
|
382
|
+
}
|
|
@@ -1,4 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
type ReactNode,
|
|
4
|
+
useRef,
|
|
5
|
+
useId,
|
|
6
|
+
useEffect,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useState,
|
|
10
|
+
} from 'react'
|
|
2
11
|
import {createPortal} from 'react-dom'
|
|
3
12
|
import {html} from 'react-strict-dom'
|
|
4
13
|
import {SPACING_PX} from '@duro-app/tokens/keys'
|
|
@@ -23,6 +32,8 @@ interface RootProps {
|
|
|
23
32
|
initialLabels?: Record<string, string>
|
|
24
33
|
/** Called when the list opens or closes (to start work only once someone is choosing). */
|
|
25
34
|
onOpenChange?: (open: boolean) => void
|
|
35
|
+
/** The whole control is disabled: the trigger is a disabled button and does not open. */
|
|
36
|
+
disabled?: boolean
|
|
26
37
|
children: ReactNode
|
|
27
38
|
}
|
|
28
39
|
|
|
@@ -33,15 +44,17 @@ function Root({
|
|
|
33
44
|
onValueChange,
|
|
34
45
|
initialLabels,
|
|
35
46
|
onOpenChange,
|
|
47
|
+
disabled = false,
|
|
36
48
|
children,
|
|
37
49
|
}: RootProps) {
|
|
38
|
-
const {ctx, rootRef} = useSelectRoot({
|
|
50
|
+
const {ctx: rootCtx, rootRef} = useSelectRoot({
|
|
39
51
|
defaultValue,
|
|
40
52
|
value,
|
|
41
53
|
onValueChange,
|
|
42
54
|
initialLabels,
|
|
43
55
|
onOpenChange,
|
|
44
56
|
})
|
|
57
|
+
const ctx = useMemo(() => ({...rootCtx, disabled}), [rootCtx, disabled])
|
|
45
58
|
|
|
46
59
|
return (
|
|
47
60
|
<SelectContext.Provider value={ctx}>
|
|
@@ -64,7 +77,7 @@ function Trigger({
|
|
|
64
77
|
/** `small` matches a small Toggle or Button beside it. Default: `default`. */
|
|
65
78
|
size?: ControlSize
|
|
66
79
|
}) {
|
|
67
|
-
const {open, toggle, listboxId, highlightedId, triggerRef} = useSelect()
|
|
80
|
+
const {open, disabled, toggle, listboxId, highlightedId, triggerRef} = useSelect()
|
|
68
81
|
const localRef = useRef<HTMLButtonElement>(null)
|
|
69
82
|
const grouped = useGroupedControl<HTMLButtonElement>()
|
|
70
83
|
// Inside a <Field.Root>, adopt its controlId so <Field.Label for={controlId}>
|
|
@@ -88,6 +101,7 @@ function Trigger({
|
|
|
88
101
|
id={fieldCtx?.controlId}
|
|
89
102
|
type="button"
|
|
90
103
|
role={'combobox' as 'listbox'}
|
|
104
|
+
disabled={disabled}
|
|
91
105
|
onClick={toggle}
|
|
92
106
|
aria-label={ariaLabel}
|
|
93
107
|
aria-expanded={open}
|
|
@@ -96,7 +110,12 @@ function Trigger({
|
|
|
96
110
|
aria-activedescendant={highlightedId ?? undefined}
|
|
97
111
|
aria-describedby={describedBy || undefined}
|
|
98
112
|
aria-invalid={fieldCtx?.invalid || undefined}
|
|
99
|
-
style={[
|
|
113
|
+
style={[
|
|
114
|
+
styles.trigger,
|
|
115
|
+
size === 'small' && styles.triggerSmall,
|
|
116
|
+
disabled && styles.triggerDisabled,
|
|
117
|
+
grouped.style,
|
|
118
|
+
]}
|
|
100
119
|
>
|
|
101
120
|
{children}
|
|
102
121
|
</html.button>
|
|
@@ -37,6 +37,12 @@ export const styles = css.create({
|
|
|
37
37
|
transitionProperty: 'border-color',
|
|
38
38
|
transitionDuration: duration.fast,
|
|
39
39
|
},
|
|
40
|
+
// The whole control disabled: Button's disabled look, and no hover accent.
|
|
41
|
+
triggerDisabled: {
|
|
42
|
+
opacity: 0.5,
|
|
43
|
+
cursor: 'not-allowed',
|
|
44
|
+
borderColor: colors.border,
|
|
45
|
+
},
|
|
40
46
|
// size="small": Toggle sizeSmall's values, as longhands so they win over the
|
|
41
47
|
// trigger's own longhands. Every small control is at least controlSm tall,
|
|
42
48
|
// whatever it holds, and the touch target under a coarse pointer.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
4
|
+
description:
|
|
5
|
+
"A video file with the browser's own controls: a <video> as wide as its container (never wider, at its own ratio) with one source of the given MIME type, named for assistive tech by label. preload is metadata by default (duration and first frame, not the whole file). Web only: on React Native, host your own player (expo-video) behind a .native.tsx leaf.",
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'A video file the person opened: a screen recording, a clip in a file viewer',
|
|
8
|
+
'A video an app turned into an object URL from bytes it fetched',
|
|
9
|
+
],
|
|
10
|
+
whenNotToUse: [
|
|
11
|
+
'A decorative or autoplaying background — Duro has none',
|
|
12
|
+
'An image — use an img or Prose',
|
|
13
|
+
],
|
|
14
|
+
example: `<Video src={objectUrl} type="video/mp4" label={fileName} />`,
|
|
15
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
+
import {expect} from 'storybook/test'
|
|
3
|
+
import {Video} from './Video'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof Video> = {
|
|
6
|
+
title: 'Components/Video',
|
|
7
|
+
component: Video,
|
|
8
|
+
}
|
|
9
|
+
export default meta
|
|
10
|
+
type Story = StoryObj<typeof Video>
|
|
11
|
+
|
|
12
|
+
// A tiny inline WebM is not needed to check the element: the source and its type are what a test reads.
|
|
13
|
+
const SRC = 'https://example.invalid/clip.mp4'
|
|
14
|
+
|
|
15
|
+
/** A video file shown with the browser's own controls, named for assistive tech by `label`. */
|
|
16
|
+
export const Default: Story = {
|
|
17
|
+
args: {src: SRC, type: 'video/mp4', label: 'Screen recording of the failing test'},
|
|
18
|
+
play: async ({canvasElement}) => {
|
|
19
|
+
const video = canvasElement.querySelector('video')
|
|
20
|
+
await expect(video).not.toBeNull()
|
|
21
|
+
await expect(video).toHaveAttribute('aria-label', 'Screen recording of the failing test')
|
|
22
|
+
await expect(video).toHaveAttribute('controls')
|
|
23
|
+
await expect(video).toHaveAttribute('preload', 'metadata')
|
|
24
|
+
const source = video!.querySelector('source')
|
|
25
|
+
await expect(source).toHaveAttribute('src', SRC)
|
|
26
|
+
await expect(source).toHaveAttribute('type', 'video/mp4')
|
|
27
|
+
},
|
|
28
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type {Ref} from 'react'
|
|
2
|
+
|
|
3
|
+
interface VideoProps {
|
|
4
|
+
/** The video's URL: a file URL, or an object URL an app made from bytes it fetched. */
|
|
5
|
+
src: string
|
|
6
|
+
/** Its MIME type (`video/mp4`, `video/webm`), so the browser picks a playable source. */
|
|
7
|
+
type: string
|
|
8
|
+
/** The accessible name: what the video shows (its file name, a caption). */
|
|
9
|
+
label: string
|
|
10
|
+
/** The browser's own play, seek and volume controls. Default: true. */
|
|
11
|
+
controls?: boolean
|
|
12
|
+
/** How much to fetch before play: metadata (duration, first frame) by default. */
|
|
13
|
+
preload?: 'none' | 'metadata' | 'auto'
|
|
14
|
+
/** The video element, e.g. to pause it from elsewhere. */
|
|
15
|
+
ref?: Ref<HTMLVideoElement>
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Relative sizes only: the video is as wide as its container, never wider, at its own ratio.
|
|
19
|
+
const VIDEO_STYLE = {display: 'block', maxWidth: '100%', height: 'auto'} as const
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Video — a video file with the browser's controls. react-strict-dom has no video element, so
|
|
23
|
+
* this is a raw <video> (as Icon is a raw <svg>). Web only: on native, host your own player.
|
|
24
|
+
*/
|
|
25
|
+
export function Video({src, type, label, controls = true, preload = 'metadata', ref}: VideoProps) {
|
|
26
|
+
return (
|
|
27
|
+
<video ref={ref} controls={controls} preload={preload} aria-label={label} style={VIDEO_STYLE}>
|
|
28
|
+
<source src={src} type={type} />
|
|
29
|
+
</video>
|
|
30
|
+
)
|
|
31
|
+
}
|
|
@@ -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
|
package/src/index.ts
CHANGED
|
@@ -76,6 +76,7 @@ export {Meter, type MeterProps, type MeterTone} from './components/Meter/Meter'
|
|
|
76
76
|
export {PageNav} from './components/PageNav/PageNav'
|
|
77
77
|
export {Panel} from './components/Panel/Panel'
|
|
78
78
|
export {Prose} from './components/Prose/Prose'
|
|
79
|
+
export {Video} from './components/Video/Video'
|
|
79
80
|
export {RadioGroup} from './components/RadioGroup/RadioGroup'
|
|
80
81
|
export {ScrollArea} from './components/ScrollArea/ScrollArea'
|
|
81
82
|
export {Combobox} from './components/Combobox/Combobox'
|