@duro-app/ui 5.20.0 → 5.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/Table-Cw1B14R3.js.map +1 -1
  2. package/dist/components/CodeBlock/CodeBlock.d.ts +3 -21
  3. package/dist/components/CodeBlock/CodeBlock.d.ts.map +1 -1
  4. package/dist/components/CodeBlock/CodeBlock.stories.d.ts +4 -0
  5. package/dist/components/CodeBlock/CodeBlock.stories.d.ts.map +1 -1
  6. package/dist/components/CodeBlock/DiffLines.d.ts +9 -0
  7. package/dist/components/CodeBlock/DiffLines.d.ts.map +1 -0
  8. package/dist/components/CodeBlock/diffRoles.d.ts +14 -0
  9. package/dist/components/CodeBlock/diffRoles.d.ts.map +1 -0
  10. package/dist/components/CodeBlock/styles.css.d.ts +24 -0
  11. package/dist/components/CodeBlock/styles.css.d.ts.map +1 -1
  12. package/dist/components/CodeBlock/types.d.ts +29 -0
  13. package/dist/components/CodeBlock/types.d.ts.map +1 -0
  14. package/dist/components/Drawer/touchInput.d.ts +5 -1
  15. package/dist/components/Drawer/touchInput.d.ts.map +1 -1
  16. package/dist/components/Heading/Heading.d.ts +10 -2
  17. package/dist/components/Heading/Heading.d.ts.map +1 -1
  18. package/dist/components/Heading/Heading.meta.d.ts.map +1 -1
  19. package/dist/components/Heading/Heading.stories.d.ts +5 -0
  20. package/dist/components/Heading/Heading.stories.d.ts.map +1 -1
  21. package/dist/components/Prose/Prose.d.ts +1 -1
  22. package/dist/components/SplitPane/SplitPane.d.ts +10 -1
  23. package/dist/components/SplitPane/SplitPane.d.ts.map +1 -1
  24. package/dist/components/SplitPane/SplitPane.stories.d.ts +11 -0
  25. package/dist/components/SplitPane/SplitPane.stories.d.ts.map +1 -1
  26. package/dist/components/SplitPane/styles.css.d.ts +22 -0
  27. package/dist/components/SplitPane/styles.css.d.ts.map +1 -1
  28. package/dist/components/Textarea/Textarea.d.ts +14 -2
  29. package/dist/components/Textarea/Textarea.d.ts.map +1 -1
  30. package/dist/components/Textarea/Textarea.meta.d.ts.map +1 -1
  31. package/dist/components/Textarea/Textarea.stories.d.ts +2 -0
  32. package/dist/components/Textarea/Textarea.stories.d.ts.map +1 -1
  33. package/dist/components/Textarea/autoGrow.d.ts +22 -0
  34. package/dist/components/Textarea/autoGrow.d.ts.map +1 -0
  35. package/dist/components/Textarea/grow.css.d.ts +12 -0
  36. package/dist/components/Textarea/grow.css.d.ts.map +1 -0
  37. package/dist/components/Textarea/grow.d.ts +2 -0
  38. package/dist/components/Textarea/grow.d.ts.map +1 -0
  39. package/dist/components/Textarea/native.css.d.ts +8 -0
  40. package/dist/components/Textarea/native.css.d.ts.map +1 -0
  41. package/dist/components/Textarea/useAutoGrowFallback.d.ts +9 -0
  42. package/dist/components/Textarea/useAutoGrowFallback.d.ts.map +1 -0
  43. package/dist/components/Toolbar/Toolbar.d.ts +2 -14
  44. package/dist/components/Toolbar/Toolbar.d.ts.map +1 -1
  45. package/dist/components/Toolbar/types.d.ts +15 -0
  46. package/dist/components/Toolbar/types.d.ts.map +1 -0
  47. package/dist/components/VirtualList/VirtualList.d.ts +5 -26
  48. package/dist/components/VirtualList/VirtualList.d.ts.map +1 -1
  49. package/dist/components/VirtualList/VirtualList.meta.d.ts.map +1 -1
  50. package/dist/components/VirtualList/VirtualList.stories.d.ts +16 -0
  51. package/dist/components/VirtualList/VirtualList.stories.d.ts.map +1 -1
  52. package/dist/components/VirtualList/native.css.d.ts +14 -0
  53. package/dist/components/VirtualList/native.css.d.ts.map +1 -0
  54. package/dist/components/VirtualList/props.d.ts +31 -0
  55. package/dist/components/VirtualList/props.d.ts.map +1 -0
  56. package/dist/index.css +1 -1
  57. package/dist/index.d.ts +1 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +4007 -3767
  60. package/dist/index.js.map +1 -1
  61. package/package.json +2 -2
  62. package/src/components/CodeBlock/CodeBlock.meta.ts +1 -1
  63. package/src/components/CodeBlock/CodeBlock.native.tsx +33 -0
  64. package/src/components/CodeBlock/CodeBlock.stories.tsx +54 -0
  65. package/src/components/CodeBlock/CodeBlock.tsx +8 -20
  66. package/src/components/CodeBlock/DiffLines.tsx +32 -0
  67. package/src/components/CodeBlock/diffRoles.ts +40 -0
  68. package/src/components/CodeBlock/styles.css.ts +17 -0
  69. package/src/components/CodeBlock/types.ts +29 -0
  70. package/src/components/Drawer/touchInput.ts +16 -2
  71. package/src/components/Heading/Heading.meta.ts +6 -3
  72. package/src/components/Heading/Heading.stories.tsx +45 -0
  73. package/src/components/Heading/Heading.tsx +51 -11
  74. package/src/components/Prose/Prose.meta.ts +1 -1
  75. package/src/components/Prose/Prose.native.tsx +23 -0
  76. package/src/components/Prose/Prose.tsx +1 -1
  77. package/src/components/Prose/styles.native.css.ts +15 -0
  78. package/src/components/SplitPane/SplitPane.meta.ts +1 -1
  79. package/src/components/SplitPane/SplitPane.native.tsx +4 -0
  80. package/src/components/SplitPane/SplitPane.stories.tsx +207 -1
  81. package/src/components/SplitPane/SplitPane.tsx +50 -7
  82. package/src/components/SplitPane/styles.css.ts +28 -0
  83. package/src/components/Text/Text.meta.ts +2 -2
  84. package/src/components/Textarea/Textarea.meta.ts +6 -2
  85. package/src/components/Textarea/Textarea.stories.tsx +136 -1
  86. package/src/components/Textarea/Textarea.tsx +50 -3
  87. package/src/components/Textarea/autoGrow.ts +33 -0
  88. package/src/components/Textarea/grow.css.ts +18 -0
  89. package/src/components/Textarea/grow.native.ts +1 -0
  90. package/src/components/Textarea/grow.ts +1 -0
  91. package/src/components/Textarea/native.css.ts +16 -0
  92. package/src/components/Textarea/useAutoGrowFallback.native.ts +5 -0
  93. package/src/components/Textarea/useAutoGrowFallback.ts +35 -0
  94. package/src/components/Toolbar/Toolbar.meta.ts +1 -1
  95. package/src/components/Toolbar/Toolbar.native.tsx +27 -0
  96. package/src/components/Toolbar/Toolbar.tsx +3 -14
  97. package/src/components/Toolbar/types.ts +15 -0
  98. package/src/components/VirtualList/VirtualList.meta.ts +2 -1
  99. package/src/components/VirtualList/VirtualList.native.tsx +80 -0
  100. package/src/components/VirtualList/VirtualList.stories.tsx +171 -1
  101. package/src/components/VirtualList/VirtualList.tsx +116 -27
  102. package/src/components/VirtualList/native.css.ts +14 -0
  103. package/src/components/VirtualList/props.ts +31 -0
  104. package/src/docs/Typography.mdx +19 -15
  105. package/src/index.native.ts +8 -1
  106. package/src/index.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "5.20.0",
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.20.0"
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, type ReactNode} from 'react'
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
- interface CodeBlockProps {
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
- /** Set the page's viewport (test runner only, like `cdpSend`). */
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
- const {page} = await import('vitest/browser')
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,11 +2,12 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Semantic heading element (h1-h6) with typography presets. Always use the correct level for document hierarchy. Visual size can differ from semantic level via the variant prop.',
5
+ 'Semantic heading element (h1-h6) with typography presets. Always use the correct level for document hierarchy. The look is `variant` (heading, display, overline) and its size is `size` (sm, md, lg, xl), both independent of the semantic level. The old combined variants (headingSm, displayLg, …) still work and are deprecated.',
6
6
  whenToUse: [
7
7
  'Page titles (level 1)',
8
8
  'Section headers (level 2-3)',
9
9
  'Card or panel titles (level 3-4)',
10
+ 'Group titles over a list of rows or a form section (level 3-4, variant="overline", any size)',
10
11
  ],
11
12
  whenNotToUse: [
12
13
  'Body text — use Text',
@@ -22,7 +23,9 @@ export const meta: ComponentMeta = {
22
23
  ],
23
24
  example: `<Stack gap="md">
24
25
  <Heading level={1}>Page Title</Heading>
25
- <Heading level={2} variant="headingMd">Section</Heading>
26
- <Heading level={3}>Subsection</Heading>
26
+ <Heading level={2} size="md">Section</Heading>
27
+ <Heading level={1} variant="display" size="lg">Hero</Heading>
28
+ <Heading level={3} variant="overline" color="muted">Group</Heading>
29
+ <Heading level={2} variant="overline" size="lg">Large group</Heading>
27
30
  </Stack>`,
28
31
  }
@@ -49,3 +49,48 @@ export const WithId: Story = {
49
49
  await expect(canvas.getByRole('link', {name: 'Install'})).toHaveAttribute('href', '#install')
50
50
  },
51
51
  }
