@duro-app/ui 5.6.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 (60) hide show
  1. package/dist/{Table-B1sWL3GA.js → Table-Dj7PXqO_.js} +3 -2
  2. package/dist/Table-Dj7PXqO_.js.map +1 -0
  3. package/dist/components/DragDrop/DragDrop.d.ts +95 -14
  4. package/dist/components/DragDrop/DragDrop.d.ts.map +1 -1
  5. package/dist/components/DragDrop/DragDrop.meta.d.ts.map +1 -1
  6. package/dist/components/DragDrop/DragDropBoard.stories.d.ts +69 -0
  7. package/dist/components/DragDrop/DragDropBoard.stories.d.ts.map +1 -0
  8. package/dist/components/DragDrop/styles.css.d.ts +80 -3
  9. package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
  10. package/dist/components/Meter/Meter.d.ts +17 -0
  11. package/dist/components/Meter/Meter.d.ts.map +1 -0
  12. package/dist/components/Meter/Meter.meta.d.ts +3 -0
  13. package/dist/components/Meter/Meter.meta.d.ts.map +1 -0
  14. package/dist/components/Meter/Meter.stories.d.ts +12 -0
  15. package/dist/components/Meter/Meter.stories.d.ts.map +1 -0
  16. package/dist/components/Meter/styles.css.d.ts +34 -0
  17. package/dist/components/Meter/styles.css.d.ts.map +1 -0
  18. package/dist/components/Timeline/Timeline.d.ts +71 -0
  19. package/dist/components/Timeline/Timeline.d.ts.map +1 -0
  20. package/dist/components/Timeline/Timeline.meta.d.ts +3 -0
  21. package/dist/components/Timeline/Timeline.meta.d.ts.map +1 -0
  22. package/dist/components/Timeline/Timeline.stories.d.ts +26 -0
  23. package/dist/components/Timeline/Timeline.stories.d.ts.map +1 -0
  24. package/dist/components/Timeline/styles.css.d.ts +158 -0
  25. package/dist/components/Timeline/styles.css.d.ts.map +1 -0
  26. package/dist/components/Toast/Toast.meta.d.ts.map +1 -1
  27. package/dist/components/Toast/Toast.stories.d.ts +9 -0
  28. package/dist/components/Toast/Toast.stories.d.ts.map +1 -1
  29. package/dist/components/Toast/ToastProvider.d.ts +11 -5
  30. package/dist/components/Toast/ToastProvider.d.ts.map +1 -1
  31. package/dist/components/Toast/index.d.ts +1 -1
  32. package/dist/components/Toast/index.d.ts.map +1 -1
  33. package/dist/components/Toast/styles.css.d.ts +6 -0
  34. package/dist/components/Toast/styles.css.d.ts.map +1 -1
  35. package/dist/index.css +1 -1
  36. package/dist/index.d.ts +4 -2
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +5029 -3927
  39. package/dist/index.js.map +1 -1
  40. package/dist/table.js +1 -1
  41. package/package.json +2 -2
  42. package/src/components/DragDrop/DragDrop.meta.ts +22 -21
  43. package/src/components/DragDrop/DragDrop.tsx +1147 -246
  44. package/src/components/DragDrop/DragDropBoard.stories.tsx +668 -0
  45. package/src/components/DragDrop/styles.css.ts +74 -4
  46. package/src/components/Meter/Meter.meta.ts +24 -0
  47. package/src/components/Meter/Meter.stories.tsx +64 -0
  48. package/src/components/Meter/Meter.tsx +55 -0
  49. package/src/components/Meter/styles.css.ts +30 -0
  50. package/src/components/Timeline/Timeline.meta.ts +46 -0
  51. package/src/components/Timeline/Timeline.stories.tsx +238 -0
  52. package/src/components/Timeline/Timeline.tsx +332 -0
  53. package/src/components/Timeline/styles.css.ts +162 -0
  54. package/src/components/Toast/Toast.meta.ts +2 -1
  55. package/src/components/Toast/Toast.stories.tsx +84 -0
  56. package/src/components/Toast/ToastProvider.tsx +49 -11
  57. package/src/components/Toast/index.ts +7 -1
  58. package/src/components/Toast/styles.css.ts +2 -0
  59. package/src/index.ts +16 -0
  60. package/dist/Table-B1sWL3GA.js.map +0 -1
