@duro-app/ui 5.6.0 → 5.8.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 (176) hide show
  1. package/dist/{Table-B1sWL3GA.js → Table-CAjPFgTX.js} +398 -394
  2. package/dist/Table-CAjPFgTX.js.map +1 -0
  3. package/dist/{compiler-runtime-B2IcI8_O.js → compiler-runtime-BuugS4D0.js} +67 -59
  4. package/dist/{compiler-runtime-B2IcI8_O.js.map → compiler-runtime-BuugS4D0.js.map} +1 -1
  5. package/dist/components/AppShell/AppShell.d.ts +74 -0
  6. package/dist/components/AppShell/AppShell.d.ts.map +1 -0
  7. package/dist/components/AppShell/AppShell.meta.d.ts +3 -0
  8. package/dist/components/AppShell/AppShell.meta.d.ts.map +1 -0
  9. package/dist/components/AppShell/AppShell.stories.d.ts +186 -0
  10. package/dist/components/AppShell/AppShell.stories.d.ts.map +1 -0
  11. package/dist/components/AppShell/styles.css.d.ts +187 -0
  12. package/dist/components/AppShell/styles.css.d.ts.map +1 -0
  13. package/dist/components/Aside/Aside.d.ts +23 -0
  14. package/dist/components/Aside/Aside.d.ts.map +1 -0
  15. package/dist/components/Aside/Aside.meta.d.ts +3 -0
  16. package/dist/components/Aside/Aside.meta.d.ts.map +1 -0
  17. package/dist/components/Aside/Aside.stories.d.ts +45 -0
  18. package/dist/components/Aside/Aside.stories.d.ts.map +1 -0
  19. package/dist/components/Aside/styles.css.d.ts +44 -0
  20. package/dist/components/Aside/styles.css.d.ts.map +1 -0
  21. package/dist/components/Button/styles.css.d.ts +2 -0
  22. package/dist/components/Button/styles.css.d.ts.map +1 -1
  23. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +3 -3
  24. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
  25. package/dist/components/CodeBlock/CodeBlock.d.ts +29 -0
  26. package/dist/components/CodeBlock/CodeBlock.d.ts.map +1 -0
  27. package/dist/components/CodeBlock/CodeBlock.meta.d.ts +3 -0
  28. package/dist/components/CodeBlock/CodeBlock.meta.d.ts.map +1 -0
  29. package/dist/components/CodeBlock/CodeBlock.stories.d.ts +67 -0
  30. package/dist/components/CodeBlock/CodeBlock.stories.d.ts.map +1 -0
  31. package/dist/components/CodeBlock/styles.css.d.ts +32 -0
  32. package/dist/components/CodeBlock/styles.css.d.ts.map +1 -0
  33. package/dist/components/DragDrop/DragDrop.d.ts +95 -14
  34. package/dist/components/DragDrop/DragDrop.d.ts.map +1 -1
  35. package/dist/components/DragDrop/DragDrop.meta.d.ts.map +1 -1
  36. package/dist/components/DragDrop/DragDropBoard.stories.d.ts +69 -0
  37. package/dist/components/DragDrop/DragDropBoard.stories.d.ts.map +1 -0
  38. package/dist/components/DragDrop/styles.css.d.ts +80 -3
  39. package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
  40. package/dist/components/Grid/Grid.d.ts +13 -4
  41. package/dist/components/Grid/Grid.d.ts.map +1 -1
  42. package/dist/components/Grid/Grid.meta.d.ts.map +1 -1
  43. package/dist/components/Grid/Grid.stories.d.ts +28 -0
  44. package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
  45. package/dist/components/Grid/styles.css.d.ts +3 -0
  46. package/dist/components/Grid/styles.css.d.ts.map +1 -1
  47. package/dist/components/Heading/Heading.d.ts +3 -1
  48. package/dist/components/Heading/Heading.d.ts.map +1 -1
  49. package/dist/components/Heading/Heading.stories.d.ts +9 -0
  50. package/dist/components/Heading/Heading.stories.d.ts.map +1 -0
  51. package/dist/components/LinkButton/LinkButton.stories.d.ts +7 -0
  52. package/dist/components/LinkButton/LinkButton.stories.d.ts.map +1 -1
  53. package/dist/components/LinkButton/styles.css.d.ts +2 -0
  54. package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
  55. package/dist/components/LiveRegion/LiveRegion.d.ts +28 -0
  56. package/dist/components/LiveRegion/LiveRegion.d.ts.map +1 -0
  57. package/dist/components/LiveRegion/LiveRegion.meta.d.ts +3 -0
  58. package/dist/components/LiveRegion/LiveRegion.meta.d.ts.map +1 -0
  59. package/dist/components/LiveRegion/LiveRegion.stories.d.ts +66 -0
  60. package/dist/components/LiveRegion/LiveRegion.stories.d.ts.map +1 -0
  61. package/dist/components/Menu/Menu.stories.d.ts +7 -0
  62. package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
  63. package/dist/components/Menu/styles.css.d.ts +2 -0
  64. package/dist/components/Menu/styles.css.d.ts.map +1 -1
  65. package/dist/components/Meter/Meter.d.ts +17 -0
  66. package/dist/components/Meter/Meter.d.ts.map +1 -0
  67. package/dist/components/Meter/Meter.meta.d.ts +3 -0
  68. package/dist/components/Meter/Meter.meta.d.ts.map +1 -0
  69. package/dist/components/Meter/Meter.stories.d.ts +12 -0
  70. package/dist/components/Meter/Meter.stories.d.ts.map +1 -0
  71. package/dist/components/Meter/styles.css.d.ts +34 -0
  72. package/dist/components/Meter/styles.css.d.ts.map +1 -0
  73. package/dist/components/PageNav/styles.css.d.ts +1 -1
  74. package/dist/components/Prose/Prose.d.ts +17 -0
  75. package/dist/components/Prose/Prose.d.ts.map +1 -0
  76. package/dist/components/Prose/Prose.meta.d.ts +3 -0
  77. package/dist/components/Prose/Prose.meta.d.ts.map +1 -0
  78. package/dist/components/Prose/Prose.stories.d.ts +63 -0
  79. package/dist/components/Prose/Prose.stories.d.ts.map +1 -0
  80. package/dist/components/SideNav/SideNav.d.ts +7 -1
  81. package/dist/components/SideNav/SideNav.d.ts.map +1 -1
  82. package/dist/components/SideNav/SideNav.stories.d.ts +3 -0
  83. package/dist/components/SideNav/SideNav.stories.d.ts.map +1 -1
  84. package/dist/components/Time/Time.d.ts +26 -0
  85. package/dist/components/Time/Time.d.ts.map +1 -0
  86. package/dist/components/Time/Time.meta.d.ts +3 -0
  87. package/dist/components/Time/Time.meta.d.ts.map +1 -0
  88. package/dist/components/Time/Time.stories.d.ts +77 -0
  89. package/dist/components/Time/Time.stories.d.ts.map +1 -0
  90. package/dist/components/Time/relative.d.ts +27 -0
  91. package/dist/components/Time/relative.d.ts.map +1 -0
  92. package/dist/components/Timeline/Timeline.d.ts +71 -0
  93. package/dist/components/Timeline/Timeline.d.ts.map +1 -0
  94. package/dist/components/Timeline/Timeline.meta.d.ts +3 -0
  95. package/dist/components/Timeline/Timeline.meta.d.ts.map +1 -0
  96. package/dist/components/Timeline/Timeline.stories.d.ts +26 -0
  97. package/dist/components/Timeline/Timeline.stories.d.ts.map +1 -0
  98. package/dist/components/Timeline/styles.css.d.ts +158 -0
  99. package/dist/components/Timeline/styles.css.d.ts.map +1 -0
  100. package/dist/components/Toast/Toast.meta.d.ts.map +1 -1
  101. package/dist/components/Toast/Toast.stories.d.ts +9 -0
  102. package/dist/components/Toast/Toast.stories.d.ts.map +1 -1
  103. package/dist/components/Toast/ToastProvider.d.ts +11 -5
  104. package/dist/components/Toast/ToastProvider.d.ts.map +1 -1
  105. package/dist/components/Toast/index.d.ts +1 -1
  106. package/dist/components/Toast/index.d.ts.map +1 -1
  107. package/dist/components/Toast/styles.css.d.ts +6 -0
  108. package/dist/components/Toast/styles.css.d.ts.map +1 -1
  109. package/dist/form.js +1 -1
  110. package/dist/hooks/useContainerBelow.d.ts +18 -0
  111. package/dist/hooks/useContainerBelow.d.ts.map +1 -0
  112. package/dist/index.css +1 -1
  113. package/dist/index.d.ts +13 -3
  114. package/dist/index.d.ts.map +1 -1
  115. package/dist/index.js +7316 -5536
  116. package/dist/index.js.map +1 -1
  117. package/dist/table.js +2 -2
  118. package/package.json +2 -2
  119. package/src/components/AppShell/AppShell.meta.ts +62 -0
  120. package/src/components/AppShell/AppShell.stories.tsx +770 -0
  121. package/src/components/AppShell/AppShell.tsx +346 -0
  122. package/src/components/AppShell/styles.css.ts +219 -0
  123. package/src/components/Aside/Aside.meta.ts +33 -0
  124. package/src/components/Aside/Aside.stories.tsx +106 -0
  125. package/src/components/Aside/Aside.tsx +42 -0
  126. package/src/components/Aside/styles.css.ts +60 -0
  127. package/src/components/Button/styles.css.ts +7 -2
  128. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +4 -5
  129. package/src/components/CodeBlock/CodeBlock.meta.ts +29 -0
  130. package/src/components/CodeBlock/CodeBlock.stories.tsx +162 -0
  131. package/src/components/CodeBlock/CodeBlock.tsx +99 -0
  132. package/src/components/CodeBlock/styles.css.ts +36 -0
  133. package/src/components/DragDrop/DragDrop.meta.ts +22 -21
  134. package/src/components/DragDrop/DragDrop.tsx +1147 -246
  135. package/src/components/DragDrop/DragDropBoard.stories.tsx +668 -0
  136. package/src/components/DragDrop/styles.css.ts +74 -4
  137. package/src/components/Grid/Grid.meta.ts +2 -1
  138. package/src/components/Grid/Grid.stories.tsx +202 -3
  139. package/src/components/Grid/Grid.tsx +35 -6
  140. package/src/components/Grid/styles.css.ts +11 -0
  141. package/src/components/Heading/Heading.stories.tsx +51 -0
  142. package/src/components/Heading/Heading.tsx +38 -7
  143. package/src/components/LinkButton/LinkButton.stories.tsx +41 -1
  144. package/src/components/LinkButton/styles.css.ts +6 -2
  145. package/src/components/LiveRegion/LiveRegion.meta.ts +29 -0
  146. package/src/components/LiveRegion/LiveRegion.stories.tsx +139 -0
  147. package/src/components/LiveRegion/LiveRegion.tsx +48 -0
  148. package/src/components/Menu/Menu.stories.tsx +62 -0
  149. package/src/components/Menu/styles.css.ts +6 -3
  150. package/src/components/Meter/Meter.meta.ts +24 -0
  151. package/src/components/Meter/Meter.stories.tsx +64 -0
  152. package/src/components/Meter/Meter.tsx +55 -0
  153. package/src/components/Meter/styles.css.ts +30 -0
  154. package/src/components/Prose/Prose.meta.ts +32 -0
  155. package/src/components/Prose/Prose.stories.tsx +233 -0
  156. package/src/components/Prose/Prose.tsx +25 -0
  157. package/src/components/Prose/prose.css +177 -0
  158. package/src/components/SideNav/SideNav.stories.tsx +29 -0
  159. package/src/components/SideNav/SideNav.tsx +14 -2
  160. package/src/components/Time/Time.meta.ts +17 -0
  161. package/src/components/Time/Time.stories.tsx +173 -0
  162. package/src/components/Time/Time.tsx +33 -0
  163. package/src/components/Time/relative.ts +89 -0
  164. package/src/components/Timeline/Timeline.meta.ts +46 -0
  165. package/src/components/Timeline/Timeline.stories.tsx +238 -0
  166. package/src/components/Timeline/Timeline.tsx +332 -0
  167. package/src/components/Timeline/styles.css.ts +162 -0
  168. package/src/components/Toast/Toast.meta.ts +2 -1
  169. package/src/components/Toast/Toast.stories.tsx +84 -0
  170. package/src/components/Toast/ToastProvider.tsx +49 -11
  171. package/src/components/Toast/index.ts +7 -1
  172. package/src/components/Toast/styles.css.ts +2 -0
  173. package/src/hooks/useContainerBelow.ts +39 -0
  174. package/src/index.ts +29 -1
  175. package/src/strict.css +4 -1
  176. package/dist/Table-B1sWL3GA.js.map +0 -1
