@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.
- package/dist/Table-Cw1B14R3.js.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/index.css +1 -1
- package/dist/index.js +3950 -3750
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- 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 +116 -27
- package/src/components/VirtualList/native.css.ts +14 -0
- package/src/components/VirtualList/props.ts +31 -0
- 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
|
|
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
|
+
}
|
|
@@ -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,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 {
|
|
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
|
|
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.
|
|
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',
|