@duro-app/ui 5.23.0 → 5.25.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.map +1 -1
- package/dist/components/Select/Select.stories.d.ts +1 -0
- package/dist/components/Select/Select.stories.d.ts.map +1 -1
- package/dist/components/Select/styles.css.d.ts +3 -0
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- package/dist/components/SplitPane/SplitPane.d.ts +7 -1
- package/dist/components/SplitPane/SplitPane.d.ts.map +1 -1
- package/dist/components/SplitPane/SplitPane.stories.d.ts +2 -0
- package/dist/components/SplitPane/SplitPane.stories.d.ts.map +1 -1
- 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/index.js +1544 -1517
- 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.stories.tsx +37 -1
- package/src/components/Select/Select.tsx +26 -10
- package/src/components/Select/styles.css.ts +4 -0
- package/src/components/SplitPane/SplitPane.meta.ts +1 -1
- package/src/components/SplitPane/SplitPane.native.tsx +2 -0
- package/src/components/SplitPane/SplitPane.stories.tsx +47 -5
- package/src/components/SplitPane/SplitPane.tsx +32 -3
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.25.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.25.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 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.',
|
|
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.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'User must pick one option from a predefined list',
|
|
8
8
|
'Form field for country, category, role selection',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
-
import {expect, fn, screen} from 'storybook/test'
|
|
2
|
+
import {expect, fn, screen, waitFor} from 'storybook/test'
|
|
3
|
+
import {css, html} from 'react-strict-dom'
|
|
3
4
|
import {Dialog} from '../Dialog/Dialog'
|
|
4
5
|
import {Select} from './Select'
|
|
5
6
|
|
|
@@ -261,3 +262,38 @@ export const LabelledAndEmpty: StoryObj<LabelledArgs> = {
|
|
|
261
262
|
await expect(screen.queryAllByRole('option')).toHaveLength(0)
|
|
262
263
|
},
|
|
263
264
|
}
|
|
265
|
+
|
|
266
|
+
// A composer's mode Select sits at the bottom of the window: its list opens above
|
|
267
|
+
// the trigger, as Menu and Popover do, instead of running off the screen.
|
|
268
|
+
const bottom = css.create({
|
|
269
|
+
frame: {height: '100vh', display: 'flex', flexDirection: 'column', justifyContent: 'flex-end'},
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
export const OpensAboveNearTheBottomEdge: Story = {
|
|
273
|
+
parameters: {layout: 'fullscreen'},
|
|
274
|
+
render: () => (
|
|
275
|
+
<html.div style={bottom.frame}>
|
|
276
|
+
<Select.Root defaultValue="default">
|
|
277
|
+
<Select.Trigger aria-label="Permission mode">
|
|
278
|
+
<Select.Value />
|
|
279
|
+
<Select.Icon />
|
|
280
|
+
</Select.Trigger>
|
|
281
|
+
<Select.Popup label="Permission mode">
|
|
282
|
+
<Select.Item value="default">Default</Select.Item>
|
|
283
|
+
<Select.Item value="read-only">Read only</Select.Item>
|
|
284
|
+
<Select.Item value="auto">Auto</Select.Item>
|
|
285
|
+
<Select.Item value="yolo">YOLO</Select.Item>
|
|
286
|
+
</Select.Popup>
|
|
287
|
+
</Select.Root>
|
|
288
|
+
</html.div>
|
|
289
|
+
),
|
|
290
|
+
play: async ({canvas, userEvent}) => {
|
|
291
|
+
const trigger = canvas.getByRole('combobox', {name: 'Permission mode'})
|
|
292
|
+
await userEvent.click(trigger)
|
|
293
|
+
const list = await screen.findByRole('listbox', {name: 'Permission mode'})
|
|
294
|
+
await waitFor(() => expect(list).toBeVisible())
|
|
295
|
+
const listBox = list.getBoundingClientRect()
|
|
296
|
+
await expect(listBox.bottom).toBeLessThanOrEqual(trigger.getBoundingClientRect().top)
|
|
297
|
+
await expect(listBox.top).toBeGreaterThanOrEqual(0)
|
|
298
|
+
},
|
|
299
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import {Children, type ReactNode, useRef, useId, useEffect, useLayoutEffect, useState} from 'react'
|
|
2
2
|
import {createPortal} from 'react-dom'
|
|
3
3
|
import {html} from 'react-strict-dom'
|
|
4
|
+
import {SPACING_PX} from '@duro-app/tokens/keys'
|
|
4
5
|
import {styles} from './styles.css'
|
|
5
6
|
import {SelectContext, useSelect} from './SelectContext'
|
|
6
7
|
import {useSelectRoot} from './useSelectRoot'
|
|
7
8
|
import {useFieldContext} from '../Field/FieldContext'
|
|
8
9
|
import {usePortalMount} from '../ThemeProvider/ThemeProvider'
|
|
10
|
+
import {computePopoverPosition} from '../Popover/position'
|
|
9
11
|
import {usePopoverLayer} from '../Popover/PopoverLayerContext'
|
|
10
12
|
import {useGroupedControl} from '../ButtonGroup/useGroupedControl'
|
|
11
13
|
import {ControlContextBoundary} from '../Toolbar/ControlContextBoundary'
|
|
@@ -140,18 +142,32 @@ function Popup({children, label, empty}: PopupProps) {
|
|
|
140
142
|
const {open, close, listboxId, triggerRef} = useSelect()
|
|
141
143
|
const mount = usePortalMount()
|
|
142
144
|
const [coords, setCoords] = useState<{top: number; left: number; width: number} | null>(null)
|
|
145
|
+
const listboxRef = useRef<HTMLDivElement>(null)
|
|
143
146
|
|
|
144
|
-
//
|
|
145
|
-
// the
|
|
146
|
-
//
|
|
147
|
+
// Place the list against the trigger when it opens, below it unless it would
|
|
148
|
+
// overflow the viewport there and fits above (as Menu and Popover do), and
|
|
149
|
+
// re-place it on scroll/resize so it follows the trigger. Capture-phase scroll
|
|
150
|
+
// catches scroll on any ancestor (e.g. a Dialog body). The list is laid out,
|
|
151
|
+
// invisible, before its first placement so its height can be measured.
|
|
147
152
|
useLayoutEffect(() => {
|
|
148
153
|
if (!open) return
|
|
149
154
|
const update = () => {
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
+
const rect = triggerRef.current?.getBoundingClientRect()
|
|
156
|
+
const list = listboxRef.current
|
|
157
|
+
if (!rect || !list) return
|
|
158
|
+
const next = computePopoverPosition({
|
|
159
|
+
anchor: {top: rect.top, left: rect.left, width: rect.width, height: rect.height},
|
|
160
|
+
popup: {width: Math.max(list.offsetWidth, rect.width), height: list.offsetHeight},
|
|
161
|
+
viewport: {width: window.innerWidth, height: window.innerHeight},
|
|
162
|
+
side: 'bottom',
|
|
163
|
+
align: 'start',
|
|
164
|
+
offset: SPACING_PX.xs,
|
|
165
|
+
})
|
|
166
|
+
setCoords((prev) =>
|
|
167
|
+
prev && prev.top === next.top && prev.left === next.left && prev.width === rect.width
|
|
168
|
+
? prev
|
|
169
|
+
: {top: next.top, left: next.left, width: rect.width},
|
|
170
|
+
)
|
|
155
171
|
}
|
|
156
172
|
update()
|
|
157
173
|
window.addEventListener('resize', update)
|
|
@@ -162,7 +178,6 @@ function Popup({children, label, empty}: PopupProps) {
|
|
|
162
178
|
}
|
|
163
179
|
}, [open, triggerRef])
|
|
164
180
|
|
|
165
|
-
const listboxRef = useRef<HTMLDivElement>(null)
|
|
166
181
|
const backdropRef = useRef<HTMLDivElement>(null)
|
|
167
182
|
usePopoverLayer(listboxRef, {active: open})
|
|
168
183
|
usePopoverLayer(backdropRef, {active: open})
|
|
@@ -178,7 +193,8 @@ function Popup({children, label, empty}: PopupProps) {
|
|
|
178
193
|
aria-hidden={!open}
|
|
179
194
|
style={[
|
|
180
195
|
styles.popup,
|
|
181
|
-
|
|
196
|
+
!open && styles.hidden,
|
|
197
|
+
open && !coords && styles.measuring,
|
|
182
198
|
coords && styles.popupPosition(coords.top, coords.left, coords.width),
|
|
183
199
|
]}
|
|
184
200
|
>
|
|
@@ -124,6 +124,10 @@ export const styles = css.create({
|
|
|
124
124
|
hidden: {
|
|
125
125
|
display: 'none',
|
|
126
126
|
},
|
|
127
|
+
// Open but not yet placed: laid out (so its height can be measured) and unseen.
|
|
128
|
+
measuring: {
|
|
129
|
+
visibility: 'hidden',
|
|
130
|
+
},
|
|
127
131
|
// Popup's `empty`: no items to choose from.
|
|
128
132
|
empty: {
|
|
129
133
|
paddingTop: spacing.md,
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Two panes side by side with a resizable separator: the first Pane is sized by the Handle (pointer drag, arrow keys, Shift for larger steps, Home and End), the second takes the rest. Min, max and starting widths are size tokens. Below collapseBelow, measured on its own width, the panes stack in one column and the handle goes; on native they always stack. A controlled collapsed prop shrinks the first pane to a strip (the iconButton size) that shows collapsedContent, removes the Handle, and keeps the first pane’s own children mounted but inert and hidden (state kept, no tab stops, out of the accessibility tree); the prior width returns when it turns false. Native ignores both. The Handle is a hairline that thickens to the accent on hover, keyboard focus or drag.',
|
|
5
|
+
'Two panes side by side with a resizable separator: the first Pane is sized by the Handle (pointer drag, arrow keys, Shift for larger steps, Home and End), the second takes the rest. Min, max and starting widths are size tokens. onSizeChange reports every width while the handle moves; onSizeCommit reports the width once a resize ends (a released drag, each keyboard step), the moment to persist it. Below collapseBelow, measured on its own width, the panes stack in one column and the handle goes; on native they always stack. A controlled collapsed prop shrinks the first pane to a strip (the iconButton size) that shows collapsedContent, removes the Handle, and keeps the first pane’s own children mounted but inert and hidden (state kept, no tab stops, out of the accessibility tree); the prior width returns when it turns false. Native ignores both. The Handle is a hairline that thickens to the accent on hover, keyboard focus or drag.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'A list beside its detail where people need more room on either side (a session list and its transcript)',
|
|
8
8
|
'An editor beside its preview',
|
|
@@ -12,6 +12,8 @@ interface RootProps {
|
|
|
12
12
|
size?: number
|
|
13
13
|
/** Web only. */
|
|
14
14
|
onSizeChange?: (px: number) => void
|
|
15
|
+
/** Web only: called once a resize ends (native always stacks). */
|
|
16
|
+
onSizeCommit?: (px: number) => void
|
|
15
17
|
/** Web only. */
|
|
16
18
|
minSize?: SizeToken
|
|
17
19
|
/** Web only. */
|
|
@@ -34,6 +34,7 @@ interface SplitArgs {
|
|
|
34
34
|
maxSize: SizeToken
|
|
35
35
|
collapseBelow: Breakpoint
|
|
36
36
|
onSizeChange?: (px: number) => void
|
|
37
|
+
onSizeCommit?: (px: number) => void
|
|
37
38
|
collapsed?: boolean
|
|
38
39
|
collapsedContent?: ReactNode
|
|
39
40
|
}
|
|
@@ -50,11 +51,19 @@ const PANE_SIZES: SizeToken[] = ['sidebarW', 'panelSm', 'panelMd', 'panelLg']
|
|
|
50
51
|
|
|
51
52
|
/** The picked B0 direction (docs/mockups/split-pane/Hairline.dc.html). */
|
|
52
53
|
function Split(props: Partial<SplitArgs>) {
|
|
53
|
-
const {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
const {
|
|
55
|
+
defaultSize,
|
|
56
|
+
minSize,
|
|
57
|
+
maxSize,
|
|
58
|
+
collapseBelow,
|
|
59
|
+
onSizeChange,
|
|
60
|
+
onSizeCommit,
|
|
61
|
+
collapsed,
|
|
62
|
+
collapsedContent,
|
|
63
|
+
} = {
|
|
64
|
+
...DEFAULT_ARGS,
|
|
65
|
+
...props,
|
|
66
|
+
}
|
|
58
67
|
return (
|
|
59
68
|
<html.div style={frame.fill}>
|
|
60
69
|
<SplitPane.Root
|
|
@@ -64,6 +73,7 @@ function Split(props: Partial<SplitArgs>) {
|
|
|
64
73
|
maxSize={maxSize}
|
|
65
74
|
collapseBelow={collapseBelow}
|
|
66
75
|
onSizeChange={onSizeChange}
|
|
76
|
+
onSizeCommit={onSizeCommit}
|
|
67
77
|
collapsed={collapsed}
|
|
68
78
|
collapsedContent={collapsedContent}
|
|
69
79
|
>
|
|
@@ -145,6 +155,38 @@ export const KeyboardMovesTheSeparator: Story = {
|
|
|
145
155
|
},
|
|
146
156
|
}
|
|
147
157
|
|
|
158
|
+
// A drag reports every width while it moves and the final one once, on release;
|
|
159
|
+
// a keyboard step is its own commit. An app persists the width on commit.
|
|
160
|
+
export const ResizeCommitsOnceWhenItEnds: StoryObj<SplitArgs> = {
|
|
161
|
+
args: {onSizeChange: fn(), onSizeCommit: fn()},
|
|
162
|
+
render: (args) => <Split {...args} />,
|
|
163
|
+
beforeEach: async () => {
|
|
164
|
+
await setViewport(1200, 800)
|
|
165
|
+
},
|
|
166
|
+
play: async ({args}) => {
|
|
167
|
+
await waitFor(() => expect(separator()).not.toBeNull())
|
|
168
|
+
const handle = separator()!
|
|
169
|
+
const box = handle.getBoundingClientRect()
|
|
170
|
+
const x = box.left + box.width / 2
|
|
171
|
+
const y = box.top + box.height / 2
|
|
172
|
+
await userEvent.pointer([
|
|
173
|
+
{keys: '[MouseLeft>]', target: handle, coords: {clientX: x, clientY: y}},
|
|
174
|
+
{target: handle, coords: {clientX: x + 20, clientY: y}},
|
|
175
|
+
{target: handle, coords: {clientX: x + 40, clientY: y}},
|
|
176
|
+
{keys: '[/MouseLeft]', target: handle, coords: {clientX: x + 40, clientY: y}},
|
|
177
|
+
])
|
|
178
|
+
// two widths while it moved, one commit when it was released
|
|
179
|
+
await expect(args.onSizeChange).toHaveBeenCalledTimes(2)
|
|
180
|
+
await expect(args.onSizeCommit).toHaveBeenCalledTimes(1)
|
|
181
|
+
await expect(args.onSizeCommit).toHaveBeenLastCalledWith(SIZES_PX.sidebarW + 40)
|
|
182
|
+
|
|
183
|
+
handle.focus()
|
|
184
|
+
await userEvent.keyboard('{ArrowLeft}')
|
|
185
|
+
await expect(args.onSizeCommit).toHaveBeenCalledTimes(2)
|
|
186
|
+
await expect(args.onSizeCommit).toHaveBeenLastCalledWith(SIZES_PX.sidebarW + 40 - SPACING_PX.md)
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
|
|
148
190
|
export const NarrowStacksInOneColumn: Story = {
|
|
149
191
|
render: () => <Split />,
|
|
150
192
|
beforeEach: async () => {
|
|
@@ -21,6 +21,7 @@ interface SplitPaneContextValue {
|
|
|
21
21
|
min: number
|
|
22
22
|
max: number
|
|
23
23
|
setSize: (px: number) => void
|
|
24
|
+
commitSize: (px: number) => void
|
|
24
25
|
stacked: boolean
|
|
25
26
|
collapsed: boolean
|
|
26
27
|
collapsedContent: ReactNode
|
|
@@ -53,6 +54,12 @@ interface RootProps {
|
|
|
53
54
|
size?: number
|
|
54
55
|
/** Called with the primary pane's new width while the handle moves */
|
|
55
56
|
onSizeChange?: (px: number) => void
|
|
57
|
+
/**
|
|
58
|
+
* Called once a resize ends, with the width it ended at: when a drag is
|
|
59
|
+
* released (or cancelled) and after each keyboard step. Persist the width
|
|
60
|
+
* here rather than on every `onSizeChange`.
|
|
61
|
+
*/
|
|
62
|
+
onSizeCommit?: (px: number) => void
|
|
56
63
|
/** The primary pane's narrowest width, a size token. Default: `sidebarW` */
|
|
57
64
|
minSize?: SizeToken
|
|
58
65
|
/** The primary pane's widest width, a size token. Default: `panelLg` */
|
|
@@ -83,6 +90,7 @@ function Root({
|
|
|
83
90
|
defaultSize = 'sidebarW',
|
|
84
91
|
size: controlledSize,
|
|
85
92
|
onSizeChange,
|
|
93
|
+
onSizeCommit,
|
|
86
94
|
minSize = 'sidebarW',
|
|
87
95
|
maxSize = 'panelLg',
|
|
88
96
|
collapseBelow = 'sm',
|
|
@@ -102,6 +110,10 @@ function Root({
|
|
|
102
110
|
},
|
|
103
111
|
[controlledSize, onSizeChange, min, max],
|
|
104
112
|
)
|
|
113
|
+
const commitSize = useCallback(
|
|
114
|
+
(px: number) => onSizeCommit?.(clamp(Math.round(px), min, max)),
|
|
115
|
+
[onSizeCommit, min, max],
|
|
116
|
+
)
|
|
105
117
|
const rootRef = useRef<HTMLDivElement>(null)
|
|
106
118
|
const stacked = useContainerBelow(rootRef, collapseBelow)
|
|
107
119
|
const primaryId = useId()
|
|
@@ -117,7 +129,18 @@ function Root({
|
|
|
117
129
|
|
|
118
130
|
return (
|
|
119
131
|
<SplitPaneContext.Provider
|
|
120
|
-
value={{
|
|
132
|
+
value={{
|
|
133
|
+
size,
|
|
134
|
+
min,
|
|
135
|
+
max,
|
|
136
|
+
setSize,
|
|
137
|
+
commitSize,
|
|
138
|
+
stacked,
|
|
139
|
+
collapsed,
|
|
140
|
+
collapsedContent,
|
|
141
|
+
primaryId,
|
|
142
|
+
label,
|
|
143
|
+
}}
|
|
121
144
|
>
|
|
122
145
|
<html.div ref={rootRef} style={[styles.root, stacked && styles.rootStacked]}>
|
|
123
146
|
{items}
|
|
@@ -171,7 +194,8 @@ function Pane({children}: {children: ReactNode}) {
|
|
|
171
194
|
}
|
|
172
195
|
|
|
173
196
|
function Handle() {
|
|
174
|
-
const {size, min, max, setSize, stacked, collapsed, primaryId, label} =
|
|
197
|
+
const {size, min, max, setSize, commitSize, stacked, collapsed, primaryId, label} =
|
|
198
|
+
useSplitPane('Handle')
|
|
175
199
|
const [hovered, setHovered] = useState(false)
|
|
176
200
|
const [keyboardFocus, setKeyboardFocus] = useState(false)
|
|
177
201
|
const [dragging, setDragging] = useState(false)
|
|
@@ -198,10 +222,11 @@ function Handle() {
|
|
|
198
222
|
if (next === null) return
|
|
199
223
|
e.preventDefault()
|
|
200
224
|
setSize(next)
|
|
225
|
+
commitSize(next)
|
|
201
226
|
}
|
|
202
227
|
el.addEventListener('keydown', onKeyDown)
|
|
203
228
|
return () => el.removeEventListener('keydown', onKeyDown)
|
|
204
|
-
}, [size, min, max, setSize, stacked, collapsed])
|
|
229
|
+
}, [size, min, max, setSize, commitSize, stacked, collapsed])
|
|
205
230
|
|
|
206
231
|
if (stacked || collapsed) return null
|
|
207
232
|
const active = hovered || keyboardFocus || dragging
|
|
@@ -240,12 +265,16 @@ function Handle() {
|
|
|
240
265
|
setSize(drag.current.startSize + e.clientX - drag.current.startX)
|
|
241
266
|
}}
|
|
242
267
|
onPointerUp={() => {
|
|
268
|
+
if (!drag.current) return
|
|
243
269
|
drag.current = null
|
|
244
270
|
setDragging(false)
|
|
271
|
+
commitSize(size)
|
|
245
272
|
}}
|
|
246
273
|
onPointerCancel={() => {
|
|
274
|
+
if (!drag.current) return
|
|
247
275
|
drag.current = null
|
|
248
276
|
setDragging(false)
|
|
277
|
+
commitSize(size)
|
|
249
278
|
}}
|
|
250
279
|
>
|
|
251
280
|
<html.span aria-hidden style={[styles.line, active && styles.lineActive]} />
|
|
@@ -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
|