@duro-app/ui 5.25.0 → 5.27.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 (54) hide show
  1. package/dist/components/CodeBlock/CodeBlock.d.ts +1 -1
  2. package/dist/components/CodeBlock/CodeBlock.d.ts.map +1 -1
  3. package/dist/components/CodeBlock/CodeBlock.meta.d.ts.map +1 -1
  4. package/dist/components/CodeBlock/CodeBlock.stories.d.ts +8 -0
  5. package/dist/components/CodeBlock/CodeBlock.stories.d.ts.map +1 -1
  6. package/dist/components/CodeBlock/DiffRows.d.ts +15 -0
  7. package/dist/components/CodeBlock/DiffRows.d.ts.map +1 -0
  8. package/dist/components/CodeBlock/diffRoles.d.ts +18 -0
  9. package/dist/components/CodeBlock/diffRoles.d.ts.map +1 -1
  10. package/dist/components/CodeBlock/styles.css.d.ts +43 -0
  11. package/dist/components/CodeBlock/styles.css.d.ts.map +1 -1
  12. package/dist/components/CodeBlock/types.d.ts +22 -0
  13. package/dist/components/CodeBlock/types.d.ts.map +1 -1
  14. package/dist/components/Textarea/Textarea.d.ts +7 -1
  15. package/dist/components/Textarea/Textarea.d.ts.map +1 -1
  16. package/dist/components/Textarea/Textarea.stories.d.ts +6 -0
  17. package/dist/components/Textarea/Textarea.stories.d.ts.map +1 -1
  18. package/dist/components/Textarea/styles.css.d.ts +3 -0
  19. package/dist/components/Textarea/styles.css.d.ts.map +1 -1
  20. package/dist/components/Textarea/useFileDrop.d.ts +9 -0
  21. package/dist/components/Textarea/useFileDrop.d.ts.map +1 -0
  22. package/dist/components/VirtualList/VirtualList.d.ts +1 -1
  23. package/dist/components/VirtualList/VirtualList.d.ts.map +1 -1
  24. package/dist/components/VirtualList/VirtualList.stories.d.ts +4 -0
  25. package/dist/components/VirtualList/VirtualList.stories.d.ts.map +1 -1
  26. package/dist/components/VirtualList/props.d.ts +13 -0
  27. package/dist/components/VirtualList/props.d.ts.map +1 -1
  28. package/dist/index.css +1 -1
  29. package/dist/index.d.ts +1 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +2632 -2457
  32. package/dist/index.js.map +1 -1
  33. package/package.json +2 -2
  34. package/src/components/CodeBlock/CodeBlock.meta.ts +2 -1
  35. package/src/components/CodeBlock/CodeBlock.native.tsx +12 -1
  36. package/src/components/CodeBlock/CodeBlock.stories.tsx +98 -0
  37. package/src/components/CodeBlock/CodeBlock.tsx +28 -10
  38. package/src/components/CodeBlock/DiffRows.tsx +101 -0
  39. package/src/components/CodeBlock/diffRoles.ts +42 -0
  40. package/src/components/CodeBlock/styles.css.ts +41 -0
  41. package/src/components/CodeBlock/types.ts +24 -0
  42. package/src/components/Textarea/Textarea.meta.ts +1 -1
  43. package/src/components/Textarea/Textarea.stories.tsx +41 -0
  44. package/src/components/Textarea/Textarea.tsx +21 -0
  45. package/src/components/Textarea/styles.css.ts +3 -0
  46. package/src/components/Textarea/useFileDrop.native.ts +6 -0
  47. package/src/components/Textarea/useFileDrop.ts +46 -0
  48. package/src/components/VirtualList/VirtualList.meta.ts +1 -1
  49. package/src/components/VirtualList/VirtualList.native.tsx +24 -1
  50. package/src/components/VirtualList/VirtualList.stories.tsx +62 -0
  51. package/src/components/VirtualList/VirtualList.tsx +23 -0
  52. package/src/components/VirtualList/props.ts +13 -0
  53. package/src/index.native.ts +1 -0
  54. package/src/index.ts +1 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "5.25.0",
3
+ "version": "5.27.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -92,7 +92,7 @@
92
92
  "dependencies": {
93
93
  "@tanstack/react-virtual": "^3.14.13",
94
94
  "qrcode": "^1.5.4",
95
- "@duro-app/tokens": "^5.25.0"
95
+ "@duro-app/tokens": "^5.27.0"
96
96
  },
