@duro-app/ui 5.12.0 → 5.13.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-Bms2ftt3.js +1694 -0
- package/dist/Table-Bms2ftt3.js.map +1 -0
- package/dist/components/AppShell/native.css.d.ts +53 -0
- package/dist/components/AppShell/native.css.d.ts.map +1 -0
- package/dist/components/Drawer/Drawer.d.ts +7 -35
- package/dist/components/Drawer/Drawer.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.meta.d.ts.map +1 -1
- package/dist/components/Drawer/DrawerScroll.stories.d.ts.map +1 -1
- package/dist/components/Drawer/DrawerSnap.stories.d.ts +20 -0
- package/dist/components/Drawer/DrawerSnap.stories.d.ts.map +1 -0
- package/dist/components/Drawer/native.css.d.ts +55 -0
- package/dist/components/Drawer/native.css.d.ts.map +1 -0
- package/dist/components/Drawer/parts.css.d.ts +96 -0
- package/dist/components/Drawer/parts.css.d.ts.map +1 -0
- package/dist/components/Drawer/parts.d.ts +56 -0
- package/dist/components/Drawer/parts.d.ts.map +1 -0
- package/dist/components/Drawer/sheetPhysics.d.ts +56 -0
- package/dist/components/Drawer/sheetPhysics.d.ts.map +1 -0
- package/dist/components/Drawer/styles.css.d.ts +13 -58
- package/dist/components/Drawer/styles.css.d.ts.map +1 -1
- package/dist/components/Drawer/touchInput.d.ts +35 -0
- package/dist/components/Drawer/touchInput.d.ts.map +1 -0
- package/dist/components/Drawer/useDrawerRoot.d.ts +33 -0
- package/dist/components/Drawer/useDrawerRoot.d.ts.map +1 -0
- package/dist/components/Drawer/useSheetHeights.d.ts +7 -0
- package/dist/components/Drawer/useSheetHeights.d.ts.map +1 -0
- package/dist/components/Drawer/useSwipeDismiss.d.ts +13 -2
- package/dist/components/Drawer/useSwipeDismiss.d.ts.map +1 -1
- package/dist/components/Menu/Menu.d.ts +7 -1
- package/dist/components/Menu/Menu.d.ts.map +1 -1
- package/dist/components/Menu/native.css.d.ts +14 -0
- package/dist/components/Menu/native.css.d.ts.map +1 -0
- package/dist/components/Menu/useMenuOpen.d.ts +16 -0
- package/dist/components/Menu/useMenuOpen.d.ts.map +1 -0
- package/dist/components/Menu/useMenuRoot.d.ts +3 -6
- package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
- package/dist/components/Popover/native.css.d.ts +11 -0
- package/dist/components/Popover/native.css.d.ts.map +1 -0
- package/dist/components/SideNav/Chevron.d.ts +4 -0
- package/dist/components/SideNav/Chevron.d.ts.map +1 -0
- package/dist/components/SideNav/SideNav.d.ts.map +1 -1
- package/dist/components/SideNav/styles.css.d.ts +6 -0
- package/dist/components/SideNav/styles.css.d.ts.map +1 -1
- package/dist/components/SideNav/types.d.ts +6 -0
- package/dist/components/SideNav/types.d.ts.map +1 -0
- package/dist/components/SplitPane/SplitPane.d.ts +38 -0
- package/dist/components/SplitPane/SplitPane.d.ts.map +1 -0
- package/dist/components/SplitPane/SplitPane.meta.d.ts +3 -0
- package/dist/components/SplitPane/SplitPane.meta.d.ts.map +1 -0
- package/dist/components/SplitPane/SplitPane.stories.d.ts +17 -0
- package/dist/components/SplitPane/SplitPane.stories.d.ts.map +1 -0
- package/dist/components/SplitPane/styles.css.d.ts +78 -0
- package/dist/components/SplitPane/styles.css.d.ts.map +1 -0
- package/dist/components/Tabs/TabLayout.d.ts +7 -0
- package/dist/components/Tabs/TabLayout.d.ts.map +1 -0
- package/dist/components/Tabs/Tabs.d.ts.map +1 -1
- package/dist/components/Tabs/indicator.d.ts +28 -0
- package/dist/components/Tabs/indicator.d.ts.map +1 -0
- package/dist/components/Tabs/styles.css.d.ts +4 -0
- package/dist/components/Tabs/styles.css.d.ts.map +1 -1
- package/dist/components/Tabs/useTabIndicator.d.ts +4 -0
- package/dist/components/Tabs/useTabIndicator.d.ts.map +1 -0
- package/dist/components/Tabs/useTabKeys.d.ts +13 -0
- package/dist/components/Tabs/useTabKeys.d.ts.map +1 -0
- package/dist/components/Tag/RemoveGlyph.d.ts +4 -0
- package/dist/components/Tag/RemoveGlyph.d.ts.map +1 -0
- package/dist/components/Tag/Tag.d.ts +2 -24
- package/dist/components/Tag/Tag.d.ts.map +1 -1
- package/dist/components/Tag/styles.css.d.ts +8 -0
- package/dist/components/Tag/styles.css.d.ts.map +1 -1
- package/dist/components/Tag/types.d.ts +39 -0
- package/dist/components/Tag/types.d.ts.map +1 -0
- package/dist/components/Toast/ToastProvider.d.ts +2 -21
- package/dist/components/Toast/ToastProvider.d.ts.map +1 -1
- package/dist/components/Toast/native.css.d.ts +81 -0
- package/dist/components/Toast/native.css.d.ts.map +1 -0
- package/dist/components/Toast/store.d.ts +47 -0
- package/dist/components/Toast/store.d.ts.map +1 -0
- package/dist/components/Tooltip/TooltipHint.d.ts +9 -0
- package/dist/components/Tooltip/TooltipHint.d.ts.map +1 -0
- package/dist/components/Tree/Tree.d.ts.map +1 -1
- package/dist/components/Tree/styles.css.d.ts +6 -0
- package/dist/components/Tree/styles.css.d.ts.map +1 -1
- package/dist/components/Tree/useTreeKeyboard.d.ts +19 -0
- package/dist/components/Tree/useTreeKeyboard.d.ts.map +1 -0
- package/dist/components/VirtualList/VirtualList.d.ts +33 -0
- package/dist/components/VirtualList/VirtualList.d.ts.map +1 -0
- package/dist/components/VirtualList/VirtualList.meta.d.ts +3 -0
- package/dist/components/VirtualList/VirtualList.meta.d.ts.map +1 -0
- package/dist/components/VirtualList/VirtualList.stories.d.ts +15 -0
- package/dist/components/VirtualList/VirtualList.stories.d.ts.map +1 -0
- package/dist/components/VirtualList/styles.css.d.ts +28 -0
- package/dist/components/VirtualList/styles.css.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4935 -4260
- package/dist/index.js.map +1 -1
- package/dist/shared/ButtonHost.d.ts +11 -0
- package/dist/shared/ButtonHost.d.ts.map +1 -0
- package/dist/shared/pressableChild.d.ts +14 -0
- package/dist/shared/pressableChild.d.ts.map +1 -0
- package/dist/table.js +1 -1
- package/package.json +2 -2
- package/src/components/AppShell/AppShell.meta.ts +1 -1
- package/src/components/AppShell/AppShell.native.tsx +174 -0
- package/src/components/AppShell/native.css.ts +56 -0
- package/src/components/Button/Button.tsx +3 -3
- package/src/components/Drawer/Drawer.meta.ts +2 -1
- package/src/components/Drawer/Drawer.native.tsx +469 -0
- package/src/components/Drawer/Drawer.tsx +58 -145
- package/src/components/Drawer/DrawerScroll.stories.tsx +4 -54
- package/src/components/Drawer/DrawerSnap.stories.tsx +205 -0
- package/src/components/Drawer/native.css.ts +58 -0
- package/src/components/Drawer/parts.css.ts +125 -0
- package/src/components/Drawer/parts.tsx +110 -0
- package/src/components/Drawer/sheetPhysics.ts +103 -0
- package/src/components/Drawer/styles.css.ts +15 -74
- package/src/components/Drawer/touchInput.ts +81 -0
- package/src/components/Drawer/useDrawerRoot.ts +90 -0
- package/src/components/Drawer/useSheetHeights.ts +42 -0
- package/src/components/Drawer/useSwipeDismiss.ts +146 -23
- package/src/components/Menu/Menu.meta.ts +1 -1
- package/src/components/Menu/Menu.native.tsx +268 -0
- package/src/components/Menu/Menu.tsx +11 -2
- package/src/components/Menu/native.css.ts +18 -0
- package/src/components/Menu/useMenuOpen.ts +33 -0
- package/src/components/Menu/useMenuRoot.ts +7 -26
- package/src/components/Popover/Popover.meta.ts +1 -1
- package/src/components/Popover/Popover.native.tsx +205 -0
- package/src/components/Popover/native.css.ts +13 -0
- package/src/components/SideNav/Chevron.native.tsx +20 -0
- package/src/components/SideNav/Chevron.tsx +20 -0
- package/src/components/SideNav/SideNav.meta.ts +1 -1
- package/src/components/SideNav/SideNav.tsx +57 -41
- package/src/components/SideNav/styles.css.ts +8 -0
- package/src/components/SideNav/types.ts +6 -0
- package/src/components/SplitPane/SplitPane.meta.ts +40 -0
- package/src/components/SplitPane/SplitPane.native.tsx +47 -0
- package/src/components/SplitPane/SplitPane.stories.tsx +148 -0
- package/src/components/SplitPane/SplitPane.tsx +218 -0
- package/src/components/SplitPane/styles.css.ts +81 -0
- package/src/components/Tabs/TabLayout.native.tsx +9 -0
- package/src/components/Tabs/TabLayout.tsx +6 -0
- package/src/components/Tabs/Tabs.meta.ts +1 -1
- package/src/components/Tabs/Tabs.tsx +38 -136
- package/src/components/Tabs/indicator.ts +36 -0
- package/src/components/Tabs/styles.css.ts +7 -0
- package/src/components/Tabs/useTabIndicator.native.ts +35 -0
- package/src/components/Tabs/useTabIndicator.ts +49 -0
- package/src/components/Tabs/useTabKeys.native.ts +2 -0
- package/src/components/Tabs/useTabKeys.ts +99 -0
- package/src/components/Tag/RemoveGlyph.native.tsx +17 -0
- package/src/components/Tag/RemoveGlyph.tsx +10 -0
- package/src/components/Tag/Tag.meta.ts +1 -1
- package/src/components/Tag/Tag.native.tsx +70 -0
- package/src/components/Tag/Tag.tsx +9 -37
- package/src/components/Tag/styles.css.ts +9 -0
- package/src/components/Tag/types.ts +43 -0
- package/src/components/Toast/Toast.meta.ts +1 -1
- package/src/components/Toast/ToastProvider.native.tsx +226 -0
- package/src/components/Toast/ToastProvider.tsx +22 -84
- package/src/components/Toast/native.css.ts +78 -0
- package/src/components/Toast/store.ts +96 -0
- package/src/components/Toggle/Toggle.tsx +3 -3
- package/src/components/Tooltip/Tooltip.meta.ts +1 -1
- package/src/components/Tooltip/Tooltip.native.tsx +36 -0
- package/src/components/Tooltip/TooltipHint.ts +13 -0
- package/src/components/Tree/Tree.meta.ts +1 -1
- package/src/components/Tree/Tree.tsx +23 -135
- package/src/components/Tree/styles.css.ts +6 -0
- package/src/components/Tree/useTreeKeyboard.native.ts +5 -0
- package/src/components/Tree/useTreeKeyboard.ts +152 -0
- package/src/components/VirtualList/VirtualList.meta.ts +39 -0
- package/src/components/VirtualList/VirtualList.stories.tsx +95 -0
- package/src/components/VirtualList/VirtualList.tsx +103 -0
- package/src/components/VirtualList/styles.css.ts +31 -0
- package/src/hooks/useContainerBelow.native.ts +30 -0
- package/src/index.native.ts +33 -0
- package/src/index.ts +8 -1
- package/src/shared/ButtonHost.native.tsx +70 -0
- package/src/shared/ButtonHost.tsx +12 -0
- package/src/shared/portal/AnchoredOverlay.native.tsx +117 -0
- package/src/shared/pressableChild.ts +33 -0
- package/src/shared/useKeyboardHeight.native.ts +18 -0
- package/src/shared/useScreenReaderEnabled.native.ts +19 -0
- package/dist/Table-ColfpgKV.js +0 -1552
- package/dist/Table-ColfpgKV.js.map +0 -1
|
@@ -1,17 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Children,
|
|
3
|
-
type ReactNode,
|
|
4
|
-
useCallback,
|
|
5
|
-
useContext,
|
|
6
|
-
useEffect,
|
|
7
|
-
useId,
|
|
8
|
-
useLayoutEffect,
|
|
9
|
-
useRef,
|
|
10
|
-
useState,
|
|
11
|
-
} from 'react'
|
|
1
|
+
import {Children, type ReactNode, useCallback, useContext, useId, useRef, useState} from 'react'
|
|
12
2
|
import {html} from 'react-strict-dom'
|
|
3
|
+
import {SPACING_PX} from '@duro-app/tokens/keys'
|
|
13
4
|
import {styles} from './styles.css'
|
|
5
|
+
import {isNative} from '../../platform'
|
|
14
6
|
import {TreeContext, TreeLevelContext, useTree} from './TreeContext'
|
|
7
|
+
import {useTreeKeyboard} from './useTreeKeyboard'
|
|
15
8
|
import {isPlainClick, type LinkClickEvent, type OnNavigate} from '../../shared/navigate'
|
|
16
9
|
|
|
17
10
|
/* Tree — a hierarchy of items (the WAI-ARIA tree pattern): single selection,
|
|
@@ -43,8 +36,6 @@ interface RootProps {
|
|
|
43
36
|
onExpandedChange?: (expanded: string[]) => void
|
|
44
37
|
}
|
|
45
38
|
|
|
46
|
-
const TYPEAHEAD_RESET_MS = 500
|
|
47
|
-
|
|
48
39
|
function Root({
|
|
49
40
|
children,
|
|
50
41
|
'aria-label': ariaLabel,
|
|
@@ -86,126 +77,16 @@ function Root({
|
|
|
86
77
|
const isExpanded = useCallback((v: string) => open.includes(v), [open])
|
|
87
78
|
const toggle = useCallback((v: string) => setExpanded(v, !open.includes(v)), [open, setExpanded])
|
|
88
79
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
}, [focusValue, open, children])
|
|
100
|
-
|
|
101
|
-
// Keyboard: DOM order over the rendered items IS the visible order (a
|
|
102
|
-
// closed branch renders no children), so navigation reads it directly.
|
|
103
|
-
const typeahead = useRef({text: '', at: 0})
|
|
104
|
-
useEffect(() => {
|
|
105
|
-
const root = rootRef.current
|
|
106
|
-
if (!root) return
|
|
107
|
-
const rootEl = root
|
|
108
|
-
const items = () => Array.from(rootEl.querySelectorAll<HTMLElement>('[role="treeitem"]'))
|
|
109
|
-
const focusItem = (el: HTMLElement | null | undefined) => {
|
|
110
|
-
if (!el) return
|
|
111
|
-
el.focus()
|
|
112
|
-
}
|
|
113
|
-
function onKeyDown(e: KeyboardEvent) {
|
|
114
|
-
const current = (e.target as HTMLElement).closest<HTMLElement>('[role="treeitem"]')
|
|
115
|
-
if (!current || !rootEl.contains(current)) return
|
|
116
|
-
const list = items()
|
|
117
|
-
const i = list.indexOf(current)
|
|
118
|
-
const val = current.dataset.treeValue ?? ''
|
|
119
|
-
const isOpen = current.getAttribute('aria-expanded')
|
|
120
|
-
switch (e.key) {
|
|
121
|
-
case 'ArrowDown':
|
|
122
|
-
e.preventDefault()
|
|
123
|
-
focusItem(list[i + 1])
|
|
124
|
-
return
|
|
125
|
-
case 'ArrowUp':
|
|
126
|
-
e.preventDefault()
|
|
127
|
-
focusItem(list[i - 1])
|
|
128
|
-
return
|
|
129
|
-
case 'ArrowRight':
|
|
130
|
-
e.preventDefault()
|
|
131
|
-
if (isOpen === 'false') setExpanded(val, true)
|
|
132
|
-
else if (isOpen === 'true') focusItem(list[i + 1])
|
|
133
|
-
return
|
|
134
|
-
case 'ArrowLeft': {
|
|
135
|
-
e.preventDefault()
|
|
136
|
-
if (isOpen === 'true') {
|
|
137
|
-
setExpanded(val, false)
|
|
138
|
-
return
|
|
139
|
-
}
|
|
140
|
-
const parent = current.parentElement?.closest<HTMLElement>('[role="treeitem"]')
|
|
141
|
-
if (parent && rootEl.contains(parent)) focusItem(parent)
|
|
142
|
-
return
|
|
143
|
-
}
|
|
144
|
-
case 'Home':
|
|
145
|
-
e.preventDefault()
|
|
146
|
-
focusItem(list[0])
|
|
147
|
-
return
|
|
148
|
-
case 'End':
|
|
149
|
-
e.preventDefault()
|
|
150
|
-
focusItem(list[list.length - 1])
|
|
151
|
-
return
|
|
152
|
-
case 'Enter':
|
|
153
|
-
case ' ': {
|
|
154
|
-
e.preventDefault()
|
|
155
|
-
// an item with href: follow its link (the click selects it and
|
|
156
|
-
// runs onNavigate, or the browser navigates)
|
|
157
|
-
const link = current.querySelector<HTMLElement>(':scope > [data-tree-link]')
|
|
158
|
-
if (link) link.click()
|
|
159
|
-
else onSelect(val)
|
|
160
|
-
return
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
// typeahead: a printable character, no modifier
|
|
164
|
-
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
165
|
-
const now = Date.now()
|
|
166
|
-
const t = typeahead.current
|
|
167
|
-
t.text = now - t.at > TYPEAHEAD_RESET_MS ? e.key : t.text + e.key
|
|
168
|
-
t.at = now
|
|
169
|
-
// the same letter typed again cycles through the items it starts;
|
|
170
|
-
// a real prefix ("fi") narrows, and may match the current item
|
|
171
|
-
const typed = t.text.toLocaleLowerCase()
|
|
172
|
-
const repeat = [...typed].every((c) => c === typed[0])
|
|
173
|
-
const needle = repeat ? typed[0] : typed
|
|
174
|
-
const start = repeat ? i + 1 : i
|
|
175
|
-
for (let k = 0; k < list.length; k++) {
|
|
176
|
-
const el = list[(start + k) % list.length]
|
|
177
|
-
if ((el.dataset.treeText ?? '').toLocaleLowerCase().startsWith(needle)) {
|
|
178
|
-
focusItem(el)
|
|
179
|
-
break
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
// the roving tab stop follows focus, however it moved (keys or pointer)
|
|
185
|
-
function onFocusIn(e: FocusEvent) {
|
|
186
|
-
const item = (e.target as HTMLElement).closest<HTMLElement>('[role="treeitem"]')
|
|
187
|
-
const v = item?.dataset.treeValue
|
|
188
|
-
if (v) setFocusValue(v)
|
|
189
|
-
setKeyboardFocus(item?.matches(':focus-visible') ?? false)
|
|
190
|
-
}
|
|
191
|
-
function onFocusOut(e: FocusEvent) {
|
|
192
|
-
if (!rootEl.contains(e.relatedTarget as Node | null)) setKeyboardFocus(false)
|
|
193
|
-
}
|
|
194
|
-
const onPointerDown = () => setKeyboardFocus(false)
|
|
195
|
-
const onKey = () => setKeyboardFocus(true)
|
|
196
|
-
root.addEventListener('keydown', onKeyDown)
|
|
197
|
-
root.addEventListener('keydown', onKey)
|
|
198
|
-
root.addEventListener('focusin', onFocusIn)
|
|
199
|
-
root.addEventListener('focusout', onFocusOut)
|
|
200
|
-
root.addEventListener('pointerdown', onPointerDown)
|
|
201
|
-
return () => {
|
|
202
|
-
root.removeEventListener('keydown', onKeyDown)
|
|
203
|
-
root.removeEventListener('keydown', onKey)
|
|
204
|
-
root.removeEventListener('focusin', onFocusIn)
|
|
205
|
-
root.removeEventListener('focusout', onFocusOut)
|
|
206
|
-
root.removeEventListener('pointerdown', onPointerDown)
|
|
207
|
-
}
|
|
208
|
-
}, [onSelect, setExpanded])
|
|
80
|
+
useTreeKeyboard({
|
|
81
|
+
rootRef,
|
|
82
|
+
focusValue,
|
|
83
|
+
setFocusValue,
|
|
84
|
+
setKeyboardFocus,
|
|
85
|
+
open,
|
|
86
|
+
children,
|
|
87
|
+
onSelect,
|
|
88
|
+
setExpanded,
|
|
89
|
+
})
|
|
209
90
|
|
|
210
91
|
return (
|
|
211
92
|
<TreeContext.Provider
|
|
@@ -272,7 +153,9 @@ function Item({value, label, textValue, children, meta, href, onNavigate}: ItemP
|
|
|
272
153
|
// and a name computed from content would read the whole subtree
|
|
273
154
|
const labelId = useId()
|
|
274
155
|
const rowStyles = [
|
|
275
|
-
|
|
156
|
+
isNative
|
|
157
|
+
? styles.indentPx(SPACING_PX.sm + Math.max(0, level - 1) * SPACING_PX.lg)
|
|
158
|
+
: styles.indent(level),
|
|
276
159
|
selected && styles.rowSelected,
|
|
277
160
|
keyboardFocus && focusValue === value && styles.rowFocused,
|
|
278
161
|
]
|
|
@@ -287,7 +170,12 @@ function Item({value, label, textValue, children, meta, href, onNavigate}: ItemP
|
|
|
287
170
|
e.preventDefault()
|
|
288
171
|
toggle(value)
|
|
289
172
|
}}
|
|
290
|
-
style={[
|
|
173
|
+
style={[
|
|
174
|
+
styles.chevron,
|
|
175
|
+
isNative && styles.nativeFlex,
|
|
176
|
+
open && styles.chevronOpen,
|
|
177
|
+
!hasChildren && styles.chevronLeaf,
|
|
178
|
+
]}
|
|
291
179
|
>
|
|
292
180
|
▸
|
|
293
181
|
</html.span>
|
|
@@ -56,6 +56,8 @@ export const styles = css.create({
|
|
|
56
56
|
outlineWidth: 0,
|
|
57
57
|
outlineStyle: 'none',
|
|
58
58
|
},
|
|
59
|
+
// Native has no calc(): the same steps, in px from the spacing scale.
|
|
60
|
+
indentPx: (px: number) => ({paddingLeft: px}),
|
|
59
61
|
// one indent step per level below the top
|
|
60
62
|
indent: (level: number) => ({
|
|
61
63
|
paddingLeft: `calc(${spacing.sm} + ${Math.max(0, level - 1)} * ${spacing.lg})`,
|
|
@@ -71,6 +73,10 @@ export const styles = css.create({
|
|
|
71
73
|
fontWeight: typography.fontWeightMedium,
|
|
72
74
|
backgroundColor: colors.bgCardHover,
|
|
73
75
|
},
|
|
76
|
+
// RSD on native lays out only display: flex, not the web's inline-flex.
|
|
77
|
+
nativeFlex: {
|
|
78
|
+
display: 'flex',
|
|
79
|
+
},
|
|
74
80
|
chevron: {
|
|
75
81
|
display: 'inline-flex',
|
|
76
82
|
alignItems: 'center',
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import {type ReactNode, useEffect, useLayoutEffect, useRef} from 'react'
|
|
2
|
+
|
|
3
|
+
/** Typed characters within this many ms build one typeahead prefix. */
|
|
4
|
+
const TYPEAHEAD_RESET_MS = 500
|
|
5
|
+
|
|
6
|
+
interface TreeKeyboardInput {
|
|
7
|
+
rootRef: React.RefObject<HTMLUListElement | null>
|
|
8
|
+
focusValue: string | null
|
|
9
|
+
setFocusValue: (value: string | null) => void
|
|
10
|
+
setKeyboardFocus: (on: boolean) => void
|
|
11
|
+
open: ReadonlyArray<string>
|
|
12
|
+
children: ReactNode
|
|
13
|
+
onSelect: (value: string) => void
|
|
14
|
+
setExpanded: (value: string, open: boolean) => void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Web: the tree's roving tab stop, arrow keys, Home/End, Enter and typeahead
|
|
19
|
+
* (WAI-ARIA tree pattern), read from the rendered DOM. Native has none of
|
|
20
|
+
* them (plan N0, Decision 6): useTreeKeyboard.native.ts.
|
|
21
|
+
*/
|
|
22
|
+
export function useTreeKeyboard({
|
|
23
|
+
rootRef,
|
|
24
|
+
focusValue,
|
|
25
|
+
setFocusValue,
|
|
26
|
+
setKeyboardFocus,
|
|
27
|
+
open,
|
|
28
|
+
children,
|
|
29
|
+
onSelect,
|
|
30
|
+
setExpanded,
|
|
31
|
+
}: TreeKeyboardInput) {
|
|
32
|
+
// Exactly one item is tabbable: the focus holder, else the selection, else
|
|
33
|
+
// the first item — re-checked when an item appears, closes or goes away.
|
|
34
|
+
useLayoutEffect(() => {
|
|
35
|
+
const root = rootRef.current
|
|
36
|
+
if (!root) return
|
|
37
|
+
const tabbable = root.querySelector('[role="treeitem"][tabindex="0"]')
|
|
38
|
+
if (tabbable) return
|
|
39
|
+
const first = root.querySelector<HTMLElement>('[role="treeitem"]')
|
|
40
|
+
const next = first?.dataset.treeValue ?? null
|
|
41
|
+
if (next !== focusValue) setFocusValue(next)
|
|
42
|
+
}, [rootRef, focusValue, setFocusValue, open, children])
|
|
43
|
+
|
|
44
|
+
// Keyboard: DOM order over the rendered items IS the visible order (a
|
|
45
|
+
// closed branch renders no children), so navigation reads it directly.
|
|
46
|
+
const typeahead = useRef({text: '', at: 0})
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const root = rootRef.current
|
|
49
|
+
if (!root) return
|
|
50
|
+
const rootEl = root
|
|
51
|
+
const items = () => Array.from(rootEl.querySelectorAll<HTMLElement>('[role="treeitem"]'))
|
|
52
|
+
const focusItem = (el: HTMLElement | null | undefined) => {
|
|
53
|
+
if (!el) return
|
|
54
|
+
el.focus()
|
|
55
|
+
}
|
|
56
|
+
function onKeyDown(e: KeyboardEvent) {
|
|
57
|
+
const current = (e.target as HTMLElement).closest<HTMLElement>('[role="treeitem"]')
|
|
58
|
+
if (!current || !rootEl.contains(current)) return
|
|
59
|
+
const list = items()
|
|
60
|
+
const i = list.indexOf(current)
|
|
61
|
+
const val = current.dataset.treeValue ?? ''
|
|
62
|
+
const isOpen = current.getAttribute('aria-expanded')
|
|
63
|
+
switch (e.key) {
|
|
64
|
+
case 'ArrowDown':
|
|
65
|
+
e.preventDefault()
|
|
66
|
+
focusItem(list[i + 1])
|
|
67
|
+
return
|
|
68
|
+
case 'ArrowUp':
|
|
69
|
+
e.preventDefault()
|
|
70
|
+
focusItem(list[i - 1])
|
|
71
|
+
return
|
|
72
|
+
case 'ArrowRight':
|
|
73
|
+
e.preventDefault()
|
|
74
|
+
if (isOpen === 'false') setExpanded(val, true)
|
|
75
|
+
else if (isOpen === 'true') focusItem(list[i + 1])
|
|
76
|
+
return
|
|
77
|
+
case 'ArrowLeft': {
|
|
78
|
+
e.preventDefault()
|
|
79
|
+
if (isOpen === 'true') {
|
|
80
|
+
setExpanded(val, false)
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
const parent = current.parentElement?.closest<HTMLElement>('[role="treeitem"]')
|
|
84
|
+
if (parent && rootEl.contains(parent)) focusItem(parent)
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
case 'Home':
|
|
88
|
+
e.preventDefault()
|
|
89
|
+
focusItem(list[0])
|
|
90
|
+
return
|
|
91
|
+
case 'End':
|
|
92
|
+
e.preventDefault()
|
|
93
|
+
focusItem(list[list.length - 1])
|
|
94
|
+
return
|
|
95
|
+
case 'Enter':
|
|
96
|
+
case ' ': {
|
|
97
|
+
e.preventDefault()
|
|
98
|
+
// an item with href: follow its link (the click selects it and
|
|
99
|
+
// runs onNavigate, or the browser navigates)
|
|
100
|
+
const link = current.querySelector<HTMLElement>(':scope > [data-tree-link]')
|
|
101
|
+
if (link) link.click()
|
|
102
|
+
else onSelect(val)
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// typeahead: a printable character, no modifier
|
|
107
|
+
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
108
|
+
const now = Date.now()
|
|
109
|
+
const t = typeahead.current
|
|
110
|
+
t.text = now - t.at > TYPEAHEAD_RESET_MS ? e.key : t.text + e.key
|
|
111
|
+
t.at = now
|
|
112
|
+
// the same letter typed again cycles through the items it starts;
|
|
113
|
+
// a real prefix ("fi") narrows, and may match the current item
|
|
114
|
+
const typed = t.text.toLocaleLowerCase()
|
|
115
|
+
const repeat = [...typed].every((c) => c === typed[0])
|
|
116
|
+
const needle = repeat ? typed[0] : typed
|
|
117
|
+
const start = repeat ? i + 1 : i
|
|
118
|
+
for (let k = 0; k < list.length; k++) {
|
|
119
|
+
const el = list[(start + k) % list.length]
|
|
120
|
+
if ((el.dataset.treeText ?? '').toLocaleLowerCase().startsWith(needle)) {
|
|
121
|
+
focusItem(el)
|
|
122
|
+
break
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// the roving tab stop follows focus, however it moved (keys or pointer)
|
|
128
|
+
function onFocusIn(e: FocusEvent) {
|
|
129
|
+
const item = (e.target as HTMLElement).closest<HTMLElement>('[role="treeitem"]')
|
|
130
|
+
const v = item?.dataset.treeValue
|
|
131
|
+
if (v) setFocusValue(v)
|
|
132
|
+
setKeyboardFocus(item?.matches(':focus-visible') ?? false)
|
|
133
|
+
}
|
|
134
|
+
function onFocusOut(e: FocusEvent) {
|
|
135
|
+
if (!rootEl.contains(e.relatedTarget as Node | null)) setKeyboardFocus(false)
|
|
136
|
+
}
|
|
137
|
+
const onPointerDown = () => setKeyboardFocus(false)
|
|
138
|
+
const onKey = () => setKeyboardFocus(true)
|
|
139
|
+
root.addEventListener('keydown', onKeyDown)
|
|
140
|
+
root.addEventListener('keydown', onKey)
|
|
141
|
+
root.addEventListener('focusin', onFocusIn)
|
|
142
|
+
root.addEventListener('focusout', onFocusOut)
|
|
143
|
+
root.addEventListener('pointerdown', onPointerDown)
|
|
144
|
+
return () => {
|
|
145
|
+
root.removeEventListener('keydown', onKeyDown)
|
|
146
|
+
root.removeEventListener('keydown', onKey)
|
|
147
|
+
root.removeEventListener('focusin', onFocusIn)
|
|
148
|
+
root.removeEventListener('focusout', onFocusOut)
|
|
149
|
+
root.removeEventListener('pointerdown', onPointerDown)
|
|
150
|
+
}
|
|
151
|
+
}, [rootRef, onSelect, setExpanded, setFocusValue, setKeyboardFocus])
|
|
152
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type {ComponentMeta} from '../component-meta'
|
|
2
|
+
|
|
3
|
+
export const meta: ComponentMeta = {
|
|
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. Web only: on native, host FlashList or Legend List behind your own .native.tsx leaf.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'Hundreds to tens of thousands of rows: sessions, a chat transcript, log lines',
|
|
8
|
+
'A list that grows at the bottom while the reader follows it (anchor="end")',
|
|
9
|
+
],
|
|
10
|
+
whenNotToUse: [
|
|
11
|
+
'Tabular data with columns and sorting — use VirtualTable',
|
|
12
|
+
'A few dozen rows — use List; windowing only adds cost there',
|
|
13
|
+
'Selectable options in a picker — use Listbox',
|
|
14
|
+
],
|
|
15
|
+
anatomy: {
|
|
16
|
+
required: [],
|
|
17
|
+
optional: [],
|
|
18
|
+
},
|
|
19
|
+
relatedTo: [
|
|
20
|
+
{
|
|
21
|
+
component: 'VirtualTable',
|
|
22
|
+
kind: 'contrast',
|
|
23
|
+
relationship: 'VirtualTable windows table rows with columns; VirtualList windows any row',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
component: 'List',
|
|
27
|
+
kind: 'composition',
|
|
28
|
+
relationship: 'Render a List.Item-like row in renderItem for the list look',
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
example: `<VirtualList
|
|
32
|
+
aria-label="Transcript"
|
|
33
|
+
items={messages}
|
|
34
|
+
getKey={(m) => m.id}
|
|
35
|
+
estimateSize="controlLg"
|
|
36
|
+
anchor="end"
|
|
37
|
+
renderItem={(m) => <Text>{m.text}</Text>}
|
|
38
|
+
/>`,
|
|
39
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
+
import {expect, waitFor} from 'storybook/test'
|
|
3
|
+
import type {SizeToken} from '@duro-app/tokens/keys'
|
|
4
|
+
import {VirtualList} from './VirtualList'
|
|
5
|
+
import {Text} from '../Text/Text'
|
|
6
|
+
|
|
7
|
+
const meta: Meta = {
|
|
8
|
+
title: 'Components/VirtualList',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default meta
|
|
12
|
+
type Story = StoryObj
|
|
13
|
+
|
|
14
|
+
const ITEMS = Array.from({length: 10_000}, (_, i) => ({id: i, text: `Message ${i + 1}`}))
|
|
15
|
+
|
|
16
|
+
function Rows({anchor}: {anchor?: 'start' | 'end'}) {
|
|
17
|
+
return (
|
|
18
|
+
<VirtualList
|
|
19
|
+
aria-label="Transcript"
|
|
20
|
+
items={ITEMS}
|
|
21
|
+
getKey={(m) => m.id}
|
|
22
|
+
estimateSize="controlLg"
|
|
23
|
+
anchor={anchor}
|
|
24
|
+
maxHeight="previewMaxH"
|
|
25
|
+
renderItem={(m) => <Text>{m.text}</Text>}
|
|
26
|
+
/>
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface PlaygroundArgs {
|
|
31
|
+
count: number
|
|
32
|
+
anchor: 'start' | 'end'
|
|
33
|
+
overscan: number
|
|
34
|
+
estimateSize: SizeToken
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const Playground: StoryObj<PlaygroundArgs> = {
|
|
38
|
+
args: {count: 10_000, anchor: 'start', overscan: 5, estimateSize: 'controlLg'},
|
|
39
|
+
argTypes: {
|
|
40
|
+
count: {
|
|
41
|
+
control: {type: 'number', min: 0, max: 10_000, step: 500},
|
|
42
|
+
description: 'How many items',
|
|
43
|
+
},
|
|
44
|
+
anchor: {
|
|
45
|
+
control: 'inline-radio',
|
|
46
|
+
options: ['start', 'end'],
|
|
47
|
+
description: "'end' opens at the last item and follows new ones",
|
|
48
|
+
},
|
|
49
|
+
overscan: {
|
|
50
|
+
control: {type: 'number', min: 0, max: 50},
|
|
51
|
+
description: 'Rows rendered past each edge',
|
|
52
|
+
},
|
|
53
|
+
estimateSize: {
|
|
54
|
+
control: 'select',
|
|
55
|
+
options: ['controlSm', 'controlLg', 'touchTarget', 'iconXl'],
|
|
56
|
+
description: "Each row's starting height estimate (a size token)",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
render: ({count, anchor, overscan, estimateSize}) => (
|
|
60
|
+
<VirtualList
|
|
61
|
+
aria-label="Transcript"
|
|
62
|
+
items={ITEMS.slice(0, count)}
|
|
63
|
+
getKey={(m) => m.id}
|
|
64
|
+
estimateSize={estimateSize}
|
|
65
|
+
anchor={anchor}
|
|
66
|
+
overscan={overscan}
|
|
67
|
+
maxHeight="previewMaxH"
|
|
68
|
+
renderItem={(m) => <Text>{m.text}</Text>}
|
|
69
|
+
/>
|
|
70
|
+
),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const TenThousand: Story = {
|
|
74
|
+
render: () => <Rows />,
|
|
75
|
+
play: async ({canvas}) => {
|
|
76
|
+
const list = canvas.getByRole('list', {name: 'Transcript'})
|
|
77
|
+
await waitFor(() => expect(canvas.getAllByRole('listitem').length).toBeGreaterThan(0))
|
|
78
|
+
const rows = canvas.getAllByRole('listitem')
|
|
79
|
+
await expect(rows.length).toBeLessThan(50)
|
|
80
|
+
await expect(rows[0]).toHaveAttribute('aria-setsize', '10000')
|
|
81
|
+
await expect(rows[0]).toHaveAttribute('aria-posinset', '1')
|
|
82
|
+
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight * 100)
|
|
83
|
+
},
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export const AnchoredToTheEnd: Story = {
|
|
87
|
+
render: () => <Rows anchor="end" />,
|
|
88
|
+
play: async ({canvas}) => {
|
|
89
|
+
await waitFor(() => expect(canvas.getByText('Message 10000')).toBeInTheDocument())
|
|
90
|
+
const rows = canvas.getAllByRole('listitem')
|
|
91
|
+
await expect(rows.length).toBeLessThan(50)
|
|
92
|
+
const last = rows.find((r) => r.getAttribute('aria-posinset') === '10000')
|
|
93
|
+
await expect(last).toBeDefined()
|
|
94
|
+
},
|
|
95
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {type ReactNode, useEffect, useLayoutEffect, useRef} from 'react'
|
|
2
|
+
import {html} from 'react-strict-dom'
|
|
3
|
+
import {useVirtualizer} from '@tanstack/react-virtual'
|
|
4
|
+
import {SIZES_PX, type SizeToken} from '@duro-app/tokens/keys'
|
|
5
|
+
import {resolveLength, type Length} from '../../shared/length'
|
|
6
|
+
import {styles} from './styles.css'
|
|
7
|
+
|
|
8
|
+
export interface VirtualListProps<T> {
|
|
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
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Within this many px of the end counts as "at the bottom" for `anchor="end"`. */
|
|
30
|
+
const BOTTOM_SLOP = SIZES_PX.touchTarget
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A windowed list: only the rows in view (plus `overscan`) are mounted, so a
|
|
34
|
+
* list of tens of thousands stays fast. Rows are measured as they render, so
|
|
35
|
+
* they may differ in height. It is a `role="list"`; each row is a listitem
|
|
36
|
+
* with `aria-setsize` and `aria-posinset`, so assistive tech knows the full
|
|
37
|
+
* length while most rows are not in the DOM. Web only: on native, host
|
|
38
|
+
* FlashList or Legend List behind your own `.native.tsx` leaf.
|
|
39
|
+
*/
|
|
40
|
+
export function VirtualList<T>({
|
|
41
|
+
items,
|
|
42
|
+
estimateSize,
|
|
43
|
+
renderItem,
|
|
44
|
+
getKey,
|
|
45
|
+
overscan = 5,
|
|
46
|
+
anchor = 'start',
|
|
47
|
+
maxHeight,
|
|
48
|
+
'aria-label': ariaLabel,
|
|
49
|
+
}: VirtualListProps<T>) {
|
|
50
|
+
const scrollRef = useRef<HTMLDivElement>(null)
|
|
51
|
+
const virtualizer = useVirtualizer({
|
|
52
|
+
count: items.length,
|
|
53
|
+
getScrollElement: () => scrollRef.current,
|
|
54
|
+
estimateSize: () => SIZES_PX[estimateSize],
|
|
55
|
+
getItemKey: (index) => getKey(items[index], index),
|
|
56
|
+
overscan,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// `end`: open at the last item, and follow new items while at the bottom.
|
|
60
|
+
const atBottom = useRef(true)
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
const el = scrollRef.current
|
|
63
|
+
if (!el || anchor !== 'end') return
|
|
64
|
+
const onScroll = () => {
|
|
65
|
+
atBottom.current = el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_SLOP
|
|
66
|
+
}
|
|
67
|
+
el.addEventListener('scroll', onScroll, {passive: true})
|
|
68
|
+
return () => el.removeEventListener('scroll', onScroll)
|
|
69
|
+
}, [anchor])
|
|
70
|
+
useLayoutEffect(() => {
|
|
71
|
+
if (anchor !== 'end' || items.length === 0 || !atBottom.current) return
|
|
72
|
+
virtualizer.scrollToIndex(items.length - 1, {align: 'end'})
|
|
73
|
+
}, [anchor, items.length, virtualizer])
|
|
74
|
+
|
|
75
|
+
const rows = virtualizer.getVirtualItems()
|
|
76
|
+
return (
|
|
77
|
+
<html.div
|
|
78
|
+
ref={scrollRef}
|
|
79
|
+
role="list"
|
|
80
|
+
aria-label={ariaLabel}
|
|
81
|
+
style={[
|
|
82
|
+
styles.scroller,
|
|
83
|
+
maxHeight !== undefined && styles.scrollMax(resolveLength(maxHeight)),
|
|
84
|
+
]}
|
|
85
|
+
>
|
|
86
|
+
<html.div style={styles.total(virtualizer.getTotalSize())}>
|
|
87
|
+
{rows.map((row) => (
|
|
88
|
+
<html.div
|
|
89
|
+
key={row.key}
|
|
90
|
+
ref={virtualizer.measureElement}
|
|
91
|
+
data-index={row.index}
|
|
92
|
+
role="listitem"
|
|
93
|
+
aria-setsize={items.length}
|
|
94
|
+
aria-posinset={row.index + 1}
|
|
95
|
+
style={styles.row(row.start)}
|
|
96
|
+
>
|
|
97
|
+
{renderItem(items[row.index], row.index)}
|
|
98
|
+
</html.div>
|
|
99
|
+
))}
|
|
100
|
+
</html.div>
|
|
101
|
+
</html.div>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
|
|
3
|
+
export const styles = css.create({
|
|
4
|
+
// The scroll container: it fills its parent (a pane, a flex column) by
|
|
5
|
+
// default, or stops at `maxHeight`.
|
|
6
|
+
scroller: {
|
|
7
|
+
position: 'relative',
|
|
8
|
+
width: '100%',
|
|
9
|
+
height: '100%',
|
|
10
|
+
minHeight: 0,
|
|
11
|
+
overflowY: 'auto',
|
|
12
|
+
overscrollBehavior: 'contain',
|
|
13
|
+
},
|
|
14
|
+
scrollMax: (maxHeight: number | string) => ({
|
|
15
|
+
height: 'auto',
|
|
16
|
+
maxHeight,
|
|
17
|
+
}),
|
|
18
|
+
// The full virtual height, so the scrollbar is the list's real length.
|
|
19
|
+
total: (height: number) => ({
|
|
20
|
+
position: 'relative',
|
|
21
|
+
width: '100%',
|
|
22
|
+
height,
|
|
23
|
+
}),
|
|
24
|
+
row: (y: number) => ({
|
|
25
|
+
position: 'absolute',
|
|
26
|
+
top: 0,
|
|
27
|
+
left: 0,
|
|
28
|
+
width: '100%',
|
|
29
|
+
transform: `translateY(${y}px)`,
|
|
30
|
+
}),
|
|
31
|
+
})
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import {useEffect, useState, type RefObject} from 'react'
|
|
2
|
+
import {useWindowDimensions} from 'react-native'
|
|
3
|
+
import {breakpointsPx, type Breakpoint} from '@duro-app/tokens/tokens/breakpoints.css'
|
|
4
|
+
|
|
5
|
+
interface Measurable {
|
|
6
|
+
measure?: (cb: (x: number, y: number, width: number) => void) => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Native: whether the view behind `ref` is narrower than a Duro breakpoint.
|
|
11
|
+
* There is no ResizeObserver; the view is measured after layout and again
|
|
12
|
+
* whenever the window changes size (a rotation, a split screen). `false`
|
|
13
|
+
* until the first measurement, as on the web.
|
|
14
|
+
*/
|
|
15
|
+
export function useContainerBelow(ref: RefObject<unknown>, breakpoint: Breakpoint): boolean {
|
|
16
|
+
const [below, setBelow] = useState(false)
|
|
17
|
+
const limit = breakpointsPx[breakpoint]
|
|
18
|
+
const {width: windowWidth} = useWindowDimensions()
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const frame = requestAnimationFrame(() => {
|
|
22
|
+
;(ref.current as Measurable | null)?.measure?.((_x, _y, width) => {
|
|
23
|
+
if (width > 0) setBelow(width < limit)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
return () => cancelAnimationFrame(frame)
|
|
27
|
+
}, [ref, limit, windowWidth])
|
|
28
|
+
|
|
29
|
+
return below
|
|
30
|
+
}
|