@charcuterie/ui 4.5.1 → 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 (58) 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/Overlay/OverlayPanel.d.ts.map +1 -1
  23. package/dist/Overlay/OverlayPanel.js +54 -2
  24. package/dist/Overlay/OverlayPanel.js.map +1 -1
  25. package/dist/Overlay/OverlayStack.d.ts +5 -0
  26. package/dist/Overlay/OverlayStack.d.ts.map +1 -1
  27. package/dist/Overlay/OverlayStack.js +2 -0
  28. package/dist/Overlay/OverlayStack.js.map +1 -1
  29. package/dist/Toast/ToastRegion.d.ts +61 -0
  30. package/dist/Toast/ToastRegion.d.ts.map +1 -1
  31. package/dist/Toast/ToastRegion.js +80 -3
  32. package/dist/Toast/ToastRegion.js.map +1 -1
  33. package/dist/Toolbar/useToolbarOverflow.d.ts.map +1 -1
  34. package/dist/Toolbar/useToolbarOverflow.js +39 -0
  35. package/dist/Toolbar/useToolbarOverflow.js.map +1 -1
  36. package/dist/index.d.ts +6 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +2 -0
  39. package/dist/index.js.map +1 -1
  40. package/package.json +3 -3
  41. package/src/LaneTimeline/LaneTimeline.mdx +191 -0
  42. package/src/LaneTimeline/LaneTimeline.stories.tsx +561 -0
  43. package/src/LaneTimeline/LaneTimeline.test.tsx +330 -0
  44. package/src/LaneTimeline/LaneTimeline.tsx +381 -0
  45. package/src/LaneTimeline/LaneTimelineBar.tsx +237 -0
  46. package/src/LaneTimeline/LaneTimelineLane.tsx +332 -0
  47. package/src/LaneTimeline/laneTimelineGeometry.test.ts +429 -0
  48. package/src/LaneTimeline/laneTimelineGeometry.ts +476 -0
  49. package/src/LaneTimeline/useTrackMeasurements.ts +165 -0
  50. package/src/Overlay/OverlayPanel.tsx +63 -2
  51. package/src/Overlay/OverlayStack.tsx +7 -1
  52. package/src/Toast/Toast.stories.tsx +154 -0
  53. package/src/Toast/ToastRegion.test.tsx +172 -0
  54. package/src/Toast/ToastRegion.tsx +120 -32
  55. package/src/Toolbar/Toolbar.test.tsx +84 -0
  56. package/src/Toolbar/useToolbarOverflow.ts +43 -0
  57. package/src/index.ts +13 -0
  58. package/src/sourceRules.test.ts +22 -1
