@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.
Files changed (37) hide show
  1. package/README.md +12 -0
  2. package/dist/LaneTimeline/LaneTimeline.d.ts +132 -0
  3. package/dist/LaneTimeline/LaneTimeline.d.ts.map +1 -0
  4. package/dist/LaneTimeline/LaneTimeline.js +165 -0
  5. package/dist/LaneTimeline/LaneTimeline.js.map +1 -0
  6. package/dist/LaneTimeline/LaneTimelineBar.d.ts +97 -0
  7. package/dist/LaneTimeline/LaneTimelineBar.d.ts.map +1 -0
  8. package/dist/LaneTimeline/LaneTimelineBar.js +69 -0
  9. package/dist/LaneTimeline/LaneTimelineBar.js.map +1 -0
  10. package/dist/LaneTimeline/LaneTimelineLane.d.ts +84 -0
  11. package/dist/LaneTimeline/LaneTimelineLane.d.ts.map +1 -0
  12. package/dist/LaneTimeline/LaneTimelineLane.js +109 -0
  13. package/dist/LaneTimeline/LaneTimelineLane.js.map +1 -0
  14. package/dist/LaneTimeline/laneTimelineGeometry.d.ts +268 -0
  15. package/dist/LaneTimeline/laneTimelineGeometry.d.ts.map +1 -0
  16. package/dist/LaneTimeline/laneTimelineGeometry.js +277 -0
  17. package/dist/LaneTimeline/laneTimelineGeometry.js.map +1 -0
  18. package/dist/LaneTimeline/useTrackMeasurements.d.ts +69 -0
  19. package/dist/LaneTimeline/useTrackMeasurements.d.ts.map +1 -0
  20. package/dist/LaneTimeline/useTrackMeasurements.js +134 -0
  21. package/dist/LaneTimeline/useTrackMeasurements.js.map +1 -0
  22. package/dist/index.d.ts +6 -0
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2 -0
  25. package/dist/index.js.map +1 -1
  26. package/package.json +1 -1
  27. package/src/LaneTimeline/LaneTimeline.mdx +191 -0
  28. package/src/LaneTimeline/LaneTimeline.stories.tsx +561 -0
  29. package/src/LaneTimeline/LaneTimeline.test.tsx +330 -0
  30. package/src/LaneTimeline/LaneTimeline.tsx +381 -0
  31. package/src/LaneTimeline/LaneTimelineBar.tsx +237 -0
  32. package/src/LaneTimeline/LaneTimelineLane.tsx +332 -0
  33. package/src/LaneTimeline/laneTimelineGeometry.test.ts +429 -0
  34. package/src/LaneTimeline/laneTimelineGeometry.ts +476 -0
  35. package/src/LaneTimeline/useTrackMeasurements.ts +165 -0
  36. package/src/index.ts +13 -0
  37. 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
+ }