@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,29 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Tells assistive tech about a change that has no focus move: a polite (role="status") or assertive (role="alert") region. Always mounted — render it empty and change its content to announce; visuallyHidden keeps it off screen.',
6
+ whenToUse: [
7
+ 'Confirming an action that leaves focus where it is ("Link copied", "Saved")',
8
+ 'A result that arrives later ("12 results", "Answer ready")',
9
+ 'An error the person must hear now — politeness="assertive"',
10
+ ],
11
+ whenNotToUse: [
12
+ 'A message that also needs to be seen and dismissed — use Toast (it announces itself)',
13
+ 'An inline field error — use Field.Error',
14
+ ],
15
+ relatedTo: [
16
+ {
17
+ component: 'VisuallyHidden',
18
+ kind: 'contrast',
19
+ relationship:
20
+ 'VisuallyHidden hides static text; LiveRegion visuallyHidden announces changing text',
21
+ },
22
+ {
23
+ component: 'CodeBlock',
24
+ kind: 'composition',
25
+ relationship: 'CodeBlock announces "copied" through a LiveRegion',
26
+ },
27
+ ],
28
+ example: `<LiveRegion visuallyHidden>{copied ? 'Link copied' : ''}</LiveRegion>`,
29
+ }
@@ -0,0 +1,139 @@
1
+ import {useState} from 'react'
2
+ import type {Meta, StoryObj} from '@storybook/react'
3
+ import {expect, fn} from 'storybook/test'
4
+ import {LiveRegion, type LiveRegionPoliteness} from './LiveRegion'
5
+ import {Button} from '../Button/Button'
6
+ import {Inline} from '../Inline/Inline'
7
+ import {Stack} from '../Stack/Stack'
8
+ import {onThemeSurface} from '../../docs/themedSurface'
9
+
10
+ interface LiveRegionArgs {
11
+ /** `polite` (role="status") waits for a pause; `assertive` (role="alert") interrupts. */
12
+ politeness: LiveRegionPoliteness
13
+ /** Off screen, still in the accessibility tree. */
14
+ visuallyHidden: boolean
15
+ /** What the region announces when the trigger button is pressed. */
16
+ message: string
17
+ /** The trigger button's label. */
18
+ triggerLabel: string
19
+ /** The button that empties the region. */
20
+ clearLabel: string
21
+ /** Called with the message the region now holds. */
22
+ onAnnounce: (message: string) => void
23
+ }
24
+
25
+ /**
26
+ * The region is mounted empty from the start; a button puts the message in
27
+ * it, which is what assistive tech announces.
28
+ */
29
+ function Announcer({
30
+ politeness,
31
+ visuallyHidden,
32
+ message,
33
+ triggerLabel,
34
+ clearLabel,
35
+ onAnnounce,
36
+ }: LiveRegionArgs) {
37
+ const [content, setContent] = useState('')
38
+ return (
39
+ <Stack gap="sm">
40
+ <Inline gap="sm">
41
+ <Button
42
+ variant="secondary"
43
+ onClick={() => {
44
+ setContent(message)
45
+ onAnnounce(message)
46
+ }}
47
+ >
48
+ {triggerLabel}
49
+ </Button>
50
+ <Button variant="secondary" onClick={() => setContent('')}>
51
+ {clearLabel}
52
+ </Button>
53
+ </Inline>
54
+ <LiveRegion politeness={politeness} visuallyHidden={visuallyHidden}>
55
+ {content}
56
+ </LiveRegion>
57
+ </Stack>
58
+ )
59
+ }
60
+
61
+ const meta = {
62
+ title: 'Components/LiveRegion',
63
+ args: {
64
+ politeness: 'polite',
65
+ visuallyHidden: false,
66
+ message: 'Link copied',
67
+ triggerLabel: 'Copy link',
68
+ clearLabel: 'Clear',
69
+ onAnnounce: fn(),
70
+ },
71
+ argTypes: {
72
+ politeness: {control: 'radio', options: ['polite', 'assertive']},
73
+ visuallyHidden: {control: 'boolean'},
74
+ message: {control: 'text'},
75
+ triggerLabel: {control: 'text'},
76
+ clearLabel: {control: 'text'},
77
+ },
78
+ parameters: {a11y: {test: 'error'}},
79
+ decorators: [onThemeSurface],
80
+ render: (args) => <Announcer {...args} />,
81
+ } satisfies Meta<LiveRegionArgs>
82
+
83
+ export default meta
84
+ type Story = StoryObj<typeof meta>
85
+
86
+ const TEST_ONLY = ['!dev', '!autodocs']
87
+
88
+ /**
89
+ * Press the trigger: the message appears in the region (unless
90
+ * `visuallyHidden`) and the Actions panel logs `onAnnounce`. Switch
91
+ * `politeness` to assertive for role="alert".
92
+ */
93
+ export const Playground: Story = {}
94
+
95
+ /** Mounted empty from the start; a change of content is what gets announced. */
96
+ export const Polite: Story = {
97
+ tags: TEST_ONLY,
98
+ play: async ({args, canvas, userEvent}) => {
99
+ const region = canvas.getByRole('status')
100
+ await expect(region).toHaveAttribute('aria-live', 'polite')
101
+ await expect(region).toHaveAttribute('aria-atomic', 'true')
102
+ await expect(region).toBeEmptyDOMElement()
103
+
104
+ await userEvent.click(canvas.getByRole('button', {name: args.triggerLabel}))
105
+ // the same element, now holding the message: a change, not a new region
106
+ await expect(canvas.getByRole('status')).toBe(region)
107
+ await expect(region).toHaveTextContent(args.message)
108
+ await expect(args.onAnnounce).toHaveBeenCalledWith(args.message)
109
+
110
+ await userEvent.click(canvas.getByRole('button', {name: args.clearLabel}))
111
+ await expect(region).toBeInTheDocument()
112
+ await expect(canvas.getByRole('status')).toBe(region)
113
+ await expect(region).toBeEmptyDOMElement()
114
+ },
115
+ }
116
+
117
+ export const Assertive: Story = {
118
+ tags: TEST_ONLY,
119
+ render: () => <LiveRegion politeness="assertive">Connection lost</LiveRegion>,
120
+ play: async ({canvas}) => {
121
+ const region = canvas.getByRole('alert')
122
+ await expect(region).toHaveAttribute('aria-live', 'assertive')
123
+ await expect(region).toHaveTextContent('Connection lost')
124
+ },
125
+ }
126
+
127
+ /** Off screen, still in the accessibility tree. */
128
+ export const VisuallyHidden: Story = {
129
+ tags: TEST_ONLY,
130
+ args: {visuallyHidden: true},
131
+ play: async ({args, canvas, userEvent}) => {
132
+ await userEvent.click(canvas.getByRole('button', {name: args.triggerLabel}))
133
+ const region = canvas.getByRole('status')
134
+ await expect(region).toHaveTextContent(args.message)
135
+ const box = region.getBoundingClientRect()
136
+ await expect(box.width).toBeLessThanOrEqual(1)
137
+ await expect(box.height).toBeLessThanOrEqual(1)
138
+ },
139
+ }
@@ -0,0 +1,48 @@
1
+ import type {ReactNode} from 'react'
2
+ import {html} from 'react-strict-dom'
3
+ import {visuallyHidden as hidden} from '../../styles/visually-hidden.css'
4
+
5
+ export type LiveRegionPoliteness = 'polite' | 'assertive'
6
+
7
+ interface LiveRegionProps {
8
+ /**
9
+ * What to announce. Change it to announce; leave it empty when there is
10
+ * nothing to say — never unmount the region to clear it.
11
+ */
12
+ children?: ReactNode
13
+ /**
14
+ * `polite` (default) waits for the screen reader to finish (`role="status"`):
15
+ * "Copied", "3 results". `assertive` interrupts (`role="alert"`): only for
16
+ * what the person must hear now, such as an error that blocks them.
17
+ */
18
+ politeness?: LiveRegionPoliteness
19
+ /** Keeps the region off screen and in the accessibility tree. Default false. */
20
+ visuallyHidden?: boolean
21
+ id?: string
22
+ }
23
+
24
+ /**
25
+ * LiveRegion — tells assistive tech about a change that has no focus move to
26
+ * go with it ("Link copied", "Answer ready"). A screen reader only announces
27
+ * a change to a region that was already in the page, so render it from the
28
+ * start (empty) and change its content; a region mounted together with its
29
+ * message is often not read at all.
30
+ */
31
+ export function LiveRegion({
32
+ children,
33
+ politeness = 'polite',
34
+ visuallyHidden = false,
35
+ id,
36
+ }: LiveRegionProps) {
37
+ return (
38
+ <html.div
39
+ id={id}
40
+ role={politeness === 'assertive' ? 'alert' : 'status'}
41
+ aria-live={politeness}
42
+ aria-atomic={true}
43
+ style={visuallyHidden ? [hidden.base, hidden.text] : null}
44
+ >
45
+ {children}
46
+ </html.div>
47
+ )
48
+ }
@@ -8,6 +8,9 @@ import {Menu} from './Menu'
8
8
  import {Button} from '../Button/Button'
