@duro-app/ui 5.21.0 → 5.22.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 (93) hide show
  1. package/dist/Table-Cw1B14R3.js.map +1 -1
  2. package/dist/components/CodeBlock/CodeBlock.d.ts +3 -21
  3. package/dist/components/CodeBlock/CodeBlock.d.ts.map +1 -1
  4. package/dist/components/CodeBlock/CodeBlock.stories.d.ts +4 -0
  5. package/dist/components/CodeBlock/CodeBlock.stories.d.ts.map +1 -1
  6. package/dist/components/CodeBlock/DiffLines.d.ts +9 -0
  7. package/dist/components/CodeBlock/DiffLines.d.ts.map +1 -0
  8. package/dist/components/CodeBlock/diffRoles.d.ts +14 -0
  9. package/dist/components/CodeBlock/diffRoles.d.ts.map +1 -0
  10. package/dist/components/CodeBlock/styles.css.d.ts +24 -0
  11. package/dist/components/CodeBlock/styles.css.d.ts.map +1 -1
  12. package/dist/components/CodeBlock/types.d.ts +29 -0
  13. package/dist/components/CodeBlock/types.d.ts.map +1 -0
  14. package/dist/components/Drawer/touchInput.d.ts +5 -1
  15. package/dist/components/Drawer/touchInput.d.ts.map +1 -1
  16. package/dist/components/Prose/Prose.d.ts +1 -1
  17. package/dist/components/SplitPane/SplitPane.d.ts +10 -1
  18. package/dist/components/SplitPane/SplitPane.d.ts.map +1 -1
  19. package/dist/components/SplitPane/SplitPane.stories.d.ts +11 -0
  20. package/dist/components/SplitPane/SplitPane.stories.d.ts.map +1 -1
  21. package/dist/components/SplitPane/styles.css.d.ts +22 -0
  22. package/dist/components/SplitPane/styles.css.d.ts.map +1 -1
  23. package/dist/components/Textarea/Textarea.d.ts +14 -2
  24. package/dist/components/Textarea/Textarea.d.ts.map +1 -1
  25. package/dist/components/Textarea/Textarea.meta.d.ts.map +1 -1
  26. package/dist/components/Textarea/Textarea.stories.d.ts +2 -0
  27. package/dist/components/Textarea/Textarea.stories.d.ts.map +1 -1
  28. package/dist/components/Textarea/autoGrow.d.ts +22 -0
  29. package/dist/components/Textarea/autoGrow.d.ts.map +1 -0
  30. package/dist/components/Textarea/grow.css.d.ts +12 -0
  31. package/dist/components/Textarea/grow.css.d.ts.map +1 -0
  32. package/dist/components/Textarea/grow.d.ts +2 -0
  33. package/dist/components/Textarea/grow.d.ts.map +1 -0
  34. package/dist/components/Textarea/native.css.d.ts +8 -0
  35. package/dist/components/Textarea/native.css.d.ts.map +1 -0
  36. package/dist/components/Textarea/useAutoGrowFallback.d.ts +9 -0
  37. package/dist/components/Textarea/useAutoGrowFallback.d.ts.map +1 -0
  38. package/dist/components/Toolbar/Toolbar.d.ts +2 -14
  39. package/dist/components/Toolbar/Toolbar.d.ts.map +1 -1
  40. package/dist/components/Toolbar/types.d.ts +15 -0
  41. package/dist/components/Toolbar/types.d.ts.map +1 -0
  42. package/dist/components/VirtualList/VirtualList.d.ts +5 -26
  43. package/dist/components/VirtualList/VirtualList.d.ts.map +1 -1
  44. package/dist/components/VirtualList/VirtualList.meta.d.ts.map +1 -1
  45. package/dist/components/VirtualList/VirtualList.stories.d.ts +16 -0
  46. package/dist/components/VirtualList/VirtualList.stories.d.ts.map +1 -1
  47. package/dist/components/VirtualList/native.css.d.ts +14 -0
  48. package/dist/components/VirtualList/native.css.d.ts.map +1 -0
  49. package/dist/components/VirtualList/props.d.ts +31 -0
  50. package/dist/components/VirtualList/props.d.ts.map +1 -0
  51. package/dist/index.css +1 -1
  52. package/dist/index.js +3950 -3750
  53. package/dist/index.js.map +1 -1
  54. package/package.json +2 -2
  55. package/src/components/CodeBlock/CodeBlock.meta.ts +1 -1
  56. package/src/components/CodeBlock/CodeBlock.native.tsx +33 -0
  57. package/src/components/CodeBlock/CodeBlock.stories.tsx +54 -0
  58. package/src/components/CodeBlock/CodeBlock.tsx +8 -20
  59. package/src/components/CodeBlock/DiffLines.tsx +32 -0
  60. package/src/components/CodeBlock/diffRoles.ts +40 -0
  61. package/src/components/CodeBlock/styles.css.ts +17 -0
  62. package/src/components/CodeBlock/types.ts +29 -0
  63. package/src/components/Drawer/touchInput.ts +16 -2
  64. package/src/components/Prose/Prose.meta.ts +1 -1
  65. package/src/components/Prose/Prose.native.tsx +23 -0
  66. package/src/components/Prose/Prose.tsx +1 -1
  67. package/src/components/Prose/styles.native.css.ts +15 -0
  68. package/src/components/SplitPane/SplitPane.meta.ts +1 -1
  69. package/src/components/SplitPane/SplitPane.native.tsx +4 -0
  70. package/src/components/SplitPane/SplitPane.stories.tsx +207 -1
  71. package/src/components/SplitPane/SplitPane.tsx +50 -7
  72. package/src/components/SplitPane/styles.css.ts +28 -0
  73. package/src/components/Textarea/Textarea.meta.ts +6 -2
  74. package/src/components/Textarea/Textarea.stories.tsx +136 -1
  75. package/src/components/Textarea/Textarea.tsx +50 -3
  76. package/src/components/Textarea/autoGrow.ts +33 -0
  77. package/src/components/Textarea/grow.css.ts +18 -0
  78. package/src/components/Textarea/grow.native.ts +1 -0
  79. package/src/components/Textarea/grow.ts +1 -0
  80. package/src/components/Textarea/native.css.ts +16 -0
  81. package/src/components/Textarea/useAutoGrowFallback.native.ts +5 -0
  82. package/src/components/Textarea/useAutoGrowFallback.ts +35 -0
  83. package/src/components/Toolbar/Toolbar.meta.ts +1 -1
  84. package/src/components/Toolbar/Toolbar.native.tsx +27 -0
  85. package/src/components/Toolbar/Toolbar.tsx +3 -14
  86. package/src/components/Toolbar/types.ts +15 -0
  87. package/src/components/VirtualList/VirtualList.meta.ts +2 -1
  88. package/src/components/VirtualList/VirtualList.native.tsx +80 -0
  89. package/src/components/VirtualList/VirtualList.stories.tsx +171 -1
  90. package/src/components/VirtualList/VirtualList.tsx +116 -27
  91. package/src/components/VirtualList/native.css.ts +14 -0
  92. package/src/components/VirtualList/props.ts +31 -0
  93. package/src/index.native.ts +7 -0
