@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.
Files changed (110) hide show
  1. package/dist/Table-Cw1B14R3.js.map +1 -1
  2. package/dist/components/AppShell/AppShell.d.ts +10 -1
  3. package/dist/components/AppShell/AppShell.d.ts.map +1 -1
  4. package/dist/components/AppShell/AppShell.stories.d.ts +8 -0
  5. package/dist/components/AppShell/AppShell.stories.d.ts.map +1 -1
  6. package/dist/components/AppShell/styles.css.d.ts +7 -0
  7. package/dist/components/AppShell/styles.css.d.ts.map +1 -1
  8. package/dist/components/CodeBlock/CodeBlock.d.ts +3 -21
  9. package/dist/components/CodeBlock/CodeBlock.d.ts.map +1 -1
  10. package/dist/components/CodeBlock/CodeBlock.stories.d.ts +4 -0
  11. package/dist/components/CodeBlock/CodeBlock.stories.d.ts.map +1 -1
  12. package/dist/components/CodeBlock/DiffLines.d.ts +9 -0
  13. package/dist/components/CodeBlock/DiffLines.d.ts.map +1 -0
  14. package/dist/components/CodeBlock/diffRoles.d.ts +14 -0
  15. package/dist/components/CodeBlock/diffRoles.d.ts.map +1 -0
  16. package/dist/components/CodeBlock/styles.css.d.ts +24 -0
  17. package/dist/components/CodeBlock/styles.css.d.ts.map +1 -1
  18. package/dist/components/CodeBlock/types.d.ts +29 -0
  19. package/dist/components/CodeBlock/types.d.ts.map +1 -0
  20. package/dist/components/Drawer/touchInput.d.ts +5 -1
  21. package/dist/components/Drawer/touchInput.d.ts.map +1 -1
  22. package/dist/components/Prose/Prose.d.ts +1 -1
  23. package/dist/components/SplitPane/SplitPane.d.ts +10 -1
  24. package/dist/components/SplitPane/SplitPane.d.ts.map +1 -1
  25. package/dist/components/SplitPane/SplitPane.stories.d.ts +11 -0
  26. package/dist/components/SplitPane/SplitPane.stories.d.ts.map +1 -1
  27. package/dist/components/SplitPane/styles.css.d.ts +22 -0
  28. package/dist/components/SplitPane/styles.css.d.ts.map +1 -1
  29. package/dist/components/Textarea/Textarea.d.ts +14 -2
  30. package/dist/components/Textarea/Textarea.d.ts.map +1 -1
  31. package/dist/components/Textarea/Textarea.meta.d.ts.map +1 -1
  32. package/dist/components/Textarea/Textarea.stories.d.ts +2 -0
  33. package/dist/components/Textarea/Textarea.stories.d.ts.map +1 -1
  34. package/dist/components/Textarea/autoGrow.d.ts +22 -0
  35. package/dist/components/Textarea/autoGrow.d.ts.map +1 -0
  36. package/dist/components/Textarea/grow.css.d.ts +12 -0
  37. package/dist/components/Textarea/grow.css.d.ts.map +1 -0
  38. package/dist/components/Textarea/grow.d.ts +2 -0
  39. package/dist/components/Textarea/grow.d.ts.map +1 -0
  40. package/dist/components/Textarea/native.css.d.ts +8 -0
  41. package/dist/components/Textarea/native.css.d.ts.map +1 -0
  42. package/dist/components/Textarea/useAutoGrowFallback.d.ts +9 -0
  43. package/dist/components/Textarea/useAutoGrowFallback.d.ts.map +1 -0
  44. package/dist/components/Toolbar/Toolbar.d.ts +2 -14
  45. package/dist/components/Toolbar/Toolbar.d.ts.map +1 -1
  46. package/dist/components/Toolbar/types.d.ts +15 -0
  47. package/dist/components/Toolbar/types.d.ts.map +1 -0
  48. package/dist/components/VirtualList/VirtualList.d.ts +5 -26
  49. package/dist/components/VirtualList/VirtualList.d.ts.map +1 -1
  50. package/dist/components/VirtualList/VirtualList.meta.d.ts.map +1 -1
  51. package/dist/components/VirtualList/VirtualList.stories.d.ts +16 -0
  52. package/dist/components/VirtualList/VirtualList.stories.d.ts.map +1 -1
  53. package/dist/components/VirtualList/native.css.d.ts +14 -0
  54. package/dist/components/VirtualList/native.css.d.ts.map +1 -0
  55. package/dist/components/VirtualList/props.d.ts +31 -0
  56. package/dist/components/VirtualList/props.d.ts.map +1 -0
  57. package/dist/components/VirtualList/styles.css.d.ts +4 -0
  58. package/dist/components/VirtualList/styles.css.d.ts.map +1 -1
  59. package/dist/index.css +1 -1
  60. package/dist/index.d.ts +1 -1
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +4442 -4215
  63. package/dist/index.js.map +1 -1
  64. package/package.json +2 -2
  65. package/src/components/AppShell/AppShell.meta.ts +1 -1
  66. package/src/components/AppShell/AppShell.native.tsx +3 -0
  67. package/src/components/AppShell/AppShell.stories.tsx +68 -0
  68. package/src/components/AppShell/AppShell.tsx +16 -5
  69. package/src/components/AppShell/styles.css.ts +10 -0
  70. package/src/components/CodeBlock/CodeBlock.meta.ts +1 -1
  71. package/src/components/CodeBlock/CodeBlock.native.tsx +33 -0
  72. package/src/components/CodeBlock/CodeBlock.stories.tsx +54 -0
  73. package/src/components/CodeBlock/CodeBlock.tsx +8 -20
  74. package/src/components/CodeBlock/DiffLines.tsx +32 -0
  75. package/src/components/CodeBlock/diffRoles.ts +40 -0
  76. package/src/components/CodeBlock/styles.css.ts +17 -0
  77. package/src/components/CodeBlock/types.ts +29 -0
  78. package/src/components/Drawer/touchInput.ts +16 -2
  79. package/src/components/Prose/Prose.meta.ts +1 -1
  80. package/src/components/Prose/Prose.native.tsx +23 -0
  81. package/src/components/Prose/Prose.tsx +1 -1
  82. package/src/components/Prose/styles.native.css.ts +15 -0
  83. package/src/components/SplitPane/SplitPane.meta.ts +1 -1
  84. package/src/components/SplitPane/SplitPane.native.tsx +4 -0
  85. package/src/components/SplitPane/SplitPane.stories.tsx +207 -1
  86. package/src/components/SplitPane/SplitPane.tsx +50 -7
  87. package/src/components/SplitPane/styles.css.ts +28 -0
  88. package/src/components/Textarea/Textarea.meta.ts +6 -2
  89. package/src/components/Textarea/Textarea.stories.tsx +136 -1
  90. package/src/components/Textarea/Textarea.tsx +50 -3
  91. package/src/components/Textarea/autoGrow.ts +33 -0
  92. package/src/components/Textarea/grow.css.ts +18 -0
  93. package/src/components/Textarea/grow.native.ts +1 -0
  94. package/src/components/Textarea/grow.ts +1 -0
  95. package/src/components/Textarea/native.css.ts +16 -0
  96. package/src/components/Textarea/useAutoGrowFallback.native.ts +5 -0
  97. package/src/components/Textarea/useAutoGrowFallback.ts +35 -0
  98. package/src/components/Toolbar/Toolbar.meta.ts +1 -1
  99. package/src/components/Toolbar/Toolbar.native.tsx +27 -0
  100. package/src/components/Toolbar/Toolbar.tsx +3 -14
  101. package/src/components/Toolbar/types.ts +15 -0
  102. package/src/components/VirtualList/VirtualList.meta.ts +2 -1
  103. package/src/components/VirtualList/VirtualList.native.tsx +80 -0
  104. package/src/components/VirtualList/VirtualList.stories.tsx +171 -1
  105. package/src/components/VirtualList/VirtualList.tsx +118 -27
  106. package/src/components/VirtualList/native.css.ts +14 -0
  107. package/src/components/VirtualList/props.ts +31 -0
  108. package/src/components/VirtualList/styles.css.ts +7 -0
  109. package/src/index.native.ts +8 -0
  110. 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} = {...DEFAULT_ARGS, ...props}
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 value={{size, min, max, setSize, stacked, primaryId, label}}>
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 ? [styles.primary, !stacked && styles.primarySize(size)] : styles.secondary,
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
- {children}
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: ['Multi-line text entry (comments, messages, descriptions)'],
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
- interface TextareaProps {
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
- ref={effectiveRef as React.Ref<HTMLTextAreaElement>}
80
- style={[styles.base, styles[effectiveVariant]]}
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
+ }