52
+
53
+ /** `variant` is the look and `size` its size, independent of the level: a group title over a
54
+ * list of rows is an `overline` heading, at any size. */
55
+ export const Overline: Story = {
56
+ render: () => (
57
+ <Stack gap="xs">
58
+ <Heading level={3} variant="overline" color="muted">
59
+ Today
60
+ </Heading>
61
+ <Heading level={3} variant="overline" size="md" color="muted">
62
+ Earlier
63
+ </Heading>
64
+ <Heading level={2} variant="overline" size="lg">
65
+ Archive
66
+ </Heading>
67
+ </Stack>
68
+ ),
69
+ play: async ({canvas}) => {
70
+ await expect(canvas.getByRole('heading', {level: 3, name: 'Today'})).toBeInTheDocument()
71
+ await expect(canvas.getAllByRole('heading', {level: 3})).toHaveLength(2)
72
+ const sm = getComputedStyle(canvas.getByRole('heading', {name: 'Today'})).fontSize
73
+ const lg = getComputedStyle(canvas.getByRole('heading', {name: 'Archive'})).fontSize
74
+ await expect(parseFloat(lg)).toBeGreaterThan(parseFloat(sm))
75
+ },
76
+ }
77
+
78
+ /** The old combined variants still render the same preset as the two props. */
79
+ export const LegacyVariant: Story = {
80
+ render: () => (
81
+ <Stack gap="xs">
82
+ <Heading level={2} variant="headingSm">
83
+ Legacy
84
+ </Heading>
85
+ <Heading level={2} variant="heading" size="sm">
86
+ Current
87
+ </Heading>
88
+ </Stack>
89
+ ),
90
+ play: async ({canvas}) => {
91
+ const legacy = getComputedStyle(canvas.getByRole('heading', {name: 'Legacy'}))
92
+ const current = getComputedStyle(canvas.getByRole('heading', {name: 'Current'}))
93
+ await expect(legacy.fontSize).toBe(current.fontSize)
94
+ await expect(legacy.fontWeight).toBe(current.fontWeight)
95
+ },
96
+ }
@@ -3,7 +3,12 @@ import {html} from 'react-strict-dom'
3
3
  import {typePresets} from '@duro-app/tokens/tokens/type-presets.css'
