@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.
- package/dist/{Table-B1sWL3GA.js → Table-Dj7PXqO_.js} +3 -2
- package/dist/Table-Dj7PXqO_.js.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/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/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/index.css +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5029 -3927
- package/dist/index.js.map +1 -1
- package/dist/table.js +1 -1
- package/package.json +2 -2
- 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/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/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/index.ts +16 -0
- 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
|
-
|
|
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
|
+
}
|