9
9
  import {Dialog} from '../Dialog/Dialog'
10
10
  import {Icon} from '../Icon/Icon'
11
+ import {onThemeSurface} from '../../docs/themedSurface'
12
+ import {SIZES_PX} from '@duro-app/tokens/keys'
13
+ import {withCoarsePointer, withFinePointer} from '../../docs/coarsePointer'
11
14
 
12
15
  const meta: Meta = {
13
16
  title: 'Components/Menu',
@@ -477,3 +480,62 @@ async function realKeyboard(): Promise<((keys: string) => Promise<void>) | null>
477
480
  return null
478
481
  }
479
482
  }
483
+
484
+ /**
485
+ * A touch screen gets 44px triggers (coarse pointer), the default and the
486
+ * ghost variant alike; a mouse keeps the compact control.
487
+ * It drives Chrome's touch emulation through Vitest's CDP session, so it runs
488
+ * as a test and stays out of the dev sidebar (there is no Vitest there).
489
+ */
490
+ export const TouchTargets: Story = {
491
+ tags: ['!dev', '!autodocs'],
492
+ parameters: {a11y: {test: 'error'}},
493
+ decorators: [onThemeSurface],
494
+ render: () => (
495
+ <html.div style={touchStyles.row}>
496
+ <Menu.Root>
497
+ <Menu.Trigger>Actions</Menu.Trigger>
498
+ <Menu.Popup>
499
+ <Menu.Item>Rename</Menu.Item>
500
+ </Menu.Popup>
501
+ </Menu.Root>
502
+ <Menu.Root>
503
+ <Menu.Trigger variant="ghost" aria-label="Page actions">
504
+ <Icon name="menu" size="sm" />
505
+ </Menu.Trigger>
506
+ <Menu.Popup align="end">
507
+ <Menu.Item>Copy link</Menu.Item>
508
+ </Menu.Popup>
509
+ </Menu.Root>
510
+ <Menu.Root>
511
+ <Menu.Trigger size="small">Sort</Menu.Trigger>
512
+ <Menu.Popup>
513
+ <Menu.Item>Name</Menu.Item>
514
+ </Menu.Popup>
515
+ </Menu.Root>
516
+ </html.div>
517
+ ),
518
+ play: async ({canvas}) => {
519
+ const triggers = () => [
520
+ canvas.getByRole('button', {name: 'Actions'}),
521
+ canvas.getByRole('button', {name: 'Page actions'}),
522
+ canvas.getByRole('button', {name: 'Sort'}),
523
+ ]
524
+ await withFinePointer()
525
+ const ghost = canvas.getByRole('button', {name: 'Page actions'}).getBoundingClientRect()
526
+ await expect(ghost.height).toBeLessThan(SIZES_PX.touchTarget)
527
+ await withCoarsePointer(async () => {
528
+ for (const trigger of triggers()) {
529
+ await waitFor(() => {
530
+ const box = trigger.getBoundingClientRect()
531
+ expect(box.height).toBeGreaterThanOrEqual(SIZES_PX.touchTarget)
532
+ expect(box.width).toBeGreaterThanOrEqual(SIZES_PX.touchTarget)
533
+ })
534
+ }
535
+ })
536
+ },
537
+ }
538
+
539
+ const touchStyles = css.create({
540
+ row: {display: 'flex', gap: spacing.md, alignItems: 'center'},
541
+ })
@@ -32,6 +32,9 @@ export const styles = css.create({
32
32
  borderStyle: 'solid',
33
33
  borderColor: colors.border,
34
34
  borderRadius: radii.sm,
35
+ // A touch screen gets the 44px target; a mouse keeps the compact control.
36
+ minWidth: {default: null, '@media (pointer: coarse)': sizes.touchTarget},
37
+ minHeight: {default: null, '@media (pointer: coarse)': sizes.touchTarget},
35
38
  cursor: 'pointer',
36
39
  transitionProperty: 'background-color, border-color',
37
40
  transitionDuration: duration.fast,
@@ -39,8 +42,8 @@ export const styles = css.create({
39
42
  // Toolbar trigger: no border or fill until hover, at least icon-button size.
40
43
  triggerGhost: {
41
44
  justifyContent: 'center',
42
- minWidth: sizes.iconButton,
43
- minHeight: sizes.iconButton,
45
+ minWidth: {default: sizes.iconButton, '@media (pointer: coarse)': sizes.touchTarget},
46
+ minHeight: {default: sizes.iconButton, '@media (pointer: coarse)': sizes.touchTarget},
44
47
  paddingTop: spacing.xs,
45
48
  paddingBottom: spacing.xs,
46
49
  paddingLeft: spacing.xs,
@@ -62,7 +65,7 @@ export const styles = css.create({
62
65
  // A small ghost trigger keeps the ghost's xs padding at the small icon-button size.
63
66
  triggerGhostSmall: {
64
67
  fontSize: typography.fontSizeXs,
65
- minWidth: sizes.iconButtonSm,
68
+ minWidth: {default: sizes.iconButtonSm, '@media (pointer: coarse)': sizes.touchTarget},
66
69
  minHeight: {default: sizes.iconButtonSm, '@media (pointer: coarse)': sizes.touchTarget},
67
70
  },
68
71
  popup: {
@@ -0,0 +1,24 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'A scalar inside a known range — WIP 2 of 3, a milestone 40% done — as role="meter" with a value text, drawn as a thin bar (the meterH token). The label is the accessible name; valueText says the value in words.',
6
+ whenToUse: [
7
+ 'A level against a limit or a total: WIP against its limit, tasks done in a milestone, quota used',
8
+ 'Beside a count, when the proportion matters more than the number',
9
+ ],
10
+ whenNotToUse: [
11
+ 'Progress of a task that is still running — use Spinner (or a determinate progress pattern)',
12
+ 'Picking a value — use an input; a meter has no thumb',
13
+ 'A status with no range (ok/failing) — use Badge or StatusIcon',
14
+ ],
15
+ relatedTo: [
16
+ {
17
+ component: 'Badge',
18
+ kind: 'contrast',
19
+ relationship: 'Badge states a status or a count; Meter shows a level within a range',
20
+ },
21
+ ],
22
+ example: `<Meter label="In progress WIP" value={2} max={3} valueText="2 of 3" />
23
+ <Meter label="Milestone done" value={0.4} tone="success" />`,
24
+ }
@@ -0,0 +1,64 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {Meter} from './Meter'
5
+ import {Text} from '../Text/Text'
6
+ import {onThemeSurface} from '../../docs/themedSurface'
7
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
8
+ import {sizes} from '@duro-app/tokens/tokens/sizes.css'
9
+
10
+ const s = css.create({
11
+ col: {display: 'flex', flexDirection: 'column', gap: spacing.md, maxWidth: sizes.gridColMd},
12
+ })
13
+
14
+ const meta: Meta<typeof Meter> = {
15
+ title: 'Components/Meter',
16
+ component: Meter,
17
+ parameters: {a11y: {test: 'error'}},
18
+ decorators: [onThemeSurface],
19
+ }
20
+ export default meta
21
+ type Story = StoryObj<typeof Meter>
22
+
23
+ export const Default: Story = {
24
+ args: {label: 'Milestone done', value: 0.4},
25
+ play: async ({canvas}) => {
26
+ const meter = canvas.getByRole('meter', {name: 'Milestone done'})
27
+ await expect(meter).toHaveAttribute('aria-valuenow', '0.4')
28
+ await expect(meter).toHaveAttribute('aria-valuemin', '0')
29
+ await expect(meter).toHaveAttribute('aria-valuemax', '1')
30
+ await expect(meter).toHaveAttribute('aria-valuetext', '40%')
31
+ },
32
+ }
33
+
34
+ /** Tones for a level against a limit: under, at, over. */
35
+ export const Tones: Story = {
36
+ render: () => (
37
+ <html.div style={s.col}>
38
+ <Text variant="label">Next · 1 of 3</Text>
39
+ <Meter label="Next WIP" value={1} max={3} valueText="1 of 3" />
40
+ <Text variant="label">In progress · 3 of 3</Text>
41
+ <Meter label="In progress WIP" value={3} max={3} valueText="3 of 3" tone="warning" />
42
+ <Text variant="label">Review · 4 of 3</Text>
43
+ <Meter label="Review WIP" value={4} max={3} valueText="4 of 3, over the limit" tone="error" />
44
+ <Text variant="label">Milestone · 70% done</Text>
45
+ <Meter label="Milestone done" value={0.7} tone="success" />
46
+ </html.div>
47
+ ),
48
+ }
49
+
50
+ /** Over the maximum, aria-valuenow stays in range and the text carries the
51
+ * true value; the bar fills, no further. */
52
+ export const OverTheLimit: Story = {
53
+ args: {label: 'Review WIP', value: 4, max: 3, valueText: '4 of 3, over the limit', tone: 'error'},
54
+ play: async ({canvas}) => {
55
+ const meter = canvas.getByRole('meter', {name: 'Review WIP'})
56
+ await expect(meter).toHaveAttribute('aria-valuenow', '3')
57
+ await expect(meter).toHaveAttribute('aria-valuetext', '4 of 3, over the limit')
58
+ const fill = meter.firstElementChild as HTMLElement
59
+ await expect(fill.getBoundingClientRect().width).toBeCloseTo(
60
+ meter.getBoundingClientRect().width,
61
+ 0,
62
+ )
63
+ },
64
+ }
@@ -0,0 +1,55 @@
1
+ import {html} from 'react-strict-dom'
2
+ import {styles} from './styles.css'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Meter — a scalar inside a known range (WIP 2 of 3, 40% of a milestone's
6
+ // tasks done): role="meter" with a value text, drawn as a thin bar of the
7
+ // `meterH` token. Not a progress bar for a running task (use Spinner) and
8
+ // not an input (no thumb).
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export type MeterTone = 'accent' | 'success' | 'warning' | 'error'
12
+
13
+ export interface MeterProps {
14
+ value: number
15
+ /** Defaults to 0. */
16
+ min?: number
17
+ /** Defaults to 1, so a fraction needs nothing else. */
18
+ max?: number
19
+ /** The accessible name, e.g. "In progress WIP". */
20
+ label: string
21
+ /** What screen readers read for the value, e.g. "4 of 3, over the limit".
22
+ * Defaults to the percentage. */
23
+ valueText?: string
24
+ /** The fill's colour; defaults to accent. */
25
+ tone?: MeterTone
26
+ }
27
+
28
+ const toneStyle = {
29
+ accent: styles.accent,
30
+ success: styles.success,
31
+ warning: styles.warning,
32
+ error: styles.error,
33
+ } as const
34
+
35
+ export function Meter({value, min = 0, max = 1, label, valueText, tone = 'accent'}: MeterProps) {
36
+ const span = max - min
37
+ const fraction = span > 0 ? (value - min) / span : 0
38
+ const clamped = Math.min(Math.max(fraction, 0), 1)
39
+ // aria-valuenow must sit inside the range; the true value (over the
40
+ // limit, say) is what valueText is for.
41
+ const now = Math.min(Math.max(value, min), max)
42
+ return (
43
+ <html.div
44
+ role="meter"
45
+ aria-label={label}
46
+ aria-valuemin={min}
47
+ aria-valuemax={max}
48
+ aria-valuenow={now}
49
+ aria-valuetext={valueText ?? `${Math.round(clamped * 100)}%`}
50
+ style={styles.track}
51
+ >
52
+ <html.div style={[styles.fill, toneStyle[tone], styles.fillAt(clamped * 100)]} />
53
+ </html.div>
54
+ )
55
+ }
@@ -0,0 +1,30 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {radii} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {sizes} from '@duro-app/tokens/tokens/sizes.css'
5
+ import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
6
+
7
+ export const styles = css.create({
8
+ track: {
9
+ position: 'relative',
10
+ width: '100%',
11
+ height: sizes.meterH,
12
+ borderRadius: radii.full,
13
+ backgroundColor: colors.border,
14
+ overflow: 'hidden',
15
+ },
16
+ fill: {
17
+ height: '100%',
18
+ borderRadius: radii.full,
19
+ transitionProperty: 'width',
20
+ transitionDuration: duration.base,
21
+ transitionTimingFunction: easing.standard,
22
+ },
23
+ accent: {backgroundColor: colors.accent},
24
+ success: {backgroundColor: colors.success},
25
+ warning: {backgroundColor: colors.warning},
26
+ error: {backgroundColor: colors.error},
27
+ fillAt: (percent: number) => ({
28
+ width: `${percent}%`,
29
+ }),
30
+ })
@@ -0,0 +1,32 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
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.",
6
+ whenToUse: [
7
+ 'The body of a docs page rendered from Markdown',
8
+ 'Any HTML produced by a renderer you do not write as JSX',
9
+ ],
10
+ whenNotToUse: [
11
+ 'Text you write as components — use Heading, Text and Stack',
12
+ 'A short label or paragraph — use Text',
13
+ ],
14
+ relatedTo: [
15
+ {
16
+ component: 'CodeBlock',
17
+ kind: 'composition',
18
+ relationship: "Pass CodeBlock as the Markdown renderer's pre, for a copy button",
19
+ },
20
+ {
21
+ component: 'TextLink',
22
+ kind: 'composition',
23
+ relationship:
24
+ "Pass TextLink as the renderer's a, for onNavigate; it keeps its own styles inside Prose",
25
+ },
26
+ ],
27
+ example: `<Prose>
28
+ <ReactMarkdown components={{pre: ({children}) => <CodeBlock copyLabel="Copy" copiedLabel="Copied">{children}</CodeBlock>}}>
29
+ {body}
30
+ </ReactMarkdown>
31
+ </Prose>`,
32
+ }