@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,173 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, waitFor} from 'storybook/test'
3
+ import {html} from 'react-strict-dom'
4
+ import {renderToString} from 'react-dom/server'
5
+ import {hydrateRoot} from 'react-dom/client'
6
+ import {Time} from './Time'
7
+ import {relative} from './relative'
8
+ import {Stack} from '../Stack/Stack'
9
+ import {Text} from '../Text/Text'
10
+ import {onThemeSurface} from '../../docs/themedSurface'
11
+
12
+ // A fixed instant, as a loader would pass it: never the clock during render.
13
+ const NOW = Date.UTC(2026, 9, 8, 12, 0, 0)
14
+ const COMMIT = '2026-10-05T09:30:00.000Z'
15
+ const DAY_MS = 24 * 60 * 60 * 1000
16
+
17
+ type Locale = 'en' | 'fr' | 'de'
18
+
19
+ interface TimeArgs {
20
+ /** The instant shown, in days from `now` (negative: in the past). */
21
+ offsetDays: number
22
+ /** The locale `relative` and the title use. */
23
+ locale: Locale
24
+ /** `auto` says "yesterday" where the locale has a word; `always` keeps the number. */
25
+ numeric: 'auto' | 'always'
26
+ /** The text before the date. */
27
+ label: string
28
+ }
29
+
30
+ const meta = {
31
+ title: 'Components/Time',
32
+ args: {offsetDays: -3, locale: 'en', numeric: 'auto', label: 'Updated'},
33
+ argTypes: {
34
+ offsetDays: {control: {type: 'range', min: -400, max: 400, step: 1}},
35
+ locale: {
36
+ control: {type: 'select', labels: {en: 'English', fr: 'Français', de: 'Deutsch'}},
37
+ options: ['en', 'fr', 'de'],
38
+ },
39
+ numeric: {control: 'radio', options: ['auto', 'always']},
40
+ label: {control: 'text'},
41
+ },
42
+ parameters: {a11y: {test: 'error'}},
43
+ decorators: [onThemeSurface],
44
+ render: ({offsetDays, locale, numeric, label}) => {
45
+ const iso = new Date(NOW + offsetDays * DAY_MS).toISOString()
46
+ return (
47
+ <Text variant="bodySm" color="muted">
48
+ {label}{' '}
49
+ <Time dateTime={iso} title={full(iso, locale)}>
50
+ {relative(iso, {now: NOW, locale, numeric})}
51
+ </Time>
52
+ </Text>
53
+ )
54
+ },
55
+ } satisfies Meta<TimeArgs>
56
+
57
+ export default meta
58
+ type Story = StoryObj<typeof meta>
59
+
60
+ const TEST_ONLY = ['!dev', '!autodocs']
61
+
62
+ function full(iso: string, locale: string) {
63
+ return new Intl.DateTimeFormat(locale, {
64
+ dateStyle: 'long',
65
+ timeStyle: 'short',
66
+ timeZone: 'UTC',
67
+ }).format(new Date(iso))
68
+ }
69
+
70
+ function Updated({locale, label}: {locale: string; label: string}) {
71
+ return (
72
+ <Text variant="bodySm" color="muted">
73
+ {label}{' '}
74
+ <Time dateTime={COMMIT} title={full(COMMIT, locale)}>
75
+ {relative(COMMIT, {now: NOW, locale})}
76
+ </Time>
77
+ </Text>
78
+ )
79
+ }
80
+
81
+ /**
82
+ * A date relative to a fixed `now` (2026-10-08 12:00 UTC). Move `offsetDays`
83
+ * to walk through the units (days, weeks, months, years), switch `locale`, and
84
+ * `numeric` to always for "1 day ago" instead of "yesterday". Hover the date
85
+ * for the exact instant (title).
86
+ */
87
+ export const Playground: Story = {}
88
+
89
+ /** Playground's checks, kept off the visible story so a Control change cannot fail them. */
90
+ export const PlaygroundChecks: Story = {
91
+ ...Playground,
92
+ tags: TEST_ONLY,
93
+ play: async ({args, canvasElement}) => {
94
+ const time = canvasElement.querySelector('time')!
95
+ const iso = new Date(NOW + args.offsetDays * DAY_MS).toISOString()
96
+ await expect(time).toHaveAttribute('datetime', iso)
97
+ await expect(time).toHaveAttribute('title', full(iso, args.locale))
98
+ },
99
+ }
100
+
101
+ /** The words come from the locale; the exact instant stays in dateTime and title. */
102
+ export const TwoLocales: Story = {
103
+ render: () => (
104
+ <Stack gap="sm">
105
+ <Updated locale="en" label="Updated" />
106
+ <Updated locale="fr" label="Mis à jour" />
107
+ </Stack>
108
+ ),
109
+ }
110
+
111
+ /** TwoLocales' checks, kept off the visible story so a Control change cannot fail them. */
112
+ export const TwoLocalesChecks: Story = {
113
+ ...TwoLocales,
114
+ tags: TEST_ONLY,
115
+ play: async ({canvasElement}) => {
116
+ const times = [...canvasElement.querySelectorAll('time')]
117
+ await expect(times).toHaveLength(2)
118
+ await expect(times[0]).toHaveTextContent('3 days ago')
119
+ await expect(times[1]).toHaveTextContent('il y a 3 jours')
120
+ for (const time of times) {
121
+ await expect(time).toHaveAttribute('datetime', COMMIT)
122
+ }
123
+ await expect(times[0]).toHaveAttribute('title', full(COMMIT, 'en'))
124
+ await expect(times[1]).toHaveAttribute('title', full(COMMIT, 'fr'))
125
+ },
126
+ }
127
+
128
+ /** A Date is written as its ISO string. */
129
+ export const FromDate: Story = {
130
+ tags: TEST_ONLY,
131
+ render: () => (
132
+ <Text>
133
+ <Time dateTime={new Date(NOW)}>{relative(NOW, {now: NOW, locale: 'en'})}</Time>
134
+ </Text>
135
+ ),
136
+ play: async ({canvasElement}) => {
137
+ const time = canvasElement.querySelector('time')!
138
+ await expect(time).toHaveAttribute('datetime', new Date(NOW).toISOString())
139
+ await expect(time).toHaveTextContent('now')
140
+ },
141
+ }
142
+
143
+ /** Server and client render the same text from the same `now`: hydration
144
+ * reports nothing. It replaces the canvas's content, so it runs as a test only. */
145
+ export const ServerRender: Story = {
146
+ tags: TEST_ONLY,
147
+ render: () => <html.div data-ssr-host="" />,
148
+ play: async ({canvasElement}) => {
149
+ const host = canvasElement.querySelector('[data-ssr-host]') as HTMLElement
150
+ const tree = <Updated locale="en" label="Updated" />
151
+ host.innerHTML = renderToString(tree)
152
+ const serverText = host.textContent
153
+ await expect(host.querySelector('time')).toHaveTextContent('3 days ago')
154
+
155
+ const errors: unknown[] = []
156
+ const consoleError = console.error
157
+ console.error = (...args: unknown[]) => {
158
+ errors.push(args)
159
+ consoleError(...args)
160
+ }
161
+ const root = hydrateRoot(host, tree, {onRecoverableError: (error) => errors.push(error)})
162
+ try {
163
+ // let React finish hydrating before reading the result
164
+ await waitFor(() => expect(host.querySelector('time')).toHaveTextContent('3 days ago'))
165
+ await new Promise((resolve) => setTimeout(resolve, 50))
166
+ await expect(host.textContent).toBe(serverText)
167
+ await expect(errors).toEqual([])
168
+ } finally {
169
+ console.error = consoleError
170
+ root.unmount()
171
+ }
172
+ },
173
+ }
@@ -0,0 +1,33 @@
1
+ import type {ReactNode} from 'react'
2
+
3
+ interface TimeProps {
4
+ /**
5
+ * The instant, machine-readable: a `Date` (written as its ISO string) or an
6
+ * ISO 8601 string, which is passed through unchanged.
7
+ */
8
+ dateTime: Date | string
9
+ /**
10
+ * The exact date and time, shown on hover ("8 October 2026, 14:03 UTC"),
11
+ * formatted by you in the reader's locale.
12
+ */
13
+ title?: string
14
+ /** What the reader sees: a relative label from `relative()`, or a date. */
15
+ children: ReactNode
16
+ }
17
+
18
+ /**
19
+ * Time — a date or time in running text as a `<time>` element: the visible
20
+ * text is yours (often `relative(date, {now, locale})`), the exact instant
21
+ * goes in `dateTime` for machines and `title` for people.
22
+ *
23
+ * Web only. A raw `<time>`: react-strict-dom has no `html.time`, and the
24
+ * element carries no style of its own (it inherits the text around it).
25
+ */
26
+ export function Time({dateTime, title, children}: TimeProps) {
27
+ const iso = typeof dateTime === 'string' ? dateTime : dateTime.toISOString()
28
+ return (
29
+ <time dateTime={iso} title={title}>
30
+ {children}
31
+ </time>
32
+ )
33
+ }
@@ -0,0 +1,89 @@
1
+ // Pure date arithmetic behind Time: no React, no react-strict-dom, so the
2
+ // unit project tests it in Node.
3
+
4
+ export interface RelativeOptions {
5
+ /**
6
+ * The instant to measure from. Required: read the clock once where the data
7
+ * is loaded (a route loader) and pass the same value to the server and the
8
+ * client render, so both produce the same text. Never `Date.now()` during a
9
+ * render — the client would hydrate a different string.
10
+ */
11
+ now: Date | number
12
+ /** BCP 47 locale(s) for the words ("3 days ago", "il y a 3 jours"). */
13
+ locale: string | readonly string[]
14
+ /**
15
+ * `auto` (default) says "yesterday" / "tomorrow" / "now" where the locale has
16
+ * a word for it; `always` keeps the number ("1 day ago").
17
+ */
18
+ numeric?: 'auto' | 'always'
19
+ }
20
+
21
+ type Step = readonly [Intl.RelativeTimeFormatUnit, number]
22
+
23
+ // Each unit is used while |difference| stays below the next one. Months and
24
+ // years are average lengths: a relative label is a rounded reading, the
25
+ // exact instant goes in the dateTime attribute.
26
+ const SECOND = 1000
27
+ const MINUTE = 60 * SECOND
28
+ const HOUR = 60 * MINUTE
29
+ const DAY = 24 * HOUR
30
+ const WEEK = 7 * DAY
31
+ const MONTH = 30.436875 * DAY
32
+ const YEAR = 365.2425 * DAY
33
+
34
+ const STEPS: readonly Step[] = [
35
+ ['second', SECOND],
36
+ ['minute', MINUTE],
37
+ ['hour', HOUR],
38
+ ['day', DAY],
39
+ ['week', WEEK],
40
+ ['month', MONTH],
41
+ ['year', YEAR],
42
+ ]
43
+
44
+ const LIMITS: Record<Intl.RelativeTimeFormatUnit, number> = {
45
+ second: MINUTE,
46
+ seconds: MINUTE,
47
+ minute: HOUR,
48
+ minutes: HOUR,
49
+ hour: DAY,
50
+ hours: DAY,
51
+ day: WEEK,
52
+ days: WEEK,
53
+ week: MONTH,
54
+ weeks: MONTH,
55
+ month: YEAR,
56
+ months: YEAR,
57
+ quarter: Infinity,
58
+ quarters: Infinity,
59
+ year: Infinity,
60
+ years: Infinity,
61
+ }
62
+
63
+ function toTime(value: Date | number | string): number {
64
+ return typeof value === 'number' ? value : new Date(value).getTime()
65
+ }
66
+
67
+ /**
68
+ * How far `date` is from `now`, in words, in the caller's locale:
69
+ * `relative(lastCommit, {now, locale: 'en'})` → "3 days ago". Built on
70
+ * `Intl.RelativeTimeFormat`; the largest unit that keeps the count at least 1
71
+ * is used, rounded to the nearest whole unit.
72
+ *
73
+ * An unparseable date throws a `RangeError` (as `Intl` does): check the data
74
+ * before rendering it.
75
+ */
76
+ export function relative(date: Date | number | string, options: RelativeOptions): string {
77
+ const {now, locale, numeric = 'auto'} = options
78
+ const target = toTime(date)
79
+ const from = toTime(now)
80
+ if (!Number.isFinite(target) || !Number.isFinite(from)) {
81
+ throw new RangeError(`relative: invalid date ${String(date)}`)
82
+ }
83
+ const diff = target - from
84
+ const [unit, size] =
85
+ STEPS.find(([unit]) => Math.abs(diff) < LIMITS[unit]) ?? STEPS[STEPS.length - 1]
86
+ // `+ 0` turns a -0 into 0, so "now" is not "0 seconds ago" in `always` mode.
87
+ const value = Math.round(diff / size) + 0
88
+ return new Intl.RelativeTimeFormat(locale as string | string[], {numeric}).format(value, unit)
89
+ }
@@ -0,0 +1,46 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Rows of date bars against a date axis (milestones, releases, sprints), with a progress fill per bar and a today marker. Dates are calendar days (YYYY-MM-DD, UTC). A bar is a toggle button (selected/onSelect), can be a DragDrop.Zone target (dropZone), and its edges are keyboard sliders whose value text is a date (onStartChange/onEndChange).',
6
+ anatomy: {required: ['Root', 'Row', 'Bar']},
7
+ whenToUse: [
8
+ 'Milestones or releases over weeks, where the overlap and today matter',
9
+ 'Dropping work onto a milestone (bars as DragDrop zones)',
10
+ ],
11
+ whenNotToUse: [
12
+ 'A schedule by hour — a calendar grid fits (timeGutterW, dayHeaderH)',
13
+ 'A dependency graph or a full Gantt chart with links — this draws bars, not edges',
14
+ 'Moving dates with the pointer — edges are keyboard sliders; give a date field for the rest',
15
+ ],
16
+ relatedTo: [
17
+ {
18
+ component: 'DragDrop',
19
+ kind: 'composition',
20
+ relationship: 'Pass dropZone to make a bar a DragDrop.Zone inside a DragDrop.Root',
21
+ },
22
+ {
23
+ component: 'Meter',
24
+ kind: 'contrast',
25
+ relationship: 'Meter shows one level; Timeline places spans of time',
26
+ },
27
+ ],
28
+ example: `<Timeline.Root aria-label="Milestones" start="2026-09-21" end="2026-11-08" today="2026-10-08">
29
+ {milestones.map((m) => (
30
+ <Timeline.Row key={m.id} label={m.title} description={\`\${m.done} of \${m.total} done\`}>
31
+ <Timeline.Bar
32
+ label={m.title}
33
+ start={m.start}
34
+ end={m.end}
35
+ progress={m.done / m.total}
36
+ selected={selected === m.id}
37
+ onSelect={() => select(m.id)}
38
+ onEndChange={(end) => reschedule(m.id, {end})}
39
+ dropZone={{id: \`ms:\${m.id}\`, label: m.title}}
40
+ >
41
+ {m.done}/{m.total}
42
+ </Timeline.Bar>
43
+ </Timeline.Row>
44
+ ))}
45
+ </Timeline.Root>`,
46
+ }
@@ -0,0 +1,238 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, fn, waitFor} from 'storybook/test'
3
+ import {useState} from 'react'
4
+ import {css, html} from 'react-strict-dom'
5
+ import {Timeline, type TimelineDate} from './Timeline'
6
+ import {DragDrop, type DragDropEvent} from '../DragDrop/DragDrop'
7
+ import {Text} from '../Text/Text'
8
+ import {onThemeSurface} from '../../docs/themedSurface'
9
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
10
+ import {radii, spacing} from '@duro-app/tokens/tokens/spacing.css'
11
+ import {borders} from '@duro-app/tokens/tokens/borders.css'
12
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
13
+
14
+ interface Args {
15
+ onDrop?: (e: DragDropEvent<Task>) => void
16
+ onSelect?: (id: string) => void
17
+ }
18
+
19
+ const meta: Meta<Args> = {
20
+ title: 'Components/Timeline',
21
+ parameters: {a11y: {test: 'error'}},
22
+ decorators: [onThemeSurface],
23
+ }
24
+ export default meta
25
+ type Story = StoryObj<Args>
26
+
27
+ const s = css.create({
28
+ stack: {display: 'flex', flexDirection: 'column', gap: spacing.lg},
29
+ card: {
30
+ padding: spacing.xs,
31
+ paddingLeft: spacing.sm,
32
+ paddingRight: spacing.sm,
33
+ borderWidth: borders.hairline,
34
+ borderStyle: 'solid',
35
+ borderColor: colors.border,
36
+ borderRadius: radii.sm,
37
+ backgroundColor: colors.bgCard,
38
+ fontSize: typography.fontSizeSm,
39
+ },
40
+ })
41
+
42
+ interface Milestone {
43
+ id: string
44
+ title: string
45
+ start: TimelineDate
46
+ end: TimelineDate
47
+ done: number
48
+ total: number
49
+ }
50
+
51
+ const MILESTONES: Milestone[] = [
52
+ {id: 'alpha', title: 'Alpha', start: '2026-09-21', end: '2026-10-04', done: 7, total: 7},
53
+ {id: 'beta', title: 'Beta', start: '2026-10-01', end: '2026-10-20', done: 4, total: 9},
54
+ {id: 'ga', title: 'GA', start: '2026-10-19', end: '2026-11-08', done: 0, total: 5},
55
+ ]
56
+
57
+ interface Task {
58
+ id: string
59
+ title: string
60
+ }
61
+
62
+ function Milestones({
63
+ onDrop,
64
+ onSelect,
65
+ droppable = false,
66
+ }: {
67
+ onDrop?: (e: DragDropEvent<Task>) => void
68
+ onSelect?: (id: string) => void
69
+ droppable?: boolean
70
+ }) {
71
+ const [items, setItems] = useState(MILESTONES)
72
+ const [selected, setSelected] = useState<string | null>(null)
73
+ const [tray, setTray] = useState<Task[]>([
74
+ {id: 't1', title: 'Write release notes'},
75
+ {id: 't2', title: 'Load test'},
76
+ ])
77
+ const patch = (id: string, change: Partial<Milestone>) =>
78
+ setItems((list) => list.map((m) => (m.id === id ? {...m, ...change} : m)))
79
+
80
+ const timeline = (
81
+ <Timeline.Root
82
+ aria-label="Milestones"
83
+ heading="Milestones"
84
+ start="2026-09-21"
85
+ end="2026-11-08"
86
+ today="2026-10-08"
87
+ locale="en-GB"
88
+ >
89
+ {items.map((m) => (
90
+ <Timeline.Row key={m.id} label={m.title} description={`${m.done} of ${m.total} done`}>
91
+ <Timeline.Bar<Task>
92
+ label={m.title}
93
+ start={m.start}
94
+ end={m.end}
95
+ progress={m.total ? m.done / m.total : 0}
96
+ tone={m.done === m.total ? 'done' : m.done === 0 ? 'planned' : 'default'}
97
+ selected={selected === m.id}
98
+ onSelect={() => {
99
+ onSelect?.(m.id)
100
+ setSelected((cur) => (cur === m.id ? null : m.id))
101
+ }}
102
+ onStartChange={(start) => patch(m.id, {start})}
103
+ onEndChange={(end) => patch(m.id, {end})}
104
+ dropZone={droppable ? {id: `ms:${m.id}`, label: m.title} : undefined}
105
+ >
106
+ {m.done}/{m.total}
107
+ </Timeline.Bar>
108
+ </Timeline.Row>
109
+ ))}
110
+ </Timeline.Root>
111
+ )
112
+
113
+ if (!droppable) return timeline
114
+ return (
115
+ <DragDrop.Root<Task>
116
+ onDrop={(e) => {
117
+ onDrop?.(e)
118
+ if (e.target.zone.startsWith('ms:')) {
119
+ const ms = e.target.zone.slice(3)
120
+ setTray((t) => t.filter((x) => x.id !== e.item.id))
121
+ setItems((list) => list.map((m) => (m.id === ms ? {...m, total: m.total + 1} : m)))
122
+ }
123
+ }}
124
+ >
125
+ <html.div style={s.stack}>
126
+ <Text variant="label">Unscheduled</Text>
127
+ <DragDrop.Zone id="tray" label="Unscheduled" list order={0}>
128
+ {tray.map((t) => (
129
+ <DragDrop.Item key={t.id} id={t.id} zone="tray" label={t.title} data={t}>
130
+ <DragDrop.Handle>
131
+ <html.div style={s.card}>{t.title}</html.div>
132
+ </DragDrop.Handle>
133
+ </DragDrop.Item>
134
+ ))}
135
+ </DragDrop.Zone>
136
+ {timeline}
137
+ </html.div>
138
+ </DragDrop.Root>
139
+ )
140
+ }
141
+
142
+ /** Three milestones over seven weeks, with progress fills, today, a
143
+ * selectable bar and keyboard edge sliders. */
144
+ export const Default: Story = {
145
+ render: (args) => <Milestones onSelect={args.onSelect} />,
146
+ args: {onSelect: fn()},
147
+ play: async ({canvas, args, userEvent}) => {
148
+ await expect(canvas.getByRole('group', {name: 'Milestones'})).toBeInTheDocument()
149
+ await expect(canvas.getByText('Today')).toBeInTheDocument()
150
+ const beta = canvas.getByRole('button', {name: 'Beta'})
151
+ await expect(beta).toHaveAttribute('aria-pressed', 'false')
152
+ await userEvent.click(beta)
153
+ await expect(args.onSelect).toHaveBeenCalledWith('beta')
154
+ await expect(beta).toHaveAttribute('aria-pressed', 'true')
155
+ },
156
+ }
157
+
158
+ /** Bar edges are sliders whose value is a date: arrows move a day, Page
159
+ * keys a week, Home/End to the limit. */
160
+ export const KeyboardEdges: Story = {
161
+ render: () => <Milestones />,
162
+ play: async ({canvas, userEvent}) => {
163
+ const end = canvas.getByRole('slider', {name: 'Beta, end'})
164
+ await expect(end).toHaveAttribute('aria-valuetext', '20 Oct')
165
+ end.focus()
166
+ await userEvent.keyboard('{ArrowRight}')
167
+ await waitFor(() => expect(end).toHaveAttribute('aria-valuetext', '21 Oct'))
168
+ await userEvent.keyboard('{PageUp}')
169
+ await waitFor(() => expect(end).toHaveAttribute('aria-valuetext', '28 Oct'))
170
+ await userEvent.keyboard('{End}')
171
+ await waitFor(() => expect(end).toHaveAttribute('aria-valuetext', '8 Nov'))
172
+ const start = canvas.getByRole('slider', {name: 'Beta, start'})
173
+ start.focus()
174
+ await userEvent.keyboard('{Home}')
175
+ await waitFor(() => expect(start).toHaveAttribute('aria-valuetext', '21 Sept'))
176
+ // The start cannot pass the end.
177
+ await expect(start).toHaveAttribute('aria-valuemax', end.getAttribute('aria-valuenow'))
178
+ },
179
+ }
180
+
181
+ /** Drag an unscheduled task onto a milestone bar (or pick it with Space and
182
+ * move it with the arrow keys). */
183
+ export const Droppable: Story = {
184
+ render: () => <Milestones droppable />,
185
+ }
186
+
187
+ /** Bars are DragDrop zones: drop a task on a milestone bar, by pointer or
188
+ * by keyboard. */
189
+ export const DropOnBar: Story = {
190
+ args: {onDrop: fn()},
191
+ render: (args) => <Milestones droppable onDrop={args.onDrop} />,
192
+ play: async ({canvas, args, userEvent}) => {
193
+ // Keyboard: pick, move right into the next zone in order (Alpha), on to
194
+ // Beta, drop.
195
+ canvas.getByRole('button', {name: 'Load test'}).focus()
196
+ await userEvent.keyboard(' ')
197
+ await userEvent.keyboard('{ArrowRight}{ArrowRight}')
198
+ await userEvent.keyboard(' ')
199
+ await expect(args.onDrop).toHaveBeenCalledTimes(1)
200
+ const call = (args.onDrop as unknown as ReturnType<typeof fn>).mock
201
+ .calls[0][0] as DragDropEvent<Task>
202
+ await expect(call.target.zone).toBe('ms:beta')
203
+ await expect(canvas.getByText('4/10')).toBeInTheDocument()
204
+
205
+ // Pointer: drag the other task onto GA.
206
+ const notes = canvas.getByRole('button', {name: 'Write release notes'})
207
+ const ga = canvas.getByRole('button', {name: 'GA'})
208
+ const a = notes.getBoundingClientRect()
209
+ const b = ga.getBoundingClientRect()
210
+ const init = (x: number, y: number, buttons = 1) => ({
211
+ bubbles: true,
212
+ pointerId: 21,
213
+ pointerType: 'mouse',
214
+ isPrimary: true,
215
+ button: 0,
216
+ buttons,
217
+ clientX: x,
218
+ clientY: y,
219
+ })
220
+ const from = {x: a.left + a.width / 2, y: a.top + a.height / 2}
221
+ const to = {x: b.left + b.width / 2, y: b.top + b.height / 2}
222
+ notes.parentElement!.dispatchEvent(new PointerEvent('pointerdown', init(from.x, from.y)))
223
+ for (let i = 1; i <= 8; i++) {
224
+ document.dispatchEvent(
225
+ new PointerEvent(
226
+ 'pointermove',
227
+ init(from.x + ((to.x - from.x) * i) / 8, from.y + ((to.y - from.y) * i) / 8),
228
+ ),
229
+ )
230
+ await new Promise((r) => requestAnimationFrame(() => r(null)))
231
+ }
232
+ document.dispatchEvent(new PointerEvent('pointerup', init(to.x, to.y, 0)))
233
+ await expect(args.onDrop).toHaveBeenCalledTimes(2)
234
+ const second = (args.onDrop as unknown as ReturnType<typeof fn>).mock
235
+ .calls[1][0] as DragDropEvent<Task>
236
+ await expect(second.target.zone).toBe('ms:ga')
237
+ },
238
+ }