@@ -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
+ }
@@ -0,0 +1,18 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
3
+ import {borders} from '@duro-app/tokens/tokens/borders.css'
4
+
5
+ // Web only: native has no field-sizing or lh unit (see native.css.ts).
6
+ export const growCss = css.create({
7
+ // The height follows the content (CSS field-sizing, or autoGrow.ts where
8
+ // it is missing); the rows attribute is the floor.
9
+ autoGrow: {
10
+ fieldSizing: 'content',
11
+ resize: 'none' as const,
12
+ overflowY: 'auto',
13
+ },
14
+ // `maxRows` lines of text plus the vertical padding and border (border-box).
15
+ maxRows: (rows: number) => ({
16
+ maxHeight: `calc(${rows}lh + ${spacing.sm} + ${spacing.sm} + ${borders.hairline} + ${borders.hairline})`,
17
+ }),
18
+ })
@@ -0,0 +1 @@
1
+ export {nativeStyles as grow} from './native.css'
@@ -0,0 +1 @@
1
+ export {growCss as grow} from './grow.css'
@@ -0,0 +1,16 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {FONT_SIZE_REM, LINE_HEIGHT, ROOT_FONT_PX, SPACING_PX} from '@duro-app/tokens/keys'
3
+
4
+ // One row of text, in px: the Textarea's font size (fontSizeSm, rem) at the
5
+ // root size, times the typography line height. Native has no `lh` unit.
6
+ const ROW_PX = FONT_SIZE_REM.fontSizeSm * ROOT_FONT_PX * LINE_HEIGHT
7
+
8
+ export const nativeStyles = css.create({
9
+ // A multiline field on native grows with its content on its own.
10
+ autoGrow: {},
11
+ // `maxRows` lines plus the vertical padding; a multiline field on native
12
+ // already grows with its content, so only the cap is needed.
13
+ maxRows: (rows: number) => ({
14
+ maxHeight: rows * ROW_PX + 2 * SPACING_PX.sm,
15
+ }),
16
+ })
@@ -0,0 +1,5 @@
1
+ import type {GrowMode} from './autoGrow'
2
+ import type {RefObject} from 'react'
3
+
4
+ /** A multiline field on native already grows with its content. */
5
+ export function useAutoGrowFallback(_ref: RefObject<unknown>, _mode: GrowMode) {}
@@ -0,0 +1,35 @@
1
+ import {useEffect, useLayoutEffect, type RefObject} from 'react'
2
+ import {fitToContent, supportsFieldSizing} from './autoGrow'
3
+
4
+ import type {GrowMode} from './autoGrow'
5
+
6
+ /** Fields this hook gave an inline height, so turning autoGrow off can take it back. */
7
+ const sized = new WeakSet<HTMLElement>()
8
+
9
+ /**
10
+ * Where CSS field-sizing is missing, size the field to its content: after each
11
+ * render (first paint, controlled value changes) and on every `input` event, so
12
+ * an uncontrolled field grows as it is typed into.
13
+ */
14
+ export function useAutoGrowFallback(ref: RefObject<HTMLTextAreaElement | null>, mode: GrowMode) {
15
+ useLayoutEffect(() => {
16
+ const el = ref.current
17
+ if (!el || supportsFieldSizing()) return
18
+ if (mode === 'content') {
19
+ fitToContent(el)
20
+ sized.add(el)
21
+ } else if (sized.delete(el)) {
22
+ el.style.height = ''
23
+ }
24
+ })
25
+ useEffect(() => {
26
+ const el = ref.current
27
+ if (mode !== 'content' || !el || supportsFieldSizing()) return
28
+ const onInput = () => {
29
+ fitToContent(el)
30
+ sized.add(el)
31
+ }
32
+ el.addEventListener('input', onInput)
33
+ return () => el.removeEventListener('input', onInput)
34
+ }, [ref, mode])
35
+ }
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'A row of controls with one tab stop (the WAI-ARIA toolbar pattern): Tab enters at the last focused control, Left/Right move across every control inside — through attached ButtonGroups too — and Home/End jump to the ends. A Select or Menu trigger keeps its own keys, and a control whose popup is open keeps Left/Right.',
5
+ 'A row of controls with one tab stop (the WAI-ARIA toolbar pattern): Tab enters at the last focused control, Left/Right move across every control inside — through attached ButtonGroups too — and Home/End jump to the ends. A Select or Menu trigger keeps its own keys, and a control whose popup is open keeps Left/Right. On native it is the same row of controls without the roving tab stop and arrow keys.',
6
6
  whenToUse: [
7
7
  'An editor toolbar: text style, format toggles, insert menus',
8
8
  'A dense set of related actions that should cost one Tab, not one per control',
@@ -0,0 +1,27 @@
1
+ import {html} from 'react-strict-dom'
2
+ import {styles} from './styles.css'
3
+ import type {ToolbarProps} from './types'
4
+
5
+ export type {ToolbarProps} from './types'
6
+
7
+ /**
8
+ * Native Toolbar: a row (or column) of controls with the web's props. The
9
+ * roving tab stop and arrow keys are web only: a touch screen reaches every
10
+ * control directly, so each control keeps its own press target.
11
+ */
12
+ export function Toolbar({
13
+ 'aria-label': ariaLabel,
14
+ orientation = 'horizontal',
15
+ children,
16
+ }: ToolbarProps) {
17
+ return (
18
+ <html.div
19
+ role="toolbar"
20
+ aria-label={ariaLabel}
21
+ aria-orientation={orientation}
22
+ style={[styles.root, orientation === 'vertical' && styles.vertical]}
23
+ >
24
+ {children}
25
+ </html.div>
26
+ )
27
+ }
@@ -1,22 +1,11 @@
1
- import {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react'
1
+ import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
- import type {Orientation} from '../../shared/types'
4
3
  import {useOrderedRegistry} from '../../shared/useOrderedRegistry'
5
4
  import {ToolbarContext, type ToolbarContextValue} from './ToolbarContext'
6
5
  import {styles} from './styles.css'
6
+ import type {ToolbarProps} from './types'
7
7
 
8
- export interface ToolbarProps {
9
- /** Names the toolbar ("Formatting"). Required: a toolbar is a landmark-like widget. */
10
- 'aria-label': string
11
- /**
12
- * Arrow keys follow the orientation: Left/Right (default) or Up/Down. A
13
- * vertical toolbar cannot hold a Select, whose closed trigger opens on
14
- * ArrowDown/ArrowUp. A Menu can: its closed trigger handles no arrow key,
15
- * so Up/Down move past it and Enter or Space opens it.
16
- */
17
- orientation?: Orientation
18
- children: ReactNode
19
- }
8
+ export type {ToolbarProps} from './types'
20
9
 
21
10
  /**
22
11
  * A row of controls with one tab stop (the WAI-ARIA toolbar pattern). Every
@@ -0,0 +1,15 @@
1
+ import type {ReactNode} from 'react'
2
+ import type {Orientation} from '../../shared/types'
3
+
4
+ export interface ToolbarProps {
5
+ /** Names the toolbar ("Formatting"). Required: a toolbar is a landmark-like widget. */
6
+ 'aria-label': string
7
+ /**
8
+ * Arrow keys follow the orientation: Left/Right (default) or Up/Down. A
9
+ * vertical toolbar cannot hold a Select, whose closed trigger opens on
10
+ * ArrowDown/ArrowUp. A Menu can: its closed trigger handles no arrow key,
11
+ * so Up/Down move past it and Enter or Space opens it.
12
+ */
13
+ orientation?: Orientation
14
+ children: ReactNode
15
+ }
@@ -2,10 +2,11 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'A windowed list on @tanstack/react-virtual: only the rows in view (plus overscan) are mounted, and rows are measured so they may differ in height. role="list" with listitem rows carrying aria-setsize and aria-posinset. anchor="end" opens at the last item and stays pinned to the bottom while new items arrive and the reader is at the bottom (a transcript, a log). It fills its parent, or stops at maxHeight. Web only: on native, host FlashList or Legend List behind your own .native.tsx leaf.',
5
+ 'A windowed list on @tanstack/react-virtual: only the rows in view (plus overscan) are mounted, and rows are measured so they may differ in height. role="list" with listitem rows carrying aria-setsize and aria-posinset. anchor="end" opens at the last item and stays pinned to the bottom while new items arrive and the reader is at the bottom (a transcript, a log). It fills its parent, or stops at maxHeight. onStartReached fires once each time the reader scrolls to the start (load older history), and prepending items keeps the first visible row in place. On native it is a FlatList with the same props (role list; rows carry no listitem role, which React Native lacks) (maintainVisibleContentPosition for prepend).',
6
6
  whenToUse: [
7
7
  'Hundreds to tens of thousands of rows: sessions, a chat transcript, log lines',
8
8
  'A list that grows at the bottom while the reader follows it (anchor="end")',
9
+ 'A transcript that pages older history in at the top (onStartReached, then prepend items)',
9
10
  ],
10
11
  whenNotToUse: [
11
12
  'Tabular data with columns and sorting — use VirtualTable',