97
97
  "devDependencies": {
98
98
  "@babel/preset-typescript": "^7.28.0",
@@ -2,10 +2,11 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'A block of code with a copy button: a pre that scrolls sideways when a line is long, and a Button that copies its text, shows the copied label for copiedDuration ms and announces it through a polite LiveRegion. Labels come from props. With language="diff", added, removed and hunk lines are coloured from tokens. On native the text is selectable and scrolls sideways; there is no copy button.',
5
+ 'A block of code with a copy button: a pre that scrolls sideways when a line is long, and a Button that copies its text, shows the copied label for copiedDuration ms and announces it through a polite LiveRegion. Labels come from props. With language="diff", added, removed and hunk lines are coloured from tokens. On native the text is selectable and scrolls sideways; there is no copy button. A diff can carry a lineAction (a button beside each line inside a hunk, one tab stop, Up and Down between them; the line handed over has its old and new line numbers) and renderAfterLine (content under a line, full width: a review comment, an editor); the copied text stays the diff.',
6
6
  whenToUse: [
7
7
  'Code or a command the reader is likely to paste: a docs page, an install step',
8
8
  "A Markdown renderer's pre, inside Prose",
9
+ 'A diff people comment on line by line (lineAction, renderAfterLine)',
9
10
  ],
10
11
  whenNotToUse: [
11
12
  'A short identifier in running text — use inline code (Prose styles it)',
@@ -2,6 +2,7 @@ import {ScrollView} from 'react-native'
2
2
  import {html} from 'react-strict-dom'
3
3
  import {typePresets} from '@duro-app/tokens/tokens/type-presets.css'
4
4
  import {DiffLines} from './DiffLines'
5
+ import {DiffRows} from './DiffRows'
5
6
  import {textOf} from './diffRoles'
6
7
  import {styles} from './styles.css'
7
8
  import type {CodeBlockProps} from './types'
@@ -12,9 +13,19 @@ export type {CodeBlockProps} from './types'
12
13
  * Native CodeBlock: selectable monospace text that scrolls sideways, with the
13
14
  * same diff line roles as the web. The copy button is web only: a phone
14
15
  * copies by selecting the text, so the labels and `onCopyError` are unused.
16
+ * With a line action or content under lines, the diff is rows: each line's
17
+ * button and content as on the web (no arrow-key roving on a phone).
15
18
  */
16
- export function CodeBlock({children, language}: CodeBlockProps) {
19
+ export function CodeBlock({children, language, lineAction, renderAfterLine}: CodeBlockProps) {
17
20
  const text = textOf(children)
21
+ if (language === 'diff' && (lineAction !== undefined || renderAfterLine !== undefined)) {
22
+ // Content under a line can hold its own controls, so the rows do not scroll sideways as one text.
23
+ return (
24
+ <html.div style={[styles.root, typePresets.code, styles.rows]}>
25
+ <DiffRows text={text} lineAction={lineAction} renderAfterLine={renderAfterLine} />
26
+ </html.div>
27
+ )
28
+ }
18
29
  return (
19
30
  <html.div style={styles.root}>
20
31
  <ScrollView horizontal>
@@ -2,6 +2,9 @@ import type {Meta, StoryObj} from '@storybook/react'
2
2
  import {expect, fn, spyOn, waitFor} from 'storybook/test'
3
3
  import {css, html} from 'react-strict-dom'
4
4
  import {CodeBlock} from './CodeBlock'
5
+ import type {DiffLineInfo} from './diffRoles'
6
+ import {Card} from '../Card/Card'
7
+ import {Text} from '../Text/Text'
5
8
  import {onThemeSurface} from '../../docs/themedSurface'
6
9
  import {withCoarsePointer, withFinePointer} from '../../docs/coarsePointer'
7
10
  import {SIZES_PX} from '@duro-app/tokens/keys'
@@ -244,3 +247,98 @@ export const DiffChecks: Story = {
244
247
  }
245
248
  },
246
249
  }
250
+
251
+ const REVIEW_DIFF = `@@ -10,3 +10,4 @@
252
+ export function guard(req, res) {
253
+ - const token = req.headers.authorization
254
+ + const token = readBearer(req.headers.authorization)
255
+ + if (!token) return unauthorized(res, 'missing_token')
256
+ `
257
+
258
+ // A removed line exists only in the old file: its name says so, so no two buttons share one.
259
+ const commentOn = (line: DiffLineInfo) =>
260
+ line.role === 'removed'
261
+ ? `Comment on old line ${line.oldLine}`
262
+ : `Comment on line ${line.newLine}`
263
+
264
+ /**
265
+ * `lineAction` puts a button beside each line inside a hunk (a review
266
+ * comment, say), and `renderAfterLine` shows content under a line, full
267
+ * width. The gutter is one tab stop: Up and Down move between its buttons.
268
+ */
269
+ export const DiffLineActions: Story = {
270
+ args: {code: REVIEW_DIFF},
271
+ render: ({code, ...args}) => (
272
+ <CodeBlock
273
+ {...args}
274
+ language="diff"
275
+ lineAction={{label: commentOn, onAction: fn()}}
276
+ renderAfterLine={(line) =>
277
+ line.newLine === 11 ? (
278
+ <Card variant="outlined" size="compact">
279
+ <Text variant="bodySm">Keep the 401 status text: clients read it.</Text>
280
+ </Card>
281
+ ) : null
282
+ }
283
+ >
284
+ <html.code>{code}</html.code>
285
+ </CodeBlock>
286
+ ),
287
+ }
288
+
289
+ /** DiffLineActions' checks: one tab stop, arrows move, the line handed over is numbered, copy keeps the diff. */
290
+ export const DiffLineActionsChecks: Story = {
291
+ ...DiffLineActions,
292
+ tags: TEST_ONLY,
293
+ args: {code: REVIEW_DIFF, onCopyError: fn()},
294
+ render: ({code, ...args}) => {
295
+ const onAction = onLineAction
296
+ return (
297
+ <CodeBlock
298
+ {...args}
299
+ language="diff"
300
+ lineAction={{label: commentOn, onAction}}
301
+ renderAfterLine={(line) =>
302
+ line.newLine === 11 ? <Text variant="bodySm">Saved comment</Text> : null
303
+ }
304
+ >
305
+ <html.code>{code}</html.code>
306
+ </CodeBlock>
307
+ )
308
+ },
309
+ play: async ({args, canvas, canvasElement, userEvent}) => {
310
+ onLineAction.mockClear()
311
+ const lineButtons = canvas.getAllByRole('button', {name: /^Comment on/})
312
+ // The hunk header has no line number, so no button: four lines, four buttons.
313
+ await expect(lineButtons).toHaveLength(4)
314
+ await expect(lineButtons.filter((button) => button.tabIndex === 0)).toHaveLength(1)
315
+
316
+ lineButtons[0].focus()
317
+ await userEvent.keyboard('{ArrowDown}')
318
+ await expect(canvas.getByRole('button', {name: 'Comment on old line 11'})).toHaveFocus()
319
+ await userEvent.keyboard('{Enter}')
320
+ await expect(onLineAction).toHaveBeenCalledWith(
321
+ expect.objectContaining({role: 'removed', oldLine: 11, newLine: null}),
322
+ )
323
+
324
+ await userEvent.click(canvas.getByRole('button', {name: 'Comment on line 12'}))
325
+ await expect(onLineAction).toHaveBeenLastCalledWith(
326
+ expect.objectContaining({role: 'added', oldLine: null, newLine: 12}),
327
+ )
328
+
329
+ // Content under a line comes right after that line's row.
330
+ const saved = canvas.getByText('Saved comment')
331
+ const row = canvasElement.querySelector('[data-diff-line="added"]')!
332
+ await expect(row.compareDocumentPosition(saved) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
333
+
334
+ const write = spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined)
335
+ try {
336
+ await userEvent.click(canvas.getByRole('button', {name: args.copyLabel}))
337
+ await expect(write).toHaveBeenCalledWith(args.code)
338
+ } finally {
339
+ write.mockRestore()
340
+ }
341
+ },
342
+ }
343
+
344
+ const onLineAction = fn()
@@ -6,6 +6,7 @@ import {typePresets} from '@duro-app/tokens/tokens/type-presets.css'
6
6
  import {styles} from './styles.css'
7
7
  import type {CodeBlockProps} from './types'
8
8
  import {DiffLines} from './DiffLines'
9
+ import {DiffRows} from './DiffRows'
9
10
  import {textOf} from './diffRoles'
10
11
 
11
12
  export type {CodeBlockProps} from './types'
@@ -24,7 +25,11 @@ export function CodeBlock({
24
25
  copiedLabel,
25
26
  copiedDuration = 2000,
26
27
  onCopyError,
28
+ lineAction,
29
+ renderAfterLine,
27
30
  }: CodeBlockProps) {
31
+ // Lines that carry an action or content under them are rows, not text in a pre.
32
+ const asRows = language === 'diff' && (lineAction !== undefined || renderAfterLine !== undefined)
28
33
  const preRef = useRef<HTMLPreElement | null>(null)
29
34
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
30
35
  const [copied, setCopied] = useState(false)
@@ -51,7 +56,10 @@ export function CodeBlock({
51
56
 
52
57
  const copy = useCallback(async () => {
53
58
  try {
54
- await navigator.clipboard.writeText(preRef.current?.textContent ?? '')
59
+ // Rows hold buttons and comments: what is copied is still the diff text.
60
+ await navigator.clipboard.writeText(
61
+ asRows ? textOf(children) : (preRef.current?.textContent ?? ''),
62
+ )
55
63
  } catch (error) {
56
64
  // Never dropped: without a handler the failure still reaches the console.
57
65
  if (onCopyError) onCopyError(error)
@@ -64,7 +72,7 @@ export function CodeBlock({
64
72
  timerRef.current = null
65
73
  setCopied(false)
66
74
  }, copiedDuration)
67
- }, [copiedDuration, onCopyError])
75
+ }, [asRows, children, copiedDuration, onCopyError])
68
76
 
69
77
  return (
70
78
  <html.div style={styles.root}>
@@ -73,14 +81,24 @@ export function CodeBlock({
73
81
  {copied ? copiedLabel : copyLabel}
74
82
  </Button>
75
83
  </html.div>
76
- <html.pre
77
- ref={preRef}
78
- tabIndex={scrollable ? 0 : undefined}
79
- data-language={language}
80
- style={[typePresets.code, styles.pre]}
81
- >
82
- {language === 'diff' ? <DiffLines text={textOf(children)} /> : children}
83
- </html.pre>
84
+ {asRows ? (
85
+ <html.div data-language={language} style={[typePresets.code, styles.rows]}>
86
+ <DiffRows
87
+ text={textOf(children)}
88
+ lineAction={lineAction}
89
+ renderAfterLine={renderAfterLine}
90
+ />
91
+ </html.div>
92
+ ) : (
93
+ <html.pre
94
+ ref={preRef}
95
+ tabIndex={scrollable ? 0 : undefined}
96
+ data-language={language}
97
+ style={[typePresets.code, styles.pre]}
98
+ >
99
+ {language === 'diff' ? <DiffLines text={textOf(children)} /> : children}
100
+ </html.pre>
101
+ )}
84
102
  <LiveRegion visuallyHidden>{copied ? copiedLabel : ''}</LiveRegion>
85
103
  </html.div>
86
104
  )
@@ -0,0 +1,101 @@
1
+ import {
2
+ Fragment,
3
+ useMemo,
4
+ useRef,
5
+ useState,
6
+ type ComponentProps,
7
+ type KeyboardEvent,
8
+ type ReactNode,
9
+ } from 'react'
10
+ import {html} from 'react-strict-dom'
11
+ import {numberDiffLines, splitDiffLines, type DiffLineInfo, type DiffLineRole} from './diffRoles'
12
+ import {styles} from './styles.css'
13
+ import type {CodeBlockLineAction} from './types'
14
+
15
+ const roleStyle = {
16
+ added: styles.added,
17
+ removed: styles.removed,
18
+ hunk: styles.hunk,
19
+ context: undefined,
20
+ } as const satisfies Record<DiffLineRole, unknown>
21
+
22
+ const inHunk = (line: DiffLineInfo) => line.oldLine !== null || line.newLine !== null
23
+
24
+ /**
25
+ * A diff as rows rather than one `pre`, for when lines carry an action or
26
+ * content under them (a pre holds text only). Each row is a gutter and the
27
+ * line; content after a line spans the row. The gutter is one tab stop: the
28
+ * active line's button takes Tab, Up and Down move between the buttons.
29
+ */
30
+ export function DiffRows({
31
+ text,
32
+ lineAction,
33
+ renderAfterLine,
34
+ }: {
35
+ text: string
36
+ lineAction?: CodeBlockLineAction
37
+ renderAfterLine?: (line: DiffLineInfo) => ReactNode
38
+ }) {
39
+ const lines = useMemo(() => numberDiffLines(splitDiffLines(text)), [text])
40
+ const available = lineAction?.available ?? inHunk
41
+ const actionable = useMemo(
42
+ () => (lineAction ? lines.filter((line) => available(line)).map((line) => line.index) : []),
43
+ [available, lineAction, lines],
44
+ )
45
+ const [activeIndex, setActiveIndex] = useState<number | null>(null)
46
+ const active =
47
+ activeIndex !== null && actionable.includes(activeIndex) ? activeIndex : (actionable[0] ?? null)
48
+ // Structural, so the native build (no DOM lib) types it too; focus exists only on the web.
49
+ const buttons = useRef(new Map<number, {focus?: () => void}>())
50
+
51
+ const move = (from: number, step: 1 | -1) => {
52
+ const position = actionable.indexOf(from) + step
53
+ const next = actionable[position]
54
+ if (next === undefined) return
55
+ setActiveIndex(next)
56
+ buttons.current.get(next)?.focus?.()
57
+ }
58
+ const onKeyDown = (line: DiffLineInfo) => (event: KeyboardEvent<HTMLButtonElement>) => {
59
+ if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
60
+ event.preventDefault()
61
+ move(line.index, event.key === 'ArrowDown' ? 1 : -1)
62
+ }
63
+
64
+ return (
65
+ <Fragment>
66
+ {lines.map((line) => {
67
+ const after = renderAfterLine?.(line)
68
+ const offered = lineAction !== undefined && actionable.includes(line.index)
69
+ return (
70
+ <Fragment key={line.index}>
71
+ <html.div data-diff-line={line.role} style={[styles.row, roleStyle[line.role]]}>
72
+ {lineAction ? (
73
+ <html.span style={styles.gutter}>
74
+ {offered ? (
75
+ <html.button
76
+ type="button"
77
+ ref={(node: {focus?: () => void} | null) => {
78
+ if (node) buttons.current.set(line.index, node)
79
+ else buttons.current.delete(line.index)
80
+ }}
81
+ aria-label={lineAction.label(line)}
82
+ tabIndex={line.index === active ? 0 : -1}
83
+ onFocus={() => setActiveIndex(line.index)}
84
+ onKeyDown={onKeyDown(line) as ComponentProps<typeof html.button>['onKeyDown']}
85
+ onClick={() => lineAction.onAction(line)}
86
+ style={styles.lineAction}
87
+ >
88
+ +
89
+ </html.button>
90
+ ) : null}
91
+ </html.span>
92
+ ) : null}
93
+ <html.span style={styles.rowText}>{line.text}</html.span>
94
+ </html.div>
95
+ {after ? <html.div style={styles.after}>{after}</html.div> : null}
96
+ </Fragment>
97
+ )
98
+ })}
99
+ </Fragment>
100
+ )
101
+ }
@@ -38,3 +38,45 @@ export function textOf(node: ReactNode): string {
38
38
  }
39
39
  return ''
40
40
  }
41
+
42
+ /** One line of a diff as a line action or after-line content sees it. */
43
+ export interface DiffLineInfo {
44
+ /** The line's position in the diff text, from 0. */
45
+ index: number
46
+ role: DiffLineRole
47
+ /** The line without its newline. */
48
+ text: string
49
+ /** Its line number in the old file: removed and context lines inside a hunk, else null. */
50
+ oldLine: number | null
51
+ /** Its line number in the new file: added and context lines inside a hunk, else null. */
52
+ newLine: number | null
53
+ }
54
+
55
+ const HUNK_HEADER = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/
56
+
57
+ /**
58
+ * Numbers a diff's lines from its hunk headers (`@@ -12,4 +12,6 @@`). Lines
59
+ * before the first hunk (file headers) and the headers themselves have no
60
+ * line numbers.
61
+ */
62
+ export function numberDiffLines(lines: readonly DiffLine[]): DiffLineInfo[] {
63
+ let oldNext: number | null = null
64
+ let newNext: number | null = null
65
+ return lines.map((line, index) => {
66
+ const text = line.text.endsWith('\n') ? line.text.slice(0, -1) : line.text
67
+ if (line.role === 'hunk') {
68
+ const match = HUNK_HEADER.exec(text)
69
+ oldNext = match ? Number(match[1]) : null
70
+ newNext = match ? Number(match[2]) : null
71
+ return {index, role: line.role, text, oldLine: null, newLine: null}
72
+ }
73
+ if (oldNext === null || newNext === null) {
74
+ return {index, role: line.role, text, oldLine: null, newLine: null}
75
+ }
76
+ const oldLine = line.role === 'added' ? null : oldNext
77
+ const newLine = line.role === 'removed' ? null : newNext
78
+ if (oldLine !== null) oldNext += 1
79
+ if (newLine !== null) newNext += 1
80
+ return {index, role: line.role, text, oldLine, newLine}
81
+ })
82
+ }
@@ -2,6 +2,7 @@ import {css} from 'react-strict-dom'
2
2
  import {colors} from '@duro-app/tokens/tokens/colors.css'
3
3
  import {radii, spacing} from '@duro-app/tokens/tokens/spacing.css'
4
4
  import {borders} from '@duro-app/tokens/tokens/borders.css'
5
+ import {sizes} from '@duro-app/tokens/tokens/sizes.css'
5
6
 
6
7
  export const styles = css.create({
7
8
  // One bordered block: a row for the copy button, then the code. The button
@@ -50,6 +51,46 @@ export const styles = css.create({
50
51
  added: {backgroundColor: colors.successBg},
51
52
  removed: {backgroundColor: colors.errorBg},
52
53
  hunk: {backgroundColor: colors.infoBg, color: colors.infoText},
54
+ // Diff rows (a line action or content under lines): the block scrolls
55
+ // sideways as a whole, each row as wide as the longest line so its tint
56
+ // bands the full width.
57
+ rows: {
58
+ display: 'flex',
59
+ flexDirection: 'column',
60
+ paddingTop: spacing.xs,
61
+ paddingBottom: spacing.md,
62
+ overflowX: 'auto',
63
+ color: colors.text,
64
+ },
65
+ row: {display: 'flex', alignItems: 'center', minWidth: 'max-content'},
66
+ gutter: {
67
+ display: 'flex',
68
+ justifyContent: 'center',
69
+ flexShrink: 0,
70
+ width: sizes.iconButtonSm,
71
+ },
72
+ rowText: {whiteSpace: 'pre', paddingRight: spacing.md},
73
+ lineAction: {
74
+ width: sizes.chip,
75
+ height: sizes.chip,
76
+ padding: 0,
77
+ borderWidth: 0,
78
+ borderRadius: radii.xs,
79
+ backgroundColor: {default: 'transparent', ':hover': colors.bgCardHover},
80
+ color: colors.textMuted,
81
+ font: 'inherit',
82
+ cursor: 'pointer',
83
+ outlineWidth: {default: 0, ':focus-visible': borders.focusRing},
84
+ outlineStyle: {default: 'none', ':focus-visible': 'solid'},
85
+ outlineColor: {default: 'transparent', ':focus-visible': colors.accent},
86
+ },
87
+ // Content under a line spans the block, indented past the gutter.
88
+ after: {
89
+ paddingTop: spacing.xs,
90
+ paddingBottom: spacing.sm,
91
+ paddingLeft: sizes.iconButtonSm,
92
+ paddingRight: spacing.md,
93
+ },
53
94
  // Native: the code scrolls sideways inside the block.
54
95
  scroll: {
55
96
  paddingTop: spacing.xs,
@@ -1,4 +1,16 @@
1
1
  import type {ReactNode} from 'react'
2
+ import type {DiffLineInfo} from './diffRoles'
3
+
4
+ export type {DiffLineInfo} from './diffRoles'
5
+
6
+ /** An action offered on diff lines (comment on a line, link to it). */
7
+ export interface CodeBlockLineAction {
8
+ /** The button's accessible name for a line ("Comment on line 12"), from your text catalog. */
9
+ label: (line: DiffLineInfo) => string
10
+ onAction: (line: DiffLineInfo) => void
11
+ /** Which lines offer it. Default: every line inside a hunk (one with a line number). */
12
+ available?: (line: DiffLineInfo) => boolean
13
+ }
2
14
 
3
15
  export interface CodeBlockProps {
4
16
  /**
@@ -26,4 +38,16 @@ export interface CodeBlockProps {
26
38
  copiedDuration?: number
27
39
  /** Called when the clipboard refuses the write (an insecure context, a denied permission). */
28
40
  onCopyError?: (error: unknown) => void
41
+ /**
42
+ * With `language="diff"`: a button in a gutter beside each line it is
43
+ * available on. The gutter is one tab stop; Up and Down move between its
44
+ * buttons.
45
+ */
46
+ lineAction?: CodeBlockLineAction
47
+ /**
48
+ * With `language="diff"`: content shown under a line, full width (a comment
49
+ * thread, an editor). Return null for lines with nothing under them. What
50
+ * is copied stays the diff text.
51
+ */
52
+ renderAfterLine?: (line: DiffLineInfo) => ReactNode
29
53
  }
@@ -2,7 +2,7 @@ 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. 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.',
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. On the web, onFiles receives files pasted into the field or dropped on it (the drop ring shows while files are dragged over it); text pastes stay text.',
6
6
  whenToUse: [
7
7
  'Multi-line text entry (comments, messages, descriptions)',
8
8
  'A composer that grows with its text (autoGrow, maxRows)',
@@ -231,3 +231,44 @@ export const AnchoredListbox: Story = {
231
231
  await expect(editor).toHaveFocus()
232
232
  },
233
233
  }
234
+
235
+ /**
236
+ * `onFiles` (web): a file pasted into the field or dropped on it goes to
237
+ * `onFiles`; while files are dragged over it the field shows the drop ring.
238
+ * Pasted text stays text.
239
+ */
240
+ export const PasteAndDropFiles: Story = {
241
+ args: {placeholder: 'Paste or drop a screenshot', onFiles: fn()},
242
+ play: async ({canvas, args}) => {
243
+ const field = canvas.getByPlaceholderText('Paste or drop a screenshot')
244
+ const withFile = () => {
245
+ const transfer = new DataTransfer()
246
+ transfer.items.add(new File(['png'], 'shot.png', {type: 'image/png'}))
247
+ return transfer
248
+ }
249
+
250
+ field.dispatchEvent(
251
+ new ClipboardEvent('paste', {clipboardData: withFile(), bubbles: true, cancelable: true}),
252
+ )
253
+ await expect(args.onFiles).toHaveBeenCalledTimes(1)
254
+ await expect((args.onFiles as ReturnType<typeof fn>).mock.calls[0][0][0].name).toBe('shot.png')
255
+
256
+ const text = new DataTransfer()
257
+ text.setData('text/plain', 'hello')
258
+ field.dispatchEvent(
259
+ new ClipboardEvent('paste', {clipboardData: text, bubbles: true, cancelable: true}),
260
+ )
261
+ await expect(args.onFiles).toHaveBeenCalledTimes(1)
262
+
263
+ const ringBefore = getComputedStyle(field).boxShadow
264
+ field.dispatchEvent(
265
+ new DragEvent('dragenter', {dataTransfer: withFile(), bubbles: true, cancelable: true}),
266
+ )
267
+ await waitFor(() => expect(getComputedStyle(field).boxShadow).not.toBe(ringBefore))
268
+ const drop = new DragEvent('drop', {dataTransfer: withFile(), bubbles: true, cancelable: true})
269
+ field.dispatchEvent(drop)
270
+ await expect(drop.defaultPrevented).toBe(true)
271
+ await expect(args.onFiles).toHaveBeenCalledTimes(2)
272
+ await waitFor(() => expect(getComputedStyle(field).boxShadow).toBe(ringBefore))
273
+ },
274
+ }
@@ -3,6 +3,7 @@ import {html} from 'react-strict-dom'
3
3
  import {useFieldContext} from '../Field/FieldContext'
4
4
  import type {ListboxAnchorProps} from '../Listbox/Listbox'
5
5
  import {useAutoGrowFallback} from './useAutoGrowFallback'
6
+ import {useFileDrop} from './useFileDrop'
6
7
  import {grow} from './grow'
7
8
  import {styles} from './styles.css'
8
9
 
@@ -39,6 +40,12 @@ interface TextareaBaseProps {
39
40
  * navigation of a list driven by this field. */
40
41
  onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
41
42
  onKeyUp?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void
43
+ /**
44
+ * Web: files pasted into the field or dropped on it (a screenshot, a log).
45
+ * The field shows the drop ring while files are dragged over it. Text
46
+ * pastes and drops stay text. Native: never called; attach with FileTrigger.
47
+ */
48
+ onFiles?: (files: readonly File[]) => void
42
49
  ref?: React.Ref<HTMLTextAreaElement>
43
50
  }
44
51
 
@@ -65,6 +72,7 @@ export function Textarea({
65
72
  onFocus,
66
73
  onKeyDown,
67
74
  onKeyUp,
75
+ onFiles,
68
76
  ref,
69
77
  }: TextareaProps) {
70
78
  const ctx = useFieldContext()
@@ -92,6 +100,17 @@ export function Textarea({
92
100
  [effectiveRef],
93
101
  )
94
102
  useAutoGrowFallback(innerRef, autoGrow ? 'content' : 'fixed')
103
+ const dropOver = useFileDrop(innerRef, onFiles)
104
+ const onPaste = onFiles
105
+ ? // Structural: the native build (no DOM lib) types this file too; only the web pastes files.
106
+ (event: {clipboardData?: {files?: ArrayLike<File>} | null; preventDefault: () => void}) => {
107
+ const files = Array.from(event.clipboardData?.files ?? [])
108
+ if (files.length === 0) return
109
+ // Files only: the field keeps no file name as text.
110
+ event.preventDefault()
111
+ onFiles(files)
112
+ }
113
+ : undefined
95
114
 
96
115
  return (
97
116
  <html.textarea
@@ -112,6 +131,7 @@ export function Textarea({
112
131
  onFocus={onFocus as StrictTextareaProps['onFocus']}
113
132
  onKeyDown={onKeyDown as StrictTextareaProps['onKeyDown']}
114
133
  onKeyUp={onKeyUp as StrictTextareaProps['onKeyUp']}
134
+ onPaste={onPaste as StrictTextareaProps['onPaste']}
115
135
  role={role}
116
136
  aria-controls={ariaControls}
117
137
  aria-expanded={ariaExpanded}
@@ -124,6 +144,7 @@ export function Textarea({
124
144
  styles[effectiveVariant],
125
145
  autoGrow && grow.autoGrow,
126
146
  autoGrow && maxRows !== undefined && grow.maxRows(maxRows),
147
+ dropOver && styles.dropOver,
127
148
  ]}
128
149
  {...extraProps}
129
150
  />
@@ -1,4 +1,5 @@
1
1
  import {css} from 'react-strict-dom'
2
+ import {shadows} from '@duro-app/tokens/tokens/shadows.css'
2
3
  import {colors} from '@duro-app/tokens/tokens/colors.css'
3
4
  import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
4
5
  import {typography} from '@duro-app/tokens/tokens/typography.css'
@@ -56,4 +57,6 @@ export const styles = css.create({
56
57
  ':focus': colors.error,
57
58
  },
58
59
  },
60
+ // Files dragged over the field: the same ring as a DragDrop zone under the pointer.
61
+ dropOver: {boxShadow: shadows.dropOver},
59
62
  })
@@ -0,0 +1,6 @@
1
+ import type {RefObject} from 'react'
2
+
3
+ /** A phone has no drag and drop of files onto a field; attach with FileTrigger. */
4
+ export function useFileDrop(_ref: RefObject<unknown>, _onFiles: unknown): boolean {
5
+ return false
6
+ }
@@ -0,0 +1,46 @@
1
+ import {useEffect, useState, type RefObject} from 'react'
2
+
3
+ const carriesFiles = (event: DragEvent) =>
4
+ Array.from(event.dataTransfer?.types ?? []).includes('Files')
5
+
6
+ /**
7
+ * Files dropped on the field go to `onFiles`, and `over` says files are
8
+ * being dragged over it (for the drop ring). A listener on the element:
9
+ * react-strict-dom passes no drag events through. Text drags stay the
10
+ * browser's.
11
+ */
12
+ export function useFileDrop(
13
+ ref: RefObject<HTMLTextAreaElement | null>,
14
+ onFiles: ((files: readonly File[]) => void) | undefined,
15
+ ): boolean {
16
+ const [over, setOver] = useState(false)
17
+ useEffect(() => {
18
+ const field = ref.current
19
+ if (!field || !onFiles) return
20
+ const enter = (event: DragEvent) => {
21
+ if (!carriesFiles(event)) return
22
+ // Claimed, or the browser opens the file instead of dropping it here.
23
+ event.preventDefault()
24
+ setOver(true)
25
+ }
26
+ const leave = () => setOver(false)
27
+ const drop = (event: DragEvent) => {
28
+ if (!carriesFiles(event)) return
29
+ event.preventDefault()
30
+ setOver(false)
31
+ const files = Array.from(event.dataTransfer?.files ?? [])
32
+ if (files.length > 0) onFiles(files)
33
+ }
34
+ field.addEventListener('dragenter', enter)
35
+ field.addEventListener('dragover', enter)
36
+ field.addEventListener('dragleave', leave)
37
+ field.addEventListener('drop', drop)
38
+ return () => {
39
+ field.removeEventListener('dragenter', enter)
40
+ field.removeEventListener('dragover', enter)
41
+ field.removeEventListener('dragleave', leave)
42
+ field.removeEventListener('drop', drop)
43
+ }
44
+ }, [onFiles, ref])
45
+ return over
46
+ }