@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.22.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.22.0"
|
|
96
96
|
},
|
|
97
97
|
"devDependencies": {
|
|
98
98
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -2,7 +2,7 @@ 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.',
|
|
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.',
|
|
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",
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import {ScrollView} from 'react-native'
|
|
2
|
+
import {html} from 'react-strict-dom'
|
|
3
|
+
import {typePresets} from '@duro-app/tokens/tokens/type-presets.css'
|
|
4
|
+
import {DiffLines} from './DiffLines'
|
|
5
|
+
import {textOf} from './diffRoles'
|
|
6
|
+
import {styles} from './styles.css'
|
|
7
|
+
import type {CodeBlockProps} from './types'
|
|
8
|
+
|
|
9
|
+
export type {CodeBlockProps} from './types'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Native CodeBlock: selectable monospace text that scrolls sideways, with the
|
|
13
|
+
* same diff line roles as the web. The copy button is web only: a phone
|
|
14
|
+
* copies by selecting the text, so the labels and `onCopyError` are unused.
|
|
15
|
+
*/
|
|
16
|
+
export function CodeBlock({children, language}: CodeBlockProps) {
|
|
17
|
+
const text = textOf(children)
|
|
18
|
+
return (
|
|
19
|
+
<html.div style={styles.root}>
|
|
20
|
+
<ScrollView horizontal>
|
|
21
|
+
<html.div style={styles.scroll}>
|
|
22
|
+
{language === 'diff' ? (
|
|
23
|
+
<html.div style={[typePresets.code, styles.text]}>
|
|
24
|
+
<DiffLines text={text} />
|
|
25
|
+
</html.div>
|
|
26
|
+
) : (
|
|
27
|
+
<html.span style={[typePresets.code, styles.text]}>{text}</html.span>
|
|
28
|
+
)}
|
|
29
|
+
</html.div>
|
|
30
|
+
</ScrollView>
|
|
31
|
+
</html.div>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -5,6 +5,7 @@ import {CodeBlock} from './CodeBlock'
|
|
|
5
5
|
import {onThemeSurface} from '../../docs/themedSurface'
|
|
6
6
|
import {withCoarsePointer, withFinePointer} from '../../docs/coarsePointer'
|
|
7
7
|
import {SIZES_PX} from '@duro-app/tokens/keys'
|
|
8
|
+
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
8
9
|
|
|
9
10
|
interface CodeBlockArgs {
|
|
10
11
|
/** The code shown (and copied). */
|
|
@@ -51,6 +52,10 @@ const TEST_ONLY = ['!dev', '!autodocs']
|
|
|
51
52
|
|
|
52
53
|
const styles = css.create({
|
|
53
54
|
half: {maxWidth: '50%'},
|
|
55
|
+
// Probes: the token colours a diff line should resolve to, set independently of CodeBlock.
|
|
56
|
+
successProbe: {backgroundColor: colors.successBg},
|
|
57
|
+
errorProbe: {backgroundColor: colors.errorBg},
|
|
58
|
+
infoProbe: {backgroundColor: colors.infoBg},
|
|
54
59
|
})
|
|
55
60
|
|
|
56
61
|
/**
|
|
@@ -190,3 +195,52 @@ export const CopyRefusedWithoutHandler: Story = {
|
|
|
190
195
|
}
|
|
191
196
|
},
|
|
192
197
|
}
|
|
198
|
+
|
|
199
|
+
const DIFF = '@@ -1,3 +1,3 @@\n context\n-old line\n+new line\n'
|
|
200
|
+
|
|
201
|
+
/** `language="diff"`: added, removed and hunk lines are tinted from the status tokens. */
|
|
202
|
+
export const Diff: Story = {
|
|
203
|
+
args: {code: DIFF},
|
|
204
|
+
render: ({code, ...args}) => (
|
|
205
|
+
<CodeBlock {...args} language="diff">
|
|
206
|
+
<html.code>{code}</html.code>
|
|
207
|
+
</CodeBlock>
|
|
208
|
+
),
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Diff's checks: each line carries its role, the language is exposed, copy keeps the raw text. */
|
|
212
|
+
export const DiffChecks: Story = {
|
|
213
|
+
...Diff,
|
|
214
|
+
tags: TEST_ONLY,
|
|
215
|
+
render: ({code, ...args}) => (
|
|
216
|
+
<>
|
|
217
|
+
<CodeBlock {...args} language="diff">
|
|
218
|
+
<html.code>{code}</html.code>
|
|
219
|
+
</CodeBlock>
|
|
220
|
+
<html.div data-testid="success-probe" style={styles.successProbe} />
|
|
221
|
+
<html.div data-testid="error-probe" style={styles.errorProbe} />
|
|
222
|
+
<html.div data-testid="info-probe" style={styles.infoProbe} />
|
|
223
|
+
</>
|
|
224
|
+
),
|
|
225
|
+
play: async ({args, canvasElement, canvas, userEvent}) => {
|
|
226
|
+
const role = (name: string) => canvasElement.querySelector(`[data-diff-line="${name}"]`)
|
|
227
|
+
await expect(canvasElement.querySelector('pre')).toHaveAttribute('data-language', 'diff')
|
|
228
|
+
await expect(role('hunk')).toHaveTextContent('@@ -1,3 +1,3 @@')
|
|
229
|
+
await expect(role('removed')).toHaveTextContent('-old line')
|
|
230
|
+
await expect(role('added')).toHaveTextContent('+new line')
|
|
231
|
+
const probe = (testId: string) =>
|
|
232
|
+
getComputedStyle(canvasElement.querySelector(`[data-testid="${testId}"]`)!).backgroundColor
|
|
233
|
+
const tint = (name: string) => getComputedStyle(role(name)!).backgroundColor
|
|
234
|
+
await expect(tint('added')).toBe(probe('success-probe'))
|
|
235
|
+
await expect(tint('removed')).toBe(probe('error-probe'))
|
|
236
|
+
await expect(tint('hunk')).toBe(probe('info-probe'))
|
|
237
|
+
await expect(tint('added')).not.toBe(tint('removed'))
|
|
238
|
+
const write = spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined)
|
|
239
|
+
try {
|
|
240
|
+
await userEvent.click(canvas.getByRole('button', {name: args.copyLabel}))
|
|
241
|
+
await expect(write).toHaveBeenCalledWith(args.code)
|
|
242
|
+
} finally {
|
|
243
|
+
write.mockRestore()
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
}
|
|
@@ -1,28 +1,14 @@
|
|
|
1
|
-
import {useCallback, useEffect, useRef, useState
|
|
1
|
+
import {useCallback, useEffect, useRef, useState} from 'react'
|
|
2
2
|
import {html} from 'react-strict-dom'
|
|
3
3
|
import {Button} from '../Button/Button'
|
|
4
4
|
import {LiveRegion} from '../LiveRegion/LiveRegion'
|
|
5
5
|
import {typePresets} from '@duro-app/tokens/tokens/type-presets.css'
|
|
6
6
|
import {styles} from './styles.css'
|
|
7
|
+
import type {CodeBlockProps} from './types'
|
|
8
|
+
import {DiffLines} from './DiffLines'
|
|
9
|
+
import {textOf} from './diffRoles'
|
|
7
10
|
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* The code: text, or the `<code>` element a Markdown renderer produced
|
|
11
|
-
* (highlighted spans included). What is copied is its text content.
|
|
12
|
-
*/
|
|
13
|
-
children: ReactNode
|
|
14
|
-
/** The copy button's label ("Copy"), from your text catalog. */
|
|
15
|
-
copyLabel: string
|
|
16
|
-
/**
|
|
17
|
-
* The label while the copy is confirmed ("Copied"), also announced to
|
|
18
|
-
* screen readers.
|
|
19
|
-
*/
|
|
20
|
-
copiedLabel: string
|
|
21
|
-
/** How long the confirmation stays, in milliseconds. Default 2000. */
|
|
22
|
-
copiedDuration?: number
|
|
23
|
-
/** Called when the clipboard refuses the write (an insecure context, a denied permission). */
|
|
24
|
-
onCopyError?: (error: unknown) => void
|
|
25
|
-
}
|
|
11
|
+
export type {CodeBlockProps} from './types'
|
|
26
12
|
|
|
27
13
|
/**
|
|
28
14
|
* CodeBlock — a block of code with a copy button: a `pre` that scrolls
|
|
@@ -33,6 +19,7 @@ interface CodeBlockProps {
|
|
|
33
19
|
*/
|
|
34
20
|
export function CodeBlock({
|
|
35
21
|
children,
|
|
22
|
+
language,
|
|
36
23
|
copyLabel,
|
|
37
24
|
copiedLabel,
|
|
38
25
|
copiedDuration = 2000,
|
|
@@ -89,9 +76,10 @@ export function CodeBlock({
|
|
|
89
76
|
<html.pre
|
|
90
77
|
ref={preRef}
|
|
91
78
|
tabIndex={scrollable ? 0 : undefined}
|
|
79
|
+
data-language={language}
|
|
92
80
|
style={[typePresets.code, styles.pre]}
|
|
93
81
|
>
|
|
94
|
-
{children}
|
|
82
|
+
{language === 'diff' ? <DiffLines text={textOf(children)} /> : children}
|
|
95
83
|
</html.pre>
|
|
96
84
|
<LiveRegion visuallyHidden>{copied ? copiedLabel : ''}</LiveRegion>
|
|
97
85
|
</html.div>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {Fragment} from 'react'
|
|
2
|
+
import {html} from 'react-strict-dom'
|
|
3
|
+
import {splitDiffLines, type DiffLineRole} from './diffRoles'
|
|
4
|
+
import {styles} from './styles.css'
|
|
5
|
+
|
|
6
|
+
const roleStyle = {
|
|
7
|
+
added: styles.added,
|
|
8
|
+
removed: styles.removed,
|
|
9
|
+
hunk: styles.hunk,
|
|
10
|
+
context: undefined,
|
|
11
|
+
} as const satisfies Record<DiffLineRole, unknown>
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The lines of a unified diff, each tagged with its role (`data-diff-line`)
|
|
15
|
+
* and coloured from the success, error and info subtle tokens. The `+`/`-`
|
|
16
|
+
* characters stay in the text, so colour is never the only signal.
|
|
17
|
+
*/
|
|
18
|
+
export function DiffLines({text}: {text: string}) {
|
|
19
|
+
return (
|
|
20
|
+
<Fragment>
|
|
21
|
+
{splitDiffLines(text).map((line, index) => (
|
|
22
|
+
<html.span
|
|
23
|
+
key={index}
|
|
24
|
+
data-diff-line={line.role}
|
|
25
|
+
style={[styles.line, roleStyle[line.role]]}
|
|
26
|
+
>
|
|
27
|
+
{line.text}
|
|
28
|
+
</html.span>
|
|
29
|
+
))}
|
|
30
|
+
</Fragment>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {Children, isValidElement, type ReactNode} from 'react'
|
|
2
|
+
|
|
3
|
+
/** What a line of a unified diff is, for its colour. */
|
|
4
|
+
export type DiffLineRole = 'added' | 'removed' | 'hunk' | 'context'
|
|
5
|
+
|
|
6
|
+
export interface DiffLine {
|
|
7
|
+
/** The line with its newline, so the lines joined are the raw text. */
|
|
8
|
+
text: string
|
|
9
|
+
role: DiffLineRole
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** `+++ b/file` and `--- a/file` are file headers, not changes. */
|
|
13
|
+
export function diffLineRole(line: string): DiffLineRole {
|
|
14
|
+
if (line.startsWith('@@')) return 'hunk'
|
|
15
|
+
if (line.startsWith('+++') || line.startsWith('---')) return 'context'
|
|
16
|
+
if (line.startsWith('+')) return 'added'
|
|
17
|
+
if (line.startsWith('-')) return 'removed'
|
|
18
|
+
return 'context'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function splitDiffLines(text: string): DiffLine[] {
|
|
22
|
+
const parts = text.split('\n')
|
|
23
|
+
const last = parts.length - 1
|
|
24
|
+
const lines: DiffLine[] = []
|
|
25
|
+
parts.forEach((part, index) => {
|
|
26
|
+
if (index === last && part === '') return
|
|
27
|
+
lines.push({text: index === last ? part : `${part}\n`, role: diffLineRole(part)})
|
|
28
|
+
})
|
|
29
|
+
return lines
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The text content of a node: strings, numbers and the elements around them. */
|
|
33
|
+
export function textOf(node: ReactNode): string {
|
|
34
|
+
if (typeof node === 'string' || typeof node === 'number') return String(node)
|
|
35
|
+
if (Array.isArray(node)) return node.map(textOf).join('')
|
|
36
|
+
if (isValidElement<{children?: ReactNode}>(node)) {
|
|
37
|
+
return Children.toArray(node.props.children).map(textOf).join('')
|
|
38
|
+
}
|
|
39
|
+
return ''
|
|
40
|
+
}
|
|
@@ -45,4 +45,21 @@ export const styles = css.create({
|
|
|
45
45
|
outlineColor: {default: 'transparent', ':focus-visible': colors.accent},
|
|
46
46
|
outlineOffset: {default: 0, ':focus-visible': borders.focusOffset},
|
|
47
47
|
},
|
|
48
|
+
// A diff line fills the block's width, so its tint is a band, not a patch.
|
|
49
|
+
line: {display: 'block'},
|
|
50
|
+
added: {backgroundColor: colors.successBg},
|
|
51
|
+
removed: {backgroundColor: colors.errorBg},
|
|
52
|
+
hunk: {backgroundColor: colors.infoBg, color: colors.infoText},
|
|
53
|
+
// Native: the code scrolls sideways inside the block.
|
|
54
|
+
scroll: {
|
|
55
|
+
paddingTop: spacing.xs,
|
|
56
|
+
paddingBottom: spacing.md,
|
|
57
|
+
paddingLeft: spacing.md,
|
|
58
|
+
paddingRight: spacing.md,
|
|
59
|
+
},
|
|
60
|
+
text: {
|
|
61
|
+
color: colors.text,
|
|
62
|
+
userSelect: 'text',
|
|
63
|
+
whiteSpace: 'pre',
|
|
64
|
+
},
|
|
48
65
|
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type {ReactNode} from 'react'
|
|
2
|
+
|
|
3
|
+
export interface CodeBlockProps {
|
|
4
|
+
/**
|
|
5
|
+
* The code: text, or the `<code>` element a Markdown renderer produced
|
|
6
|
+
* (highlighted spans included). What is copied is its text content.
|
|
7
|
+
*/
|
|
8
|
+
children: ReactNode
|
|
9
|
+
/**
|
|
10
|
+
* The code's language ("diff", "ts"), exposed as `data-language` on the
|
|
11
|
+
* `pre`. `"diff"` also gives each added (`+`), removed (`-`) and hunk (`@@`)
|
|
12
|
+
* line its own colour; what is copied stays the raw text. Diff rendering
|
|
13
|
+
* works on the text content, so highlighted spans inside `children` are
|
|
14
|
+
* flattened to plain text. The language is a data attribute only: it is not
|
|
15
|
+
* announced to assistive technology.
|
|
16
|
+
*/
|
|
17
|
+
language?: string
|
|
18
|
+
/** The copy button's label ("Copy"), from your text catalog. */
|
|
19
|
+
copyLabel: string
|
|
20
|
+
/**
|
|
21
|
+
* The label while the copy is confirmed ("Copied"), also announced to
|
|
22
|
+
* screen readers.
|
|
23
|
+
*/
|
|
24
|
+
copiedLabel: string
|
|
25
|
+
/** How long the confirmation stays, in milliseconds. Default 2000. */
|
|
26
|
+
copiedDuration?: number
|
|
27
|
+
/** Called when the clipboard refuses the write (an insecure context, a denied permission). */
|
|
28
|
+
onCopyError?: (error: unknown) => void
|
|
29
|
+
}
|
|
@@ -18,9 +18,23 @@ export async function cdpSend(method: string, params: object) {
|
|
|
18
18
|
return session.send(method, params)
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/**
|
|
21
|
+
/** The refusal `vitest/browser` throws when loaded outside the test runner. */
|
|
22
|
+
const BROWSER_MODE_ONLY = 'can be imported only inside the Browser Mode'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Set the page's viewport under the test runner. In dev Storybook `vitest/browser`
|
|
26
|
+
* refuses to load (that refusal only); there the story keeps the browser's own
|
|
27
|
+
* viewport instead of failing to render. Any other import failure is thrown.
|
|
28
|
+
*/
|
|
22
29
|
export async function setViewport(width: number, height: number) {
|
|
23
|
-
|
|
30
|
+
let page: {viewport: (w: number, h: number) => Promise<unknown>}
|
|
31
|
+
try {
|
|
32
|
+
;({page} = await import('vitest/browser'))
|
|
33
|
+
} catch (error) {
|
|
34
|
+
// Only the dev-Storybook refusal is expected; anything else is a real failure.
|
|
35
|
+
if (error instanceof Error && error.message.includes(BROWSER_MODE_ONLY)) return
|
|
36
|
+
throw error
|
|
37
|
+
}
|
|
24
38
|
await page.viewport(width, height)
|
|
25
39
|
}
|
|
26
40
|
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
"The container for HTML Duro does not author — rendered Markdown, CMS output: headings, paragraphs, lists, tables, blockquotes, inline code, links and images are styled from tokens by descendant rules scoped to it (plain CSS in dist/index.css, :where() and its own cascade layer). Duro components inside it keep their styles, and an app's own CSS wins over every Prose rule.
|
|
5
|
+
"The container for HTML Duro does not author — rendered Markdown, CMS output: headings, paragraphs, lists, tables, blockquotes, inline code, links and images are styled from tokens by descendant rules scoped to it (plain CSS in dist/index.css, :where() and its own cascade layer). Duro components inside it keep their styles, and an app's own CSS wins over every Prose rule. On native it is a reading-width column with a token gap; the renderer maps Markdown to Duro primitives.",
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'The body of a docs page rendered from Markdown',
|
|
8
8
|
'Any HTML produced by a renderer you do not write as JSX',
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type {ReactNode, Ref} from 'react'
|
|
2
|
+
import {html} from 'react-strict-dom'
|
|
3
|
+
import {styles} from './styles.native.css'
|
|
4
|
+
|
|
5
|
+
interface ProseProps {
|
|
6
|
+
/** Duro primitives (Heading, Text, TextLink, CodeBlock) a Markdown renderer produced. */
|
|
7
|
+
children: ReactNode
|
|
8
|
+
/** The container element. */
|
|
9
|
+
ref?: Ref<HTMLDivElement>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Native Prose: the reading container. Native has no descendant CSS, so it
|
|
14
|
+
* styles nothing inside it: a renderer maps Markdown to Duro primitives, and
|
|
15
|
+
* Prose gives them one column at reading width with a token gap between blocks.
|
|
16
|
+
*/
|
|
17
|
+
export function Prose({children, ref}: ProseProps) {
|
|
18
|
+
return (
|
|
19
|
+
<html.div ref={ref} style={styles.root}>
|
|
20
|
+
{children}
|
|
21
|
+
</html.div>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
@@ -14,7 +14,7 @@ interface ProseProps {
|
|
|
14
14
|
* output): headings, paragraphs, lists, tables, blockquotes, inline code,
|
|
15
15
|
* links and images are styled from tokens by descendant rules scoped to it.
|
|
16
16
|
* Duro components inside it (a TextLink, a CodeBlock) keep their own styles,
|
|
17
|
-
* and an app's own CSS wins over every Prose rule.
|
|
17
|
+
* and an app's own CSS wins over every Prose rule. Native: Prose.native.tsx.
|
|
18
18
|
*/
|
|
19
19
|
export function Prose({children, ref}: ProseProps) {
|
|
20
20
|
return (
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
import {sizes} from '@duro-app/tokens/tokens/sizes.css'
|
|
3
|
+
import {spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
|
+
|
|
5
|
+
export const styles = css.create({
|
|
6
|
+
// One column at reading width, one gap between blocks.
|
|
7
|
+
root: {
|
|
8
|
+
display: 'flex',
|
|
9
|
+
flexDirection: 'column',
|
|
10
|
+
gap: spacing.md,
|
|
11
|
+
width: '100%',
|
|
12
|
+
maxWidth: sizes.panelLg,
|
|
13
|
+
minWidth: 0,
|
|
14
|
+
},
|
|
15
|
+
})
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Two panes side by side with a resizable separator: the first Pane is sized by the Handle (pointer drag, arrow keys, Shift for larger steps, Home and End), the second takes the rest. Min, max and starting widths are size tokens. Below collapseBelow, measured on its own width, the panes stack in one column and the handle goes; on native they always stack. The Handle is a hairline that thickens to the accent on hover, keyboard focus or drag.',
|
|
5
|
+
'Two panes side by side with a resizable separator: the first Pane is sized by the Handle (pointer drag, arrow keys, Shift for larger steps, Home and End), the second takes the rest. Min, max and starting widths are size tokens. Below collapseBelow, measured on its own width, the panes stack in one column and the handle goes; on native they always stack. A controlled collapsed prop shrinks the first pane to a strip (the iconButton size) that shows collapsedContent, removes the Handle, and keeps the first pane’s own children mounted but inert and hidden (state kept, no tab stops, out of the accessibility tree); the prior width returns when it turns false. Native ignores both. The Handle is a hairline that thickens to the accent on hover, keyboard focus or drag.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'A list beside its detail where people need more room on either side (a session list and its transcript)',
|
|
8
8
|
'An editor beside its preview',
|
|
@@ -18,6 +18,10 @@ interface RootProps {
|
|
|
18
18
|
maxSize?: SizeToken
|
|
19
19
|
/** Web only: a phone is below every breakpoint, so native always stacks. */
|
|
20
20
|
collapseBelow?: Breakpoint
|
|
21
|
+
/** Web only: native always stacks, so there is no strip to collapse to. */
|
|
22
|
+
collapsed?: boolean
|
|
23
|
+
/** Web only: shown in the collapsed strip. */
|
|
24
|
+
collapsedContent?: ReactNode
|
|
21
25
|
/** The handle's accessible name (web only: native has no handle). */
|
|
22
26
|
'aria-label': string
|
|
23
27
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
2
|
import {expect, fn, userEvent, waitFor} from 'storybook/test'
|
|
3
|
+
import {useState, type ReactNode} from 'react'
|
|
3
4
|
import {css, html} from 'react-strict-dom'
|
|
4
5
|
import {SIZES_PX, SPACING_PX, type SizeToken} from '@duro-app/tokens/keys'
|
|
5
6
|
import type {Breakpoint} from '@duro-app/tokens/tokens/breakpoints.css'
|
|
@@ -9,6 +10,10 @@ import {List} from '../List/List'
|
|
|
9
10
|
import {Panel} from '../Panel/Panel'
|
|
10
11
|
import {Heading} from '../Heading/Heading'
|
|
11
12
|
import {Text} from '../Text/Text'
|
|
13
|
+
import {Input} from '../Input/Input'
|
|
14
|
+
import {Button} from '../Button/Button'
|
|
15
|
+
import {Icon} from '../Icon/Icon'
|
|
16
|
+
import {VirtualList} from '../VirtualList/VirtualList'
|
|
12
17
|
|
|
13
18
|
const meta: Meta = {
|
|
14
19
|
title: 'Components/SplitPane',
|
|
@@ -29,6 +34,8 @@ interface SplitArgs {
|
|
|
29
34
|
maxSize: SizeToken
|
|
30
35
|
collapseBelow: Breakpoint
|
|
31
36
|
onSizeChange?: (px: number) => void
|
|
37
|
+
collapsed?: boolean
|
|
38
|
+
collapsedContent?: ReactNode
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
const DEFAULT_ARGS: SplitArgs = {
|
|
@@ -43,7 +50,11 @@ const PANE_SIZES: SizeToken[] = ['sidebarW', 'panelSm', 'panelMd', 'panelLg']
|
|
|
43
50
|
|
|
44
51
|
/** The picked B0 direction (docs/mockups/split-pane/Hairline.dc.html). */
|
|
45
52
|
function Split(props: Partial<SplitArgs>) {
|
|
46
|
-
const {defaultSize, minSize, maxSize, collapseBelow, onSizeChange
|
|
53
|
+
const {defaultSize, minSize, maxSize, collapseBelow, onSizeChange, collapsed, collapsedContent} =
|
|
54
|
+
{
|
|
55
|
+
...DEFAULT_ARGS,
|
|
56
|
+
...props,
|
|
57
|
+
}
|
|
47
58
|
return (
|
|
48
59
|
<html.div style={frame.fill}>
|
|
49
60
|
<SplitPane.Root
|
|
@@ -53,8 +64,11 @@ function Split(props: Partial<SplitArgs>) {
|
|
|
53
64
|
maxSize={maxSize}
|
|
54
65
|
collapseBelow={collapseBelow}
|
|
55
66
|
onSizeChange={onSizeChange}
|
|
67
|
+
collapsed={collapsed}
|
|
68
|
+
collapsedContent={collapsedContent}
|
|
56
69
|
>
|
|
57
70
|
<SplitPane.Pane>
|
|
71
|
+
<Button variant="secondary">Filter sessions</Button>
|
|
58
72
|
<List.Root aria-label="Sessions">
|
|
59
73
|
{SESSIONS.map((s) => (
|
|
60
74
|
<List.Item key={s}>
|
|
@@ -146,3 +160,195 @@ export const NarrowStacksInOneColumn: Story = {
|
|
|
146
160
|
)
|
|
147
161
|
},
|
|
148
162
|
}
|
|
163
|
+
|
|
164
|
+
function Collapsible() {
|
|
165
|
+
const [collapsed, setCollapsed] = useState(false)
|
|
166
|
+
return (
|
|
167
|
+
<>
|
|
168
|
+
<Button variant="secondary" onClick={() => setCollapsed((c) => !c)}>
|
|
169
|
+
Toggle sessions
|
|
170
|
+
</Button>
|
|
171
|
+
<Split
|
|
172
|
+
collapsed={collapsed}
|
|
173
|
+
collapsedContent={
|
|
174
|
+
// As in the Focus artboard: an icon button that fits the strip.
|
|
175
|
+
<Button
|
|
176
|
+
variant="secondary"
|
|
177
|
+
size="small"
|
|
178
|
+
aria-label="Expand sessions"
|
|
179
|
+
onClick={() => setCollapsed(false)}
|
|
180
|
+
>
|
|
181
|
+
<Icon name="menu" size="sm" />
|
|
182
|
+
</Button>
|
|
183
|
+
}
|
|
184
|
+
/>
|
|
185
|
+
</>
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* `collapsed`: the first pane shrinks to a strip holding `collapsedContent`, the
|
|
191
|
+
* handle goes away, and expanding restores the width it had.
|
|
192
|
+
*/
|
|
193
|
+
export const Collapsed: Story = {
|
|
194
|
+
render: () => <Collapsible />,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export const CollapsedShowsTheStrip: Story = {
|
|
198
|
+
render: () => <Collapsible />,
|
|
199
|
+
beforeEach: async () => {
|
|
200
|
+
await setViewport(1200, 800)
|
|
201
|
+
},
|
|
202
|
+
play: async ({canvas}) => {
|
|
203
|
+
await waitFor(() => expect(separator()).not.toBeNull())
|
|
204
|
+
const handle = separator()!
|
|
205
|
+
const primary = document.getElementById(handle.getAttribute('aria-controls')!)!
|
|
206
|
+
const toggle = canvas.getByRole('button', {name: 'Toggle sessions'})
|
|
207
|
+
await waitFor(() =>
|
|
208
|
+
expect(primary.getBoundingClientRect().width).toBeCloseTo(SIZES_PX.sidebarW, 0),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
// widen the pane, then collapse
|
|
212
|
+
handle.focus()
|
|
213
|
+
await userEvent.keyboard('{ArrowRight}')
|
|
214
|
+
const widened = SIZES_PX.sidebarW + SPACING_PX.md
|
|
215
|
+
await waitFor(() => expect(primary.getBoundingClientRect().width).toBeCloseTo(widened, 0))
|
|
216
|
+
|
|
217
|
+
await userEvent.click(toggle)
|
|
218
|
+
await waitFor(() =>
|
|
219
|
+
expect(primary.getBoundingClientRect().width).toBeCloseTo(SIZES_PX.iconButton, 0),
|
|
220
|
+
)
|
|
221
|
+
// the handle is not rendered; the pane's own children are inert and out of the a11y tree
|
|
222
|
+
await expect(separator()).toBeNull()
|
|
223
|
+
await expect(canvas.queryByRole('separator')).toBeNull()
|
|
224
|
+
await expect(canvas.queryByRole('button', {name: 'Filter sessions'})).toBeNull()
|
|
225
|
+
|
|
226
|
+
// Tab from the toggle never reaches a control inside the collapsed children,
|
|
227
|
+
// but the strip's own button is reachable
|
|
228
|
+
toggle.focus()
|
|
229
|
+
const reached = new Set<Element>()
|
|
230
|
+
for (let i = 0; i < 6; i++) {
|
|
231
|
+
await userEvent.tab()
|
|
232
|
+
if (document.activeElement) reached.add(document.activeElement)
|
|
233
|
+
}
|
|
234
|
+
const filter = document.evaluate(
|
|
235
|
+
"//button[contains(., 'Filter sessions')]",
|
|
236
|
+
document,
|
|
237
|
+
null,
|
|
238
|
+
XPathResult.FIRST_ORDERED_NODE_TYPE,
|
|
239
|
+
null,
|
|
240
|
+
).singleNodeValue
|
|
241
|
+
await expect(filter).not.toBeNull()
|
|
242
|
+
await expect(reached.has(filter as Element)).toBe(false)
|
|
243
|
+
await expect(reached.has(canvas.getByRole('button', {name: 'Expand sessions'}))).toBe(true)
|
|
244
|
+
|
|
245
|
+
// expanded again: the prior size returns and the handle is back
|
|
246
|
+
await userEvent.click(toggle)
|
|
247
|
+
await waitFor(() => expect(primary.getBoundingClientRect().width).toBeCloseTo(widened, 0))
|
|
248
|
+
await expect(separator()).toHaveAttribute('aria-valuenow', String(widened))
|
|
249
|
+
await expect(canvas.getByRole('button', {name: 'Filter sessions'})).toBeVisible()
|
|
250
|
+
|
|
251
|
+
// the re-mounted handle resizes by keyboard again
|
|
252
|
+
const again = separator()!
|
|
253
|
+
again.focus()
|
|
254
|
+
await userEvent.keyboard('{ArrowRight}')
|
|
255
|
+
await waitFor(() =>
|
|
256
|
+
expect(primary.getBoundingClientRect().width).toBeCloseTo(widened + SPACING_PX.md, 0),
|
|
257
|
+
)
|
|
258
|
+
},
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function KeepsState() {
|
|
262
|
+
const [collapsed, setCollapsed] = useState(false)
|
|
263
|
+
return (
|
|
264
|
+
<>
|
|
265
|
+
<Button variant="secondary" onClick={() => setCollapsed((c) => !c)}>
|
|
266
|
+
Toggle sessions
|
|
267
|
+
</Button>
|
|
268
|
+
<html.div style={frame.fill}>
|
|
269
|
+
<SplitPane.Root
|
|
270
|
+
aria-label="Resize the session list"
|
|
271
|
+
collapsed={collapsed}
|
|
272
|
+
collapsedContent={
|
|
273
|
+
<Button variant="secondary" size="small" aria-label="Expand sessions">
|
|
274
|
+
<Icon name="menu" size="sm" />
|
|
275
|
+
</Button>
|
|
276
|
+
}
|
|
277
|
+
>
|
|
278
|
+
<SplitPane.Pane>
|
|
279
|
+
<Input aria-label="Search sessions" />
|
|
280
|
+
</SplitPane.Pane>
|
|
281
|
+
<SplitPane.Handle />
|
|
282
|
+
<SplitPane.Pane>
|
|
283
|
+
<Text>Detail</Text>
|
|
284
|
+
</SplitPane.Pane>
|
|
285
|
+
</SplitPane.Root>
|
|
286
|
+
</html.div>
|
|
287
|
+
</>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export const CollapseKeepsChildState: Story = {
|
|
292
|
+
render: () => <KeepsState />,
|
|
293
|
+
beforeEach: async () => {
|
|
294
|
+
await setViewport(1200, 800)
|
|
295
|
+
},
|
|
296
|
+
play: async ({canvas}) => {
|
|
297
|
+
const field = await canvas.findByRole('textbox', {name: 'Search sessions'})
|
|
298
|
+
await userEvent.type(field, 'auth')
|
|
299
|
+
await expect(field).toHaveValue('auth')
|
|
300
|
+
|
|
301
|
+
const toggle = canvas.getByRole('button', {name: 'Toggle sessions'})
|
|
302
|
+
await userEvent.click(toggle)
|
|
303
|
+
await waitFor(() => expect(separator()).toBeNull())
|
|
304
|
+
await userEvent.click(toggle)
|
|
305
|
+
await waitFor(() => expect(separator()).not.toBeNull())
|
|
306
|
+
|
|
307
|
+
const after = await canvas.findByRole('textbox', {name: 'Search sessions'})
|
|
308
|
+
await expect(after).toBe(field)
|
|
309
|
+
await expect(after).toHaveValue('auth')
|
|
310
|
+
},
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const ROWS = Array.from({length: 500}, (_, i) => ({id: i, text: `Row ${i + 1}`}))
|
|
314
|
+
|
|
315
|
+
const list = (name: string) => (
|
|
316
|
+
<VirtualList
|
|
317
|
+
aria-label={name}
|
|
318
|
+
items={ROWS}
|
|
319
|
+
getKey={(r) => r.id}
|
|
320
|
+
estimateSize="controlLg"
|
|
321
|
+
renderItem={(r) => <Text>{r.text}</Text>}
|
|
322
|
+
/>
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
function ListsInEachPane() {
|
|
326
|
+
return (
|
|
327
|
+
<html.div style={frame.fill}>
|
|
328
|
+
<SplitPane.Root aria-label="Resize the lists">
|
|
329
|
+
<SplitPane.Pane>{list('Left')}</SplitPane.Pane>
|
|
330
|
+
<SplitPane.Handle />
|
|
331
|
+
<SplitPane.Pane>{list('Right')}</SplitPane.Pane>
|
|
332
|
+
</SplitPane.Root>
|
|
333
|
+
</html.div>
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export const VirtualListsFillTheirPanes: Story = {
|
|
338
|
+
render: () => <ListsInEachPane />,
|
|
339
|
+
beforeEach: async () => {
|
|
340
|
+
await setViewport(1200, 800)
|
|
341
|
+
},
|
|
342
|
+
play: async ({canvas}) => {
|
|
343
|
+
// Each list sits in a pane that is a direct child of the split's root.
|
|
344
|
+
const root = (await canvas.findByRole('separator')).parentElement!
|
|
345
|
+
for (const name of ['Left', 'Right']) {
|
|
346
|
+
const list = await canvas.findByRole('list', {name})
|
|
347
|
+
let pane: HTMLElement = list
|
|
348
|
+
while (pane.parentElement !== root) pane = pane.parentElement!
|
|
349
|
+
await waitFor(() => expect(list.clientHeight).toBeGreaterThan(0))
|
|
350
|
+
await waitFor(() => expect(list.clientHeight).toBe(pane.clientHeight))
|
|
351
|
+
await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight)
|
|
352
|
+
}
|
|
353
|
+
},
|
|
354
|
+
}
|