@duro-app/ui 5.21.0 → 5.23.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 (110) hide show
  1. package/dist/Table-Cw1B14R3.js.map +1 -1
  2. package/dist/components/AppShell/AppShell.d.ts +10 -1
  3. package/dist/components/AppShell/AppShell.d.ts.map +1 -1
  4. package/dist/components/AppShell/AppShell.stories.d.ts +8 -0
  5. package/dist/components/AppShell/AppShell.stories.d.ts.map +1 -1
  6. package/dist/components/AppShell/styles.css.d.ts +7 -0
  7. package/dist/components/AppShell/styles.css.d.ts.map +1 -1
  8. package/dist/components/CodeBlock/CodeBlock.d.ts +3 -21
  9. package/dist/components/CodeBlock/CodeBlock.d.ts.map +1 -1
  10. package/dist/components/CodeBlock/CodeBlock.stories.d.ts +4 -0
  11. package/dist/components/CodeBlock/CodeBlock.stories.d.ts.map +1 -1
  12. package/dist/components/CodeBlock/DiffLines.d.ts +9 -0
  13. package/dist/components/CodeBlock/DiffLines.d.ts.map +1 -0
  14. package/dist/components/CodeBlock/diffRoles.d.ts +14 -0
  15. package/dist/components/CodeBlock/diffRoles.d.ts.map +1 -0
  16. package/dist/components/CodeBlock/styles.css.d.ts +24 -0
  17. package/dist/components/CodeBlock/styles.css.d.ts.map +1 -1
  18. package/dist/components/CodeBlock/types.d.ts +29 -0
  19. package/dist/components/CodeBlock/types.d.ts.map +1 -0
  20. package/dist/components/Drawer/touchInput.d.ts +5 -1
  21. package/dist/components/Drawer/touchInput.d.ts.map +1 -1
  22. package/dist/components/Prose/Prose.d.ts +1 -1
  23. package/dist/components/SplitPane/SplitPane.d.ts +10 -1
  24. package/dist/components/SplitPane/SplitPane.d.ts.map +1 -1
  25. package/dist/components/SplitPane/SplitPane.stories.d.ts +11 -0
  26. package/dist/components/SplitPane/SplitPane.stories.d.ts.map +1 -1
  27. package/dist/components/SplitPane/styles.css.d.ts +22 -0
  28. package/dist/components/SplitPane/styles.css.d.ts.map +1 -1
  29. package/dist/components/Textarea/Textarea.d.ts +14 -2
  30. package/dist/components/Textarea/Textarea.d.ts.map +1 -1
  31. package/dist/components/Textarea/Textarea.meta.d.ts.map +1 -1
  32. package/dist/components/Textarea/Textarea.stories.d.ts +2 -0
  33. package/dist/components/Textarea/Textarea.stories.d.ts.map +1 -1
  34. package/dist/components/Textarea/autoGrow.d.ts +22 -0
  35. package/dist/components/Textarea/autoGrow.d.ts.map +1 -0
  36. package/dist/components/Textarea/grow.css.d.ts +12 -0
  37. package/dist/components/Textarea/grow.css.d.ts.map +1 -0
  38. package/dist/components/Textarea/grow.d.ts +2 -0
  39. package/dist/components/Textarea/grow.d.ts.map +1 -0
  40. package/dist/components/Textarea/native.css.d.ts +8 -0
  41. package/dist/components/Textarea/native.css.d.ts.map +1 -0
  42. package/dist/components/Textarea/useAutoGrowFallback.d.ts +9 -0
  43. package/dist/components/Textarea/useAutoGrowFallback.d.ts.map +1 -0
  44. package/dist/components/Toolbar/Toolbar.d.ts +2 -14
  45. package/dist/components/Toolbar/Toolbar.d.ts.map +1 -1
  46. package/dist/components/Toolbar/types.d.ts +15 -0
  47. package/dist/components/Toolbar/types.d.ts.map +1 -0
  48. package/dist/components/VirtualList/VirtualList.d.ts +5 -26
  49. package/dist/components/VirtualList/VirtualList.d.ts.map +1 -1
  50. package/dist/components/VirtualList/VirtualList.meta.d.ts.map +1 -1
  51. package/dist/components/VirtualList/VirtualList.stories.d.ts +16 -0
  52. package/dist/components/VirtualList/VirtualList.stories.d.ts.map +1 -1
  53. package/dist/components/VirtualList/native.css.d.ts +14 -0
  54. package/dist/components/VirtualList/native.css.d.ts.map +1 -0
  55. package/dist/components/VirtualList/props.d.ts +31 -0
  56. package/dist/components/VirtualList/props.d.ts.map +1 -0
  57. package/dist/components/VirtualList/styles.css.d.ts +4 -0
  58. package/dist/components/VirtualList/styles.css.d.ts.map +1 -1
  59. package/dist/index.css +1 -1
  60. package/dist/index.d.ts +1 -1
  61. package/dist/index.d.ts.map +1 -1
  62. package/dist/index.js +4442 -4215
  63. package/dist/index.js.map +1 -1
  64. package/package.json +2 -2
  65. package/src/components/AppShell/AppShell.meta.ts +1 -1
  66. package/src/components/AppShell/AppShell.native.tsx +3 -0
  67. package/src/components/AppShell/AppShell.stories.tsx +68 -0
  68. package/src/components/AppShell/AppShell.tsx +16 -5
  69. package/src/components/AppShell/styles.css.ts +10 -0
  70. package/src/components/CodeBlock/CodeBlock.meta.ts +1 -1
  71. package/src/components/CodeBlock/CodeBlock.native.tsx +33 -0
  72. package/src/components/CodeBlock/CodeBlock.stories.tsx +54 -0
  73. package/src/components/CodeBlock/CodeBlock.tsx +8 -20
  74. package/src/components/CodeBlock/DiffLines.tsx +32 -0
  75. package/src/components/CodeBlock/diffRoles.ts +40 -0
  76. package/src/components/CodeBlock/styles.css.ts +17 -0
  77. package/src/components/CodeBlock/types.ts +29 -0
  78. package/src/components/Drawer/touchInput.ts +16 -2
  79. package/src/components/Prose/Prose.meta.ts +1 -1
  80. package/src/components/Prose/Prose.native.tsx +23 -0
  81. package/src/components/Prose/Prose.tsx +1 -1
  82. package/src/components/Prose/styles.native.css.ts +15 -0
  83. package/src/components/SplitPane/SplitPane.meta.ts +1 -1
  84. package/src/components/SplitPane/SplitPane.native.tsx +4 -0
  85. package/src/components/SplitPane/SplitPane.stories.tsx +207 -1
  86. package/src/components/SplitPane/SplitPane.tsx +50 -7
  87. package/src/components/SplitPane/styles.css.ts +28 -0
  88. package/src/components/Textarea/Textarea.meta.ts +6 -2
  89. package/src/components/Textarea/Textarea.stories.tsx +136 -1
  90. package/src/components/Textarea/Textarea.tsx +50 -3
  91. package/src/components/Textarea/autoGrow.ts +33 -0
  92. package/src/components/Textarea/grow.css.ts +18 -0
  93. package/src/components/Textarea/grow.native.ts +1 -0
  94. package/src/components/Textarea/grow.ts +1 -0
  95. package/src/components/Textarea/native.css.ts +16 -0
  96. package/src/components/Textarea/useAutoGrowFallback.native.ts +5 -0
  97. package/src/components/Textarea/useAutoGrowFallback.ts +35 -0
  98. package/src/components/Toolbar/Toolbar.meta.ts +1 -1
  99. package/src/components/Toolbar/Toolbar.native.tsx +27 -0
  100. package/src/components/Toolbar/Toolbar.tsx +3 -14
  101. package/src/components/Toolbar/types.ts +15 -0
  102. package/src/components/VirtualList/VirtualList.meta.ts +2 -1
  103. package/src/components/VirtualList/VirtualList.native.tsx +80 -0
  104. package/src/components/VirtualList/VirtualList.stories.tsx +171 -1
  105. package/src/components/VirtualList/VirtualList.tsx +118 -27
  106. package/src/components/VirtualList/native.css.ts +14 -0
  107. package/src/components/VirtualList/props.ts +31 -0
  108. package/src/components/VirtualList/styles.css.ts +7 -0
  109. package/src/index.native.ts +8 -0
  110. 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.21.0",
