@duro-app/ui 5.5.0 → 5.7.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-DYN-bp0e.js → Table-Dj7PXqO_.js} +405 -387
- package/dist/Table-Dj7PXqO_.js.map +1 -0
- package/dist/components/Button/Button.d.ts +2 -1
- package/dist/components/Button/Button.d.ts.map +1 -1
- package/dist/components/Button/styles.css.d.ts +1 -0
- package/dist/components/Button/styles.css.d.ts.map +1 -1
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +14 -0
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
- 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/LinkButton/styles.css.d.ts +1 -0
- package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
- package/dist/components/Menu/Menu.d.ts +4 -1
- package/dist/components/Menu/Menu.d.ts.map +1 -1
- package/dist/components/Menu/styles.css.d.ts +14 -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/Popover/Popover.d.ts +14 -2
- package/dist/components/Popover/Popover.d.ts.map +1 -1
- package/dist/components/Popover/Popover.meta.d.ts.map +1 -1
- package/dist/components/Popover/PopoverLayerContext.d.ts +3 -0
- package/dist/components/Popover/PopoverLayerContext.d.ts.map +1 -1
- package/dist/components/Popover/PopoverRaised.stories.d.ts +28 -0
- package/dist/components/Popover/PopoverRaised.stories.d.ts.map +1 -0
- package/dist/components/Popover/styles.css.d.ts +15 -0
- package/dist/components/Popover/styles.css.d.ts.map +1 -1
- package/dist/components/Select/Select.d.ts +4 -1
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/styles.css.d.ts +9 -0
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- 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/components/Toggle/Toggle.d.ts +2 -1
- package/dist/components/Toggle/Toggle.d.ts.map +1 -1
- package/dist/components/Toggle/styles.css.d.ts +1 -0
- package/dist/components/Toggle/styles.css.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4992 -3845
- package/dist/index.js.map +1 -1
- package/dist/shared/ModalContext.d.ts +7 -0
- package/dist/shared/ModalContext.d.ts.map +1 -1
- package/dist/shared/exports.typecheck.d.ts +9 -0
- package/dist/shared/exports.typecheck.d.ts.map +1 -0
- package/dist/shared/types.d.ts +2 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/table.js +1 -1
- package/package.json +2 -2
- package/src/components/Button/Button.tsx +2 -1
- package/src/components/Button/styles.css.ts +3 -0
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +172 -14
- 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/LinkButton/styles.css.ts +4 -0
- package/src/components/Menu/Menu.meta.ts +1 -1
- package/src/components/Menu/Menu.tsx +17 -2
- package/src/components/Menu/styles.css.ts +18 -0
- 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/Popover/Popover.meta.ts +2 -1
- package/src/components/Popover/Popover.tsx +29 -4
- package/src/components/Popover/PopoverLayerContext.ts +3 -0
- package/src/components/Popover/PopoverRaised.stories.tsx +334 -0
- package/src/components/Popover/styles.css.ts +21 -0
- package/src/components/Select/Select.meta.ts +1 -1
- package/src/components/Select/Select.tsx +5 -1
- package/src/components/Select/styles.css.ts +12 -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/components/Toggle/Toggle.tsx +2 -1
- package/src/components/Toggle/styles.css.ts +2 -0
- package/src/index.ts +18 -0
- package/src/shared/ModalContext.ts +8 -1
- package/src/shared/exports.typecheck.ts +16 -0
- package/src/shared/types.ts +3 -0
- package/dist/Table-DYN-bp0e.js.map +0 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import {createContext, useContext, useMemo, type ReactNode} from 'react'
|
|
2
|
+
import {html} from 'react-strict-dom'
|
|
3
|
+
import {DragDrop, type DragDropItemData, type DragDropVerdict} from '../DragDrop/DragDrop'
|
|
4
|
+
import {styles} from './styles.css'
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Timeline — rows of date bars against a date axis: milestones, releases,
|
|
8
|
+
// sprints. Dates are calendar days ('YYYY-MM-DD', read as UTC) so a bar
|
|
9
|
+
// never shifts with the viewer's time zone.
|
|
10
|
+
//
|
|
11
|
+
// A bar is a toggle button (select it) with a progress fill, and can be a
|
|
12
|
+
// DragDrop.Zone target (`dropZone`) inside a DragDrop.Root. Its two edges are
|
|
13
|
+
// sliders whose value text is a date: Left/Right move a day, PageUp/PageDown
|
|
14
|
+
// a week, Home/End to the limit. They are keyboard-only — there is no
|
|
15
|
+
// pointer drag of an edge.
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
/** A calendar day, 'YYYY-MM-DD'. */
|
|
19
|
+
export type TimelineDate = string
|
|
20
|
+
|
|
21
|
+
const DAY_MS = 86_400_000
|
|
22
|
+
|
|
23
|
+
const dayOf = (date: TimelineDate): number => Math.floor(Date.parse(`${date}T00:00:00Z`) / DAY_MS)
|
|
24
|
+
const dateOf = (day: number): TimelineDate => new Date(day * DAY_MS).toISOString().slice(0, 10)
|
|
25
|
+
|
|
26
|
+
interface TimelineContextValue {
|
|
27
|
+
readonly first: number
|
|
28
|
+
readonly days: number
|
|
29
|
+
readonly today: number | null
|
|
30
|
+
readonly todayLabel: string
|
|
31
|
+
readonly formatDate: (date: TimelineDate) => string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const TimelineContext = createContext<TimelineContextValue | null>(null)
|
|
35
|
+
|
|
36
|
+
const useTimeline = (part: string) => {
|
|
37
|
+
const ctx = useContext(TimelineContext)
|
|
38
|
+
if (!ctx) throw new Error(`Timeline.${part} must be used within Timeline.Root`)
|
|
39
|
+
return ctx
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Percent of the axis from its first day to `day`. */
|
|
43
|
+
const at = (ctx: TimelineContextValue, day: number) => ((day - ctx.first) / ctx.days) * 100
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Root
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export interface TimelineRootProps {
|
|
50
|
+
/** First day on the axis. */
|
|
51
|
+
start: TimelineDate
|
|
52
|
+
/** Last day on the axis (included). */
|
|
53
|
+
end: TimelineDate
|
|
54
|
+
/** Draws the today marker when inside the range. */
|
|
55
|
+
today?: TimelineDate
|
|
56
|
+
/** The timeline's accessible name. */
|
|
57
|
+
'aria-label': string
|
|
58
|
+
/** Text above the row labels, e.g. "Milestones". */
|
|
59
|
+
heading?: ReactNode
|
|
60
|
+
/** The today marker's text. */
|
|
61
|
+
todayLabel?: string
|
|
62
|
+
/** BCP 47 locale for the default date format. */
|
|
63
|
+
locale?: string
|
|
64
|
+
/** Formats a day for the axis and the edge sliders' value text. */
|
|
65
|
+
formatDate?: (date: TimelineDate) => string
|
|
66
|
+
children: ReactNode
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function Root({
|
|
70
|
+
start,
|
|
71
|
+
end,
|
|
72
|
+
today,
|
|
73
|
+
'aria-label': ariaLabel,
|
|
74
|
+
heading,
|
|
75
|
+
todayLabel = 'Today',
|
|
76
|
+
locale,
|
|
77
|
+
formatDate,
|
|
78
|
+
children,
|
|
79
|
+
}: TimelineRootProps) {
|
|
80
|
+
const ctx = useMemo<TimelineContextValue>(() => {
|
|
81
|
+
const first = dayOf(start)
|
|
82
|
+
const days = Math.max(dayOf(end) - first + 1, 1)
|
|
83
|
+
const t = today === undefined ? null : dayOf(today)
|
|
84
|
+
return {
|
|
85
|
+
first,
|
|
86
|
+
days,
|
|
87
|
+
today: t !== null && t >= first && t < first + days ? t : null,
|
|
88
|
+
todayLabel,
|
|
89
|
+
formatDate:
|
|
90
|
+
formatDate ??
|
|
91
|
+
((date) =>
|
|
92
|
+
new Date(`${date}T00:00:00Z`).toLocaleDateString(locale, {
|
|
93
|
+
day: 'numeric',
|
|
94
|
+
month: 'short',
|
|
95
|
+
timeZone: 'UTC',
|
|
96
|
+
})),
|
|
97
|
+
}
|
|
98
|
+
}, [start, end, today, todayLabel, locale, formatDate])
|
|
99
|
+
|
|
100
|
+
// Daily ticks for up to three weeks, weekly beyond.
|
|
101
|
+
const step = ctx.days > 21 ? 7 : 1
|
|
102
|
+
const ticks: number[] = []
|
|
103
|
+
for (let d = ctx.first; d < ctx.first + ctx.days; d += step) ticks.push(d)
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<TimelineContext.Provider value={ctx}>
|
|
107
|
+
<html.div role="group" aria-label={ariaLabel} style={styles.root}>
|
|
108
|
+
<html.div style={styles.heading}>{heading}</html.div>
|
|
109
|
+
<html.div aria-hidden style={styles.axis}>
|
|
110
|
+
{ticks.map((d) => (
|
|
111
|
+
<html.span key={d} style={[styles.tick, styles.left(at(ctx, d))]}>
|
|
112
|
+
{ctx.formatDate(dateOf(d))}
|
|
113
|
+
</html.span>
|
|
114
|
+
))}
|
|
115
|
+
{ctx.today !== null && (
|
|
116
|
+
<html.span style={[styles.todayLabel, styles.left(at(ctx, ctx.today + 0.5))]}>
|
|
117
|
+
{todayLabel}
|
|
118
|
+
</html.span>
|
|
119
|
+
)}
|
|
120
|
+
</html.div>
|
|
121
|
+
{children}
|
|
122
|
+
</html.div>
|
|
123
|
+
</TimelineContext.Provider>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Row
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
|
|
131
|
+
export interface TimelineRowProps {
|
|
132
|
+
/** The row's name, shown in the label column. */
|
|
133
|
+
label: string
|
|
134
|
+
/** A second line under the label, e.g. "4 of 7 done". */
|
|
135
|
+
description?: ReactNode
|
|
136
|
+
children?: ReactNode
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function Row({label, description, children}: TimelineRowProps) {
|
|
140
|
+
const ctx = useTimeline('Row')
|
|
141
|
+
return (
|
|
142
|
+
<>
|
|
143
|
+
<html.div style={styles.label}>
|
|
144
|
+
<html.span style={styles.labelText}>{label}</html.span>
|
|
145
|
+
{description !== undefined && (
|
|
146
|
+
<html.span style={styles.labelDescription}>{description}</html.span>
|
|
147
|
+
)}
|
|
148
|
+
</html.div>
|
|
149
|
+
<html.div role="group" aria-label={label} style={styles.track}>
|
|
150
|
+
{ctx.today !== null && (
|
|
151
|
+
<html.div aria-hidden style={[styles.today, styles.left(at(ctx, ctx.today + 0.5))]} />
|
|
152
|
+
)}
|
|
153
|
+
{children}
|
|
154
|
+
</html.div>
|
|
155
|
+
</>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Bar
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
export interface TimelineBarDropZone<T = unknown> {
|
|
164
|
+
/** The DragDrop zone id; drops arrive in the Root's onDrop with it. */
|
|
165
|
+
id: string
|
|
166
|
+
/** Read to screen readers when an item moves or drops here. */
|
|
167
|
+
label: string
|
|
168
|
+
accepts?: (item: DragDropItemData<T>) => DragDropVerdict
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface TimelineBarProps<T = unknown> {
|
|
172
|
+
start: TimelineDate
|
|
173
|
+
end: TimelineDate
|
|
174
|
+
/** The bar's accessible name. */
|
|
175
|
+
label: string
|
|
176
|
+
/** Done fraction, 0–1, drawn as a fill from the start edge. */
|
|
177
|
+
progress?: number
|
|
178
|
+
/** `planned` draws a dashed outline; `done` the success tone. */
|
|
179
|
+
tone?: 'default' | 'planned' | 'done'
|
|
180
|
+
/** Makes the bar a toggle button (aria-pressed). */
|
|
181
|
+
selected?: boolean
|
|
182
|
+
onSelect?: () => void
|
|
183
|
+
/** Adds the start-edge slider. */
|
|
184
|
+
onStartChange?: (date: TimelineDate) => void
|
|
185
|
+
/** Adds the end-edge slider. */
|
|
186
|
+
onEndChange?: (date: TimelineDate) => void
|
|
187
|
+
/** Names of the edge sliders; default "<label>, start" / "<label>, end". */
|
|
188
|
+
startLabel?: string
|
|
189
|
+
endLabel?: string
|
|
190
|
+
/** Makes the bar a DragDrop.Zone (needs a DragDrop.Root above). */
|
|
191
|
+
dropZone?: TimelineBarDropZone<T>
|
|
192
|
+
/** Text inside the bar, e.g. "4/7". */
|
|
193
|
+
children?: ReactNode
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const toneStyle = {
|
|
197
|
+
default: null,
|
|
198
|
+
planned: styles.barPlanned,
|
|
199
|
+
done: styles.barDone,
|
|
200
|
+
} as const
|
|
201
|
+
|
|
202
|
+
function Bar<T = unknown>({
|
|
203
|
+
start,
|
|
204
|
+
end,
|
|
205
|
+
label,
|
|
206
|
+
progress,
|
|
207
|
+
tone = 'default',
|
|
208
|
+
selected,
|
|
209
|
+
onSelect,
|
|
210
|
+
onStartChange,
|
|
211
|
+
onEndChange,
|
|
212
|
+
startLabel,
|
|
213
|
+
endLabel,
|
|
214
|
+
dropZone,
|
|
215
|
+
children,
|
|
216
|
+
}: TimelineBarProps<T>) {
|
|
217
|
+
const ctx = useTimeline('Bar')
|
|
218
|
+
const s = dayOf(start)
|
|
219
|
+
const e = dayOf(end)
|
|
220
|
+
const left = at(ctx, s)
|
|
221
|
+
const width = ((e - s + 1) / ctx.days) * 100
|
|
222
|
+
const last = ctx.first + ctx.days - 1
|
|
223
|
+
|
|
224
|
+
const face = (
|
|
225
|
+
<>
|
|
226
|
+
<html.button
|
|
227
|
+
type="button"
|
|
228
|
+
aria-label={label}
|
|
229
|
+
aria-pressed={selected}
|
|
230
|
+
onClick={onSelect}
|
|
231
|
+
style={[styles.face, toneStyle[tone], selected === true && styles.faceSelected]}
|
|
232
|
+
>
|
|
233
|
+
{progress !== undefined && progress > 0 && (
|
|
234
|
+
<html.span
|
|
235
|
+
aria-hidden
|
|
236
|
+
style={[
|
|
237
|
+
styles.fill,
|
|
238
|
+
tone === 'done' && styles.fillDone,
|
|
239
|
+
styles.width(Math.min(Math.max(progress, 0), 1) * 100),
|
|
240
|
+
]}
|
|
241
|
+
/>
|
|
242
|
+
)}
|
|
243
|
+
{children !== undefined && <html.span style={styles.barText}>{children}</html.span>}
|
|
244
|
+
</html.button>
|
|
245
|
+
{onStartChange && (
|
|
246
|
+
<EdgeSlider
|
|
247
|
+
edge="start"
|
|
248
|
+
label={startLabel ?? `${label}, start`}
|
|
249
|
+
day={s}
|
|
250
|
+
min={ctx.first}
|
|
251
|
+
max={e}
|
|
252
|
+
onChange={onStartChange}
|
|
253
|
+
/>
|
|
254
|
+
)}
|
|
255
|
+
{onEndChange && (
|
|
256
|
+
<EdgeSlider
|
|
257
|
+
edge="end"
|
|
258
|
+
label={endLabel ?? `${label}, end`}
|
|
259
|
+
day={e}
|
|
260
|
+
min={s}
|
|
261
|
+
max={last}
|
|
262
|
+
onChange={onEndChange}
|
|
263
|
+
/>
|
|
264
|
+
)}
|
|
265
|
+
</>
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
return (
|
|
269
|
+
<html.div style={[styles.bar, styles.left(left), styles.width(width)]}>
|
|
270
|
+
{dropZone ? (
|
|
271
|
+
<DragDrop.Zone<T> id={dropZone.id} label={dropZone.label} accepts={dropZone.accepts} fill>
|
|
272
|
+
{face}
|
|
273
|
+
</DragDrop.Zone>
|
|
274
|
+
) : (
|
|
275
|
+
face
|
|
276
|
+
)}
|
|
277
|
+
</html.div>
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function EdgeSlider({
|
|
282
|
+
edge,
|
|
283
|
+
label,
|
|
284
|
+
day,
|
|
285
|
+
min,
|
|
286
|
+
max,
|
|
287
|
+
onChange,
|
|
288
|
+
}: {
|
|
289
|
+
edge: 'start' | 'end'
|
|
290
|
+
label: string
|
|
291
|
+
day: number
|
|
292
|
+
min: number
|
|
293
|
+
max: number
|
|
294
|
+
onChange: (date: TimelineDate) => void
|
|
295
|
+
}) {
|
|
296
|
+
const ctx = useTimeline('Bar')
|
|
297
|
+
const onKeyDown = (event: unknown) => {
|
|
298
|
+
const e = event as {key: string; preventDefault(): void}
|
|
299
|
+
const by: Record<string, number> = {
|
|
300
|
+
ArrowLeft: -1,
|
|
301
|
+
ArrowDown: -1,
|
|
302
|
+
ArrowRight: 1,
|
|
303
|
+
ArrowUp: 1,
|
|
304
|
+
PageDown: -7,
|
|
305
|
+
PageUp: 7,
|
|
306
|
+
}
|
|
307
|
+
let next: number
|
|
308
|
+
if (e.key in by) next = day + by[e.key]
|
|
309
|
+
else if (e.key === 'Home') next = min
|
|
310
|
+
else if (e.key === 'End') next = max
|
|
311
|
+
else return
|
|
312
|
+
e.preventDefault()
|
|
313
|
+
next = Math.min(Math.max(next, min), max)
|
|
314
|
+
if (next !== day) onChange(dateOf(next))
|
|
315
|
+
}
|
|
316
|
+
return (
|
|
317
|
+
<html.div
|
|
318
|
+
role="slider"
|
|
319
|
+
tabIndex={0}
|
|
320
|
+
aria-label={label}
|
|
321
|
+
aria-orientation="horizontal"
|
|
322
|
+
aria-valuemin={min - ctx.first}
|
|
323
|
+
aria-valuemax={max - ctx.first}
|
|
324
|
+
aria-valuenow={day - ctx.first}
|
|
325
|
+
aria-valuetext={ctx.formatDate(dateOf(day))}
|
|
326
|
+
onKeyDown={onKeyDown}
|
|
327
|
+
style={[styles.edge, edge === 'start' ? styles.edgeStart : styles.edgeEnd]}
|
|
328
|
+
/>
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export const Timeline = {Root, Row, Bar}
|