4
4
  import {styles} from './styles.css'
5
5
 
6
- export type HeadingVariant =
6
+ /** The heading's look; its size is the `size` prop. */
7
+ export type HeadingFamily = 'heading' | 'display' | 'overline'
8
+ export type HeadingSize = 'sm' | 'md' | 'lg' | 'xl'
9
+
10
+ /** @deprecated Use `variant` with `size`: `headingSm` is `variant="heading" size="sm"`. */
11
+ export type HeadingLegacyVariant =
7
12
  | 'displayLg'
8
13
  | 'displayMd'
9
14
  | 'displaySm'
@@ -12,9 +17,14 @@ export type HeadingVariant =
12
17
  | 'headingMd'
13
18
  | 'headingSm'
14
19
 
20
+ export type HeadingVariant = HeadingFamily | HeadingLegacyVariant
21
+
15
22
  interface HeadingProps {
16
23
  level: 1 | 2 | 3 | 4 | 5 | 6
24
+ /** The look: `heading` (default), `display` (fluid hero titles), `overline` (small uppercase group titles). */
17
25
  variant?: HeadingVariant
26
+ /** The size within the look. Default: the level's size for `heading`, `md` for `display`, `sm` for `overline`; `display` and `overline` stop at `lg`. */
27
+ size?: HeadingSize
18
28
  color?: 'default' | 'muted' | 'accent'
19
29
  align?: 'start' | 'center' | 'end'
20
30
  /** The heading's id: an in-page link's target (`href="#id"`), an `aria-labelledby` reference. */
@@ -22,13 +32,32 @@ interface HeadingProps {
22
32
  children: ReactNode
23
33
  }
24
34
 
25
- const defaultVariantMap: Record<number, HeadingVariant> = {
26
- 1: 'headingXl',
27
- 2: 'headingLg',
28
- 3: 'headingMd',
29
- 4: 'headingSm',
30
- 5: 'headingSm',
31
- 6: 'headingSm',
35
+ type Preset = keyof typeof typePresets
36
+
37
+ const levelSize: Record<HeadingProps['level'], HeadingSize> = {
38
+ 1: 'xl',
39
+ 2: 'lg',
40
+ 3: 'md',
41
+ 4: 'sm',
42
+ 5: 'sm',
43
+ 6: 'sm',
44
+ }
45
+
46
+ const presets = {
47
+ heading: {sm: 'headingSm', md: 'headingMd', lg: 'headingLg', xl: 'headingXl'},
48
+ display: {sm: 'displaySm', md: 'displayMd', lg: 'displayLg'},
49
+ overline: {sm: 'overline', md: 'overlineMd', lg: 'overlineLg'},
50
+ } as const satisfies Record<HeadingFamily, Partial<Record<HeadingSize, Preset>>>
51
+
52
+ function presetOf(
53
+ level: HeadingProps['level'],
54
+ variant: HeadingVariant = 'heading',
55
+ size?: HeadingSize,
56
+ ): Preset {
57
+ if (variant === 'heading') return presets.heading[size ?? levelSize[level]]
58
+ if (variant === 'display') return presets.display[size === 'xl' ? 'lg' : (size ?? 'md')]
59
+ if (variant === 'overline') return presets.overline[size === 'xl' ? 'lg' : (size ?? 'sm')]
60
+ return variant
32
61
  }
33
62
 
34
63
  const alignMap = {
@@ -37,9 +66,20 @@ const alignMap = {
37
66
  end: styles.alignEnd,
38
67
  } as const
39
68
 
40
- export function Heading({level, variant, color = 'default', align, id, children}: HeadingProps) {
41
- const resolvedVariant = variant ?? defaultVariantMap[level]
42
- const style = [typePresets[resolvedVariant], styles[color], align && alignMap[align]]
69
+ export function Heading({
70
+ level,
71
+ variant,
72
+ size,
73
+ color = 'default',
74
+ align,
75
+ id,
76
+ children,
77
+ }: HeadingProps) {
78
+ const style = [
79
+ typePresets[presetOf(level, variant, size)],
80
+ styles[color],
81
+ align && alignMap[align],
82
+ ]
43
83
 
44
84
  if (level === 1)
45
85
  return (
@@ -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. Web only.",
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. Web only.
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
  }