@duro-app/ui 5.26.0 → 5.28.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/components/CodeBlock/CodeBlock.d.ts +1 -1
- package/dist/components/CodeBlock/CodeBlock.d.ts.map +1 -1
- package/dist/components/CodeBlock/CodeBlock.meta.d.ts.map +1 -1
- package/dist/components/CodeBlock/CodeBlock.stories.d.ts +8 -0
- package/dist/components/CodeBlock/CodeBlock.stories.d.ts.map +1 -1
- package/dist/components/CodeBlock/DiffRows.d.ts +15 -0
- package/dist/components/CodeBlock/DiffRows.d.ts.map +1 -0
- package/dist/components/CodeBlock/diffRoles.d.ts +18 -0
- package/dist/components/CodeBlock/diffRoles.d.ts.map +1 -1
- package/dist/components/CodeBlock/styles.css.d.ts +43 -0
- package/dist/components/CodeBlock/styles.css.d.ts.map +1 -1
- package/dist/components/CodeBlock/types.d.ts +22 -0
- package/dist/components/CodeBlock/types.d.ts.map +1 -1
- package/dist/components/Select/Select.d.ts +6 -2
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.stories.d.ts +6 -0
- package/dist/components/Select/Select.stories.d.ts.map +1 -1
- package/dist/components/Select/SelectContext.d.ts +1 -1
- package/dist/components/Select/SelectContext.d.ts.map +1 -1
- package/dist/components/Select/selectState.d.ts +7 -1
- package/dist/components/Select/selectState.d.ts.map +1 -1
- package/dist/components/Select/styles.css.d.ts +4 -0
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
- package/dist/components/Textarea/Textarea.d.ts +7 -1
- package/dist/components/Textarea/Textarea.d.ts.map +1 -1
- package/dist/components/Textarea/Textarea.stories.d.ts +6 -0
- package/dist/components/Textarea/Textarea.stories.d.ts.map +1 -1
- package/dist/components/Textarea/styles.css.d.ts +3 -0
- package/dist/components/Textarea/styles.css.d.ts.map +1 -1
- package/dist/components/Textarea/useFileDrop.d.ts +9 -0
- package/dist/components/Textarea/useFileDrop.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3266 -3090
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/CodeBlock/CodeBlock.meta.ts +2 -1
- package/src/components/CodeBlock/CodeBlock.native.tsx +12 -1
- package/src/components/CodeBlock/CodeBlock.stories.tsx +98 -0
- package/src/components/CodeBlock/CodeBlock.tsx +28 -10
- package/src/components/CodeBlock/DiffRows.tsx +101 -0
- package/src/components/CodeBlock/diffRoles.ts +42 -0
- package/src/components/CodeBlock/styles.css.ts +41 -0
- package/src/components/CodeBlock/types.ts +24 -0
- package/src/components/Select/Select.meta.ts +1 -1
- package/src/components/Select/Select.native.tsx +15 -4
- package/src/components/Select/Select.stories.tsx +52 -0
- package/src/components/Select/Select.tsx +27 -6
- package/src/components/Select/SelectContext.ts +1 -1
- package/src/components/Select/selectState.ts +17 -1
- package/src/components/Select/styles.css.ts +4 -0
- package/src/components/Select/useSelectRoot.ts +32 -22
- package/src/components/Textarea/Textarea.meta.ts +1 -1
- package/src/components/Textarea/Textarea.stories.tsx +41 -0
- package/src/components/Textarea/Textarea.tsx +21 -0
- package/src/components/Textarea/styles.css.ts +3 -0
- package/src/components/Textarea/useFileDrop.native.ts +6 -0
- package/src/components/Textarea/useFileDrop.ts +46 -0
- package/src/index.native.ts +1 -0
- 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.
|
|
3
|
+
"version": "5.28.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.
|
|
95
|
+
"@duro-app/tokens": "^5.28.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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
'Dropdown select for choosing one value from a list. Compound component — Root is required (throws without it). Select.Trigger takes size="small" to match small Toggles and Buttons in a toolbar. Select.Popup takes label (the list\'s accessible name) and empty (shown in place of the list when it has no items). On the web the list opens below the trigger, or above it when it would overflow the viewport below and fits above. On React Native the trigger opens a bottom sheet (a Drawer at the medium and large detents) titled by label, with a Cancel text button (cancelLabel); the chosen option is filled with a trailing check, and a press picks an option and closes the sheet. Native has no arrow keys: each option is its own screen-reader stop.',
|
|
5
|
+
'Dropdown select for choosing one value from a list. Compound component — Root is required (throws without it). Select.Trigger takes size="small" to match small Toggles and Buttons in a toolbar. Select.Popup takes label (the list\'s accessible name) and empty (shown in place of the list when it has no items). On the web the list opens below the trigger, or above it when it would overflow the viewport below and fits above. On React Native the trigger opens a bottom sheet (a Drawer at the medium and large detents) titled by label, with a Cancel text button (cancelLabel); the chosen option is filled with a trailing check, and a press picks an option and closes the sheet. Native has no arrow keys: each option is its own screen-reader stop. An Item can be disabled (shown, skipped by the keys, inert to a press, announced as disabled), and onOpenChange on Root reports the list opening and closing, so work such as checking which options are available starts only once someone is choosing.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'User must pick one option from a predefined list',
|
|
8
8
|
'Form field for country, category, role selection',
|
|
@@ -57,6 +57,8 @@ interface RootProps {
|
|
|
57
57
|
value?: string
|
|
58
58
|
onValueChange?: (value: string | null) => void
|
|
59
59
|
initialLabels?: Record<string, string>
|
|
60
|
+
/** Called when the sheet opens or closes. */
|
|
61
|
+
onOpenChange?: (open: boolean) => void
|
|
60
62
|
children: ReactNode
|
|
61
63
|
}
|
|
62
64
|
|
|
@@ -66,8 +68,15 @@ interface RootProps {
|
|
|
66
68
|
* one and closes the sheet (C0 direction A, "Check"). The screen reader's
|
|
67
69
|
* focus returns to the trigger.
|
|
68
70
|
*/
|
|
69
|
-
function Root({
|
|
70
|
-
|
|
71
|
+
function Root({
|
|
72
|
+
defaultValue,
|
|
73
|
+
value,
|
|
74
|
+
onValueChange,
|
|
75
|
+
initialLabels,
|
|
76
|
+
onOpenChange,
|
|
77
|
+
children,
|
|
78
|
+
}: RootProps) {
|
|
79
|
+
const state = useSelectState({defaultValue, value, onValueChange, initialLabels, onOpenChange})
|
|
71
80
|
const {open, setOpen, setValue} = state
|
|
72
81
|
const listboxId = useId()
|
|
73
82
|
const triggerRef = useRef<unknown>(null)
|
|
@@ -191,10 +200,12 @@ function Popup({children, label, cancelLabel = 'Cancel', empty}: PopupProps) {
|
|
|
191
200
|
|
|
192
201
|
interface ItemProps {
|
|
193
202
|
value: string
|
|
203
|
+
/** Shown but not choosable. */
|
|
204
|
+
disabled?: boolean
|
|
194
205
|
children: ReactNode
|
|
195
206
|
}
|
|
196
207
|
|
|
197
|
-
function Item({value: itemValue, children}: ItemProps) {
|
|
208
|
+
function Item({value: itemValue, disabled = false, children}: ItemProps) {
|
|
198
209
|
const collecting = useContext(CollectContext)
|
|
199
210
|
const {value, registerLabel} = useSelect()
|
|
200
211
|
const label = textOf(children)
|
|
@@ -203,7 +214,7 @@ function Item({value: itemValue, children}: ItemProps) {
|
|
|
203
214
|
}, [collecting, itemValue, label, registerLabel])
|
|
204
215
|
if (collecting) return null
|
|
205
216
|
return (
|
|
206
|
-
<Listbox.Option value={itemValue} selected={value === itemValue}>
|
|
217
|
+
<Listbox.Option value={itemValue} selected={value === itemValue} disabled={disabled}>
|
|
207
218
|
{children}
|
|
208
219
|
</Listbox.Option>
|
|
209
220
|
)
|
|
@@ -297,3 +297,55 @@ export const OpensAboveNearTheBottomEdge: Story = {
|
|
|
297
297
|
await expect(listBox.top).toBeGreaterThanOrEqual(0)
|
|
298
298
|
},
|
|
299
299
|
}
|
|
300
|
+
|
|
301
|
+
const onAgentOpenChange = fn()
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* An option can be `disabled` (shown, not choosable: an agent that cannot take
|
|
305
|
+
* over), and `onOpenChange` reports the list opening and closing, so work such
|
|
306
|
+
* as checking which options are available starts only once someone is choosing.
|
|
307
|
+
*/
|
|
308
|
+
export const DisabledOptionAndOpenChange: Story = {
|
|
309
|
+
render: () => (
|
|
310
|
+
<Select.Root defaultValue="claude" onOpenChange={onAgentOpenChange}>
|
|
311
|
+
<Select.Trigger aria-label="Agent">
|
|
312
|
+
<Select.Value />
|
|
313
|
+
<Select.Icon />
|
|
314
|
+
</Select.Trigger>
|
|
315
|
+
<Select.Popup label="Agent">
|
|
316
|
+
<Select.Item value="claude">Claude</Select.Item>
|
|
317
|
+
<Select.Item value="gemini" disabled>
|
|
318
|
+
Gemini
|
|
319
|
+
</Select.Item>
|
|
320
|
+
<Select.Item value="codex">Codex</Select.Item>
|
|
321
|
+
</Select.Popup>
|
|
322
|
+
</Select.Root>
|
|
323
|
+
),
|
|
324
|
+
play: async ({canvas, userEvent}) => {
|
|
325
|
+
onAgentOpenChange.mockClear()
|
|
326
|
+
const trigger = canvas.getByRole('combobox')
|
|
327
|
+
await userEvent.click(trigger)
|
|
328
|
+
await waitFor(() => expect(onAgentOpenChange).toHaveBeenCalledWith(true))
|
|
329
|
+
const gemini = screen.getByRole('option', {name: 'Gemini'})
|
|
330
|
+
await expect(gemini).toHaveAttribute('aria-disabled', 'true')
|
|
331
|
+
|
|
332
|
+
// The keys step over it: from Claude, Down lands on Codex.
|
|
333
|
+
await userEvent.keyboard('{ArrowDown}')
|
|
334
|
+
await waitFor(() =>
|
|
335
|
+
expect(trigger).toHaveAttribute(
|
|
336
|
+
'aria-activedescendant',
|
|
337
|
+
screen.getByRole('option', {name: 'Codex'}).id,
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
// A press does nothing: the value stays and the list stays open.
|
|
342
|
+
await userEvent.click(gemini)
|
|
343
|
+
await expect(trigger).toHaveTextContent(/Claude/)
|
|
344
|
+
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
345
|
+
|
|
346
|
+
// Escape from the trigger closes it, and the close is reported too.
|
|
347
|
+
trigger.focus()
|
|
348
|
+
await userEvent.keyboard('{Escape}')
|
|
349
|
+
await waitFor(() => expect(onAgentOpenChange).toHaveBeenLastCalledWith(false))
|
|
350
|
+
},
|
|
351
|
+
}
|
|
@@ -21,11 +21,27 @@ interface RootProps {
|
|
|
21
21
|
value?: string
|
|
22
22
|
onValueChange?: (value: string | null) => void
|
|
23
23
|
initialLabels?: Record<string, string>
|
|
24
|
+
/** Called when the list opens or closes (to start work only once someone is choosing). */
|
|
25
|
+
onOpenChange?: (open: boolean) => void
|
|
24
26
|
children: ReactNode
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
function Root({
|
|
28
|
-
|
|
29
|
+
function Root({
|
|
30
|
+
name,
|
|
31
|
+
defaultValue,
|
|
32
|
+
value,
|
|
33
|
+
onValueChange,
|
|
34
|
+
initialLabels,
|
|
35
|
+
onOpenChange,
|
|
36
|
+
children,
|
|
37
|
+
}: RootProps) {
|
|
38
|
+
const {ctx, rootRef} = useSelectRoot({
|
|
39
|
+
defaultValue,
|
|
40
|
+
value,
|
|
41
|
+
onValueChange,
|
|
42
|
+
initialLabels,
|
|
43
|
+
onOpenChange,
|
|
44
|
+
})
|
|
29
45
|
|
|
30
46
|
return (
|
|
31
47
|
<SelectContext.Provider value={ctx}>
|
|
@@ -217,10 +233,12 @@ function Popup({children, label, empty}: PopupProps) {
|
|
|
217
233
|
// --- Item ---
|
|
218
234
|
interface ItemProps {
|
|
219
235
|
value: string
|
|
236
|
+
/** Shown but not choosable: skipped by the arrow keys, inert to a press, announced as disabled. */
|
|
237
|
+
disabled?: boolean
|
|
220
238
|
children: ReactNode
|
|
221
239
|
}
|
|
222
240
|
|
|
223
|
-
function Item({value: itemValue, children}: ItemProps) {
|
|
241
|
+
function Item({value: itemValue, disabled = false, children}: ItemProps) {
|
|
224
242
|
const {
|
|
225
243
|
value: selectedValue,
|
|
226
244
|
setValue,
|
|
@@ -246,10 +264,11 @@ function Item({value: itemValue, children}: ItemProps) {
|
|
|
246
264
|
useEffect(() => {
|
|
247
265
|
const el = ref.current
|
|
248
266
|
if (!el) return
|
|
249
|
-
return registerItem(id, itemValue, el)
|
|
250
|
-
}, [id, itemValue, registerItem])
|
|
267
|
+
return registerItem(id, itemValue, el, disabled)
|
|
268
|
+
}, [disabled, id, itemValue, registerItem])
|
|
251
269
|
|
|
252
270
|
const handleClick = () => {
|
|
271
|
+
if (disabled) return
|
|
253
272
|
setValue(itemValue)
|
|
254
273
|
close()
|
|
255
274
|
}
|
|
@@ -260,12 +279,14 @@ function Item({value: itemValue, children}: ItemProps) {
|
|
|
260
279
|
id={id}
|
|
261
280
|
role="option"
|
|
262
281
|
aria-selected={isSelected}
|
|
282
|
+
aria-disabled={disabled || undefined}
|
|
263
283
|
onClick={handleClick}
|
|
264
|
-
onPointerEnter={() => setHighlightedId(id)}
|
|
284
|
+
onPointerEnter={disabled ? undefined : () => setHighlightedId(id)}
|
|
265
285
|
style={[
|
|
266
286
|
styles.item,
|
|
267
287
|
isSelected && styles.itemSelected,
|
|
268
288
|
isHighlighted && styles.itemHighlighted,
|
|
289
|
+
disabled && styles.itemDisabled,
|
|
269
290
|
]}
|
|
270
291
|
>
|
|
271
292
|
{children}
|