@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.
Files changed (115) hide show
  1. package/dist/{Table-DYN-bp0e.js → Table-Dj7PXqO_.js} +405 -387
  2. package/dist/Table-Dj7PXqO_.js.map +1 -0
  3. package/dist/components/Button/Button.d.ts +2 -1
  4. package/dist/components/Button/Button.d.ts.map +1 -1
  5. package/dist/components/Button/styles.css.d.ts +1 -0
  6. package/dist/components/Button/styles.css.d.ts.map +1 -1
  7. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +14 -0
  8. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
  9. package/dist/components/DragDrop/DragDrop.d.ts +95 -14
  10. package/dist/components/DragDrop/DragDrop.d.ts.map +1 -1
  11. package/dist/components/DragDrop/DragDrop.meta.d.ts.map +1 -1
  12. package/dist/components/DragDrop/DragDropBoard.stories.d.ts +69 -0
  13. package/dist/components/DragDrop/DragDropBoard.stories.d.ts.map +1 -0
  14. package/dist/components/DragDrop/styles.css.d.ts +80 -3
  15. package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
  16. package/dist/components/LinkButton/styles.css.d.ts +1 -0
  17. package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
  18. package/dist/components/Menu/Menu.d.ts +4 -1
  19. package/dist/components/Menu/Menu.d.ts.map +1 -1
  20. package/dist/components/Menu/styles.css.d.ts +14 -0
  21. package/dist/components/Menu/styles.css.d.ts.map +1 -1
  22. package/dist/components/Meter/Meter.d.ts +17 -0
  23. package/dist/components/Meter/Meter.d.ts.map +1 -0
  24. package/dist/components/Meter/Meter.meta.d.ts +3 -0
  25. package/dist/components/Meter/Meter.meta.d.ts.map +1 -0
  26. package/dist/components/Meter/Meter.stories.d.ts +12 -0
  27. package/dist/components/Meter/Meter.stories.d.ts.map +1 -0
  28. package/dist/components/Meter/styles.css.d.ts +34 -0
  29. package/dist/components/Meter/styles.css.d.ts.map +1 -0
  30. package/dist/components/Popover/Popover.d.ts +14 -2
  31. package/dist/components/Popover/Popover.d.ts.map +1 -1
  32. package/dist/components/Popover/Popover.meta.d.ts.map +1 -1
  33. package/dist/components/Popover/PopoverLayerContext.d.ts +3 -0
  34. package/dist/components/Popover/PopoverLayerContext.d.ts.map +1 -1
  35. package/dist/components/Popover/PopoverRaised.stories.d.ts +28 -0
  36. package/dist/components/Popover/PopoverRaised.stories.d.ts.map +1 -0
  37. package/dist/components/Popover/styles.css.d.ts +15 -0
  38. package/dist/components/Popover/styles.css.d.ts.map +1 -1
  39. package/dist/components/Select/Select.d.ts +4 -1
  40. package/dist/components/Select/Select.d.ts.map +1 -1
  41. package/dist/components/Select/styles.css.d.ts +9 -0
  42. package/dist/components/Select/styles.css.d.ts.map +1 -1
  43. package/dist/components/Timeline/Timeline.d.ts +71 -0
  44. package/dist/components/Timeline/Timeline.d.ts.map +1 -0
  45. package/dist/components/Timeline/Timeline.meta.d.ts +3 -0
  46. package/dist/components/Timeline/Timeline.meta.d.ts.map +1 -0
  47. package/dist/components/Timeline/Timeline.stories.d.ts +26 -0
  48. package/dist/components/Timeline/Timeline.stories.d.ts.map +1 -0
  49. package/dist/components/Timeline/styles.css.d.ts +158 -0
  50. package/dist/components/Timeline/styles.css.d.ts.map +1 -0
  51. package/dist/components/Toast/Toast.meta.d.ts.map +1 -1
  52. package/dist/components/Toast/Toast.stories.d.ts +9 -0
  53. package/dist/components/Toast/Toast.stories.d.ts.map +1 -1
  54. package/dist/components/Toast/ToastProvider.d.ts +11 -5
  55. package/dist/components/Toast/ToastProvider.d.ts.map +1 -1
  56. package/dist/components/Toast/index.d.ts +1 -1
  57. package/dist/components/Toast/index.d.ts.map +1 -1
  58. package/dist/components/Toast/styles.css.d.ts +6 -0
  59. package/dist/components/Toast/styles.css.d.ts.map +1 -1
  60. package/dist/components/Toggle/Toggle.d.ts +2 -1
  61. package/dist/components/Toggle/Toggle.d.ts.map +1 -1
  62. package/dist/components/Toggle/styles.css.d.ts +1 -0
  63. package/dist/components/Toggle/styles.css.d.ts.map +1 -1
  64. package/dist/index.css +1 -1
  65. package/dist/index.d.ts +6 -2
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +4992 -3845
  68. package/dist/index.js.map +1 -1
  69. package/dist/shared/ModalContext.d.ts +7 -0
  70. package/dist/shared/ModalContext.d.ts.map +1 -1
  71. package/dist/shared/exports.typecheck.d.ts +9 -0
  72. package/dist/shared/exports.typecheck.d.ts.map +1 -0
  73. package/dist/shared/types.d.ts +2 -0
  74. package/dist/shared/types.d.ts.map +1 -1
  75. package/dist/table.js +1 -1
  76. package/package.json +2 -2
  77. package/src/components/Button/Button.tsx +2 -1
  78. package/src/components/Button/styles.css.ts +3 -0
  79. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +172 -14
  80. package/src/components/DragDrop/DragDrop.meta.ts +22 -21
  81. package/src/components/DragDrop/DragDrop.tsx +1147 -246
  82. package/src/components/DragDrop/DragDropBoard.stories.tsx +668 -0
  83. package/src/components/DragDrop/styles.css.ts +74 -4
  84. package/src/components/LinkButton/styles.css.ts +4 -0
  85. package/src/components/Menu/Menu.meta.ts +1 -1
  86. package/src/components/Menu/Menu.tsx +17 -2
  87. package/src/components/Menu/styles.css.ts +18 -0
  88. package/src/components/Meter/Meter.meta.ts +24 -0
  89. package/src/components/Meter/Meter.stories.tsx +64 -0
  90. package/src/components/Meter/Meter.tsx +55 -0
  91. package/src/components/Meter/styles.css.ts +30 -0
  92. package/src/components/Popover/Popover.meta.ts +2 -1
  93. package/src/components/Popover/Popover.tsx +29 -4
  94. package/src/components/Popover/PopoverLayerContext.ts +3 -0
  95. package/src/components/Popover/PopoverRaised.stories.tsx +334 -0
  96. package/src/components/Popover/styles.css.ts +21 -0
  97. package/src/components/Select/Select.meta.ts +1 -1
  98. package/src/components/Select/Select.tsx +5 -1
  99. package/src/components/Select/styles.css.ts +12 -0
  100. package/src/components/Timeline/Timeline.meta.ts +46 -0
  101. package/src/components/Timeline/Timeline.stories.tsx +238 -0
  102. package/src/components/Timeline/Timeline.tsx +332 -0
  103. package/src/components/Timeline/styles.css.ts +162 -0
  104. package/src/components/Toast/Toast.meta.ts +2 -1
  105. package/src/components/Toast/Toast.stories.tsx +84 -0
  106. package/src/components/Toast/ToastProvider.tsx +49 -11
  107. package/src/components/Toast/index.ts +7 -1
  108. package/src/components/Toast/styles.css.ts +2 -0
  109. package/src/components/Toggle/Toggle.tsx +2 -1
  110. package/src/components/Toggle/styles.css.ts +2 -0
  111. package/src/index.ts +18 -0
  112. package/src/shared/ModalContext.ts +8 -1
  113. package/src/shared/exports.typecheck.ts +16 -0
  114. package/src/shared/types.ts +3 -0
  115. 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}