@@ -0,0 +1,237 @@
1
+ import type { CategoricalIndex } from "@charcuterie/tokens"
2
+ import type { ReactNode } from "react"
3
+
4
+ import {
5
+ CATEGORICAL_APPEARANCE_CLASS,
6
+ CATEGORICAL_HOVER_CLASS,
7
+ } from "../categoricalStyles.ts"
8
+ import { FOCUS_RING_CLASS } from "../intentStyles.ts"
9
+ import { UnstyledLink } from "../RouterLink/UnstyledLink.tsx"
10
+ import { toClassName } from "../toClassName.ts"
11
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
12
+ import type { TimelineSpan } from "./laneTimelineGeometry.ts"
13
+
14
+ /**
15
+ * Who owns the item's activation — nobody, a router, or a handler.
16
+ *
17
+ * A union rather than two loose optionals, the same call
18
+ * `BoardItemTitleLink` makes and for the same reason: `href` renders
19
+ * an `<a>` and `onSelect` renders a `<button>`, so a bar carrying
20
+ * both is one of them with the other silently dropped. On screen the
21
+ * two are identical.
22
+ */
23
+ type TimelineItemAction =
24
+ | {
25
+ /**
26
+ * Where the bar navigates. Routed through
27
+ * `RouterLinkProvider` like every other `href` in this
28
+ * library.
29
+ */
30
+ href?: string
31
+ onSelect?: never
32
+ }
33
+ | {
34
+ href?: never
35
+ /** For a timeline that opens a panel rather than a route. */
36
+ onSelect?: () => void
37
+ }
38
+
39
+ export type TimelineItem = TimelineSpan & {
40
+ /**
41
+ * The bar's colour, when somebody has chosen one. Omitted, it is
42
+ * the **lane's**, because on this component colour means *which
43
+ * lane* — an item that picked its own hue out of the ten would
44
+ * read as belonging somewhere else. Pass this only when the item
45
+ * genuinely carries its own identity, and then put that identity
46
+ * in words too.
47
+ */
48
+ categorical?: CategoricalIndex
49
+ key: string
50
+ /**
51
+ * The words. A **string**, not a `ReactNode`, for the reason
52
+ * `BoardLane.label` is one: the same value goes into the bar's
53
+ * accessible name, the `title` a hovering reader gets, and the
54
+ * Narrow View's row — none of which can hold a node.
55
+ */
56
+ title: string
57
+ } & TimelineItemAction
58
+
59
+ export type LaneTimelineBarProps = {
60
+ className?: string
61
+ item: TimelineItem
62
+ /** The lane's hue, used when the item picked none. */
63
+ laneCategorical: CategoricalIndex
64
+ /** What the bar says out loud: title, then the span in words. */
65
+ description: string
66
+ /** Square the leading edge — the item began before this range. */
67
+ hasEarlierStart: boolean
68
+ /** Square the trailing edge — it runs past this range. */
69
+ hasLaterEnd: boolean
70
+ /**
71
+ * `bar` is the block on the axis. `row` is the Narrow View's
72
+ * full-width line, where the same item has room for two lines of
73
+ * text and no position to carry meaning.
74
+ */
75
+ shape: "bar" | "row"
76
+ }
77
+
78
+ /**
79
+ * One item, in either of the two shapes this component draws it in.
80
+ *
81
+ * ### The name is the whole of the accessibility argument
82
+ *
83
+ * A bar means something by **where it sits and how long it is**, and
84
+ * neither of those reaches a screen reader. Nor does the hue: ten
85
+ * categorical colours carry *which lane*, and colour alone failing
86
+ * WCAG 1.4.1 is the least of it — the lane is already a named group,
87
+ * so the colour is reinforcement rather than information.
88
+ *
89
+ * What is genuinely only in the geometry is the **span**, so every
90
+ * bar carries it as text: `description` is appended to the title in
91
+ * a `VisuallyHidden` and repeated in the `title` attribute. That
92
+ * second one is not an accessibility feature, it is for the sighted
93
+ * reader: a one-day bar is about twenty pixels wide and cannot show
94
+ * its own name at all.
95
+ *
96
+ * A native `title` rather than `Tooltip`, deliberately. A household
97
+ * week is two hundred bars, and two hundred floating-ui instances to
98
+ * describe boxes that are already described is a cost with nothing
99
+ * on the other side of it.
100
+ *
101
+ * ### The clipped edge is square, and it is also in words
102
+ *
103
+ * An item running out of the visible range loses the radius on that
104
+ * edge, which is the convention every Gantt chart uses. It is **not**
105
+ * the only signal:
106
+ * `description` already says "starts before this range", because a
107
+ * missing corner is not something anybody perceives without the
108
+ * unclipped bar beside it to compare against.
109
+ */
110
+ export const LaneTimelineBar = ({
111
+ className,
112
+ description,
113
+ hasEarlierStart,
114
+ hasLaterEnd,
115
+ item,
116
+ laneCategorical,
117
+ shape,
118
+ }: LaneTimelineBarProps): ReactNode => {
119
+ const categorical = item.categorical ?? laneCategorical
120
+
121
+ const isInteractive = Boolean(item.href ?? item.onSelect)
122
+
123
+ const content = (
124
+ <>
125
+ {/*
126
+ * `truncate` and a `min-w-0`, per the flex-overflow rule: the
127
+ * title is the flex row's text child and a single unbreakable
128
+ * word would otherwise become the bar's floor and push the
129
+ * bar past its own grid column. The whole string is in the
130
+ * `title` attribute and in the hidden sentence, so nothing is
131
+ * lost to the ellipsis.
132
+ */}
133
+ <span
134
+ className={toClassName(
135
+ "min-w-0 flex-1",
136
+ shape === "bar"
137
+ ? "truncate"
138
+ : "wrap-anywhere text-sm",
139
+ )}
140
+ >
141
+ {item.title}
142
+ </span>
143
+
144
+ {/*
145
+ * Three cases, and which one applies is the reason this is not
146
+ * one line.
147
+ *
148
+ * The Narrow View has room to print the span, so it prints it
149
+ * — a reader who can see the row should not need a hover to
150
+ * learn when a thing is on.
151
+ *
152
+ * A bar that is a control takes its span through `aria-label`
153
+ * instead, because a name assembled from two elements is
154
+ * assembled by the **browser**: chromium joins the two text
155
+ * runs with a space and firefox with nothing, so
156
+ * `getByRole("button", { name })` matches on one engine and
157
+ * not the other. One attribute makes the name exact, and the
158
+ * visible title still opens it, which is what WCAG 2.5.3 asks.
159
+ *
160
+ * A bar that is not a control has no name to write, so the
161
+ * sentence goes in the content where a screen reader will
162
+ * still read it.
163
+ */}
164
+ {shape === "row" ? (
165
+ <span className="text-content-muted text-xs">
166
+ {description}
167
+ </span>
168
+ ) : isInteractive ? null : (
169
+ <VisuallyHidden>{description}</VisuallyHidden>
170
+ )}
171
+ </>
172
+ )
173
+
174
+ /**
175
+ * Only the bar shape gets one, and it carries the **title** as
176
+ * well as the span. The Narrow View prints both already, and a
177
+ * tooltip repeating text that is on screen is noise a pointer
178
+ * user cannot dismiss.
179
+ */
180
+ const sentence = `${item.title}, ${description}`
181
+
182
+ const hoverText = shape === "bar" ? sentence : undefined
183
+
184
+ const shellClassName = toClassName(
185
+ "w-full min-w-0 border px-1.5 text-xs leading-tight",
186
+ shape === "bar"
187
+ ? "flex min-h-5 items-center rounded-sm"
188
+ : "flex flex-col gap-0.5 rounded-md py-1",
189
+ CATEGORICAL_APPEARANCE_CLASS[categorical].soft,
190
+ // A square edge where the item leaves the range. `rounded-*-none`
191
+ // rather than a different component, so a clipped bar and a whole
192
+ // one are the same box with one corner rule changed.
193
+ hasEarlierStart && shape === "bar" && "rounded-s-none",
194
+ hasLaterEnd && shape === "bar" && "rounded-e-none",
195
+ isInteractive && "cursor-pointer",
196
+ isInteractive &&
197
+ CATEGORICAL_HOVER_CLASS[categorical].soft,
198
+ isInteractive && FOCUS_RING_CLASS,
199
+ className,
200
+ )
201
+
202
+ if (item.href) {
203
+ return (
204
+ <UnstyledLink
205
+ aria-label={sentence}
206
+ className={shellClassName}
207
+ href={item.href}
208
+ title={hoverText}
209
+ >
210
+ {content}
211
+ </UnstyledLink>
212
+ )
213
+ }
214
+
215
+ if (item.onSelect) {
216
+ return (
217
+ <button
218
+ aria-label={sentence}
219
+ className={toClassName(
220
+ shellClassName,
221
+ "text-start",
222
+ )}
223
+ onClick={item.onSelect}
224
+ title={hoverText}
225
+ type="button"
226
+ >
227
+ {content}
228
+ </button>
229
+ )
230
+ }
231
+
232
+ return (
233
+ <span className={shellClassName} title={hoverText}>
234
+ {content}
235
+ </span>
236
+ )
237
+ }
@@ -0,0 +1,332 @@
1
+ import { useUniqueId } from "@charcuterie/logic"
2
+ import type { CategoricalIndex } from "@charcuterie/tokens"
3
+ import {
4
+ CATEGORICAL_INDEX_COUNT,
5
+ CATEGORICAL_SEQUENCE,
6
+ } from "@charcuterie/tokens"
7
+ import type { ReactNode } from "react"
8
+
9
+ import { Badge } from "../Badge/Badge.tsx"
10
+ import { CATEGORICAL_SOLID_FILL_CLASS } from "../categoricalStyles.ts"
11
+ import { TextLink } from "../TextLink/TextLink.tsx"
12
+ import { toClassName } from "../toClassName.ts"
13
+ import type { TimelineItem } from "./LaneTimelineBar.tsx"
14
+ import { LaneTimelineBar } from "./LaneTimelineBar.tsx"
15
+ import type { TimelineAxis } from "./laneTimelineGeometry.ts"
16
+ import {
17
+ describeTimelineSpan,
18
+ toPackedLane,
19
+ } from "./laneTimelineGeometry.ts"
20
+
21
+ export type TimelineLane = {
22
+ /**
23
+ * Which of the ten hues this lane wears, overriding the one its
24
+ * **position** would have given it.
25
+ *
26
+ * Reach for it when a lane's colour means something outside this
27
+ * set — a person who is the same colour on every screen of the
28
+ * app, a pair that must not drift apart when a third lane is
29
+ * inserted between them. An overridden lane keeps its hue and the
30
+ * rest go on walking `CATEGORICAL_SEQUENCE` from their own
31
+ * positions, so naming one does not force naming all of them.
32
+ */
33
+ categorical?: CategoricalIndex
34
+ /**
35
+ * Where the lane's heading navigates — the "show me only this
36
+ * lane" route. A link **inside** the heading, never a heading
37
+ * replaced by a link, exactly as `BoardLane.href` is: the heading
38
+ * keeps its level, its id, and its job of naming the group.
39
+ */
40
+ href?: string
41
+ items: readonly TimelineItem[]
42
+ key: string
43
+ /**
44
+ * The lane's name. A **string**: it names the group, and a group
45
+ * with a `ReactNode` name is a group with no name.
46
+ */
47
+ label: string
48
+ }
49
+
50
+ export type LaneTimelineLaneProps = {
51
+ axis: TimelineAxis
52
+ /** The CSS track list, built once by the parent and shared. */
53
+ columnTemplate: string
54
+ headingLevel: 2 | 3 | 4 | 5
55
+ lane: TimelineLane
56
+ locale?: string
57
+ maxRowCount: number
58
+ /** Where this lane sits in the set, for its default hue. */
59
+ position: number
60
+ /**
61
+ * `axis` is the bars across the date axis. `list` is the Narrow
62
+ * View, where there is no axis and each item is a line of text.
63
+ */
64
+ shape: "axis" | "list"
65
+ /** Draw a separator every this many columns. */
66
+ tickStep: number
67
+ }
68
+
69
+ /**
70
+ * One lane: its name, how much it is carrying, and either a track of
71
+ * bars or a list of lines.
72
+ *
73
+ * ### A lane is a `group`, not a landmark
74
+ *
75
+ * The same call `BoardLaneList` makes, and it was a real axe failure
76
+ * there before it changed. A named `<section>` is a `region`, so a
77
+ * timeline of eight lanes would put eight landmarks on a page that
78
+ * has one job, and two timelines on one page would put two landmarks
79
+ * with the same name in the document — which is `landmark-unique`.
80
+ * It is a `<div role="group">` rather than a `<section role="group">`
81
+ * because axe matches `section[aria-labelledby]` on the **element**
82
+ * and audits it as a landmark even after the role is overridden.
83
+ *
84
+ * ### The count is what the lane carries IN THIS RANGE
85
+ *
86
+ * Not `items.length`. A consumer hands over a month and shows a
87
+ * week; counting the array would print "31" beside a lane with two
88
+ * bars on it. `inRangeCount` includes anything the row limit hid, so
89
+ * a lane cannot look lighter by being more loaded.
90
+ *
91
+ * ### A lane gets TALLER when it is busy, and that is the design
92
+ *
93
+ * Overlapping items stack into rows (`toPackedLane`), so a lane
94
+ * carrying three things at once is three bars tall while one
95
+ * carrying them in sequence is one. Load asymmetry then reads as
96
+ * height from across the room, before anybody counts anything —
97
+ * which is the whole reason this component exists rather than a
98
+ * month grid, where every cell is the same size no matter what is
99
+ * in it.
100
+ */
101
+ export const LaneTimelineLane = ({
102
+ axis,
103
+ columnTemplate,
104
+ headingLevel,
105
+ lane,
106
+ locale,
107
+ maxRowCount,
108
+ position,
109
+ shape,
110
+ tickStep,
111
+ }: LaneTimelineLaneProps): ReactNode => {
112
+ const headingId = useUniqueId()
113
+
114
+ /*
115
+ * Position, not `getCategoricalIndex(lane.key)`, and `ActionTiles`
116
+ * made the same call for the same reason. That helper hashes, so a
117
+ * row keeps its colour across a list nobody controls the order of
118
+ * — right for Docket's labels, wrong here: a hash of two lane keys
119
+ * can collide, and two lanes in one hue defeats the only thing the
120
+ * colour is for. Measured on this component's own fixtures, where
121
+ * `wren` and `juno` both hashed to Pink and the screenshot showed
122
+ * one person's week twice.
123
+ *
124
+ * `CATEGORICAL_SEQUENCE` rather than the ring: the ring is
125
+ * hue-ordered for the swatch picker's sake, so walking it in order
126
+ * hands a two-lane set the two hardest colours in the family to
127
+ * tell apart.
128
+ */
129
+ const categorical =
130
+ lane.categorical ??
131
+ CATEGORICAL_SEQUENCE[position % CATEGORICAL_INDEX_COUNT]
132
+
133
+ const packed = toPackedLane({
134
+ axis,
135
+ items: lane.items,
136
+ /*
137
+ * The Narrow View has no row limit, because there are no rows:
138
+ * it is a vertical list, so the thing the limit protects
139
+ * against — a lane growing past the height of the screen while
140
+ * every other lane stays one bar tall — cannot happen. Hiding
141
+ * items there would lose them for the reader who has the least
142
+ * room to spare.
143
+ */
144
+ maxRowCount:
145
+ shape === "list" ? lane.items.length : maxRowCount,
146
+ })
147
+
148
+ const Heading = `h${headingLevel}` as const
149
+
150
+ const header = (
151
+ <header className="flex min-w-0 items-center gap-2">
152
+ {/*
153
+ * The lane's hue, beside its name, so the reader learns which
154
+ * colour is which lane once instead of per bar. Decoration
155
+ * only — the heading is the thing that says whose lane it is,
156
+ * which is what keeps the colour from being the sole carrier
157
+ * of anything.
158
+ */}
159
+ <span
160
+ aria-hidden="true"
161
+ className={toClassName(
162
+ "h-4 w-1 shrink-0 rounded-full",
163
+ CATEGORICAL_SOLID_FILL_CLASS[categorical],
164
+ )}
165
+ />
166
+
167
+ <Heading
168
+ className="min-w-0 truncate font-semibold text-content-primary text-sm"
169
+ id={headingId}
170
+ title={lane.label}
171
+ >
172
+ {lane.href ? (
173
+ <TextLink
174
+ appearance="standalone"
175
+ className="font-semibold text-inherit"
176
+ href={lane.href}
177
+ >
178
+ {lane.label}
179
+ </TextLink>
180
+ ) : (
181
+ lane.label
182
+ )}
183
+ </Heading>
184
+
185
+ <Badge appearance="soft" intent="neutral" size="sm">
186
+ {String(packed.inRangeCount)}
187
+ </Badge>
188
+ </header>
189
+ )
190
+
191
+ if (shape === "list") {
192
+ const items = packed.rows
193
+ .flat()
194
+ .sort(
195
+ (first, second) =>
196
+ first.placement.columnStart -
197
+ second.placement.columnStart,
198
+ )
199
+
200
+ return (
201
+ // biome-ignore lint/a11y/useSemanticElements: the semantic element for `group` is `<fieldset>`, which drags `<legend>`, a border and form-reset behaviour onto a lane of dates. Same call `BoardLaneList`, `Menu`'s group and `AccordionSection` already made.
202
+ <div
203
+ aria-labelledby={headingId}
204
+ className="flex flex-col gap-1.5 border-border-subtle border-t py-2"
205
+ role="group"
206
+ >
207
+ {header}
208
+
209
+ {items.length === 0 ? (
210
+ <p className="text-content-muted text-xs">
211
+ Nothing in this range.
212
+ </p>
213
+ ) : (
214
+ <ul className="flex flex-col gap-1">
215
+ {items.map(({ item, placement }) => (
216
+ <li className="min-w-0" key={item.key}>
217
+ <LaneTimelineBar
218
+ laneCategorical={categorical}
219
+ description={describeTimelineSpan({
220
+ locale,
221
+ placement,
222
+ span: item,
223
+ })}
224
+ hasEarlierStart={
225
+ placement.hasEarlierStart
226
+ }
227
+ hasLaterEnd={placement.hasLaterEnd}
228
+ item={item}
229
+ shape="row"
230
+ />
231
+ </li>
232
+ ))}
233
+ </ul>
234
+ )}
235
+ </div>
236
+ )
237
+ }
238
+
239
+ return (
240
+ // biome-ignore lint/a11y/useSemanticElements: see the Narrow View branch above — `<fieldset>` is the only semantic element for `group`, and it is a form-control grouping.
241
+ <div
242
+ aria-labelledby={headingId}
243
+ className="grid grid-cols-[12rem_minmax(0,1fr)] items-start gap-x-3 border-border-subtle border-t py-1.5"
244
+ role="group"
245
+ >
246
+ {header}
247
+
248
+ <div className="relative min-h-6">
249
+ {/*
250
+ * The separators, drawn behind the bars rather than between
251
+ * them. A real element between two columns takes up space,
252
+ * which moves the columns it was measuring itself against —
253
+ * the same feedback loop `BoardLaneList`'s drop indicator
254
+ * avoids by being absolute.
255
+ */}
256
+ <div
257
+ aria-hidden="true"
258
+ className="pointer-events-none absolute inset-0 grid"
259
+ style={{ gridTemplateColumns: columnTemplate }}
260
+ >
261
+ {/*
262
+ * Keyed by the DAY, not by the loop index — the day number
263
+ * is what the cell is, and a key that is an index is a key
264
+ * that moves when the range does.
265
+ */}
266
+ {Array.from(
267
+ { length: axis.columnCount },
268
+ (_unused, index) => axis.startDay + index,
269
+ ).map((dayNumber) => (
270
+ <span
271
+ className={toClassName(
272
+ // Never on the leading edge: a line down the start of
273
+ // the track reads as a frame the track does not have.
274
+ dayNumber > axis.startDay &&
275
+ (dayNumber - axis.startDay) % tickStep ===
276
+ 0 &&
277
+ "border-border-subtle border-s",
278
+ )}
279
+ key={dayNumber}
280
+ />
281
+ ))}
282
+ </div>
283
+
284
+ <ul
285
+ className="relative grid gap-1"
286
+ style={{ gridTemplateColumns: columnTemplate }}
287
+ >
288
+ {packed.rows.flatMap((row, rowIndex) =>
289
+ row.map(({ item, placement }) => (
290
+ <li
291
+ className="min-w-0"
292
+ key={item.key}
293
+ style={{
294
+ /*
295
+ * `span`, never a start/end pair. An end line is
296
+ * one past the last column the bar covers, so
297
+ * writing it is the single easiest place to lose
298
+ * a day — and it is invisible, because a bar one
299
+ * day short still looks like a bar.
300
+ */
301
+ gridColumn: `${placement.columnStart} / span ${placement.columnSpan}`,
302
+ gridRow: rowIndex + 1,
303
+ }}
304
+ >
305
+ <LaneTimelineBar
306
+ laneCategorical={categorical}
307
+ description={describeTimelineSpan({
308
+ locale,
309
+ placement,
310
+ span: item,
311
+ })}
312
+ hasEarlierStart={
313
+ placement.hasEarlierStart
314
+ }
315
+ hasLaterEnd={placement.hasLaterEnd}
316
+ item={item}
317
+ shape="bar"
318
+ />
319
+ </li>
320
+ )),
321
+ )}
322
+ </ul>
323
+
324
+ {packed.hiddenCount > 0 ? (
325
+ <p className="pt-1 text-content-muted text-xs">
326
+ {`+ ${packed.hiddenCount} more overlapping in ${lane.label}`}
327
+ </p>
328
+ ) : null}
329
+ </div>
330
+ </div>
331
+ )
332
+ }