@duro-app/ui 5.21.0 → 5.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Table-Cw1B14R3.js.map +1 -1
- package/dist/components/AppShell/AppShell.d.ts +10 -1
- package/dist/components/AppShell/AppShell.d.ts.map +1 -1
- package/dist/components/AppShell/AppShell.stories.d.ts +8 -0
- package/dist/components/AppShell/AppShell.stories.d.ts.map +1 -1
- package/dist/components/AppShell/styles.css.d.ts +7 -0
- package/dist/components/AppShell/styles.css.d.ts.map +1 -1
- package/dist/components/CodeBlock/CodeBlock.d.ts +3 -21
- package/dist/components/CodeBlock/CodeBlock.d.ts.map +1 -1
- package/dist/components/CodeBlock/CodeBlock.stories.d.ts +4 -0
- package/dist/components/CodeBlock/CodeBlock.stories.d.ts.map +1 -1
- package/dist/components/CodeBlock/DiffLines.d.ts +9 -0
- package/dist/components/CodeBlock/DiffLines.d.ts.map +1 -0
- package/dist/components/CodeBlock/diffRoles.d.ts +14 -0
- package/dist/components/CodeBlock/diffRoles.d.ts.map +1 -0
- package/dist/components/CodeBlock/styles.css.d.ts +24 -0
- package/dist/components/CodeBlock/styles.css.d.ts.map +1 -1
- package/dist/components/CodeBlock/types.d.ts +29 -0
- package/dist/components/CodeBlock/types.d.ts.map +1 -0
- package/dist/components/Drawer/touchInput.d.ts +5 -1
- package/dist/components/Drawer/touchInput.d.ts.map +1 -1
- package/dist/components/Prose/Prose.d.ts +1 -1
- package/dist/components/SplitPane/SplitPane.d.ts +10 -1
- package/dist/components/SplitPane/SplitPane.d.ts.map +1 -1
- package/dist/components/SplitPane/SplitPane.stories.d.ts +11 -0
- package/dist/components/SplitPane/SplitPane.stories.d.ts.map +1 -1
- package/dist/components/SplitPane/styles.css.d.ts +22 -0
- package/dist/components/SplitPane/styles.css.d.ts.map +1 -1
- package/dist/components/Textarea/Textarea.d.ts +14 -2
- package/dist/components/Textarea/Textarea.d.ts.map +1 -1
- package/dist/components/Textarea/Textarea.meta.d.ts.map +1 -1
- package/dist/components/Textarea/Textarea.stories.d.ts +2 -0
- package/dist/components/Textarea/Textarea.stories.d.ts.map +1 -1
- package/dist/components/Textarea/autoGrow.d.ts +22 -0
- package/dist/components/Textarea/autoGrow.d.ts.map +1 -0
- package/dist/components/Textarea/grow.css.d.ts +12 -0
- package/dist/components/Textarea/grow.css.d.ts.map +1 -0
- package/dist/components/Textarea/grow.d.ts +2 -0
- package/dist/components/Textarea/grow.d.ts.map +1 -0
- package/dist/components/Textarea/native.css.d.ts +8 -0
- package/dist/components/Textarea/native.css.d.ts.map +1 -0
- package/dist/components/Textarea/useAutoGrowFallback.d.ts +9 -0
- package/dist/components/Textarea/useAutoGrowFallback.d.ts.map +1 -0
- package/dist/components/Toolbar/Toolbar.d.ts +2 -14
- package/dist/components/Toolbar/Toolbar.d.ts.map +1 -1
- package/dist/components/Toolbar/types.d.ts +15 -0
- package/dist/components/Toolbar/types.d.ts.map +1 -0
- package/dist/components/VirtualList/VirtualList.d.ts +5 -26
- package/dist/components/VirtualList/VirtualList.d.ts.map +1 -1
- package/dist/components/VirtualList/VirtualList.meta.d.ts.map +1 -1
- package/dist/components/VirtualList/VirtualList.stories.d.ts +16 -0
- package/dist/components/VirtualList/VirtualList.stories.d.ts.map +1 -1
- package/dist/components/VirtualList/native.css.d.ts +14 -0
- package/dist/components/VirtualList/native.css.d.ts.map +1 -0
- package/dist/components/VirtualList/props.d.ts +31 -0
- package/dist/components/VirtualList/props.d.ts.map +1 -0
- package/dist/components/VirtualList/styles.css.d.ts +4 -0
- package/dist/components/VirtualList/styles.css.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4442 -4215
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/AppShell/AppShell.meta.ts +1 -1
- package/src/components/AppShell/AppShell.native.tsx +3 -0
- package/src/components/AppShell/AppShell.stories.tsx +68 -0
- package/src/components/AppShell/AppShell.tsx +16 -5
- package/src/components/AppShell/styles.css.ts +10 -0
- package/src/components/CodeBlock/CodeBlock.meta.ts +1 -1
- package/src/components/CodeBlock/CodeBlock.native.tsx +33 -0
- package/src/components/CodeBlock/CodeBlock.stories.tsx +54 -0
- package/src/components/CodeBlock/CodeBlock.tsx +8 -20
- package/src/components/CodeBlock/DiffLines.tsx +32 -0
- package/src/components/CodeBlock/diffRoles.ts +40 -0
- package/src/components/CodeBlock/styles.css.ts +17 -0
- package/src/components/CodeBlock/types.ts +29 -0
- package/src/components/Drawer/touchInput.ts +16 -2
- package/src/components/Prose/Prose.meta.ts +1 -1
- package/src/components/Prose/Prose.native.tsx +23 -0
- package/src/components/Prose/Prose.tsx +1 -1
- package/src/components/Prose/styles.native.css.ts +15 -0
- package/src/components/SplitPane/SplitPane.meta.ts +1 -1
- package/src/components/SplitPane/SplitPane.native.tsx +4 -0
- package/src/components/SplitPane/SplitPane.stories.tsx +207 -1
- package/src/components/SplitPane/SplitPane.tsx +50 -7
- package/src/components/SplitPane/styles.css.ts +28 -0
- package/src/components/Textarea/Textarea.meta.ts +6 -2
- package/src/components/Textarea/Textarea.stories.tsx +136 -1
- package/src/components/Textarea/Textarea.tsx +50 -3
- package/src/components/Textarea/autoGrow.ts +33 -0
- package/src/components/Textarea/grow.css.ts +18 -0
- package/src/components/Textarea/grow.native.ts +1 -0
- package/src/components/Textarea/grow.ts +1 -0
- package/src/components/Textarea/native.css.ts +16 -0
- package/src/components/Textarea/useAutoGrowFallback.native.ts +5 -0
- package/src/components/Textarea/useAutoGrowFallback.ts +35 -0
- package/src/components/Toolbar/Toolbar.meta.ts +1 -1
- package/src/components/Toolbar/Toolbar.native.tsx +27 -0
- package/src/components/Toolbar/Toolbar.tsx +3 -14
- package/src/components/Toolbar/types.ts +15 -0
- package/src/components/VirtualList/VirtualList.meta.ts +2 -1
- package/src/components/VirtualList/VirtualList.native.tsx +80 -0
- package/src/components/VirtualList/VirtualList.stories.tsx +171 -1
- package/src/components/VirtualList/VirtualList.tsx +118 -27
- package/src/components/VirtualList/native.css.ts +14 -0
- package/src/components/VirtualList/props.ts +31 -0
- package/src/components/VirtualList/styles.css.ts +7 -0
- package/src/index.native.ts +8 -0
- package/src/index.ts +1 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
2
|
import {expect, fn, userEvent, waitFor} from 'storybook/test'
|
|
3
|
+
import {useState, type ReactNode} from 'react'
|
|
3
4
|
import {css, html} from 'react-strict-dom'
|
|
4
5
|
import {SIZES_PX, SPACING_PX, type SizeToken} from '@duro-app/tokens/keys'
|
|
5
6
|
import type {Breakpoint} from '@duro-app/tokens/tokens/breakpoints.css'
|
|
@@ -9,6 +10,10 @@ import {List} from '../List/List'
|
|
|
9
10
|
import {Panel} from '../Panel/Panel'
|
|
10
11
|
import {Heading} from '../Heading/Heading'
|
|
11
12
|
import {Text} from '../Text/Text'
|
|
13
|
+
import {Input} from '../Input/Input'
|
|
14
|
+
import {Button} from '../Button/Button'
|
|
15
|
+
import {Icon} from '../Icon/Icon'
|
|
16
|
+
import {VirtualList} from '../VirtualList/VirtualList'
|
|
12
17
|
|
|
13
18
|
const meta: Meta = {
|
|
14
19
|
title: 'Components/SplitPane',
|
|
@@ -29,6 +34,8 @@ interface SplitArgs {
|
|
|
29
34
|
maxSize: SizeToken
|
|
30
35
|
collapseBelow: Breakpoint
|
|
31
36
|
onSizeChange?: (px: number) => void
|
|
37
|
+
collapsed?: boolean
|
|
38
|
+
collapsedContent?: ReactNode
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
const DEFAULT_ARGS: SplitArgs = {
|
|
@@ -43,7 +50,11 @@ const PANE_SIZES: SizeToken[] = ['sidebarW', 'panelSm', 'panelMd', 'panelLg']
|
|
|
43
50
|
|
|
44
51
|
/** The picked B0 direction (docs/mockups/split-pane/Hairline.dc.html). */
|
|
45
52
|
function Split(props: Partial<SplitArgs>) {
|
|
46
|
-
const {defaultSize, minSize, maxSize, collapseBelow, onSizeChange
|
|
53
|
+
const {defaultSize, minSize, maxSize, collapseBelow, onSizeChange, collapsed, collapsedContent} =
|
|
54
|
+
{
|
|
55
|
+
...DEFAULT_ARGS,
|
|
56
|
+
...props,
|
|
57
|
+
}
|
|
47
58
|
return (
|
|
48
59
|
<html.div style={frame.fill}>
|
|
49
60
|
<SplitPane.Root
|
|
@@ -53,8 +64,11 @@ function Split(props: Partial<SplitArgs>) {
|
|
|
53
64
|
maxSize={maxSize}
|
|
54
65
|
collapseBelow={collapseBelow}
|
|
55
66
|
onSizeChange={onSizeChange}
|
|
67
|
+
collapsed={collapsed}
|
|
68
|
+
collapsedContent={collapsedContent}
|
|
56
69
|
>
|
|
57
70
|
<SplitPane.Pane>
|
|
71
|
+
<Button variant="secondary">Filter sessions</Button>
|
|
58
72
|
<List.Root aria-label="Sessions">
|
|
59
73
|
{SESSIONS.map((s) => (
|
|
60
74
|
<List.Item key={s}>
|
|
@@ -146,3 +160,195 @@ export const NarrowStacksInOneColumn: Story = {
|
|
|
146
160
|
)
|
|
147
161
|
},
|
|
148
162
|
}
|
|
163
|
+
|
|
164
|
+
function Collapsible() {
|
|
165
|
+
const [collapsed, setCollapsed] = useState(false)
|
|
166
|
+
return (
|
|
167
|
+
<>
|
|
168
|
+
<Button variant="secondary" onClick={() => setCollapsed((c) => !c)}>
|
|
169
|
+
Toggle sessions
|
|
170
|
+
</Button>
|
|
171
|
+
<Split
|
|
172
|
+
collapsed={collapsed}
|
|
173
|
+
collapsedContent={
|
|
174
|
+
// As in the Focus artboard: an icon button that fits the strip.
|
|
175
|
+
<Button
|
|
176
|
+
variant="secondary"
|
|
177
|
+
size="small"
|
|
178
|
+
aria-label="Expand sessions"
|
|
179
|
+
onClick={() => setCollapsed(false)}
|
|
180
|
+
>
|
|
181
|
+
<Icon name="menu" size="sm" />
|
|
182
|
+
</Button>
|
|
183
|
+
}
|
|
184
|
+
/>
|
|
185
|
+
</>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* `collapsed`: the first pane shrinks to a strip holding `collapsedContent`, the
|
|
191
|
+
* handle goes away, and expanding restores the width it had.
|
|
192
|
+
*/
|
|
193
|
+
export const Collapsed: Story = {
|
|
194
|
+
render: () => <Collapsible />,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export const CollapsedShowsTheStrip: Story = {
|
|
198
|
+
render: () => <Collapsible />,
|
|
199
|
+
beforeEach: async () => {
|
|
200
|
+
await setViewport(1200, 800)
|
|
201
|
+
},
|
|
202
|
+
play: async ({canvas}) => {
|
|
203
|
+
await waitFor(() => expect(separator()).not.toBeNull())
|
|
204
|
+
const handle = separator()!
|
|
205
|
+
const primary = document.getElementById(handle.getAttribute('aria-controls')!)!
|
|
206
|
+
const toggle = canvas.getByRole('button', {name: 'Toggle sessions'})
|
|
207
|
+
await waitFor(() =>
|
|
208
|
+
expect(primary.getBoundingClientRect().width).toBeCloseTo(SIZES_PX.sidebarW, 0),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
// widen the pane, then collapse
|
|
212
|
+
handle.focus()
|
|
213
|
+
await userEvent.keyboard('{ArrowRight}')
|
|
214
|
+
const widened = SIZES_PX.sidebarW + SPACING_PX.md
|
|
215
|
+
await waitFor(() => expect(primary.getBoundingClientRect().width).toBeCloseTo(widened, 0))
|
|
216
|
+
|
|
217
|
+
await userEvent.click(toggle)
|
|
218
|
+
await waitFor(() =>
|
|
219
|
+
expect(primary.getBoundingClientRect().width).toBeCloseTo(SIZES_PX.iconButton, 0),
|
|
220
|
+
)
|
|
221
|
+
// the handle is not rendered; the pane's own children are inert and out of the a11y tree
|
|
222
|
+
await expect(separator()).toBeNull()
|
|
223
|
+
await expect(canvas.queryByRole('separator')).toBeNull()
|
|
224
|
+
await expect(canvas.queryByRole('button', {name: 'Filter sessions'})).toBeNull()
|
|
225
|
+
|
|
226
|
+
// Tab from the toggle never reaches a control inside the collapsed children,
|
|
227
|
+
// but the strip's own button is reachable
|
|
228
|
+
toggle.focus()
|
|
229
|
+
const reached = new Set<Element>()
|
|
230
|
+
for (let i = 0; i < 6; i++) {
|
|
231
|
+
await userEvent.tab()
|
|
232
|
+
if (document.activeElement) reached.add(document.activeElement)
|
|
233
|
+
}
|
|
234
|
+
const filter = document.evaluate(
|
|
235
|
+
"//button[contains(., 'Filter sessions')]",
|
|
236
|
+
document,
|
|
237
|
+
null,
|
|
238
|
+
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
|
239
|
+
null,
|
|
240
|
+
).singleNodeValue
|
|
241
|
+
await expect(filter).not.toBeNull()
|
|
242
|
+
await expect(reached.has(filter as Element)).toBe(false)
|
|
243
|
+
await expect(reached.has(canvas.getByRole('button', {name: 'Expand sessions'}))).toBe(true)
|
|
244
|
+
|
|
245
|
+
// expanded again: the prior size returns and the handle is back
|
|
246
|
+
await userEvent.click(toggle)
|
|
247
|
+
await waitFor(() => expect(primary.getBoundingClientRect().width).toBeCloseTo(widened, 0))
|
|
248
|
+
await expect(separator()).toHaveAttribute('aria-valuenow', String(widened))
|
|
249
|
+
await expect(canvas.getByRole('button', {name: 'Filter sessions'})).toBeVisible()
|
|
250
|
+
|
|
251
|
+
// the re-mounted handle resizes by keyboard again
|
|
252
|
+
const again = separator()!
|
|
253
|
+
again.focus()
|
|
254
|
+
await userEvent.keyboard('{ArrowRight}')
|
|
255
|
+
await waitFor(() =>
|
|
256
|
+
expect(primary.getBoundingClientRect().width).toBeCloseTo(widened + SPACING_PX.md, 0),
|
|
257
|
+
)
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function KeepsState() {
|
|
262
|
+
const [collapsed, setCollapsed] = useState(false)
|
|
263
|
+
return (
|
|
264
|
+
<>
|
|
265
|
+
<Button variant="secondary" onClick={() => setCollapsed((c) => !c)}>
|
|
266
|
+
Toggle sessions
|
|
267
|
+
</Button>
|
|
268
|
+
<html.div style={frame.fill}>
|
|
269
|
+
<SplitPane.Root
|
|
270
|
+
aria-label="Resize the session list"
|
|
271
|
+
collapsed={collapsed}
|
|
272
|
+
collapsedContent={
|
|
273
|
+
<Button variant="secondary" size="small" aria-label="Expand sessions">
|
|
274
|
+
<Icon name="menu" size="sm" />
|
|
275
|
+
</Button>
|
|
276
|
+
}
|
|
277
|
+
>
|
|
278
|
+
<SplitPane.Pane>
|
|
279
|
+
<Input aria-label="Search sessions" />
|
|
280
|
+
</SplitPane.Pane>
|
|
281
|
+
<SplitPane.Handle />
|
|
282
|
+
<SplitPane.Pane>
|
|
283
|
+
<Text>Detail</Text>
|
|
284
|
+
</SplitPane.Pane>
|
|
285
|
+
</SplitPane.Root>
|
|
286
|
+
</html.div>
|
|
287
|
+
</>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export const CollapseKeepsChildState: Story = {
|
|
292
|
+
render: () => <KeepsState />,
|
|
293
|
+
beforeEach: async () => {
|
|
294
|
+
await setViewport(1200, 800)
|
|
295
|
+
},
|
|
296
|
+
play: async ({canvas}) => {
|
|
297
|
+
const field = await canvas.findByRole('textbox', {name: 'Search sessions'})
|
|
298
|
+
await userEvent.type(field, 'auth')
|
|
299
|
+
await expect(field).toHaveValue('auth')
|
|
300
|
+
|
|
301
|
+
const toggle = canvas.getByRole('button', {name: 'Toggle sessions'})
|
|
302
|
+
await userEvent.click(toggle)
|
|
303
|
+
await waitFor(() => expect(separator()).toBeNull())
|
|
304
|
+
await userEvent.click(toggle)
|
|
305
|
+
await waitFor(() => expect(separator()).not.toBeNull())
|
|
306
|
+
|
|
307
|
+
const after = await canvas.findByRole('textbox', {name: 'Search sessions'})
|
|
308
|
+
await expect(after).toBe(field)
|
|
309
|
+
await expect(after).toHaveValue('auth')
|
|
310
|
+
},
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const ROWS = Array.from({length: 500}, (_, i) => ({id: i, text: `Row ${i + 1}`}))
|
|
314
|
+
|
|
315
|
+
const list = (name: string) => (
|
|
316
|
+
<VirtualList
|
|
317
|
+
aria-label={name}
|
|
318
|
+
items={ROWS}
|
|
319
|
+
getKey={(r) => r.id}
|
|
320
|
+
estimateSize="controlLg"
|
|
321
|
+
renderItem={(r) => <Text>{r.text}</Text>}
|
|
322
|
+
/>
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
function ListsInEachPane() {
|
|
326
|
+
return (
|
|
327
|
+
<html.div style={frame.fill}>
|
|
328
|
+
<SplitPane.Root aria-label="Resize the lists">
|
|
329
|
+
<SplitPane.Pane>{list('Left')}</SplitPane.Pane>
|
|
330
|
+
<SplitPane.Handle />
|
|
331
|
+
<SplitPane.Pane>{list('Right')}</SplitPane.Pane>
|
|
332
|
+
</SplitPane.Root>
|
|
333
|
+
</html.div>
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export const VirtualListsFillTheirPanes: Story = {
|
|
338
|
+
render: () => <ListsInEachPane />,
|
|
339
|
+
beforeEach: async () => {
|
|
340
|
+
await setViewport(1200, 800)
|
|
341
|
+
},
|
|
342
|
+
play: async ({canvas}) => {
|
|
343
|
+
// Each list sits in a pane that is a direct child of the split's root.
|
|
344
|
+
const root = (await canvas.findByRole('separator')).parentElement!
|
|
345
|
+
for (const name of ['Left', 'Right']) {
|
|
346
|
+
const list = await canvas.findByRole('list', {name})
|
|
347
|
+
let pane: HTMLElement = list
|
|
348
|
+
while (pane.parentElement !== root) pane = pane.parentElement!
|
|
349
|
+
await waitFor(() => expect(list.clientHeight).toBeGreaterThan(0))
|
|
350
|
+
await waitFor(() => expect(list.clientHeight).toBe(pane.clientHeight))
|
|
351
|
+
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight)
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
}
|
|
@@ -22,6 +22,8 @@ interface SplitPaneContextValue {
|
|
|
22
22
|
max: number
|
|
23
23
|
setSize: (px: number) => void
|
|
24
24
|
stacked: boolean
|
|
25
|
+
collapsed: boolean
|
|
26
|
+
collapsedContent: ReactNode
|
|
25
27
|
primaryId: string
|
|
26
28
|
label: string
|
|
27
29
|
}
|
|
@@ -57,6 +59,15 @@ interface RootProps {
|
|
|
57
59
|
maxSize?: SizeToken
|
|
58
60
|
/** Below this breakpoint, measured on the split's own width, the panes stack. Default: `sm` */
|
|
59
61
|
collapseBelow?: Breakpoint
|
|
62
|
+
/**
|
|
63
|
+
* Controlled: shrink the primary pane to a strip (the `iconButton` size),
|
|
64
|
+
* show `collapsedContent` in it and remove the handle. The pane's own
|
|
65
|
+
* children stay mounted (their state is kept) but are inert and hidden. When
|
|
66
|
+
* it turns false the pane returns to its prior width. Ignored when stacked.
|
|
67
|
+
*/
|
|
68
|
+
collapsed?: boolean
|
|
69
|
+
/** What the strip shows while `collapsed` (an expand button, icons). */
|
|
70
|
+
collapsedContent?: ReactNode
|
|
60
71
|
/** The handle's accessible name ("Resize list"). */
|
|
61
72
|
'aria-label': string
|
|
62
73
|
}
|
|
@@ -75,6 +86,8 @@ function Root({
|
|
|
75
86
|
minSize = 'sidebarW',
|
|
76
87
|
maxSize = 'panelLg',
|
|
77
88
|
collapseBelow = 'sm',
|
|
89
|
+
collapsed = false,
|
|
90
|
+
collapsedContent,
|
|
78
91
|
'aria-label': label,
|
|
79
92
|
}: RootProps) {
|
|
80
93
|
const min = SIZES_PX[minSize]
|
|
@@ -103,7 +116,9 @@ function Root({
|
|
|
103
116
|
})
|
|
104
117
|
|
|
105
118
|
return (
|
|
106
|
-
<SplitPaneContext.Provider
|
|
119
|
+
<SplitPaneContext.Provider
|
|
120
|
+
value={{size, min, max, setSize, stacked, collapsed, collapsedContent, primaryId, label}}
|
|
121
|
+
>
|
|
107
122
|
<html.div ref={rootRef} style={[styles.root, stacked && styles.rootStacked]}>
|
|
108
123
|
{items}
|
|
109
124
|
</html.div>
|
|
@@ -112,23 +127,51 @@ function Root({
|
|
|
112
127
|
}
|
|
113
128
|
|
|
114
129
|
function Pane({children}: {children: ReactNode}) {
|
|
115
|
-
const {size, stacked, primaryId} = useSplitPane('Pane')
|
|
130
|
+
const {size, stacked, collapsed, collapsedContent, primaryId} = useSplitPane('Pane')
|
|
116
131
|
const primary = useContext(PaneIndexContext) === 0
|
|
132
|
+
const strip = primary && collapsed && !stacked
|
|
133
|
+
const bodyRef = useRef<HTMLDivElement>(null)
|
|
134
|
+
|
|
135
|
+
// `inert` takes the stowed children out of the tab order and the accessibility
|
|
136
|
+
// tree; set on the node because the strict-DOM element has no such prop.
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
const el = bodyRef.current
|
|
139
|
+
if (!el) return
|
|
140
|
+
if (strip) el.setAttribute('inert', '')
|
|
141
|
+
else el.removeAttribute('inert')
|
|
142
|
+
}, [strip])
|
|
143
|
+
|
|
117
144
|
return (
|
|
118
145
|
<html.div
|
|
119
146
|
id={primary ? primaryId : undefined}
|
|
120
147
|
style={[
|
|
121
|
-
primary
|
|
148
|
+
primary
|
|
149
|
+
? [
|
|
150
|
+
styles.primary,
|
|
151
|
+
!stacked && styles.primarySize(collapsed ? SIZES_PX.iconButton : size),
|
|
152
|
+
!stacked && collapsed && styles.primaryStrip,
|
|
153
|
+
]
|
|
154
|
+
: styles.secondary,
|
|
122
155
|
stacked && styles.stackedPane,
|
|
123
156
|
]}
|
|
124
157
|
>
|
|
125
|
-
{
|
|
158
|
+
{strip ? collapsedContent : null}
|
|
159
|
+
{primary ? (
|
|
160
|
+
// The wrapper exists for the primary pane only, to stow its children
|
|
161
|
+
// (inert) while collapsed; unstowed it fills the pane, so a child that
|
|
162
|
+
// takes the pane's height keeps scrolling on its own.
|
|
163
|
+
<html.div ref={bodyRef} style={strip ? styles.stowed : styles.fill}>
|
|
164
|
+
{children}
|
|
165
|
+
</html.div>
|
|
166
|
+
) : (
|
|
167
|
+
children
|
|
168
|
+
)}
|
|
126
169
|
</html.div>
|
|
127
170
|
)
|
|
128
171
|
}
|
|
129
172
|
|
|
130
173
|
function Handle() {
|
|
131
|
-
const {size, min, max, setSize, stacked, primaryId, label} = useSplitPane('Handle')
|
|
174
|
+
const {size, min, max, setSize, stacked, collapsed, primaryId, label} = useSplitPane('Handle')
|
|
132
175
|
const [hovered, setHovered] = useState(false)
|
|
133
176
|
const [keyboardFocus, setKeyboardFocus] = useState(false)
|
|
134
177
|
const [dragging, setDragging] = useState(false)
|
|
@@ -158,9 +201,9 @@ function Handle() {
|
|
|
158
201
|
}
|
|
159
202
|
el.addEventListener('keydown', onKeyDown)
|
|
160
203
|
return () => el.removeEventListener('keydown', onKeyDown)
|
|
161
|
-
}, [size, min, max, setSize, stacked])
|
|
204
|
+
}, [size, min, max, setSize, stacked, collapsed])
|
|
162
205
|
|
|
163
|
-
if (stacked) return null
|
|
206
|
+
if (stacked || collapsed) return null
|
|
164
207
|
const active = hovered || keyboardFocus || dragging
|
|
165
208
|
|
|
166
209
|
return (
|
|
@@ -28,6 +28,34 @@ export const styles = css.create({
|
|
|
28
28
|
minWidth: 0,
|
|
29
29
|
overflow: 'auto',
|
|
30
30
|
},
|
|
31
|
+
// Collapsed: only a strip is left, so nothing spills out of it.
|
|
32
|
+
// Its content is centred under a top inset, and a hairline stands in for the
|
|
33
|
+
// handle it no longer has.
|
|
34
|
+
primaryStrip: {
|
|
35
|
+
position: 'relative',
|
|
36
|
+
overflow: 'hidden',
|
|
37
|
+
display: 'flex',
|
|
38
|
+
flexDirection: 'column',
|
|
39
|
+
alignItems: 'center',
|
|
40
|
+
paddingTop: spacing.sm,
|
|
41
|
+
borderRightWidth: borders.hairline,
|
|
42
|
+
borderRightStyle: 'solid',
|
|
43
|
+
borderRightColor: colors.border,
|
|
44
|
+
},
|
|
45
|
+
// The collapsed pane's own children: mounted, so their state is kept, but
|
|
46
|
+
// out of sight, out of the tab order (with `inert`) and unclickable.
|
|
47
|
+
stowed: {
|
|
48
|
+
position: 'absolute',
|
|
49
|
+
top: 0,
|
|
50
|
+
left: 0,
|
|
51
|
+
visibility: 'hidden',
|
|
52
|
+
pointerEvents: 'none',
|
|
53
|
+
},
|
|
54
|
+
// The primary pane's children wrapper, unstowed: it fills the pane.
|
|
55
|
+
fill: {
|
|
56
|
+
height: '100%',
|
|
57
|
+
minHeight: 0,
|
|
58
|
+
},
|
|
31
59
|
primarySize: (px: number) => ({width: px}),
|
|
32
60
|
secondary: {
|
|
33
61
|
flexGrow: 1,
|
|
@@ -2,8 +2,12 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Multi-line text input with automatic Field/Form integration. Same Field binding behavior as Input.',
|
|
6
|
-
whenToUse: [
|
|
5
|
+
'Multi-line text input with automatic Field/Form integration. Same Field binding behavior as Input. autoGrow makes the height follow the content from rows up to maxRows, then it scrolls (CSS field-sizing, with a scrollHeight fallback; native grows with the content and caps at maxRows). The anchor props of Listbox.getAnchorProps() (role combobox, aria-controls, aria-expanded, aria-activedescendant) are forwarded as ARIA attributes on web and as the matching accessibility props on native, so a Listbox can be anchored to it while the editor keeps focus.',
|
|
6
|
+
whenToUse: [
|
|
7
|
+
'Multi-line text entry (comments, messages, descriptions)',
|
|
8
|
+
'A composer that grows with its text (autoGrow, maxRows)',
|
|
9
|
+
'An editor a Listbox is anchored to for mentions or slash commands (spread Listbox.getAnchorProps())',
|
|
10
|
+
],
|
|
7
11
|
whenNotToUse: ['Single-line input — use Input'],
|
|
8
12
|
relatedTo: [
|
|
9
13
|
{
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
-
import {expect, fn} from 'storybook/test'
|
|
2
|
+
import {expect, fn, waitFor, within} from 'storybook/test'
|
|
3
|
+
import {useCallback, useId, useRef, useState} from 'react'
|
|
3
4
|
import {css, html} from 'react-strict-dom'
|
|
4
5
|
import {Textarea} from './Textarea'
|
|
6
|
+
import {Listbox} from '../Listbox/Listbox'
|
|
5
7
|
import {spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
6
8
|
import {sizes} from '@duro-app/tokens/tokens/sizes.css'
|
|
7
9
|
|
|
@@ -96,3 +98,136 @@ export const AllVariants: Story = {
|
|
|
96
98
|
await expect(disabled).toBeDisabled()
|
|
97
99
|
},
|
|
98
100
|
}
|
|
101
|
+
|
|
102
|
+
export const AutoGrow: Story = {
|
|
103
|
+
args: {placeholder: 'Grows', autoGrow: true, rows: 2, maxRows: 5},
|
|
104
|
+
play: async ({canvas, userEvent}) => {
|
|
105
|
+
const textarea = canvas.getByPlaceholderText('Grows') as HTMLTextAreaElement
|
|
106
|
+
const empty = textarea.getBoundingClientRect().height
|
|
107
|
+
|
|
108
|
+
await userEvent.click(textarea)
|
|
109
|
+
await userEvent.keyboard(
|
|
110
|
+
'a{Shift>}{Enter}{/Shift}b{Shift>}{Enter}{/Shift}c{Shift>}{Enter}{/Shift}d',
|
|
111
|
+
)
|
|
112
|
+
// four lines: taller than empty, nothing to scroll yet
|
|
113
|
+
await waitFor(() => expect(textarea.getBoundingClientRect().height).toBeGreaterThan(empty))
|
|
114
|
+
await expect(textarea.scrollHeight).toBeLessThanOrEqual(textarea.clientHeight)
|
|
115
|
+
|
|
116
|
+
// many more lines: the height stops at maxRows, then the field scrolls
|
|
117
|
+
await userEvent.keyboard('{Shift>}{Enter}{/Shift}e'.repeat(10))
|
|
118
|
+
await waitFor(() => expect(textarea.scrollHeight).toBeGreaterThan(textarea.clientHeight))
|
|
119
|
+
const capped = textarea.getBoundingClientRect().height
|
|
120
|
+
await userEvent.keyboard('{Shift>}{Enter}{/Shift}f')
|
|
121
|
+
await expect(textarea.getBoundingClientRect().height).toBe(capped)
|
|
122
|
+
await expect(capped).toBeLessThan(textarea.scrollHeight)
|
|
123
|
+
// the cap is maxRows (5) lines plus the vertical padding and border
|
|
124
|
+
const box = getComputedStyle(textarea)
|
|
125
|
+
const px = (v: string) => parseFloat(v)
|
|
126
|
+
const fiveLines =
|
|
127
|
+
5 * px(box.lineHeight) +
|
|
128
|
+
px(box.paddingTop) +
|
|
129
|
+
px(box.paddingBottom) +
|
|
130
|
+
px(box.borderTopWidth) +
|
|
131
|
+
px(box.borderBottomWidth)
|
|
132
|
+
await expect(Math.abs(capped - fiveLines)).toBeLessThan(1)
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const FRUITS = ['Apple', 'Apricot', 'Banana', 'Cherry']
|
|
137
|
+
|
|
138
|
+
// The editor keeps focus while the anchored Listbox filters and selects.
|
|
139
|
+
function AnchoredEditor() {
|
|
140
|
+
const id = useId()
|
|
141
|
+
const ref = useRef<HTMLTextAreaElement | null>(null)
|
|
142
|
+
const [text, setText] = useState('')
|
|
143
|
+
const [highlighted, setHighlighted] = useState(0)
|
|
144
|
+
const [dismissed, setDismissed] = useState(false)
|
|
145
|
+
const query = text.startsWith('/') ? text.slice(1).toLowerCase() : null
|
|
146
|
+
const matches = query === null ? [] : FRUITS.filter((f) => f.toLowerCase().startsWith(query))
|
|
147
|
+
const open = query !== null && !dismissed && matches.length > 0
|
|
148
|
+
const optionId = useCallback((value: string) => `${id}-${value}`, [id])
|
|
149
|
+
const highlightedId = open ? optionId(matches[Math.min(highlighted, matches.length - 1)]!) : null
|
|
150
|
+
|
|
151
|
+
const pick = (value: string) => {
|
|
152
|
+
setText(`${value} `)
|
|
153
|
+
setDismissed(false)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<>
|
|
158
|
+
<Textarea
|
|
159
|
+
placeholder="Type / to pick"
|
|
160
|
+
ref={ref}
|
|
161
|
+
value={text}
|
|
162
|
+
onChange={(e) => {
|
|
163
|
+
setText(e.target.value)
|
|
164
|
+
setHighlighted(0)
|
|
165
|
+
setDismissed(false)
|
|
166
|
+
}}
|
|
167
|
+
onKeyDown={(e) => {
|
|
168
|
+
if (!open) return
|
|
169
|
+
if (e.key === 'ArrowDown') {
|
|
170
|
+
e.preventDefault()
|
|
171
|
+
setHighlighted((i) => (i + 1) % matches.length)
|
|
172
|
+
} else if (e.key === 'Enter') {
|
|
173
|
+
e.preventDefault()
|
|
174
|
+
pick(matches[Math.min(highlighted, matches.length - 1)]!)
|
|
175
|
+
} else if (e.key === 'Escape') {
|
|
176
|
+
setDismissed(true)
|
|
177
|
+
}
|
|
178
|
+
}}
|
|
179
|
+
role="combobox"
|
|
180
|
+
{...Listbox.getAnchorProps({id, open, highlightedId})}
|
|
181
|
+
/>
|
|
182
|
+
<Listbox.Root
|
|
183
|
+
id={id}
|
|
184
|
+
open={open}
|
|
185
|
+
anchor={() => ref.current?.getBoundingClientRect()}
|
|
186
|
+
highlightedId={highlightedId}
|
|
187
|
+
onSelect={pick}
|
|
188
|
+
getOptionId={optionId}
|
|
189
|
+
aria-label="Fruits"
|
|
190
|
+
>
|
|
191
|
+
{matches.map((f) => (
|
|
192
|
+
<Listbox.Option key={f} value={f}>
|
|
193
|
+
{f}
|
|
194
|
+
</Listbox.Option>
|
|
195
|
+
))}
|
|
196
|
+
</Listbox.Root>
|
|
197
|
+
</>
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export const AnchoredListbox: Story = {
|
|
202
|
+
render: () => <AnchoredEditor />,
|
|
203
|
+
play: async ({canvas, userEvent}) => {
|
|
204
|
+
const editor = canvas.getByRole('combobox')
|
|
205
|
+
await userEvent.click(editor)
|
|
206
|
+
await userEvent.keyboard('/a')
|
|
207
|
+
const list = await within(document.body).findByRole('listbox', {name: 'Fruits'})
|
|
208
|
+
await expect(editor).toHaveFocus()
|
|
209
|
+
await expect(editor).toHaveAttribute('aria-expanded', 'true')
|
|
210
|
+
await expect(editor).toHaveAttribute('aria-controls', list.id)
|
|
211
|
+
await expect(within(list).getAllByRole('option')).toHaveLength(2)
|
|
212
|
+
|
|
213
|
+
// filtering narrows the list; focus stays in the editor
|
|
214
|
+
await userEvent.keyboard('pr')
|
|
215
|
+
await waitFor(() => expect(within(list).getAllByRole('option')).toHaveLength(1))
|
|
216
|
+
await expect(editor).toHaveFocus()
|
|
217
|
+
await expect(editor).toHaveAttribute('aria-activedescendant', `${list.id}-Apricot`)
|
|
218
|
+
|
|
219
|
+
// selecting by key keeps focus and closes the list
|
|
220
|
+
await userEvent.keyboard('{Enter}')
|
|
221
|
+
await expect(editor).toHaveValue('Apricot ')
|
|
222
|
+
await expect(editor).toHaveFocus()
|
|
223
|
+
await waitFor(() => expect(editor).toHaveAttribute('aria-expanded', 'false'))
|
|
224
|
+
|
|
225
|
+
// selecting by pointer keeps focus too
|
|
226
|
+
await userEvent.clear(editor)
|
|
227
|
+
await userEvent.keyboard('/b')
|
|
228
|
+
const banana = await within(document.body).findByRole('option', {name: 'Banana'})
|
|
229
|
+
await userEvent.click(banana)
|
|
230
|
+
await expect(editor).toHaveValue('Banana ')
|
|
231
|
+
await expect(editor).toHaveFocus()
|
|
232
|
+
},
|
|
233
|
+
}
|
|
@@ -1,12 +1,28 @@
|
|
|
1
|
+
import {useCallback, useRef} from 'react'
|
|
1
2
|
import {html} from 'react-strict-dom'
|
|
2
3
|
import {useFieldContext} from '../Field/FieldContext'
|
|
4
|
+
import type {ListboxAnchorProps} from '../Listbox/Listbox'
|
|
5
|
+
import {useAutoGrowFallback} from './useAutoGrowFallback'
|
|
6
|
+
import {grow} from './grow'
|
|
3
7
|
import {styles} from './styles.css'
|
|
4
8
|
|
|
5
9
|
type StrictTextareaProps = React.ComponentProps<typeof html.textarea>
|
|
6
10
|
|
|
7
11
|
export type TextareaVariant = 'default' | 'error'
|
|
8
12
|
|
|
9
|
-
|
|
13
|
+
/** `maxRows` only means something on a field that grows. */
|
|
14
|
+
type GrowProps =
|
|
15
|
+
| {
|
|
16
|
+
/** The height follows the content, from `rows` up to `maxRows`, then it scrolls. */
|
|
17
|
+
autoGrow: true
|
|
18
|
+
/** The most rows the field shows before it scrolls. Default: no limit */
|
|
19
|
+
maxRows?: number
|
|
20
|
+
}
|
|
21
|
+
| {autoGrow?: false; maxRows?: never}
|
|
22
|
+
|
|
23
|
+
type TextareaProps = Partial<ListboxAnchorProps> & GrowProps & TextareaBaseProps
|
|
24
|
+
|
|
25
|
+
interface TextareaBaseProps {
|
|
10
26
|
variant?: TextareaVariant
|
|
11
27
|
name?: string
|
|
12
28
|
placeholder?: string
|
|
@@ -32,6 +48,14 @@ export function Textarea({
|
|
|
32
48
|
placeholder,
|
|
33
49
|
required,
|
|
34
50
|
rows = 3,
|
|
51
|
+
autoGrow,
|
|
52
|
+
maxRows,
|
|
53
|
+
role,
|
|
54
|
+
'aria-controls': ariaControls,
|
|
55
|
+
'aria-expanded': ariaExpanded,
|
|
56
|
+
'aria-activedescendant': ariaActiveDescendant,
|
|
57
|
+
'aria-autocomplete': ariaAutocomplete,
|
|
58
|
+
'aria-haspopup': ariaHasPopup,
|
|
35
59
|
value,
|
|
36
60
|
defaultValue,
|
|
37
61
|
disabled,
|
|
@@ -57,6 +81,18 @@ export function Textarea({
|
|
|
57
81
|
const extraProps: Record<string, unknown> = {}
|
|
58
82
|
if (autoFocus) extraProps.autoFocus = true
|
|
59
83
|
|
|
84
|
+
// Without CSS field-sizing, measure the content after each render.
|
|
85
|
+
const innerRef = useRef<HTMLTextAreaElement | null>(null)
|
|
86
|
+
const setRef = useCallback(
|
|
87
|
+
(el: HTMLTextAreaElement | null) => {
|
|
88
|
+
innerRef.current = el
|
|
89
|
+
if (typeof effectiveRef === 'function') effectiveRef(el)
|
|
90
|
+
else if (effectiveRef) (effectiveRef as {current: unknown}).current = el
|
|
91
|
+
},
|
|
92
|
+
[effectiveRef],
|
|
93
|
+
)
|
|
94
|
+
useAutoGrowFallback(innerRef, autoGrow ? 'content' : 'fixed')
|
|
95
|
+
|
|
60
96
|
return (
|
|
61
97
|
<html.textarea
|
|
62
98
|
id={ctx?.controlId}
|
|
@@ -76,8 +112,19 @@ export function Textarea({
|
|
|
76
112
|
onFocus={onFocus as StrictTextareaProps['onFocus']}
|
|
77
113
|
onKeyDown={onKeyDown as StrictTextareaProps['onKeyDown']}
|
|
78
114
|
onKeyUp={onKeyUp as StrictTextareaProps['onKeyUp']}
|
|
79
|
-
|
|
80
|
-
|
|
115
|
+
role={role}
|
|
116
|
+
aria-controls={ariaControls}
|
|
117
|
+
aria-expanded={ariaExpanded}
|
|
118
|
+
aria-activedescendant={ariaActiveDescendant}
|
|
119
|
+
aria-autocomplete={ariaAutocomplete}
|
|
120
|
+
aria-haspopup={ariaHasPopup}
|
|
121
|
+
ref={setRef}
|
|
122
|
+
style={[
|
|
123
|
+
styles.base,
|
|
124
|
+
styles[effectiveVariant],
|
|
125
|
+
autoGrow && grow.autoGrow,
|
|
126
|
+
autoGrow && maxRows !== undefined && grow.maxRows(maxRows),
|
|
127
|
+
]}
|
|
81
128
|
{...extraProps}
|
|
82
129
|
/>
|
|
83
130
|
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** How a Textarea sizes itself: to its content, or to its fixed rows. */
|
|
2
|
+
export type GrowMode = 'content' | 'fixed'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fallback for browsers without CSS `field-sizing: content`: size the field to
|
|
6
|
+
* its content. The element's own `rows` give the floor, the style's maxHeight
|
|
7
|
+
* the ceiling (past it the field scrolls).
|
|
8
|
+
*/
|
|
9
|
+
export interface GrowableField {
|
|
10
|
+
style: {height: string}
|
|
11
|
+
scrollHeight: number
|
|
12
|
+
offsetHeight: number
|
|
13
|
+
clientHeight: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** True when the browser sizes a textarea to its content in CSS. */
|
|
17
|
+
export function supportsFieldSizing(
|
|
18
|
+
css: {supports?: (property: string, value: string) => boolean} | undefined = (
|
|
19
|
+
globalThis as {CSS?: {supports?: (property: string, value: string) => boolean}}
|
|
20
|
+
).CSS,
|
|
21
|
+
): boolean {
|
|
22
|
+
return css?.supports?.('field-sizing', 'content') === true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Resize `el` to fit its content; returns the height set, in px. */
|
|
26
|
+
export function fitToContent(el: GrowableField): number {
|
|
27
|
+
el.style.height = 'auto'
|
|
28
|
+
// scrollHeight excludes the border; add it back for a border-box field.
|
|
29
|
+
const border = el.offsetHeight - el.clientHeight
|
|
30
|
+
const height = el.scrollHeight + border
|
|
31
|
+
el.style.height = `${height}px`
|
|
32
|
+
return height
|
|
33
|
+
}
|