3
+ "version": "5.23.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.21.0"
95
+ "@duro-app/tokens": "^5.23.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
- "An application's frame: a navigation rail (a labelled nav, the brand at its top, an optional footer at its foot) beside the main content (main), with an optional Header. Below collapseBelow (sm default, md or lg), measured on the shell's own width, the rail is hidden and one sticky bar (a banner, appBarH tall) holds a Menu button that opens the same navigation in a left Drawer, the brand, and the Header's content when there is one — a shell without a Header still has its navigation on a phone. Above it the Header is a row that scrolls with the page and the rail stays sticky. CSS makes the switch: the server renders one HTML for every width with the brand in both places, and display: none hides the inactive copy, so nothing moves after hydration. A skip link (the first tab stop) focuses Main; an Aside inside Main sticks below the bar. Labels come from props. Compound component — Root is required. On React Native it is always the narrow shape: the bar below the status bar, Main under it, and the Rail in a left Drawer; there is no skip link.",
5
+ "An application's frame: a navigation rail (a labelled nav, the brand at its top, an optional footer at its foot) beside the main content (main), with an optional Header. Below collapseBelow (sm default, md or lg), measured on the shell's own width, the rail is hidden and one sticky bar (a banner, appBarH tall) holds a Menu button that opens the same navigation in a left Drawer, the brand, and the Header's content when there is one — a shell without a Header still has its navigation on a phone. Above it the Header is a row that scrolls with the page and the rail stays sticky. CSS makes the switch: the server renders one HTML for every width with the brand in both places, and display: none hides the inactive copy, so nothing moves after hydration. A skip link (the first tab stop) focuses Main; an Aside inside Main sticks below the bar. Labels come from props. Compound component — Root is required. On React Native it is always the narrow shape: the bar below the status bar, Main under it, and the Rail in a left Drawer; there is no skip link. scroll=\"main\" makes it an app frame rather than a page: the shell is the viewport's height and Main scrolls on its own, so a SplitPane or VirtualList inside it fills Main and scrolls its own panes.",
6
6
  whenToUse: [
7
7
  'The frame of an app with a persistent navigation rail: an admin, a docs site, a dashboard',
8
8
  'A layout whose navigation must collapse into a drawer on a phone',
@@ -18,6 +18,7 @@ import {InLandmarkContext} from '../../shared/InLandmarkContext'
18
18
  import {nativeStyles} from './native.css'
19
19
 
20
20
  export type AppShellCollapse = 'sm' | 'md' | 'lg'
21
+ export type AppShellScroll = 'page' | 'main'
21
22
 
22
23
  interface AppShellContextValue {
23
24
  open: boolean
@@ -47,6 +48,8 @@ interface RootProps {
47
48
  brand?: ReactNode
48
49
  /** Web only: a phone is below every breakpoint, so native always shows the bar and the drawer. */
49
50
  collapseBelow?: AppShellCollapse
51
+ /** Web only: native Main always fills the screen under the bar and its content scrolls itself. */
52
+ scroll?: AppShellScroll
50
53
  /** `AppShell.Rail`, `AppShell.Main` and an optional `AppShell.Header`, as direct children. */
51
54
  children: ReactNode
52
55
  }
@@ -20,6 +20,8 @@ import {TextLink} from '../TextLink/TextLink'
20
20
  import {Aside} from '../Aside/Aside'
21
21
  import {Stack} from '../Stack/Stack'
22
22
  import {Prose} from '../Prose/Prose'
23
+ import {SplitPane} from '../SplitPane/SplitPane'
24
+ import {VirtualList} from '../VirtualList/VirtualList'
23
25
 
24
26
  // --- the story's frame -----------------------------------------------------
25
27
 
@@ -853,3 +855,69 @@ export const MenuButtonTouchTarget: Story = {
853
855
  })
854
856
  },