@@ -0,0 +1,106 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, waitFor} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {sizes} from '@duro-app/tokens/tokens/sizes.css'
5
+ import {SPACING_KEYS, SPACING_PX, type SpacingToken} from '@duro-app/tokens/keys'
6
+ import {Aside} from './Aside'
7
+ import {Grid} from '../Grid/Grid'
8
+ import {Stack} from '../Stack/Stack'
9
+ import {Text} from '../Text/Text'
10
+ import {TextLink} from '../TextLink/TextLink'
11
+ import {onThemeSurface} from '../../docs/themedSurface'
12
+
13
+ interface AsideArgs {
14
+ 'aria-label': string
15
+ /** The gap kept between the viewport's top and the Aside while it sticks. */
16
+ offset: SpacingToken
17
+ }
18
+
19
+ const meta = {
20
+ title: 'Layout/Aside',
21
+ args: {'aria-label': 'Page outline', offset: 'lg'},
22
+ argTypes: {
23
+ 'aria-label': {control: 'text'},
24
+ offset: {control: 'select', options: [...SPACING_KEYS]},
25
+ },
26
+ parameters: {a11y: {test: 'error'}},
27
+ decorators: [onThemeSurface],
28
+ render: (args) => <LongPage {...args} />,
29
+ } satisfies Meta<AsideArgs>
30
+
31
+ export default meta
32
+ type Story = StoryObj<typeof meta>
33
+
34
+ const styles = css.create({
35
+ page: {width: sizes.pageLg},
36
+ })
37
+
38
+ const PARAGRAPHS = Array.from({length: 80}, (_, i) => `Paragraph ${i + 1} of a long runbook.`)
39
+ const SECTIONS = Array.from({length: 60}, (_, i) => `Section ${i + 1}`)
40
+
41
+ const asideRef: {current: HTMLElement | null} = {current: null}
42
+
43
+ function LongPage({'aria-label': ariaLabel, offset}: AsideArgs) {
44
+ return (
45
+ <html.div style={styles.page}>
46
+ <Grid layout="content-aside" gap="xl">
47
+ <Stack gap="md">
48
+ {PARAGRAPHS.map((text) => (
49
+ <Text key={text}>{text}</Text>
50
+ ))}
51
+ </Stack>
52
+ <Aside aria-label={ariaLabel} offset={offset} ref={asideRef}>
53
+ <Stack gap="xs">
54
+ {SECTIONS.map((name) => (
55
+ <TextLink key={name} href={`#${name}`}>
56
+ {name}
57
+ </TextLink>
58
+ ))}
59
+ </Stack>
60
+ </Aside>
61
+ </Grid>
62
+ </html.div>
63
+ )
64
+ }
65
+
66
+ /**
67
+ * A long page with its outline beside it. Scroll the canvas: the Aside sticks
68
+ * `offset` below the viewport's top and scrolls on its own, being taller than
69
+ * the viewport. Change `offset` to move where it sticks.
70
+ */
71
+ export const Playground: Story = {}
72
+
73
+ /**
74
+ * Sticks within the viewport while the page scrolls, and scrolls on its own
75
+ * when it is taller than the viewport; a labelled complementary landmark.
76
+ * It scrolls the page, so it runs as a test only.
77
+ */
78
+ export const StickyAndScrolling: Story = {
79
+ tags: ['!dev', '!autodocs'],
80
+ play: async ({args, canvas}) => {
81
+ const aside = canvas.getByRole('complementary', {name: args['aria-label']})
82
+ await expect(asideRef.current).toBe(aside)
83
+ await expect(aside.tagName).toBe('ASIDE')
84
+
85
+ const cs = getComputedStyle(aside)
86
+ await expect(cs.position).toBe('sticky')
87
+ // outside AppShell there is no bar to clear: the bar's custom property is
88
+ // unset, its fallback is 0, and only the offset remains
89
+ await expect(cs.getPropertyValue('--duro-app-shell-bar')).toBe('')
90
+ await expect(cs.top).toBe(`${SPACING_PX.lg}px`)
91
+ await expect(cs.zIndex).toBe('1')
92
+ // taller than the room it has: it scrolls on its own, inside the viewport
93
+ await expect(aside.clientHeight).toBeLessThanOrEqual(window.innerHeight - 2 * SPACING_PX.lg)
94
+ await expect(aside.scrollHeight).toBeGreaterThan(aside.clientHeight)
95
+
96
+ const scroller = document.scrollingElement as HTMLElement
97
+ const start = scroller.scrollTop
98
+ try {
99
+ scroller.scrollTop = start + 600
100
+ await waitFor(() => expect(scroller.scrollTop).toBeGreaterThan(start))
101
+ await waitFor(() => expect(Math.round(aside.getBoundingClientRect().top)).toBe(SPACING_PX.lg))
102
+ } finally {
103
+ scroller.scrollTop = start
104
+ }
105
+ },
106
+ }
@@ -0,0 +1,42 @@
1
+ import type {ReactNode, Ref} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import type {SpacingToken} from '@duro-app/tokens/keys'
4
+ import {styles} from './styles.css'
5
+
6
+ interface AsideProps {
7
+ /** Names the landmark ("Page outline"); required, from your text catalog. */
8
+ 'aria-label': string
9
+ /**
10
+ * The gap kept above the aside while it is stuck, and below it when it is
11
+ * taller than the viewport. Default `lg`.
12
+ */
13
+ offset?: SpacingToken
14
+ /** The `aside` element. */
15
+ ref?: Ref<HTMLElement>
16
+ children: ReactNode
17
+ }
18
+
19
+ const offsetMap = {
20
+ xs: styles.offsetXs,
21
+ sm: styles.offsetSm,
22
+ ms: styles.offsetMs,
23
+ md: styles.offsetMd,
24
+ lg: styles.offsetLg,
25
+ xl: styles.offsetXl,
26
+ xxl: styles.offsetXxl,
27
+ xxxl: styles.offsetXxxl,
28
+ } as const satisfies Record<SpacingToken, unknown>
29
+
30
+ /**
31
+ * Aside — content beside the main reading column (a table of contents,
32
+ * related pages): a labelled `aside` landmark that sticks within the viewport
33
+ * while the page scrolls and scrolls on its own when it is taller than the
34
+ * viewport. Put it in the second track of `Grid layout="content-aside"`.
35
+ */
36
+ export function Aside({'aria-label': ariaLabel, offset = 'lg', ref, children}: AsideProps) {
37
+ return (
38
+ <html.aside ref={ref} aria-label={ariaLabel} style={[styles.root, offsetMap[offset]]}>
39
+ {children}
40
+ </html.aside>
41
+ )
42
+ }
@@ -0,0 +1,60 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
3
+ import {layers} from '@duro-app/tokens/tokens/layers.css'
4
+
5
+ // Kept in step with `--duro-app-shell-bar`, which AppShell/styles.css.ts sets (StyleX needs
6
+ // the literal here, so the name is spelled out twice).
7
+ const BAR = 'var(--duro-app-shell-bar, 0px)'
8
+
9
+ export const styles = css.create({
10
+ // Sticky within the viewport, and its own scroll box when taller than the
11
+ // room it has, so a long table of contents never runs off the screen.
12
+ // alignSelf: as a grid or flex item it would otherwise stretch to the
13
+ // row's height and have nowhere to stick. The raised layer keeps it over
14
+ // content that scrolls under it.
15
+ root: {
16
+ position: 'sticky',
17
+ alignSelf: 'start',
18
+ zIndex: layers.raised,
19
+ overflowY: 'auto',
20
+ overscrollBehavior: 'contain',
21
+ minWidth: 0,
22
+ },
23
+ // The gap above it while stuck, and the same gap kept below it. Inside
24
+ // AppShell below its collapse point the sticky bar covers the top of the
25
+ // viewport, and AppShell sets `--duro-app-shell-bar` to the bar's height (appBarH)
26
+ // so the aside sticks under it; anywhere else the property is unset and
27
+ // the fallback 0 leaves the offset alone.
28
+ offsetXs: {
29
+ top: `calc(${BAR} + ${spacing.xs})`,
30
+ maxHeight: `calc(100dvh - ${BAR} - 2 * ${spacing.xs})`,
31
+ },
32
+ offsetSm: {
33
+ top: `calc(${BAR} + ${spacing.sm})`,
34
+ maxHeight: `calc(100dvh - ${BAR} - 2 * ${spacing.sm})`,
35
+ },
36
+ offsetMs: {
37
+ top: `calc(${BAR} + ${spacing.ms})`,
38
+ maxHeight: `calc(100dvh - ${BAR} - 2 * ${spacing.ms})`,
39
+ },
40
+ offsetMd: {
41
+ top: `calc(${BAR} + ${spacing.md})`,
42
+ maxHeight: `calc(100dvh - ${BAR} - 2 * ${spacing.md})`,
43
+ },
44
+ offsetLg: {
45
+ top: `calc(${BAR} + ${spacing.lg})`,
46
+ maxHeight: `calc(100dvh - ${BAR} - 2 * ${spacing.lg})`,
47
+ },
48
+ offsetXl: {
49
+ top: `calc(${BAR} + ${spacing.xl})`,
50
+ maxHeight: `calc(100dvh - ${BAR} - 2 * ${spacing.xl})`,
51
+ },
52
+ offsetXxl: {
53
+ top: `calc(${BAR} + ${spacing.xxl})`,
54
+ maxHeight: `calc(100dvh - ${BAR} - 2 * ${spacing.xxl})`,
55
+ },
56
+ offsetXxxl: {
57
+ top: `calc(${BAR} + ${spacing.xxxl})`,
58
+ maxHeight: `calc(100dvh - ${BAR} - 2 * ${spacing.xxxl})`,
59
+ },
60
+ })
@@ -25,6 +25,9 @@ export const styles = css.create({
25
25
  transitionTimingFunction: easing.standard,
26
26
  textDecoration: 'none',
27
27
  whiteSpace: 'nowrap',
28
+ // A touch screen gets the 44px target, as Toggle and the triggers do; a
29
+ // mouse keeps the compact control (5.6, the person's decision).
30
+ minWidth: {default: null, '@media (pointer: coarse)': sizes.touchTarget},
28
31
  // Tactile press feedback. The scale is suppressed under reduced motion (and
29
32
  // duration.fast collapses to 0ms there too), so it's inert for those users.
30
33
  transform: {
@@ -56,6 +59,7 @@ export const styles = css.create({
56
59
  paddingBottom: spacing.sm,
57
60
  paddingLeft: spacing.md,
58
61
  paddingRight: spacing.md,
62
+ minHeight: {default: null, '@media (pointer: coarse)': sizes.touchTarget},
59
63
  },
60
64
  sizeSmall: {
61
65
  paddingTop: spacing.xs,
@@ -63,8 +67,9 @@ export const styles = css.create({
63
67
  paddingLeft: spacing.sm,
64
68
  paddingRight: spacing.sm,
65
69
  fontSize: typography.fontSizeXs,
66
- // Every small control is at least controlSm tall, whatever it holds (5.6).
67
- minHeight: sizes.controlSm,
70
+ // Every small control is at least controlSm tall, whatever it holds (5.6),
71
+ // and the touch target under a coarse pointer.
72
+ minHeight: {default: sizes.controlSm, '@media (pointer: coarse)': sizes.touchTarget},
68
73
  },
69
74
  primary: {
70
75
  backgroundColor: {
@@ -507,13 +507,14 @@ export const SmallTriggersRow: Story = {
507
507
  },
508
508
  }
509
509
 
510
- /** The same row under a coarse pointer: Toggle and the three triggers reach
511
- * touchTarget, the ghost Menu too. Button has no touch size at any size, so the small Button stays
512
- * controlSm (the 5.5 plan's addendum). */
510
+ /** The same row under a coarse pointer: every control reaches touchTarget —
511
+ * Toggle, the three triggers, the ghost Menu, and since 5.6 Button too (the
512
+ * 5.5 addendum had left Button out; the person chose to close it). */
513
513
  export const SmallTriggersRowCoarsePointer: Story = {
514
514
  render: () => <SmallControlsRow />,
515
515
  play: async ({canvas}) => {
516
516
  const touch = [
517
+ canvas.getByRole('button', {name: 'Clear'}),
517
518
  canvas.getByRole('button', {name: 'Bold'}),
518
519
  canvas.getByRole('button', {name: 'Search'}),
519
520
  canvas.getByRole('combobox', {name: 'Block type'}),
@@ -521,12 +522,10 @@ export const SmallTriggersRowCoarsePointer: Story = {
521
522
  canvas.getByRole('button', {name: 'Text colour'}),
522
523
  canvas.getByRole('button', {name: 'More'}),
523
524
  ]
524
- const button = canvas.getByRole('button', {name: 'Clear'})
525
525
  await withCoarsePointer(async () => {
526
526
  for (const el of touch) {
527
527
  await waitFor(() => expect(el.offsetHeight).toBe(SIZES_PX.touchTarget))
528
528
  }
529
- await expect(button.offsetHeight).toBe(SIZES_PX.controlSm)
530
529
  })
531
530
  },
532
531
  }
@@ -0,0 +1,29 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
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.',
6
+ whenToUse: [
7
+ 'Code or a command the reader is likely to paste: a docs page, an install step',
8
+ "A Markdown renderer's pre, inside Prose",
9
+ ],
10
+ whenNotToUse: [
11
+ 'A short identifier in running text — use inline code (Prose styles it)',
12
+ 'Editable code — use Textarea',
13
+ ],
14
+ relatedTo: [
15
+ {
16
+ component: 'Prose',
17
+ kind: 'composition',
18
+ relationship: 'CodeBlock keeps its own styles inside Prose',
19
+ },
20
+ {
21
+ component: 'LiveRegion',
22
+ kind: 'composition',
23
+ relationship: 'The copied confirmation is announced through a LiveRegion',
24
+ },
25
+ ],
26
+ example: `<CodeBlock copyLabel="Copy" copiedLabel="Copied">
27
+ <code>pnpm add @duro-app/ui</code>
28
+ </CodeBlock>`,
29
+ }
@@ -0,0 +1,162 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, fn, spyOn, waitFor} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {CodeBlock} from './CodeBlock'
5
+ import {onThemeSurface} from '../../docs/themedSurface'
6
+
7
+ interface CodeBlockArgs {
8
+ /** The code shown (and copied). */
9
+ code: string
10
+ copyLabel: string
11
+ copiedLabel: string
12
+ /** How long the copied label stays, in ms. */
13
+ copiedDuration: number
14
+ /** A refused clipboard (permission denied, an insecure page). */
15
+ onCopyError: (error: unknown) => void
16
+ }
17
+
18
+ const COMMAND =
19
+ 'flux bootstrap git --url=ssh://git@forge/homelab --branch=main --path=clusters/homelab'
20
+
21
+ const meta = {
22
+ title: 'Components/CodeBlock',
23
+ args: {
24
+ code: COMMAND,
25
+ copyLabel: 'Copy',
26
+ copiedLabel: 'Copied',
27
+ copiedDuration: 2000,
28
+ onCopyError: fn(),
29
+ },
30
+ argTypes: {
31
+ code: {control: 'text'},
32
+ copyLabel: {control: 'text'},
33
+ copiedLabel: {control: 'text'},
34
+ copiedDuration: {control: {type: 'number', min: 0, step: 100}},
35
+ },
36
+ parameters: {a11y: {test: 'error'}},
37
+ decorators: [onThemeSurface],
38
+ render: ({code, ...args}) => (
39
+ <CodeBlock {...args}>
40
+ <html.code>{code}</html.code>
41
+ </CodeBlock>
42
+ ),
43
+ } satisfies Meta<CodeBlockArgs>
44
+
45
+ export default meta
46
+ type Story = StoryObj<typeof meta>
47
+
48
+ const TEST_ONLY = ['!dev', '!autodocs']
49
+
50
+ const styles = css.create({
51
+ half: {maxWidth: '50%'},
52
+ })
53
+
54
+ /**
55
+ * Press the copy button: the label changes to `copiedLabel` for
56
+ * `copiedDuration` ms and a polite region announces it. A refused clipboard
57
+ * shows up in Actions as `onCopyError`.
58
+ */
59
+ export const Playground: Story = {}
60
+
61
+ /** Playground's checks, kept off the visible story so a Control change cannot fail them. */
62
+ export const PlaygroundChecks: Story = {
63
+ ...Playground,
64
+ tags: TEST_ONLY,
65
+ play: async ({args, canvas}) => {
66
+ await expect(canvas.getByRole('button', {name: args.copyLabel})).toBeVisible()
67
+ await expect(canvas.getByText(args.code)).toBeInTheDocument()
68
+ },
69
+ }
70
+
71
+ /** A long line scrolls inside the block instead of widening the page. */
72
+ export const LongLineScrolls: Story = {
73
+ args: {code: COMMAND.repeat(3)},
74
+ render: ({code, ...args}) => (
75
+ <html.div style={styles.half}>
76
+ <CodeBlock {...args}>
77
+ <html.code>{code}</html.code>
78
+ </CodeBlock>
79
+ </html.div>
80
+ ),
81
+ }
82
+
83
+ /** LongLineScrolls's checks, kept off the visible story so a Control change cannot fail them. */
84
+ export const LongLineScrollsChecks: Story = {
85
+ ...LongLineScrolls,
86
+ tags: TEST_ONLY,
87
+ play: async ({canvasElement}) => {
88
+ const pre = canvasElement.querySelector('pre') as HTMLElement
89
+ await expect(pre.scrollWidth).toBeGreaterThan(pre.clientWidth)
90
+ await expect(getComputedStyle(pre).overflowX).toBe('auto')
91
+ // and a keyboard can reach it to scroll it
92
+ await waitFor(() => expect(pre).toHaveAttribute('tabindex', '0'))
93
+ },
94
+ }
95
+
96
+ /** Copies the text, confirms on the button and announces it, then resets. */
97
+ export const CopiesAndAnnounces: Story = {
98
+ tags: TEST_ONLY,
99
+ args: {copiedDuration: 300},
100
+ play: async ({args, canvas, userEvent}) => {
101
+ const write = spyOn(navigator.clipboard, 'writeText').mockResolvedValue(undefined)
102
+ try {
103
+ const status = canvas.getByRole('status')
104
+ await expect(status).toBeEmptyDOMElement()
105
+ await userEvent.click(canvas.getByRole('button', {name: args.copyLabel}))
106
+ await expect(write).toHaveBeenCalledWith(args.code)
107
+ await waitFor(() =>
108
+ expect(canvas.getByRole('button', {name: args.copiedLabel})).toBeVisible(),
109
+ )
110
+ await expect(status).toHaveTextContent(args.copiedLabel)
111
+ // back to the label after copiedDuration; the region stays mounted
112
+ await waitFor(() => expect(canvas.getByRole('button', {name: args.copyLabel})).toBeVisible())
113
+ await expect(canvas.getByRole('status')).toBe(status)
114
+ await expect(status).toBeEmptyDOMElement()
115
+ } finally {
116
+ write.mockRestore()
117
+ }
118
+ },
119
+ }
120
+
121
+ /** A refused clipboard confirms nothing and reports the error. */
122
+ export const CopyRefused: Story = {
123
+ tags: TEST_ONLY,
124
+ play: async ({args, canvas, userEvent}) => {
125
+ const write = spyOn(navigator.clipboard, 'writeText').mockRejectedValue(
126
+ new DOMException('Denied', 'NotAllowedError'),
127
+ )
128
+ try {
129
+ await userEvent.click(canvas.getByRole('button', {name: args.copyLabel}))
130
+ await waitFor(() => expect(args.onCopyError).toHaveBeenCalledTimes(1))
131
+ await expect(canvas.getByRole('button', {name: args.copyLabel})).toBeInTheDocument()
132
+ await expect(canvas.getByRole('status')).toBeEmptyDOMElement()
133
+ } finally {
134
+ write.mockRestore()
135
+ }
136
+ },
137
+ }
138
+
139
+ /** Without onCopyError, a refused clipboard still reaches the console. */
140
+ export const CopyRefusedWithoutHandler: Story = {
141
+ tags: TEST_ONLY,
142
+ render: ({code, onCopyError: _onCopyError, ...args}) => (
143
+ <CodeBlock {...args}>
144
+ <html.code>{code}</html.code>
145
+ </CodeBlock>
146
+ ),
147
+ play: async ({args, canvas, userEvent}) => {
148
+ const write = spyOn(navigator.clipboard, 'writeText').mockRejectedValue(
149
+ new DOMException('Denied', 'NotAllowedError'),
150
+ )
151
+ const logged = spyOn(console, 'error').mockImplementation(() => {})
152
+ try {
153
+ await userEvent.click(canvas.getByRole('button', {name: args.copyLabel}))
154
+ await waitFor(() => expect(logged).toHaveBeenCalledTimes(1))
155
+ await expect(String(logged.mock.calls[0]?.[0])).toContain('CodeBlock')
156
+ await expect(canvas.getByRole('status')).toBeEmptyDOMElement()
157
+ } finally {
158
+ logged.mockRestore()
159
+ write.mockRestore()
160
+ }
161
+ },
162
+ }
@@ -0,0 +1,99 @@
1
+ import {useCallback, useEffect, useRef, useState, type ReactNode} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {Button} from '../Button/Button'
4
+ import {LiveRegion} from '../LiveRegion/LiveRegion'
5
+ import {typePresets} from '@duro-app/tokens/tokens/type-presets.css'
6
+ import {styles} from './styles.css'
7
+
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
+ }
26
+
27
+ /**
28
+ * CodeBlock — a block of code with a copy button: a `pre` that scrolls
29
+ * sideways when a line is long (and is then focusable, so a keyboard can
30
+ * scroll it), and a Button that copies its text and
31
+ * confirms ("Copied") on the button and through a polite LiveRegion. Inside
32
+ * `Prose`, pass it as the Markdown renderer's `pre`.
33
+ */
34
+ export function CodeBlock({
35
+ children,
36
+ copyLabel,
37
+ copiedLabel,
38
+ copiedDuration = 2000,
39
+ onCopyError,
40
+ }: CodeBlockProps) {
41
+ const preRef = useRef<HTMLPreElement | null>(null)
42
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
43
+ const [copied, setCopied] = useState(false)
44
+ // A block that scrolls sideways joins the tab order, so a keyboard can
45
+ // scroll it too; one that fits stays out of it.
46
+ const [scrollable, setScrollable] = useState(false)
47
+
48
+ useEffect(() => {
49
+ const pre = preRef.current
50
+ if (!pre || typeof ResizeObserver === 'undefined') return
51
+ const measure = () => setScrollable(pre.scrollWidth > pre.clientWidth)
52
+ const observer = new ResizeObserver(measure)
53
+ observer.observe(pre)
54
+ measure()
55
+ return () => observer.disconnect()
56
+ }, [])
57
+
58
+ useEffect(
59
+ () => () => {
60
+ if (timerRef.current) clearTimeout(timerRef.current)
61
+ },
62
+ [],
63
+ )
64
+
65
+ const copy = useCallback(async () => {
66
+ try {
67
+ await navigator.clipboard.writeText(preRef.current?.textContent ?? '')
68
+ } catch (error) {
69
+ // Never dropped: without a handler the failure still reaches the console.
70
+ if (onCopyError) onCopyError(error)
71
+ else console.error('CodeBlock: copying to the clipboard failed', error)
72
+ return
73
+ }
74
+ if (timerRef.current) clearTimeout(timerRef.current)
75
+ setCopied(true)
76
+ timerRef.current = setTimeout(() => {
77
+ timerRef.current = null
78
+ setCopied(false)
79
+ }, copiedDuration)
80
+ }, [copiedDuration, onCopyError])
81
+
82
+ return (
83
+ <html.div style={styles.root}>
84
+ <html.pre
85
+ ref={preRef}
86
+ tabIndex={scrollable ? 0 : undefined}
87
+ style={[typePresets.code, styles.pre]}
88
+ >
89
+ {children}
90
+ </html.pre>
91
+ <html.div style={styles.copy}>
92
+ <Button variant="secondary" size="small" onClick={() => void copy()}>
93
+ {copied ? copiedLabel : copyLabel}
94
+ </Button>
95
+ </html.div>
96
+ <LiveRegion visuallyHidden>{copied ? copiedLabel : ''}</LiveRegion>
97
+ </html.div>
98
+ )
99
+ }
@@ -0,0 +1,36 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {radii, spacing} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {borders} from '@duro-app/tokens/tokens/borders.css'
5
+
6
+ export const styles = css.create({
7
+ // Anchors the copy button to the block's top-right corner.
8
+ root: {
9
+ position: 'relative',
10
+ minWidth: 0,
11
+ },
12
+ pre: {
13
+ margin: 0,
14
+ paddingTop: spacing.md,
15
+ paddingBottom: spacing.md,
16
+ paddingLeft: spacing.md,
17
+ paddingRight: spacing.md,
18
+ overflowX: 'auto',
19
+ color: colors.text,
20
+ backgroundColor: colors.bgCard,
21
+ borderWidth: borders.hairline,
22
+ borderStyle: 'solid',
23
+ borderColor: colors.border,
24
+ borderRadius: radii.md,
25
+ whiteSpace: 'pre',
26
+ outlineWidth: {default: 0, ':focus-visible': borders.focusRing},
27
+ outlineStyle: {default: 'none', ':focus-visible': 'solid'},
28
+ outlineColor: {default: 'transparent', ':focus-visible': colors.accent},
29
+ outlineOffset: {default: 0, ':focus-visible': borders.focusOffset},
30
+ },
31
+ copy: {
32
+ position: 'absolute',
33
+ top: spacing.sm,
34
+ right: spacing.sm,
35
+ },
36
+ })
@@ -2,15 +2,15 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Move items between zones with one pointer — mouse, pen and touch through the same pointer-event path (touch holds briefly, then drags; a moving touch stays a scroll). A ghost follows the pointer, the zone under it lights up, and the Root announces picks and drops to screen readers. Dragging is pointer-only by design: keep a button or remove affordance that does the same thing.',
6
- anatomy: {required: ['Root', 'Zone', 'Item']},
5
+ 'Move items between zones by pointer (mouse, pen and touch through one pointer-event path; touch holds briefly, then drags) or by keyboard (a Handle per item: Space picks up, arrows move, Space drops, Escape cancels). The innermost accepting zone wins, so a zone nested in an Item (a card in a column) takes its own drops; a zone can refuse with a reason that is shown and announced. Placeholders, edge auto-scroll and list-style announcements (through `announce`, for catalog strings) come with it.',
6
+ anatomy: {required: ['Root', 'Zone', 'Item'], optional: ['Handle']},
7
7
  whenToUse: [
8
8
  'Slotting things into places — people into approval gates, cards into columns, files into folders',
9
9
  'Reordering a short row or list when the order is visible and meaningful',
10
+ 'A board: columns as labelled lists (`list`), cards with a Handle, an attach zone nested in each card',
10
11
  ],
11
12
  whenNotToUse: [
12
- 'As the ONLY way to do something — every drag needs a tap/click equivalent (WCAG 2.5.7); this component does not provide one',
13
- 'Long lists that scroll while dragging — the ghost follows the pointer but zones do not auto-scroll',
13
+ 'As the ONLY way to do something — WCAG 2.5.7 asks for a single-pointer alternative to dragging (a button or menu that moves the item); a Handle adds the keyboard path, not a click one',
14
14
  'Sorting tabular data — sort the Table instead',
15
15
  'React Native — Item renders its children without drag behaviour there',
16
16
  ],
@@ -26,24 +26,25 @@ export const meta: ComponentMeta = {
26
26
  relationship: 'ScrollArea drags a thumb along one axis; DragDrop moves items between zones',
27
27
  },
28
28
  ],
29
- example: `<DragDrop.Root onDrop={({item, target}) => move(item.id, target.zone, target.index)}>
30
- <DragDrop.Zone id="roster" label="Roster">
31
- <Cluster gap="sm">
32
- {people.map((p) => (
33
- <DragDrop.Item key={p.id} id={p.id} zone="roster" label={p.name} data={p}>
34
- <Button variant="secondary" size="small" onClick={() => slot(p)}>{p.name}</Button>
29
+ example: `<DragDrop.Root
30
+ onDrop={({item, target}) => move(item.id, target.zone, target.index)}
31
+ renderPlaceholder={({label}) => <Text variant="caption">→ {label}</Text>}
32
+ announce={(e) => t(\`board.drag.\${e.kind}\`, e)}
33
+ >
34
+ {columns.map((col) => (
35
+ <DragDrop.Zone key={col.id} id={col.id} label={col.name} orientation="vertical" list
36
+ accepts={(item) => item.data.kind === 'task' || {ok: false, reason: t('board.attachOnCard')}}>
37
+ {col.cards.map((card) => (
38
+ <DragDrop.Item key={card.id} id={card.id} zone={col.id} label={card.title} data={card}>
39
+ <DragDrop.Handle>
40
+ <DragDrop.Zone id={\`attach:\${card.id}\`} label={card.title}
41
+ accepts={(item) => item.data.kind === 'activity'}>
42
+ <Card>{card.title}</Card>
43
+ </DragDrop.Zone>
44
+ </DragDrop.Handle>
35
45
  </DragDrop.Item>
36
46
  ))}
37
- </Cluster>
38
- </DragDrop.Zone>
39
- <DragDrop.Zone id="gates" label="Gates" orientation="horizontal">
40
- <Inline gap="sm">
41
- {gates.map((g) => (
42
- <DragDrop.Item key={g.id} id={g.id} zone="gates" label={g.name} data={g}>
43
- <Tag removable onRemove={() => unslot(g)}>{g.name}</Tag>
44
- </DragDrop.Item>
45
- ))}
46
- </Inline>
47
- </DragDrop.Zone>
47
+ </DragDrop.Zone>
48
+ ))}
48
49
  </DragDrop.Root>`,
49
50
  }