@charcuterie/ui 4.6.0 → 4.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/README.md +12 -0
- package/dist/LaneTimeline/LaneTimeline.d.ts +132 -0
- package/dist/LaneTimeline/LaneTimeline.d.ts.map +1 -0
- package/dist/LaneTimeline/LaneTimeline.js +165 -0
- package/dist/LaneTimeline/LaneTimeline.js.map +1 -0
- package/dist/LaneTimeline/LaneTimelineBar.d.ts +97 -0
- package/dist/LaneTimeline/LaneTimelineBar.d.ts.map +1 -0
- package/dist/LaneTimeline/LaneTimelineBar.js +69 -0
- package/dist/LaneTimeline/LaneTimelineBar.js.map +1 -0
- package/dist/LaneTimeline/LaneTimelineLane.d.ts +84 -0
- package/dist/LaneTimeline/LaneTimelineLane.d.ts.map +1 -0
- package/dist/LaneTimeline/LaneTimelineLane.js +109 -0
- package/dist/LaneTimeline/LaneTimelineLane.js.map +1 -0
- package/dist/LaneTimeline/laneTimelineGeometry.d.ts +268 -0
- package/dist/LaneTimeline/laneTimelineGeometry.d.ts.map +1 -0
- package/dist/LaneTimeline/laneTimelineGeometry.js +277 -0
- package/dist/LaneTimeline/laneTimelineGeometry.js.map +1 -0
- package/dist/LaneTimeline/useTrackMeasurements.d.ts +69 -0
- package/dist/LaneTimeline/useTrackMeasurements.d.ts.map +1 -0
- package/dist/LaneTimeline/useTrackMeasurements.js +134 -0
- package/dist/LaneTimeline/useTrackMeasurements.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/LaneTimeline/LaneTimeline.mdx +191 -0
- package/src/LaneTimeline/LaneTimeline.stories.tsx +561 -0
- package/src/LaneTimeline/LaneTimeline.test.tsx +330 -0
- package/src/LaneTimeline/LaneTimeline.tsx +381 -0
- package/src/LaneTimeline/LaneTimelineBar.tsx +237 -0
- package/src/LaneTimeline/LaneTimelineLane.tsx +332 -0
- package/src/LaneTimeline/laneTimelineGeometry.test.ts +429 -0
- package/src/LaneTimeline/laneTimelineGeometry.ts +476 -0
- package/src/LaneTimeline/useTrackMeasurements.ts +165 -0
- package/src/index.ts +13 -0
- package/src/sourceRules.test.ts +22 -1
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { composeStories } from "@storybook/react"
|
|
2
|
+
import { expect, userEvent, within } from "storybook/test"
|
|
3
|
+
import { test } from "vitest"
|
|
4
|
+
|
|
5
|
+
import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
|
|
6
|
+
import { mountStory } from "../mountStory.testHelpers.ts"
|
|
7
|
+
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
|
+
import * as stories from "./LaneTimeline.stories.tsx"
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
AllStates,
|
|
12
|
+
AllVariants,
|
|
13
|
+
Interactive,
|
|
14
|
+
LongRange,
|
|
15
|
+
NarrowView,
|
|
16
|
+
Playground,
|
|
17
|
+
Responsive,
|
|
18
|
+
} = composeStories(stories)
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The timeline is the landmark; the lanes are **groups** inside it.
|
|
22
|
+
*
|
|
23
|
+
* Eight lanes as eight `region`s would bury the page's real
|
|
24
|
+
* landmarks, and two timelines on one page would put two landmarks
|
|
25
|
+
* with the same name in the document — axe's `landmark-unique`,
|
|
26
|
+
* which `BoardLaneList` hit for real before its lanes became groups.
|
|
27
|
+
*/
|
|
28
|
+
test("the timeline is a named landmark and its lanes are named groups", async () => {
|
|
29
|
+
const { canvas } = await mountStory(Playground)
|
|
30
|
+
|
|
31
|
+
const timeline = expectAgentDrivable(canvas, {
|
|
32
|
+
name: "The week",
|
|
33
|
+
role: "region",
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
for (const laneLabel of ["Ada", "Wren", "Juno", "Rook"]) {
|
|
37
|
+
expectAgentDrivable(canvas, {
|
|
38
|
+
name: laneLabel,
|
|
39
|
+
role: "group",
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await expectNoAxeViolations(timeline)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The claim the component is built on: a lane carrying overlapping
|
|
48
|
+
* work is **taller**, so load asymmetry reads before anybody counts
|
|
49
|
+
* anything.
|
|
50
|
+
*
|
|
51
|
+
* Measured as real geometry rather than as a row count, because the
|
|
52
|
+
* row count is the thing that could be right while the CSS grid drew
|
|
53
|
+
* them all on top of each other.
|
|
54
|
+
*/
|
|
55
|
+
test("a lane carrying more is taller than a lane carrying less", async () => {
|
|
56
|
+
const { canvas } = await mountStory(Playground)
|
|
57
|
+
|
|
58
|
+
const getLaneHeight = (name: string) =>
|
|
59
|
+
canvas
|
|
60
|
+
.getByRole("group", { name })
|
|
61
|
+
.getBoundingClientRect().height
|
|
62
|
+
|
|
63
|
+
await expect(getLaneHeight("Wren")).toBeGreaterThan(
|
|
64
|
+
getLaneHeight("Ada"),
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The count is what the lane carries **in this range**, and it keeps
|
|
70
|
+
* counting what the row limit hid — a lane must not look lighter by
|
|
71
|
+
* being more loaded.
|
|
72
|
+
*/
|
|
73
|
+
test("a lane past its row limit counts the rest and says what is missing", async () => {
|
|
74
|
+
const { canvas } = await mountStory(AllStates)
|
|
75
|
+
|
|
76
|
+
const wren = canvas.getAllByRole("group", {
|
|
77
|
+
name: "Wren",
|
|
78
|
+
})[0]
|
|
79
|
+
|
|
80
|
+
if (!wren) {
|
|
81
|
+
throw new Error("the Wren lane must render")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await expect(
|
|
85
|
+
within(wren).getByText("+ 3 more overlapping in Wren"),
|
|
86
|
+
).toBeInTheDocument()
|
|
87
|
+
|
|
88
|
+
// Six in the range, three of them hidden behind `maxRowCount={2}`.
|
|
89
|
+
await expect(
|
|
90
|
+
within(wren).getByText("6"),
|
|
91
|
+
).toBeInTheDocument()
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A multi-day thing is one bar covering the days it covers — the
|
|
96
|
+
* shape a month grid cannot draw, and the assertion that catches an
|
|
97
|
+
* off-by-one that no screenshot can.
|
|
98
|
+
*/
|
|
99
|
+
test("a multi-day item is one bar spanning its own days", async () => {
|
|
100
|
+
const { canvas } = await mountStory(Playground)
|
|
101
|
+
|
|
102
|
+
const bar = canvas.getByTitle(
|
|
103
|
+
/^Field recording trip, Mon 6 Apr to Thu 9 Apr, 4 days$/,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
const cell = bar.closest("li")
|
|
107
|
+
|
|
108
|
+
// Monday is column 1 of this week, and four days is a span of 4 —
|
|
109
|
+
// not 3, which is what a half-open reading of `end` produces, and
|
|
110
|
+
// not 5, which is what adding a day twice produces.
|
|
111
|
+
await expect(cell?.style.gridColumn).toBe("1 / span 4")
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The four edge cases, each of which is a place a naive
|
|
116
|
+
* implementation draws nothing or draws it somewhere else.
|
|
117
|
+
*/
|
|
118
|
+
test("an item starting before the range is clipped to the first column and says so", async () => {
|
|
119
|
+
const { canvas } = await mountStory(AllStates)
|
|
120
|
+
|
|
121
|
+
const bar = canvas.getByTitle(
|
|
122
|
+
/^Desk hire, started last month, Mon 30 Mar to Wed 8 Apr, 10 days, starts before this range$/,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
// Column 1, never 0 and never negative — a negative
|
|
126
|
+
// `grid-column-start` counts back from the END of the row, so the
|
|
127
|
+
// naive subtraction puts the bar at the far side of the week and
|
|
128
|
+
// nothing fails.
|
|
129
|
+
await expect(bar.closest("li")?.style.gridColumn).toBe(
|
|
130
|
+
"1 / span 3",
|
|
131
|
+
)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
test("an item running past the range is clipped to the last column and says so", async () => {
|
|
135
|
+
const { canvas } = await mountStory(AllStates)
|
|
136
|
+
|
|
137
|
+
const bar = canvas.getByTitle(
|
|
138
|
+
/^Residency, runs into May, Fri 10 Apr to Thu 30 Apr, 21 days, continues past this range$/,
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
await expect(bar.closest("li")?.style.gridColumn).toBe(
|
|
142
|
+
"5 / span 3",
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
test("a single-day item is one column wide and still a bar", async () => {
|
|
147
|
+
const { canvas } = await mountStory(AllStates)
|
|
148
|
+
|
|
149
|
+
const bar = canvas.getByTitle(
|
|
150
|
+
/^Tape delivery, Thu 9 Apr$/,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
await expect(bar.closest("li")?.style.gridColumn).toBe(
|
|
154
|
+
"4 / span 1",
|
|
155
|
+
)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
test("a zero-width item is drawn on its start day rather than dropped", async () => {
|
|
159
|
+
const { canvas } = await mountStory(AllStates)
|
|
160
|
+
|
|
161
|
+
// `end` before `start`. It is drawn, because a dropped row is a
|
|
162
|
+
// data fault nobody can see, and it is one column, because there
|
|
163
|
+
// is no such thing as a backwards bar.
|
|
164
|
+
const bar = canvas.getByTitle(
|
|
165
|
+
/^Kit return, dates entered backwards, Sat 11 Apr$/,
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
await expect(bar.closest("li")?.style.gridColumn).toBe(
|
|
169
|
+
"6 / span 1",
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test("an item outside the range is not drawn and is not counted", async () => {
|
|
174
|
+
const { canvas } = await mountStory(AllStates)
|
|
175
|
+
|
|
176
|
+
await expect(
|
|
177
|
+
canvas.queryByText("Next month's session"),
|
|
178
|
+
).toBeNull()
|
|
179
|
+
|
|
180
|
+
const studio = canvas.getByRole("group", {
|
|
181
|
+
name: "Studio floor",
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
// Five items in the fixture, four of them in the week.
|
|
185
|
+
await expect(
|
|
186
|
+
within(studio).getByText("4"),
|
|
187
|
+
).toBeInTheDocument()
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The Narrow View. There is no axis, nothing scrolls sideways, and
|
|
192
|
+
* every span that the geometry used to carry is now printed as text
|
|
193
|
+
* — which is the trade being made, stated as an assertion.
|
|
194
|
+
*/
|
|
195
|
+
test("the Narrow View drops the axis and writes every span out in words", async () => {
|
|
196
|
+
const { canvas, canvasElement } =
|
|
197
|
+
await mountStory(NarrowView)
|
|
198
|
+
|
|
199
|
+
await expect(
|
|
200
|
+
canvas.getByText("Field recording trip"),
|
|
201
|
+
).toBeInTheDocument()
|
|
202
|
+
|
|
203
|
+
await expect(
|
|
204
|
+
canvas.getByText("Mon 6 Apr to Thu 9 Apr, 4 days"),
|
|
205
|
+
).toBeInTheDocument()
|
|
206
|
+
|
|
207
|
+
/*
|
|
208
|
+
* No bar carries a grid placement, because there is no grid. The
|
|
209
|
+
* selector is `li[style]` rather than a substring match on
|
|
210
|
+
* `grid-column`: chromium serialises the two placement properties
|
|
211
|
+
* back out as the `grid-area` shorthand, so a `[style*=…]` test
|
|
212
|
+
* for the name React set finds nothing and passes for the wrong
|
|
213
|
+
* reason.
|
|
214
|
+
*/
|
|
215
|
+
await expect(
|
|
216
|
+
canvasElement.querySelectorAll("li[style]"),
|
|
217
|
+
).toHaveLength(0)
|
|
218
|
+
|
|
219
|
+
// And nothing is hidden off the side of the box. `scrollWidth`
|
|
220
|
+
// past `clientWidth` is the silent overflow this component refuses
|
|
221
|
+
// to ship — *"no horizontal scroll, ever"*.
|
|
222
|
+
const timeline = canvas.getByRole("region", {
|
|
223
|
+
name: "The week, Narrow View",
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
await expect(timeline.scrollWidth).toBeLessThanOrEqual(
|
|
227
|
+
timeline.clientWidth,
|
|
228
|
+
)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The fold is the width **per column**, which is why it is measured
|
|
233
|
+
* rather than queried. One container width, two answers, because the
|
|
234
|
+
* other number is in the data.
|
|
235
|
+
*/
|
|
236
|
+
test("the same width keeps its axis for a week and loses it for a month", async () => {
|
|
237
|
+
const { canvasElement } = await mountStory(Responsive)
|
|
238
|
+
|
|
239
|
+
const getBarCount = (name: string) =>
|
|
240
|
+
within(canvasElement)
|
|
241
|
+
.getByRole("region", { name })
|
|
242
|
+
.querySelectorAll("li[style]").length
|
|
243
|
+
|
|
244
|
+
// The 34rem panel has room for a seven-column axis.
|
|
245
|
+
await expect(
|
|
246
|
+
getBarCount("The week at 34rem"),
|
|
247
|
+
).toBeGreaterThan(0)
|
|
248
|
+
|
|
249
|
+
// The same 34rem panel has no room for a thirty-column one, so it
|
|
250
|
+
// becomes the list instead. No media query could tell these two
|
|
251
|
+
// apart: the box never changed.
|
|
252
|
+
await expect(getBarCount("The month at 34rem")).toBe(0)
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Thinning the labels rather than shrinking the type, because type
|
|
257
|
+
* that shrinks to fit is type somebody cannot read.
|
|
258
|
+
*/
|
|
259
|
+
test("a long range labels the axis in whole weeks", async () => {
|
|
260
|
+
const { canvas } = await mountStory(LongRange)
|
|
261
|
+
|
|
262
|
+
// Thirty days, labelled every seven: 1, 8, 15, 22, 29.
|
|
263
|
+
await expect(
|
|
264
|
+
canvas.getByText("Wed 1 Apr"),
|
|
265
|
+
).toBeInTheDocument()
|
|
266
|
+
|
|
267
|
+
await expect(
|
|
268
|
+
canvas.getByText("Wed 8 Apr"),
|
|
269
|
+
).toBeInTheDocument()
|
|
270
|
+
|
|
271
|
+
await expect(canvas.queryByText("Thu 2 Apr")).toBeNull()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* A bar with an `onSelect` is a real control, found by the same
|
|
276
|
+
* `getByRole(role, { name })` an agent or a Playwright script
|
|
277
|
+
* writes — and its name carries the span, so thirty bars are thirty
|
|
278
|
+
* distinguishable controls rather than thirty of the same word.
|
|
279
|
+
*/
|
|
280
|
+
test("a bar that acts is drivable by role and name", async () => {
|
|
281
|
+
const { canvas } = await mountStory(Interactive)
|
|
282
|
+
|
|
283
|
+
const bar = expectAgentDrivable(canvas, {
|
|
284
|
+
name: "Foley session, Tue 7 Apr to Wed 8 Apr, 2 days",
|
|
285
|
+
role: "button",
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
await userEvent.click(bar)
|
|
289
|
+
|
|
290
|
+
await expect(
|
|
291
|
+
canvas.getByText("Picked: Foley session"),
|
|
292
|
+
).toBeInTheDocument()
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test("a bar that navigates is a real link", async () => {
|
|
296
|
+
const { canvas } = await mountStory(AllVariants)
|
|
297
|
+
|
|
298
|
+
const link = canvas.getAllByRole("link", {
|
|
299
|
+
name: "Foley session, Tue 7 Apr to Wed 8 Apr, 2 days",
|
|
300
|
+
})[0]
|
|
301
|
+
|
|
302
|
+
await expect(link).toHaveAttribute("href", "/item/foley")
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* *"Real empty states"* is a standing requirement, and an empty
|
|
307
|
+
* timeline is what a household sees on a good week.
|
|
308
|
+
*/
|
|
309
|
+
test("a timeline with no lanes shows a real empty state", async () => {
|
|
310
|
+
const { canvas } = await mountStory(AllStates)
|
|
311
|
+
|
|
312
|
+
await expect(
|
|
313
|
+
canvas.getByText("No lanes yet"),
|
|
314
|
+
).toBeInTheDocument()
|
|
315
|
+
|
|
316
|
+
await expect(
|
|
317
|
+
canvas.getByText("Nobody has a lane yet"),
|
|
318
|
+
).toBeInTheDocument()
|
|
319
|
+
|
|
320
|
+
// An empty *lane* is not an empty timeline. It keeps its heading
|
|
321
|
+
// and its zero, because a lane carrying nothing is the fact the
|
|
322
|
+
// asymmetry is measured against.
|
|
323
|
+
const spare = canvas.getByRole("group", {
|
|
324
|
+
name: "Spare room",
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
await expect(
|
|
328
|
+
within(spare).getByText("0"),
|
|
329
|
+
).toBeInTheDocument()
|
|
330
|
+
})
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
import type { ReactNode } from "react"
|
|
2
|
+
|
|
3
|
+
import { getPlainDateFromDayNumber } from "../DatePicker/plainDate.ts"
|
|
4
|
+
import { EmptyState } from "../EmptyState/EmptyState.tsx"
|
|
5
|
+
import { toClassName } from "../toClassName.ts"
|
|
6
|
+
import type { TimelineLane } from "./LaneTimelineLane.tsx"
|
|
7
|
+
import { LaneTimelineLane } from "./LaneTimelineLane.tsx"
|
|
8
|
+
import type { TimelineSpan } from "./laneTimelineGeometry.ts"
|
|
9
|
+
import {
|
|
10
|
+
chooseAxisTickStep,
|
|
11
|
+
formatAxisDate,
|
|
12
|
+
getIsAxisReadable,
|
|
13
|
+
toTimelineAxis,
|
|
14
|
+
} from "./laneTimelineGeometry.ts"
|
|
15
|
+
import { useTrackMeasurements } from "./useTrackMeasurements.ts"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Room one tick label needs, as a multiple of the timeline's own
|
|
19
|
+
* font size.
|
|
20
|
+
*
|
|
21
|
+
* `Mon 12 Apr` is about 4.5 of them, and the number is bigger than
|
|
22
|
+
* the label looks because the two are measured against **different**
|
|
23
|
+
* sizes: the label renders at `text-xs` while this multiplies the
|
|
24
|
+
* container's own font size, which is the one the probe can read. A
|
|
25
|
+
* constant rather than a prop, because it is a fact about the label
|
|
26
|
+
* this component prints rather than a preference anybody has.
|
|
27
|
+
*/
|
|
28
|
+
const TICK_LABEL_SIZE = 4.5
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The narrowest a day column may get before the axis stops being
|
|
32
|
+
* worth drawing, in em.
|
|
33
|
+
*
|
|
34
|
+
* ⚠️ **The one number in here that is a taste call**, and it was
|
|
35
|
+
* moved once already. 1.25em drew a seven-day axis into a 24rem
|
|
36
|
+
* sidebar, which is 25 pixels a day: the bars were real shapes and
|
|
37
|
+
* every one of their titles was `Mi…`, so the picture was worse than
|
|
38
|
+
* the list it was chosen over. 1.75em is roughly the narrowest
|
|
39
|
+
* column that still holds a short word, which puts a month at about
|
|
40
|
+
* 1030px of component and a week in anything past a phone.
|
|
41
|
+
*
|
|
42
|
+
* The case still worth arguing about is a quarter. Ninety days wants
|
|
43
|
+
* more than 2500px and therefore falls to the Narrow View on every
|
|
44
|
+
* screen here — and 6px a day does still show a *shape*, so somebody
|
|
45
|
+
* may prefer the smear to the list. It is a prop for that reason.
|
|
46
|
+
*/
|
|
47
|
+
const DEFAULT_MIN_COLUMN_SIZE = 1.75
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* How many bars a lane may stack before the rest are counted rather
|
|
51
|
+
* than drawn. Six is roughly a screen's worth of one lane.
|
|
52
|
+
*/
|
|
53
|
+
const DEFAULT_MAX_ROW_COUNT = 6
|
|
54
|
+
|
|
55
|
+
export type LaneTimelineProps = {
|
|
56
|
+
className?: string
|
|
57
|
+
/**
|
|
58
|
+
* Shown when there are no lanes, or when `range` is not a pair of
|
|
59
|
+
* calendar dates. Defaults to a real `EmptyState` — *"real empty
|
|
60
|
+
* states"* is a standing requirement, and an empty timeline is
|
|
61
|
+
* what a household sees on a good week.
|
|
62
|
+
*/
|
|
63
|
+
emptyState?: ReactNode
|
|
64
|
+
/**
|
|
65
|
+
* What level the lane headings are. The caller's, because only
|
|
66
|
+
* the caller knows what the timeline sits under: one directly
|
|
67
|
+
* below a page `<h1>` wants `2`. Getting it wrong is an axe
|
|
68
|
+
* `heading-order` failure and a hole in the screen-reader
|
|
69
|
+
* outline.
|
|
70
|
+
*/
|
|
71
|
+
headingLevel?: 2 | 3 | 4 | 5
|
|
72
|
+
/** The timeline's accessible name. Required — it is a landmark. */
|
|
73
|
+
label: string
|
|
74
|
+
lanes: readonly TimelineLane[]
|
|
75
|
+
/**
|
|
76
|
+
* BCP-47, for the dates only. Omitted, `Intl` uses the runtime's
|
|
77
|
+
* own — which is right on a personal machine and wrong on a
|
|
78
|
+
* server, so a server render should pass one rather than let two
|
|
79
|
+
* environments disagree about what "6 Apr" looks like.
|
|
80
|
+
*/
|
|
81
|
+
locale?: string
|
|
82
|
+
/** How many bars one lane may stack. Default 6. */
|
|
83
|
+
maxRowCount?: number
|
|
84
|
+
/**
|
|
85
|
+
* The narrowest a day column may get before the axis is abandoned
|
|
86
|
+
* for the Narrow View, as a multiple of the timeline's **own font
|
|
87
|
+
* size**. Default 1.75.
|
|
88
|
+
*
|
|
89
|
+
* In em rather than pixels because every length in this token set
|
|
90
|
+
* moves with the density axis: a floor pinned at 20px is generous
|
|
91
|
+
* on a desktop and clips a two-digit date on the kiosk. Same
|
|
92
|
+
* argument `Avatar` makes about a chip that stays 20px while the
|
|
93
|
+
* prose around it scales.
|
|
94
|
+
*/
|
|
95
|
+
minColumnSize?: number
|
|
96
|
+
/**
|
|
97
|
+
* The days on screen. **Both ends inclusive**, like every other
|
|
98
|
+
* span in this component — a Monday-to-Sunday week is
|
|
99
|
+
* `2026-04-06` to `2026-04-12`, which is seven columns, not eight.
|
|
100
|
+
*/
|
|
101
|
+
range: TimelineSpan
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* One horizontal lane per group, across a date axis, with anything
|
|
106
|
+
* that lasts more than a day drawn as a **bar**.
|
|
107
|
+
*
|
|
108
|
+
* ## What it is for
|
|
109
|
+
*
|
|
110
|
+
* A month grid gives every day the same box, so a thing that runs
|
|
111
|
+
* Tuesday to Friday is four separate entries and the fact that it is
|
|
112
|
+
* *one* thing is lost. Worse, every cell is the same size no matter
|
|
113
|
+
* how much is in it, so a grid cannot show that one person is
|
|
114
|
+
* carrying most of the week. A lane can: bars keep their real
|
|
115
|
+
* shape, and a lane with overlapping work stacks into rows and gets
|
|
116
|
+
* **taller**. The asymmetry then reads from across the room, before
|
|
117
|
+
* anybody counts anything.
|
|
118
|
+
*
|
|
119
|
+
* It is not a calendar. There is no event, no attendee and no
|
|
120
|
+
* recurrence in this API, because Docket wants the same shape for
|
|
121
|
+
* phases and Horizon wants it for a household — the library owns the
|
|
122
|
+
* **shape** and the app owns the **data**. What a lane holds is a
|
|
123
|
+
* `key`, a `label` and items with a `start`, an `end` and a `title`.
|
|
124
|
+
*
|
|
125
|
+
* ## `end` is INCLUSIVE, and that is the decision to know
|
|
126
|
+
*
|
|
127
|
+
* An off-by-one on a date range is invisible in both directions: a
|
|
128
|
+
* bar one day short and a bar one day long are both bars. So it is
|
|
129
|
+
* stated loudly here and again on the type — **`end` is the last day
|
|
130
|
+
* the item covers**. `2026-04-06` to `2026-04-06` is one day, and a
|
|
131
|
+
* seven-day week ends on its seventh day, not on the eighth. The
|
|
132
|
+
* alternative (a half-open interval, the way `Date` ranges and
|
|
133
|
+
* iCalendar's `DTEND` work) is the better model for *instants* and
|
|
134
|
+
* the wrong one for a human writing down when the roofers are
|
|
135
|
+
* coming: nobody says "Monday to Saturday" about a working week, so
|
|
136
|
+
* an exclusive end would make every consumer add a day on the way in
|
|
137
|
+
* and forget to on one of the paths.
|
|
138
|
+
* [Decision](../../../docs/decisions/2026-09-18-a-timeline-span-ends-on-its-last-day-and-the-axis-folds-on-measurement.md).
|
|
139
|
+
*
|
|
140
|
+
* ## The Narrow View is a LIST, not a pan surface
|
|
141
|
+
*
|
|
142
|
+
* Thirty columns have no narrow layout, and the two obvious answers
|
|
143
|
+
* are both wrong. Horizontal scrolling hides lanes behind a gesture
|
|
144
|
+
* with no affordance and fights the trackpad's own back-navigation
|
|
145
|
+
* — the same objection `Board` settled with *"no horizontal scroll,
|
|
146
|
+
* ever"*. Squeezing thirty columns into 390px gives each day 13
|
|
147
|
+
* pixels, which is not a timeline, it is a texture.
|
|
148
|
+
*
|
|
149
|
+
* So below the threshold the axis is **abandoned** rather than
|
|
150
|
+
* shrunk: each lane becomes a one-column list of its items, each one
|
|
151
|
+
* a line of text with its span written out in words. Nothing is
|
|
152
|
+
* hidden, nothing overflows, and the reading order is the one the
|
|
153
|
+
* fleet's text-heavy lists already use.
|
|
154
|
+
*
|
|
155
|
+
* ## It folds on a MEASUREMENT, not a breakpoint
|
|
156
|
+
*
|
|
157
|
+
* There is no `@media` and no `cq-` in this component, and neither
|
|
158
|
+
* could do the job: the question is not *how wide is the box* but
|
|
159
|
+
* **how wide is one day**, which is the width divided by a number
|
|
160
|
+
* that lives in the data. Thirty columns in 900px is a readable
|
|
161
|
+
* month and three hundred and sixty five columns in the same 900px
|
|
162
|
+
* is a grey smear, and no query can tell those apart. `Nav` folds
|
|
163
|
+
* its bar on a measured width for the weaker version of this
|
|
164
|
+
* reason; `useToolbarOverflow` writes the argument down.
|
|
165
|
+
*
|
|
166
|
+
* ⚠️ **It must not be sized by its own contents.** The component
|
|
167
|
+
* reads its own box to decide what to draw in it, so a shrink-
|
|
168
|
+
* wrapping parent makes the answer its own input. The root declares
|
|
169
|
+
* `container-type: inline-size` — not to query it, the same reason
|
|
170
|
+
* `Main` declares one — which forbids the element from being sized
|
|
171
|
+
* by what is inside it and makes that loop unformable. In a story
|
|
172
|
+
* this is what `align="stretch"` on the `StoryCell` is for.
|
|
173
|
+
*
|
|
174
|
+
* ## What it deliberately does not do
|
|
175
|
+
*
|
|
176
|
+
* No time of day: this is a **calendar-date** axis, so two things on
|
|
177
|
+
* one Tuesday stack rather than sit at 09:00 and 14:00. No "now"
|
|
178
|
+
* marker, no zoom, no drag to reschedule, no data. A range control
|
|
179
|
+
* belongs to the page — `DatePicker` already is one — and a bar you
|
|
180
|
+
* can drag is a write operation, which is a different component with
|
|
181
|
+
* a different decision behind it.
|
|
182
|
+
*/
|
|
183
|
+
export const LaneTimeline = ({
|
|
184
|
+
className,
|
|
185
|
+
emptyState,
|
|
186
|
+
headingLevel = 3,
|
|
187
|
+
label,
|
|
188
|
+
lanes,
|
|
189
|
+
locale,
|
|
190
|
+
maxRowCount = DEFAULT_MAX_ROW_COUNT,
|
|
191
|
+
minColumnSize = DEFAULT_MIN_COLUMN_SIZE,
|
|
192
|
+
range,
|
|
193
|
+
}: LaneTimelineProps): ReactNode => {
|
|
194
|
+
const { fontSize, inlineSize, isMeasured, trackRef } =
|
|
195
|
+
useTrackMeasurements()
|
|
196
|
+
|
|
197
|
+
const axis = toTimelineAxis(range)
|
|
198
|
+
|
|
199
|
+
const columnCount = axis?.columnCount ?? 0
|
|
200
|
+
|
|
201
|
+
const isAxisReadable = getIsAxisReadable({
|
|
202
|
+
columnCount,
|
|
203
|
+
inlineSize,
|
|
204
|
+
isMeasured,
|
|
205
|
+
minColumnInlineSize: minColumnSize * fontSize,
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
const tickStep = chooseAxisTickStep({
|
|
209
|
+
columnCount,
|
|
210
|
+
inlineSize,
|
|
211
|
+
minTickInlineSize: TICK_LABEL_SIZE * fontSize,
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
/*
|
|
215
|
+
* An inline style, not a class. Tailwind scans source text for
|
|
216
|
+
* complete class strings, so a `grid-cols-[repeat(${n},…)]` is
|
|
217
|
+
* generated **never** — the grid silently falls back to one column
|
|
218
|
+
* and every bar lands on top of every other one, with nothing
|
|
219
|
+
* failing. `AdaptiveGrid` carries the same note about the same
|
|
220
|
+
* trap.
|
|
221
|
+
*/
|
|
222
|
+
const columnTemplate = `repeat(${Math.max(columnCount, 1)}, minmax(0, 1fr))`
|
|
223
|
+
|
|
224
|
+
const rangeCaption =
|
|
225
|
+
axis &&
|
|
226
|
+
`${formatAxisDate({
|
|
227
|
+
date: getPlainDateFromDayNumber(axis.startDay),
|
|
228
|
+
locale,
|
|
229
|
+
})} to ${formatAxisDate({
|
|
230
|
+
date: getPlainDateFromDayNumber(axis.endDay),
|
|
231
|
+
locale,
|
|
232
|
+
})}`
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<section
|
|
236
|
+
aria-label={label}
|
|
237
|
+
className={toClassName(
|
|
238
|
+
"@container flex flex-col",
|
|
239
|
+
className,
|
|
240
|
+
)}
|
|
241
|
+
>
|
|
242
|
+
{/*
|
|
243
|
+
* The measuring probe, and it is load-bearing.
|
|
244
|
+
*
|
|
245
|
+
* Zero height, `aria-hidden`, and rendered in **both**
|
|
246
|
+
* layouts — it is the only element that can answer "how wide
|
|
247
|
+
* would a day column be" in the layout where no day column
|
|
248
|
+
* exists. Measuring the real track instead is a one-way door:
|
|
249
|
+
* the Narrow View unmounts it, so the width never updates and
|
|
250
|
+
* the axis can never come back when the window widens.
|
|
251
|
+
*
|
|
252
|
+
* `overflow-hidden` because the 12rem rail is wider than the
|
|
253
|
+
* whole component in the Narrow View, and a zero-height box is
|
|
254
|
+
* still allowed to give an ancestor a horizontal scrollbar.
|
|
255
|
+
*/}
|
|
256
|
+
<div
|
|
257
|
+
aria-hidden="true"
|
|
258
|
+
className="grid grid-cols-[12rem_minmax(0,1fr)] gap-x-3 overflow-hidden"
|
|
259
|
+
>
|
|
260
|
+
<div />
|
|
261
|
+
|
|
262
|
+
<div className="h-0" ref={trackRef} />
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
{axis === null ? (
|
|
266
|
+
(emptyState ?? (
|
|
267
|
+
<EmptyState
|
|
268
|
+
description="This timeline has no visible date range."
|
|
269
|
+
heading="Nothing to show"
|
|
270
|
+
headingLevel={headingLevel}
|
|
271
|
+
size="sm"
|
|
272
|
+
/>
|
|
273
|
+
))
|
|
274
|
+
) : lanes.length === 0 ? (
|
|
275
|
+
(emptyState ?? (
|
|
276
|
+
<EmptyState
|
|
277
|
+
description="Nothing has been added to this timeline yet."
|
|
278
|
+
heading="No lanes yet"
|
|
279
|
+
headingLevel={headingLevel}
|
|
280
|
+
size="sm"
|
|
281
|
+
/>
|
|
282
|
+
))
|
|
283
|
+
) : isAxisReadable ? (
|
|
284
|
+
<>
|
|
285
|
+
<div className="grid grid-cols-[12rem_minmax(0,1fr)] items-end gap-x-3 pb-1">
|
|
286
|
+
<span
|
|
287
|
+
className="min-w-0 truncate text-content-muted text-xs"
|
|
288
|
+
title={rangeCaption ?? undefined}
|
|
289
|
+
>
|
|
290
|
+
{rangeCaption}
|
|
291
|
+
</span>
|
|
292
|
+
|
|
293
|
+
{/*
|
|
294
|
+
* `aria-hidden`, and that is a deliberate call rather
|
|
295
|
+
* than an oversight. Every bar already says its own
|
|
296
|
+
* span in words, so an axis read aloud is thirty
|
|
297
|
+
* consecutive dates in front of the content — noise
|
|
298
|
+
* that costs a screen-reader user the page. The axis is
|
|
299
|
+
* a *visual* ruler for a mark whose position a sighted
|
|
300
|
+
* reader has to measure against something.
|
|
301
|
+
*/}
|
|
302
|
+
<div
|
|
303
|
+
aria-hidden="true"
|
|
304
|
+
className="grid"
|
|
305
|
+
style={{
|
|
306
|
+
gridTemplateColumns: columnTemplate,
|
|
307
|
+
}}
|
|
308
|
+
>
|
|
309
|
+
{Array.from(
|
|
310
|
+
{ length: columnCount },
|
|
311
|
+
(_unused, index) => index,
|
|
312
|
+
)
|
|
313
|
+
.filter((index) => index % tickStep === 0)
|
|
314
|
+
.map((index) => (
|
|
315
|
+
<span
|
|
316
|
+
className="min-w-0 truncate text-content-muted text-xs"
|
|
317
|
+
key={axis.startDay + index}
|
|
318
|
+
style={{
|
|
319
|
+
gridColumn: `${index + 1} / span ${Math.min(
|
|
320
|
+
tickStep,
|
|
321
|
+
columnCount - index,
|
|
322
|
+
)}`,
|
|
323
|
+
}}
|
|
324
|
+
>
|
|
325
|
+
{formatAxisDate({
|
|
326
|
+
date: getPlainDateFromDayNumber(
|
|
327
|
+
axis.startDay + index,
|
|
328
|
+
),
|
|
329
|
+
locale,
|
|
330
|
+
})}
|
|
331
|
+
</span>
|
|
332
|
+
))}
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
|
|
336
|
+
{lanes.map((lane, position) => (
|
|
337
|
+
<LaneTimelineLane
|
|
338
|
+
axis={axis}
|
|
339
|
+
columnTemplate={columnTemplate}
|
|
340
|
+
headingLevel={headingLevel}
|
|
341
|
+
key={lane.key}
|
|
342
|
+
lane={lane}
|
|
343
|
+
locale={locale}
|
|
344
|
+
maxRowCount={maxRowCount}
|
|
345
|
+
position={position}
|
|
346
|
+
shape="axis"
|
|
347
|
+
tickStep={tickStep}
|
|
348
|
+
/>
|
|
349
|
+
))}
|
|
350
|
+
</>
|
|
351
|
+
) : (
|
|
352
|
+
<>
|
|
353
|
+
{/*
|
|
354
|
+
* The range still has to be said, and in the Narrow View
|
|
355
|
+
* there is no rail to say it in. Without this the list
|
|
356
|
+
* reads as "everything", which is the one thing it is
|
|
357
|
+
* not.
|
|
358
|
+
*/}
|
|
359
|
+
<p className="pb-1 text-content-muted text-xs">
|
|
360
|
+
{rangeCaption}
|
|
361
|
+
</p>
|
|
362
|
+
|
|
363
|
+
{lanes.map((lane, position) => (
|
|
364
|
+
<LaneTimelineLane
|
|
365
|
+
axis={axis}
|
|
366
|
+
columnTemplate={columnTemplate}
|
|
367
|
+
headingLevel={headingLevel}
|
|
368
|
+
key={lane.key}
|
|
369
|
+
lane={lane}
|
|
370
|
+
locale={locale}
|
|
371
|
+
maxRowCount={maxRowCount}
|
|
372
|
+
position={position}
|
|
373
|
+
shape="list"
|
|
374
|
+
tickStep={tickStep}
|
|
375
|
+
/>
|
|
376
|
+
))}
|
|
377
|
+
</>
|
|
378
|
+
)}
|
|
379
|
+
</section>
|
|
380
|
+
)
|
|
381
|
+
}
|