@@ -1,10 +1,11 @@
1
1
  import {css} from 'react-strict-dom'
2
2
  import {colors} from '@duro-app/tokens/tokens/colors.css'
3
- import {radii} from '@duro-app/tokens/tokens/spacing.css'
3
+ import {radii, spacing} from '@duro-app/tokens/tokens/spacing.css'
4
4
  import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
5
5
  import {shadows} from '@duro-app/tokens/tokens/shadows.css'
6
6
  import {borders} from '@duro-app/tokens/tokens/borders.css'
7
7
  import {layers} from '@duro-app/tokens/tokens/layers.css'
8
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
8
9
 
9
10
  export const styles = css.create({
10
11
  // The item is the drag handle: no browser panning starts on it, so a touch
@@ -26,12 +27,45 @@ export const styles = css.create({
26
27
  opacity: 0.4,
27
28
  cursor: 'grabbing',
28
29
  },
30
+ // The keyboard path: one focusable widget per item.
31
+ handle: {
32
+ borderRadius: radii.sm,
33
+ outlineStyle: {default: 'none', ':focus-visible': 'solid'},
34
+ outlineWidth: borders.focusRing,
35
+ outlineColor: colors.accent,
36
+ outlineOffset: borders.focusOffset,
37
+ },
38
+ // Picked up by keyboard: the ring stays on even without :focus-visible.
39
+ handlePicked: {
40
+ outlineStyle: 'dashed',
41
+ },
29
42
  zone: {
30
43
  borderRadius: radii.sm,
31
44
  transitionProperty: 'box-shadow, background-color',
32
45
  transitionDuration: duration.fast,
33
46
  transitionTimingFunction: easing.standard,
34
47
  },
48
+ listVertical: {
49
+ display: 'flex',
50
+ flexDirection: 'column',
51
+ },
52
+ listHorizontal: {
53
+ display: 'flex',
54
+ flexDirection: 'row',
55
+ },
56
+ gapXs: {gap: spacing.xs},
57
+ gapSm: {gap: spacing.sm},
58
+ gapMs: {gap: spacing.ms},
59
+ gapMd: {gap: spacing.md},
60
+ gapLg: {gap: spacing.lg},
61
+ gapXl: {gap: spacing.xl},
62
+ gapXxl: {gap: spacing.xxl},
63
+ gapXxxl: {gap: spacing.xxxl},
64
+ fill: {
65
+ boxSizing: 'border-box',
66
+ width: '100%',
67
+ height: '100%',
68
+ },
35
69
  // Every zone shows it can receive while something is in the air; the one
36
70
  // under the pointer lights up. Drop-target rings, not focus: keyboard focus
37
71
  // stays on outlines, which survive forced-colors mode.
@@ -52,6 +86,18 @@ export const styles = css.create({
52
86
  outlineColor: 'transparent',
53
87
  outlineOffset: `calc(-1 * ${borders.strong})`,
54
88
  },
89
+ // Refused with a reason: the zone says no in the error tone, the ghost
90
+ // carries the reason.
91
+ zoneRefused: {
92
+ backgroundColor: colors.errorBg,
93
+ outlineStyle: 'dashed',
94
+ outlineWidth: borders.strong,
95
+ outlineColor: colors.errorBorder,
96
+ outlineOffset: `calc(-1 * ${borders.strong})`,
97
+ },
98
+ placeholder: {
99
+ flexShrink: 0,
100
+ },
55
101
  ghost: {
56
102
  position: 'fixed',
57
103
  top: 0,
@@ -63,9 +109,33 @@ export const styles = css.create({
63
109
  opacity: 0.9,
64
110
  cursor: 'grabbing',
65
111
  },
66
- ghostAt: (x: number, y: number, width: number, height: number) => ({
112
+ // The lifted look: a slight tilt, dropped under reduced motion.
113
+ ghostTilt: {
114
+ transform: {
115
+ default: 'rotate(2deg)',
116
+ '@media (prefers-reduced-motion: reduce)': 'none',
117
+ },
118
+ },
119
+ refusal: {
120
+ marginTop: spacing.xs,
121
+ paddingTop: spacing.xs,
122
+ paddingBottom: spacing.xs,
123
+ paddingLeft: spacing.sm,
124
+ paddingRight: spacing.sm,
125
+ borderRadius: radii.xs,
126
+ borderWidth: borders.hairline,
127
+ borderStyle: 'solid',
128
+ borderColor: colors.errorBorder,
129
+ backgroundColor: colors.bgCard,
130
+ color: colors.errorText,
131
+ fontSize: typography.fontSizeXs,
132
+ lineHeight: typography.lineHeight,
133
+ boxShadow: shadows.md,
134
+ width: 'max-content',
135
+ maxWidth: '100%',
136
+ },
137
+ ghostSize: (width: number, height: number) => ({
67
138
  width,
68
- height,
69
- transform: `translate3d(${x}px, ${y}px, 0)`,
139
+ minHeight: height,
70
140
  }),
71
141
  })
@@ -0,0 +1,24 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'A scalar inside a known range — WIP 2 of 3, a milestone 40% done — as role="meter" with a value text, drawn as a thin bar (the meterH token). The label is the accessible name; valueText says the value in words.',
6
+ whenToUse: [
7
+ 'A level against a limit or a total: WIP against its limit, tasks done in a milestone, quota used',
8
+ 'Beside a count, when the proportion matters more than the number',
9
+ ],
10
+ whenNotToUse: [
11
+ 'Progress of a task that is still running — use Spinner (or a determinate progress pattern)',
12
+ 'Picking a value — use an input; a meter has no thumb',
13
+ 'A status with no range (ok/failing) — use Badge or StatusIcon',
14
+ ],
15
+ relatedTo: [
16
+ {
17
+ component: 'Badge',
18
+ kind: 'contrast',
19
+ relationship: 'Badge states a status or a count; Meter shows a level within a range',
20
+ },
21
+ ],
22
+ example: `<Meter label="In progress WIP" value={2} max={3} valueText="2 of 3" />
23
+ <Meter label="Milestone done" value={0.4} tone="success" />`,
24
+ }
@@ -0,0 +1,64 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect} from 'storybook/test'
3
+ import {css, html} from 'react-strict-dom'
4
+ import {Meter} from './Meter'
5
+ import {Text} from '../Text/Text'
6
+ import {onThemeSurface} from '../../docs/themedSurface'
7
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
8
+ import {sizes} from '@duro-app/tokens/tokens/sizes.css'
9
+
10
+ const s = css.create({
11
+ col: {display: 'flex', flexDirection: 'column', gap: spacing.md, maxWidth: sizes.gridColMd},
12
+ })
13
+
14
+ const meta: Meta<typeof Meter> = {
15
+ title: 'Components/Meter',
16
+ component: Meter,
17
+ parameters: {a11y: {test: 'error'}},
18
+ decorators: [onThemeSurface],
19
+ }
20
+ export default meta
21
+ type Story = StoryObj<typeof Meter>
22
+
23
+ export const Default: Story = {
24
+ args: {label: 'Milestone done', value: 0.4},
25
+ play: async ({canvas}) => {
26
+ const meter = canvas.getByRole('meter', {name: 'Milestone done'})
27
+ await expect(meter).toHaveAttribute('aria-valuenow', '0.4')
28
+ await expect(meter).toHaveAttribute('aria-valuemin', '0')
29
+ await expect(meter).toHaveAttribute('aria-valuemax', '1')
30
+ await expect(meter).toHaveAttribute('aria-valuetext', '40%')
31
+ },
32
+ }
33
+
34
+ /** Tones for a level against a limit: under, at, over. */
35
+ export const Tones: Story = {
36
+ render: () => (
37
+ <html.div style={s.col}>
38
+ <Text variant="label">Next · 1 of 3</Text>
39
+ <Meter label="Next WIP" value={1} max={3} valueText="1 of 3" />
40
+ <Text variant="label">In progress · 3 of 3</Text>
41
+ <Meter label="In progress WIP" value={3} max={3} valueText="3 of 3" tone="warning" />
42
+ <Text variant="label">Review · 4 of 3</Text>
43
+ <Meter label="Review WIP" value={4} max={3} valueText="4 of 3, over the limit" tone="error" />
44
+ <Text variant="label">Milestone · 70% done</Text>
45
+ <Meter label="Milestone done" value={0.7} tone="success" />
46
+ </html.div>
47
+ ),
48
+ }
49
+
50
+ /** Over the maximum, aria-valuenow stays in range and the text carries the
51
+ * true value; the bar fills, no further. */
52
+ export const OverTheLimit: Story = {
53
+ args: {label: 'Review WIP', value: 4, max: 3, valueText: '4 of 3, over the limit', tone: 'error'},
54
+ play: async ({canvas}) => {
55
+ const meter = canvas.getByRole('meter', {name: 'Review WIP'})
56
+ await expect(meter).toHaveAttribute('aria-valuenow', '3')
57
+ await expect(meter).toHaveAttribute('aria-valuetext', '4 of 3, over the limit')
58
+ const fill = meter.firstElementChild as HTMLElement
59
+ await expect(fill.getBoundingClientRect().width).toBeCloseTo(
60
+ meter.getBoundingClientRect().width,
61
+ 0,
62
+ )
63
+ },
64
+ }
@@ -0,0 +1,55 @@
1
+ import {html} from 'react-strict-dom'
2
+ import {styles} from './styles.css'
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Meter — a scalar inside a known range (WIP 2 of 3, 40% of a milestone's
6
+ // tasks done): role="meter" with a value text, drawn as a thin bar of the
7
+ // `meterH` token. Not a progress bar for a running task (use Spinner) and
8
+ // not an input (no thumb).
9
+ // ---------------------------------------------------------------------------
10
+
11
+ export type MeterTone = 'accent' | 'success' | 'warning' | 'error'
12
+
13
+ export interface MeterProps {
14
+ value: number
15
+ /** Defaults to 0. */
16
+ min?: number
17
+ /** Defaults to 1, so a fraction needs nothing else. */
18
+ max?: number
19
+ /** The accessible name, e.g. "In progress WIP". */
20
+ label: string
21
+ /** What screen readers read for the value, e.g. "4 of 3, over the limit".
22
+ * Defaults to the percentage. */
23
+ valueText?: string
24
+ /** The fill's colour; defaults to accent. */
25
+ tone?: MeterTone
26
+ }
27
+
28
+ const toneStyle = {
29
+ accent: styles.accent,
30
+ success: styles.success,
31
+ warning: styles.warning,
32
+ error: styles.error,
33
+ } as const
34
+
35
+ export function Meter({value, min = 0, max = 1, label, valueText, tone = 'accent'}: MeterProps) {
36
+ const span = max - min
37
+ const fraction = span > 0 ? (value - min) / span : 0
38
+ const clamped = Math.min(Math.max(fraction, 0), 1)
39
+ // aria-valuenow must sit inside the range; the true value (over the
40
+ // limit, say) is what valueText is for.
41
+ const now = Math.min(Math.max(value, min), max)
42
+ return (
43
+ <html.div
44
+ role="meter"
45
+ aria-label={label}
46
+ aria-valuemin={min}
47
+ aria-valuemax={max}
48
+ aria-valuenow={now}
49
+ aria-valuetext={valueText ?? `${Math.round(clamped * 100)}%`}
50
+ style={styles.track}
51
+ >
52
+ <html.div style={[styles.fill, toneStyle[tone], styles.fillAt(clamped * 100)]} />
53
+ </html.div>
54
+ )
55
+ }
@@ -0,0 +1,30 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {radii} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {sizes} from '@duro-app/tokens/tokens/sizes.css'
5
+ import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
6
+
7
+ export const styles = css.create({
8
+ track: {
9
+ position: 'relative',
10
+ width: '100%',
11
+ height: sizes.meterH,
12
+ borderRadius: radii.full,
13
+ backgroundColor: colors.border,
14
+ overflow: 'hidden',
15
+ },
16
+ fill: {
17
+ height: '100%',
18
+ borderRadius: radii.full,
19
+ transitionProperty: 'width',
20
+ transitionDuration: duration.base,
21
+ transitionTimingFunction: easing.standard,
22
+ },
23
+ accent: {backgroundColor: colors.accent},
24
+ success: {backgroundColor: colors.success},
25
+ warning: {backgroundColor: colors.warning},
26
+ error: {backgroundColor: colors.error},
27
+ fillAt: (percent: number) => ({
28
+ width: `${percent}%`,
29
+ }),
30
+ })
@@ -0,0 +1,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
+ }