855
857
  }
858
+
859
+ const ROWS = Array.from({length: 500}, (_, i) => ({id: i, text: `Message ${i + 1}`}))
860
+
861
+ /**
862
+ * `scroll="main"`: an app, not a document. The shell is the viewport's height and Main scrolls on
863
+ * its own, so a SplitPane inside it fills Main and each pane scrolls by itself — here a 500-row
864
+ * VirtualList that mounts only the rows in view.
865
+ */
866
+ export const ScrollMain: Story = {
867
+ render: () => (
868
+ <AppShell.Root
869
+ menuLabel="Menu"
870
+ closeLabel="Close navigation"
871
+ skipLabel="Skip to content"
872
+ brand={BRAND}
873
+ scroll="main"
874
+ >
875
+ <AppShell.Rail aria-label="Navigation">
876
+ <SideNav.Root value="/sessions">
877
+ <SideNav.Item value="/sessions">Sessions</SideNav.Item>
878
+ </SideNav.Root>
879
+ </AppShell.Rail>
880
+ <AppShell.Main>
881
+ <SplitPane.Root
882
+ aria-label="Resize the session list"
883
+ defaultSize="sidebarW"
884
+ minSize="sidebarW"
885
+ maxSize="panelLg"
886
+ >
887
+ <SplitPane.Pane>
888
+ <Text>Sessions</Text>
889
+ </SplitPane.Pane>
890
+ <SplitPane.Handle />
891
+ <SplitPane.Pane>
892
+ <VirtualList
893
+ aria-label="Transcript"
894
+ items={ROWS}
895
+ getKey={(row) => row.id}
896
+ estimateSize="controlLg"
897
+ anchor="end"
898
+ renderItem={(row) => <Text>{row.text}</Text>}
899
+ />
900
+ </SplitPane.Pane>
901
+ </SplitPane.Root>
902
+ </AppShell.Main>
903
+ </AppShell.Root>
904
+ ),
905
+ }
906
+
907
+ /** ScrollMain's checks: Main stays within the viewport and the list windows its rows. */
908
+ export const ScrollMainBoundsMain: Story = {
909
+ ...ScrollMain,
910
+ tags: TEST_ONLY,
911
+ play: async () => {
912
+ const main = document.querySelector('main')!
913
+ const list = await waitFor(() => page().getByRole('list', {name: 'Transcript'}))
914
+ await waitFor(() =>
915
+ expect(list.querySelectorAll('[role="listitem"]').length).toBeGreaterThan(0),
916
+ )
917
+ await expect(main.getBoundingClientRect().bottom).toBeLessThanOrEqual(window.innerHeight)
918
+ await expect(list.querySelectorAll('[role="listitem"]').length).toBeLessThan(100)
919
+ await expect(list.scrollHeight).toBeGreaterThan(list.clientHeight)
920
+ // The document itself does not scroll: Main does.
921
+ await expect(document.documentElement.scrollHeight).toBeLessThanOrEqual(window.innerHeight)
922
+ },
923
+ }
@@ -39,6 +39,8 @@ import {styles} from './styles.css'
39
39
  * stop) focuses. */
