@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.
- package/dist/{Table-B1sWL3GA.js → Table-CAjPFgTX.js} +398 -394
- package/dist/Table-CAjPFgTX.js.map +1 -0
- package/dist/{compiler-runtime-B2IcI8_O.js → compiler-runtime-BuugS4D0.js} +67 -59
- package/dist/{compiler-runtime-B2IcI8_O.js.map → compiler-runtime-BuugS4D0.js.map} +1 -1
- package/dist/components/AppShell/AppShell.d.ts +74 -0
- package/dist/components/AppShell/AppShell.d.ts.map +1 -0
- package/dist/components/AppShell/AppShell.meta.d.ts +3 -0
- package/dist/components/AppShell/AppShell.meta.d.ts.map +1 -0
- package/dist/components/AppShell/AppShell.stories.d.ts +186 -0
- package/dist/components/AppShell/AppShell.stories.d.ts.map +1 -0
- package/dist/components/AppShell/styles.css.d.ts +187 -0
- package/dist/components/AppShell/styles.css.d.ts.map +1 -0
- package/dist/components/Aside/Aside.d.ts +23 -0
- package/dist/components/Aside/Aside.d.ts.map +1 -0
- package/dist/components/Aside/Aside.meta.d.ts +3 -0
- package/dist/components/Aside/Aside.meta.d.ts.map +1 -0
- package/dist/components/Aside/Aside.stories.d.ts +45 -0
- package/dist/components/Aside/Aside.stories.d.ts.map +1 -0
- package/dist/components/Aside/styles.css.d.ts +44 -0
- package/dist/components/Aside/styles.css.d.ts.map +1 -0
- package/dist/components/Button/styles.css.d.ts +2 -0
- package/dist/components/Button/styles.css.d.ts.map +1 -1
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +3 -3
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
- package/dist/components/CodeBlock/CodeBlock.d.ts +29 -0
- package/dist/components/CodeBlock/CodeBlock.d.ts.map +1 -0
- package/dist/components/CodeBlock/CodeBlock.meta.d.ts +3 -0
- package/dist/components/CodeBlock/CodeBlock.meta.d.ts.map +1 -0
- package/dist/components/CodeBlock/CodeBlock.stories.d.ts +67 -0
- package/dist/components/CodeBlock/CodeBlock.stories.d.ts.map +1 -0
- package/dist/components/CodeBlock/styles.css.d.ts +32 -0
- package/dist/components/CodeBlock/styles.css.d.ts.map +1 -0
- package/dist/components/DragDrop/DragDrop.d.ts +95 -14
- package/dist/components/DragDrop/DragDrop.d.ts.map +1 -1
- package/dist/components/DragDrop/DragDrop.meta.d.ts.map +1 -1
- package/dist/components/DragDrop/DragDropBoard.stories.d.ts +69 -0
- package/dist/components/DragDrop/DragDropBoard.stories.d.ts.map +1 -0
- package/dist/components/DragDrop/styles.css.d.ts +80 -3
- package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
- package/dist/components/Grid/Grid.d.ts +13 -4
- package/dist/components/Grid/Grid.d.ts.map +1 -1
- package/dist/components/Grid/Grid.meta.d.ts.map +1 -1
- package/dist/components/Grid/Grid.stories.d.ts +28 -0
- package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
- package/dist/components/Grid/styles.css.d.ts +3 -0
- package/dist/components/Grid/styles.css.d.ts.map +1 -1
- package/dist/components/Heading/Heading.d.ts +3 -1
- package/dist/components/Heading/Heading.d.ts.map +1 -1
- package/dist/components/Heading/Heading.stories.d.ts +9 -0
- package/dist/components/Heading/Heading.stories.d.ts.map +1 -0
- package/dist/components/LinkButton/LinkButton.stories.d.ts +7 -0
- package/dist/components/LinkButton/LinkButton.stories.d.ts.map +1 -1
- package/dist/components/LinkButton/styles.css.d.ts +2 -0
- package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
- package/dist/components/LiveRegion/LiveRegion.d.ts +28 -0
- package/dist/components/LiveRegion/LiveRegion.d.ts.map +1 -0
- package/dist/components/LiveRegion/LiveRegion.meta.d.ts +3 -0
- package/dist/components/LiveRegion/LiveRegion.meta.d.ts.map +1 -0
- package/dist/components/LiveRegion/LiveRegion.stories.d.ts +66 -0
- package/dist/components/LiveRegion/LiveRegion.stories.d.ts.map +1 -0
- package/dist/components/Menu/Menu.stories.d.ts +7 -0
- package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
- package/dist/components/Menu/styles.css.d.ts +2 -0
- package/dist/components/Menu/styles.css.d.ts.map +1 -1
- package/dist/components/Meter/Meter.d.ts +17 -0
- package/dist/components/Meter/Meter.d.ts.map +1 -0
- package/dist/components/Meter/Meter.meta.d.ts +3 -0
- package/dist/components/Meter/Meter.meta.d.ts.map +1 -0
- package/dist/components/Meter/Meter.stories.d.ts +12 -0
- package/dist/components/Meter/Meter.stories.d.ts.map +1 -0
- package/dist/components/Meter/styles.css.d.ts +34 -0
- package/dist/components/Meter/styles.css.d.ts.map +1 -0
- package/dist/components/PageNav/styles.css.d.ts +1 -1
- package/dist/components/Prose/Prose.d.ts +17 -0
- package/dist/components/Prose/Prose.d.ts.map +1 -0
- package/dist/components/Prose/Prose.meta.d.ts +3 -0
- package/dist/components/Prose/Prose.meta.d.ts.map +1 -0
- package/dist/components/Prose/Prose.stories.d.ts +63 -0
- package/dist/components/Prose/Prose.stories.d.ts.map +1 -0
- package/dist/components/SideNav/SideNav.d.ts +7 -1
- package/dist/components/SideNav/SideNav.d.ts.map +1 -1
- package/dist/components/SideNav/SideNav.stories.d.ts +3 -0
- package/dist/components/SideNav/SideNav.stories.d.ts.map +1 -1
- package/dist/components/Time/Time.d.ts +26 -0
- package/dist/components/Time/Time.d.ts.map +1 -0
- package/dist/components/Time/Time.meta.d.ts +3 -0
- package/dist/components/Time/Time.meta.d.ts.map +1 -0
- package/dist/components/Time/Time.stories.d.ts +77 -0
- package/dist/components/Time/Time.stories.d.ts.map +1 -0
- package/dist/components/Time/relative.d.ts +27 -0
- package/dist/components/Time/relative.d.ts.map +1 -0
- package/dist/components/Timeline/Timeline.d.ts +71 -0
- package/dist/components/Timeline/Timeline.d.ts.map +1 -0
- package/dist/components/Timeline/Timeline.meta.d.ts +3 -0
- package/dist/components/Timeline/Timeline.meta.d.ts.map +1 -0
- package/dist/components/Timeline/Timeline.stories.d.ts +26 -0
- package/dist/components/Timeline/Timeline.stories.d.ts.map +1 -0
- package/dist/components/Timeline/styles.css.d.ts +158 -0
- package/dist/components/Timeline/styles.css.d.ts.map +1 -0
- package/dist/components/Toast/Toast.meta.d.ts.map +1 -1
- package/dist/components/Toast/Toast.stories.d.ts +9 -0
- package/dist/components/Toast/Toast.stories.d.ts.map +1 -1
- package/dist/components/Toast/ToastProvider.d.ts +11 -5
- package/dist/components/Toast/ToastProvider.d.ts.map +1 -1
- package/dist/components/Toast/index.d.ts +1 -1
- package/dist/components/Toast/index.d.ts.map +1 -1
- package/dist/components/Toast/styles.css.d.ts +6 -0
- package/dist/components/Toast/styles.css.d.ts.map +1 -1
- package/dist/form.js +1 -1
- package/dist/hooks/useContainerBelow.d.ts +18 -0
- package/dist/hooks/useContainerBelow.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7316 -5536
- package/dist/index.js.map +1 -1
- package/dist/table.js +2 -2
- package/package.json +2 -2
- package/src/components/AppShell/AppShell.meta.ts +62 -0
- package/src/components/AppShell/AppShell.stories.tsx +770 -0
- package/src/components/AppShell/AppShell.tsx +346 -0
- package/src/components/AppShell/styles.css.ts +219 -0
- package/src/components/Aside/Aside.meta.ts +33 -0
- package/src/components/Aside/Aside.stories.tsx +106 -0
- package/src/components/Aside/Aside.tsx +42 -0
- package/src/components/Aside/styles.css.ts +60 -0
- package/src/components/Button/styles.css.ts +7 -2
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +4 -5
- package/src/components/CodeBlock/CodeBlock.meta.ts +29 -0
- package/src/components/CodeBlock/CodeBlock.stories.tsx +162 -0
- package/src/components/CodeBlock/CodeBlock.tsx +99 -0
- package/src/components/CodeBlock/styles.css.ts +36 -0
- package/src/components/DragDrop/DragDrop.meta.ts +22 -21
- package/src/components/DragDrop/DragDrop.tsx +1147 -246
- package/src/components/DragDrop/DragDropBoard.stories.tsx +668 -0
- package/src/components/DragDrop/styles.css.ts +74 -4
- package/src/components/Grid/Grid.meta.ts +2 -1
- package/src/components/Grid/Grid.stories.tsx +202 -3
- package/src/components/Grid/Grid.tsx +35 -6
- package/src/components/Grid/styles.css.ts +11 -0
- package/src/components/Heading/Heading.stories.tsx +51 -0
- package/src/components/Heading/Heading.tsx +38 -7
- package/src/components/LinkButton/LinkButton.stories.tsx +41 -1
- package/src/components/LinkButton/styles.css.ts +6 -2
- package/src/components/LiveRegion/LiveRegion.meta.ts +29 -0
- package/src/components/LiveRegion/LiveRegion.stories.tsx +139 -0
- package/src/components/LiveRegion/LiveRegion.tsx +48 -0
- package/src/components/Menu/Menu.stories.tsx +62 -0
- package/src/components/Menu/styles.css.ts +6 -3
- package/src/components/Meter/Meter.meta.ts +24 -0
- package/src/components/Meter/Meter.stories.tsx +64 -0
- package/src/components/Meter/Meter.tsx +55 -0
- package/src/components/Meter/styles.css.ts +30 -0
- package/src/components/Prose/Prose.meta.ts +32 -0
- package/src/components/Prose/Prose.stories.tsx +233 -0
- package/src/components/Prose/Prose.tsx +25 -0
- package/src/components/Prose/prose.css +177 -0
- package/src/components/SideNav/SideNav.stories.tsx +29 -0
- package/src/components/SideNav/SideNav.tsx +14 -2
- package/src/components/Time/Time.meta.ts +17 -0
- package/src/components/Time/Time.stories.tsx +173 -0
- package/src/components/Time/Time.tsx +33 -0
- package/src/components/Time/relative.ts +89 -0
- package/src/components/Timeline/Timeline.meta.ts +46 -0
- package/src/components/Timeline/Timeline.stories.tsx +238 -0
- package/src/components/Timeline/Timeline.tsx +332 -0
- package/src/components/Timeline/styles.css.ts +162 -0
- package/src/components/Toast/Toast.meta.ts +2 -1
- package/src/components/Toast/Toast.stories.tsx +84 -0
- package/src/components/Toast/ToastProvider.tsx +49 -11
- package/src/components/Toast/index.ts +7 -1
- package/src/components/Toast/styles.css.ts +2 -0
- package/src/hooks/useContainerBelow.ts +39 -0
- package/src/index.ts +29 -1
- package/src/strict.css +4 -1
- 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
|
+
}
|