40
40
 
41
41
  export type AppShellCollapse = 'sm' | 'md' | 'lg'
42
+ /** `page`: the document scrolls (a docs site). `main`: the shell is the viewport, Main scrolls (an app). */
43
+ export type AppShellScroll = 'page' | 'main'
42
44
 
43
45
  interface AppShellContextValue {
44
46
  open: boolean
@@ -46,6 +48,7 @@ interface AppShellContextValue {
46
48
  closeLabel: string
47
49
  brand: ReactNode
48
50
  collapseBelow: AppShellCollapse
51
+ scroll: AppShellScroll
49
52
  mainId: string
50
53
  mainRef: RefObject<HTMLElement | null>
51
54
  }
@@ -109,6 +112,13 @@ interface RootProps {
109
112
  * becomes a Drawer behind the Menu button. Default `sm`.
110
113
  */
111
114
  collapseBelow?: AppShellCollapse
115
+ /**
116
+ * What scrolls. `page` (default): the document, a page that grows with its
117
+ * content. `main`: the shell is exactly the viewport's height and Main
118
+ * scrolls on its own, so a SplitPane or VirtualList inside it can fill Main
119
+ * and scroll its own panes (an app such as a chat or a mail client).
120
+ */
121
+ scroll?: AppShellScroll
112
122
  /**
113
123
  * `AppShell.Rail`, `AppShell.Main` and an optional `AppShell.Header`, as
114
124
  * direct children (Root places the Header's content in its bar).
@@ -122,6 +132,7 @@ function Root({
122
132
  skipLabel,
123
133
  brand,
124
134
  collapseBelow = 'sm',
135
+ scroll = 'page',
125
136
  children,
126
137
  }: RootProps) {
127
138
  const [open, setOpen] = useState(false)
@@ -165,8 +176,8 @@ function Root({
165
176
  }, [open])
166
177
 
167
178
  const value = useMemo(
168
- () => ({open, setOpen, closeLabel, brand, collapseBelow, mainId, mainRef}),
169
- [open, closeLabel, brand, collapseBelow, mainId],
179
+ () => ({open, setOpen, closeLabel, brand, collapseBelow, scroll, mainId, mainRef}),
180
+ [open, closeLabel, brand, collapseBelow, scroll, mainId],
170
181
  )
171
182
 
172
183
  // Focus Main itself (tabIndex -1), not just scroll to it, and leave the
@@ -179,7 +190,7 @@ function Root({
179
190
  return (
180
191
  <AppShellContext.Provider value={value}>
181
192
  <html.div ref={containerRef} style={styles.container}>
182
- <html.div style={[styles.grid, variant.grid]}>
193
+ <html.div style={[styles.grid, variant.grid, scroll === 'main' && styles.gridViewport]}>
183
194
  <html.div style={styles.skip}>
184
195
  <TextLink href={`#${mainId}`} onNavigate={skip}>
185
196
  {skipLabel}
@@ -318,7 +329,7 @@ interface MainProps {
318
329
  }
319
330
 
320
331
  function Main({children, id, ref}: MainProps) {
321
- const {mainId, mainRef, collapseBelow} = useAppShell()
332
+ const {mainId, mainRef, collapseBelow, scroll} = useAppShell()
322
333
  const setRef = useCallback(
323
334
  (node: HTMLElement | null) => {
324
335
  mainRef.current = node
@@ -332,7 +343,7 @@ function Main({children, id, ref}: MainProps) {
332
343
  ref={setRef}
333
344
  id={id ?? mainId}
334
345
  tabIndex={-1}
335
- style={[styles.main, variants[collapseBelow].main]}
346
+ style={[styles.main, variants[collapseBelow].main, scroll === 'main' && styles.mainScrolls]}
336
347
  >
337
348
  {children}
338
349
  </html.main>
@@ -47,6 +47,10 @@ export const styles = css.create({
47
47
  backgroundColor: colors.bg,
48
48
  gridTemplateRows: 'auto 1fr',
49
49
  },
50
+ // scroll="main": the shell is the viewport, so Main's row has a definite height.
51
+ gridViewport: {
52
+ height: '100dvh',
53
+ },
50
54
  gridSm: {
51
55
  gridTemplateColumns: {default: 'minmax(0, 1fr)', [WIDE_SM]: RAIL_COLUMNS},
52
56
  gridTemplateAreas: {default: NARROW_AREAS, [WIDE_SM]: WIDE_AREAS},
@@ -204,6 +208,12 @@ export const styles = css.create({
204
208
  // Focused by the skip link: no ring on a region that is not a control.
205
209
  outlineStyle: 'none',
206
210
  },
211
+ // scroll="main": Main is a bounded region that scrolls by itself, and a
212
+ // child that fills it (height 100%) gets that bound.
213
+ mainScrolls: {
214
+ minHeight: 0,
215
+ overflowY: 'auto',
216
+ },
207
217
  mainSm: {
208
218
  paddingLeft: {default: spacing.md, [WIDE_SM]: spacing.lg},
209
219
  paddingRight: {default: spacing.md, [WIDE_SM]: spacing.lg},
@@ -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,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
  }