@charcuterie/ui 4.6.0 → 4.7.1

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 (41) 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/dist/testing/expectAgentDrivable.d.ts +1 -1
  27. package/dist/testing/expectAgentDrivable.js +1 -1
  28. package/package.json +3 -3
  29. package/src/LaneTimeline/LaneTimeline.mdx +191 -0
  30. package/src/LaneTimeline/LaneTimeline.stories.tsx +561 -0
  31. package/src/LaneTimeline/LaneTimeline.test.tsx +330 -0
  32. package/src/LaneTimeline/LaneTimeline.tsx +381 -0
  33. package/src/LaneTimeline/LaneTimelineBar.tsx +237 -0
  34. package/src/LaneTimeline/LaneTimelineLane.tsx +332 -0
  35. package/src/LaneTimeline/laneTimelineGeometry.test.ts +429 -0
  36. package/src/LaneTimeline/laneTimelineGeometry.ts +476 -0
  37. package/src/LaneTimeline/useTrackMeasurements.ts +165 -0
  38. package/src/Toast/Toast.mdx +1 -1
  39. package/src/index.ts +13 -0
  40. package/src/sourceRules.test.ts +22 -1
  41. package/src/testing/expectAgentDrivable.ts +1 -1
@@ -0,0 +1,561 @@
1
+ import { playgroundParameters } from "@charcuterie/storybook-config/story-parameters"
2
+ import type { Meta, StoryObj } from "@storybook/react"
3
+ import type { ReactNode } from "react"
4
+ import { useState } from "react"
5
+
6
+ import {
7
+ ContainerBoard,
8
+ StoryCell,
9
+ StoryGrid,
10
+ } from "../board.storyHelpers.tsx"
11
+ import { EmptyState } from "../EmptyState/EmptyState.tsx"
12
+ import { LaneTimeline } from "./LaneTimeline.tsx"
13
+ import type { TimelineItem } from "./LaneTimelineBar.tsx"
14
+ import type { TimelineLane } from "./LaneTimelineLane.tsx"
15
+
16
+ /**
17
+ * **Invented, all of it.** The calendar this component was built for
18
+ * is a real household, and a fixture taken from one would be
19
+ * unremovable the moment it reached a committed screenshot — a PNG
20
+ * is opaque to every grep and no text scrub redacts it.
21
+ *
22
+ * So this is a fictional recording studio's week. The names are the
23
+ * same four the `Board` stories use, because a reader moving between
24
+ * two docs pages should not have to learn a second cast.
25
+ *
26
+ * Every date is **April 2026**, and `2026-04-06` is a Monday — so a
27
+ * seven-day week is `06` to `12`, and the inclusive end is something
28
+ * a reader can check by counting columns rather than by trusting
29
+ * prose.
30
+ */
31
+ const WEEK = {
32
+ end: "2026-04-12",
33
+ start: "2026-04-06",
34
+ }
35
+
36
+ const MONTH = {
37
+ end: "2026-04-30",
38
+ start: "2026-04-01",
39
+ }
40
+
41
+ /**
42
+ * The load asymmetry, which is the whole reason the component exists
43
+ * rather than a month grid. Wren is carrying most of this week and
44
+ * Ada is carrying one afternoon of it, and that is meant to be
45
+ * obvious before anybody reads a word: Wren's lane stacks three bars
46
+ * deep and is three times the height of Ada's.
47
+ */
48
+ const LANES: TimelineLane[] = [
49
+ {
50
+ items: [
51
+ {
52
+ end: "2026-04-08",
53
+ key: "foley",
54
+ start: "2026-04-07",
55
+ title: "Foley session",
56
+ },
57
+ ],
58
+ key: "ada",
59
+ label: "Ada",
60
+ },
61
+ {
62
+ items: [
63
+ {
64
+ end: "2026-04-12",
65
+ key: "on-call",
66
+ start: "2026-04-06",
67
+ title: "On call for the rig",
68
+ },
69
+ {
70
+ end: "2026-04-09",
71
+ key: "field-trip",
72
+ start: "2026-04-06",
73
+ title: "Field recording trip",
74
+ },
75
+ {
76
+ end: "2026-04-06",
77
+ key: "mic-locker",
78
+ start: "2026-04-06",
79
+ title: "Mic locker inventory",
80
+ },
81
+ {
82
+ end: "2026-04-10",
83
+ key: "mixing",
84
+ start: "2026-04-08",
85
+ title: "Mixing block",
86
+ },
87
+ {
88
+ end: "2026-04-09",
89
+ key: "listen-back",
90
+ start: "2026-04-09",
91
+ title: "Client listen back",
92
+ },
93
+ {
94
+ end: "2026-04-12",
95
+ key: "stems",
96
+ start: "2026-04-10",
97
+ title: "Stems delivery",
98
+ },
99
+ ],
100
+ key: "wren",
101
+ label: "Wren",
102
+ },
103
+ {
104
+ items: [
105
+ {
106
+ end: "2026-04-08",
107
+ key: "score-revisions",
108
+ start: "2026-04-06",
109
+ title: "Score revisions",
110
+ },
111
+ {
112
+ end: "2026-04-10",
113
+ key: "coast-train",
114
+ start: "2026-04-10",
115
+ title: "Train to the coast",
116
+ },
117
+ {
118
+ end: "2026-04-12",
119
+ key: "rig-service",
120
+ start: "2026-04-11",
121
+ title: "Rig service",
122
+ },
123
+ ],
124
+ key: "juno",
125
+ label: "Juno",
126
+ },
127
+ {
128
+ items: [
129
+ {
130
+ end: "2026-04-11",
131
+ key: "patch-bay",
132
+ start: "2026-04-09",
133
+ title: "Patch bay rewire",
134
+ },
135
+ ],
136
+ key: "rook",
137
+ label: "Rook",
138
+ },
139
+ ]
140
+
141
+ /**
142
+ * The four edge cases, in one lane, because each of them is a place
143
+ * a naive implementation draws nothing or draws it somewhere else —
144
+ * and none of the four is visible in a screenshot without a correct
145
+ * one beside it.
146
+ */
147
+ const EDGE_CASE_ITEMS: TimelineItem[] = [
148
+ {
149
+ // Starts nine days before the week. Clipped to column 1, square
150
+ // leading edge, and its hidden sentence still says 30 March.
151
+ end: "2026-04-08",
152
+ key: "long-hire",
153
+ start: "2026-03-30",
154
+ title: "Desk hire, started last month",
155
+ },
156
+ {
157
+ // Runs to the end of April. Clipped to the last column, square
158
+ // trailing edge.
159
+ end: "2026-04-30",
160
+ key: "residency",
161
+ start: "2026-04-10",
162
+ title: "Residency, runs into May",
163
+ },
164
+ {
165
+ // One day. A bar, not a dot — one vocabulary on the axis.
166
+ end: "2026-04-09",
167
+ key: "delivery",
168
+ start: "2026-04-09",
169
+ title: "Tape delivery",
170
+ },
171
+ {
172
+ // `end` before `start`: somebody typed the dates the wrong way
173
+ // round. Drawn on its start day rather than dropped, because a
174
+ // dropped row is a data fault nobody can see.
175
+ end: "2026-04-06",
176
+ key: "reversed",
177
+ start: "2026-04-11",
178
+ title: "Kit return, dates entered backwards",
179
+ },
180
+ {
181
+ // Entirely outside the week. Not drawn at all, and not counted —
182
+ // nothing is missing from this picture, the picture is of
183
+ // another week.
184
+ end: "2026-04-26",
185
+ key: "next-month",
186
+ start: "2026-04-20",
187
+ title: "Next month's session",
188
+ },
189
+ ]
190
+
191
+ /**
192
+ * A **fixed** inline size, because the fold is a measurement of the
193
+ * component's own box. A story that inherited the canvas width would
194
+ * draw an axis on one machine and the Narrow View on another, and a
195
+ * screenshot of it would be a screenshot of somebody's window.
196
+ */
197
+ const Frame = ({
198
+ children,
199
+ inlineSize,
200
+ }: {
201
+ children: ReactNode
202
+ inlineSize: string
203
+ }): ReactNode => (
204
+ <div style={{ inlineSize }}>{children}</div>
205
+ )
206
+
207
+ const meta = {
208
+ title: "Components/Data/LaneTimeline",
209
+ component: LaneTimeline,
210
+ parameters: { layout: "padded" },
211
+ args: {
212
+ headingLevel: 3,
213
+ label: "The week",
214
+ lanes: LANES,
215
+ /*
216
+ * Pinned, and not because anybody prefers it. `Intl` with no
217
+ * locale uses the runtime's, so the same story renders
218
+ * "Mon 6 Apr" on one machine and "Mon, Apr 6" on the next — and
219
+ * the test asserting the bar's name would then be asserting the
220
+ * test runner's locale. An app passes its own, or omits it and
221
+ * gets the reader's.
222
+ */
223
+ locale: "en-GB",
224
+ maxRowCount: 6,
225
+ minColumnSize: 1.75,
226
+ range: WEEK,
227
+ },
228
+ } satisfies Meta<typeof LaneTimeline>
229
+
230
+ export default meta
231
+
232
+ type Story = StoryObj<typeof meta>
233
+
234
+ /**
235
+ * Four lanes across one week, at the density a real schedule
236
+ * reaches.
237
+ *
238
+ * Read it for the **shape** rather than the entries: Wren's lane is
239
+ * three bars deep for most of the week and Ada's is one bar on one
240
+ * afternoon. That is the question a month grid cannot answer, because
241
+ * a grid gives every day the same box no matter what is in it.
242
+ */
243
+ export const Playground: Story = {
244
+ parameters: playgroundParameters,
245
+ render: (timelineProps) => (
246
+ <Frame inlineSize="64rem">
247
+ <LaneTimeline {...timelineProps} />
248
+ </Frame>
249
+ ),
250
+ }
251
+
252
+ /**
253
+ * Plain bars, lanes whose headings navigate, and bars that act.
254
+ *
255
+ * `href` and `onSelect` are a **union** on the item, not two
256
+ * optionals: the first renders an `<a>` and the second a `<button>`,
257
+ * so an item carrying both would be one of them with the other
258
+ * silently dropped, and the two are identical on screen.
259
+ */
260
+ export const AllVariants: Story = {
261
+ render: (timelineProps) => (
262
+ <StoryGrid columns={1}>
263
+ <StoryCell
264
+ align="stretch"
265
+ label="plain — nothing to press, which is a real mode for a wall display"
266
+ >
267
+ <Frame inlineSize="56rem">
268
+ <LaneTimeline
269
+ {...timelineProps}
270
+ label="The week, read-only"
271
+ />
272
+ </Frame>
273
+ </StoryCell>
274
+
275
+ <StoryCell
276
+ align="stretch"
277
+ label="lane headings that navigate — the show-me-only-this-lane route"
278
+ >
279
+ <Frame inlineSize="56rem">
280
+ <LaneTimeline
281
+ {...timelineProps}
282
+ label="The week, with linked lanes"
283
+ lanes={LANES.map((lane) => ({
284
+ ...lane,
285
+ href: `/lane/${lane.key}`,
286
+ }))}
287
+ />
288
+ </Frame>
289
+ </StoryCell>
290
+
291
+ <StoryCell
292
+ align="stretch"
293
+ label="bars that navigate — every bar is a real `<a href>`"
294
+ >
295
+ <Frame inlineSize="56rem">
296
+ <LaneTimeline
297
+ {...timelineProps}
298
+ label="The week, with linked bars"
299
+ lanes={LANES.map((lane) => ({
300
+ ...lane,
301
+ /*
302
+ * The two action keys are taken off before the new
303
+ * one goes on. A spread of a union widens both of
304
+ * them back to optional, and the type then refuses
305
+ * the result — which is the type doing its job: an
306
+ * item with an `href` *and* an `onSelect` is an `<a>`
307
+ * with a dropped handler, and it looks correct.
308
+ */
309
+ items: lane.items.map(
310
+ ({
311
+ href: _href,
312
+ onSelect: _onSelect,
313
+ ...item
314
+ }): TimelineItem => ({
315
+ ...item,
316
+ href: `/item/${item.key}`,
317
+ }),
318
+ ),
319
+ }))}
320
+ />
321
+ </Frame>
322
+ </StoryCell>
323
+ </StoryGrid>
324
+ ),
325
+ }
326
+
327
+ /**
328
+ * The states nobody screenshots: the four edge cases, a lane past
329
+ * its row limit, a lane with nothing on, and a timeline with no
330
+ * lanes at all.
331
+ *
332
+ * `maxRowCount={2}` on the second panel is the honest-overflow rule
333
+ * `Board`'s `+ n more` already follows. A lane that silently drops
334
+ * its ninth overlapping thing is a lane that lies about how loaded
335
+ * it is, which is the one question this component answers.
336
+ */
337
+ export const AllStates: Story = {
338
+ render: (timelineProps) => (
339
+ <StoryGrid columns={1}>
340
+ <StoryCell
341
+ align="stretch"
342
+ label="the four edge cases, plus one item that is in another week entirely"
343
+ >
344
+ <Frame inlineSize="56rem">
345
+ <LaneTimeline
346
+ {...timelineProps}
347
+ label="The week, edge cases"
348
+ lanes={[
349
+ {
350
+ items: EDGE_CASE_ITEMS,
351
+ key: "studio",
352
+ label: "Studio floor",
353
+ },
354
+ ]}
355
+ />
356
+ </Frame>
357
+ </StoryCell>
358
+
359
+ <StoryCell
360
+ align="stretch"
361
+ label="a lane past its row limit, and a lane with nothing on"
362
+ >
363
+ <Frame inlineSize="56rem">
364
+ <LaneTimeline
365
+ {...timelineProps}
366
+ label="The week, at the row limit"
367
+ lanes={[
368
+ LANES[1] as TimelineLane,
369
+ {
370
+ items: [],
371
+ key: "spare",
372
+ label: "Spare room",
373
+ },
374
+ ]}
375
+ maxRowCount={2}
376
+ />
377
+ </Frame>
378
+ </StoryCell>
379
+
380
+ <StoryCell
381
+ align="stretch"
382
+ label="no lanes — a real `EmptyState`, which is what a good week looks like"
383
+ >
384
+ <Frame inlineSize="56rem">
385
+ <LaneTimeline
386
+ {...timelineProps}
387
+ label="The week, empty"
388
+ lanes={[]}
389
+ />
390
+ </Frame>
391
+ </StoryCell>
392
+
393
+ <StoryCell
394
+ align="stretch"
395
+ label="the consumer's own empty state"
396
+ >
397
+ <Frame inlineSize="56rem">
398
+ <LaneTimeline
399
+ {...timelineProps}
400
+ emptyState={
401
+ <EmptyState
402
+ description="Add a lane for each person and the week fills itself in."
403
+ heading="Nobody has a lane yet"
404
+ headingLevel={3}
405
+ size="sm"
406
+ />
407
+ }
408
+ label="The week, empty with a custom message"
409
+ lanes={[]}
410
+ />
411
+ </Frame>
412
+ </StoryCell>
413
+ </StoryGrid>
414
+ ),
415
+ }
416
+
417
+ /**
418
+ * **The story that proves the fold**, and the only honest way to show
419
+ * it: three fixed-width panels inside one browser window that never
420
+ * moves.
421
+ *
422
+ * The narrow panel is not a phone. It is the same timeline in a
423
+ * sidebar, in a split window, or on a display somebody has zoomed to
424
+ * 175% — and the component cannot tell those apart from each other,
425
+ * only from a box with room for an axis in it.
426
+ *
427
+ * Watch what the fold actually is. It is **not** a width: it is the
428
+ * width divided by the number of days, so the same 24rem panel keeps
429
+ * its axis for a week and loses it for a quarter. When it goes, the
430
+ * axis is abandoned rather than squeezed — each lane becomes a
431
+ * one-column list with every span written out in words, and nothing
432
+ * scrolls sideways.
433
+ */
434
+ export const Responsive: Story = {
435
+ render: (timelineProps) => (
436
+ <div className="flex flex-col gap-8">
437
+ <ContainerBoard>
438
+ {(width) => (
439
+ <LaneTimeline
440
+ {...timelineProps}
441
+ label={`The week at ${width}`}
442
+ />
443
+ )}
444
+ </ContainerBoard>
445
+
446
+ <div className="flex flex-col gap-2">
447
+ <span className="font-mono text-content-muted text-xs">
448
+ the same three widths, one month instead of one
449
+ week
450
+ </span>
451
+
452
+ <ContainerBoard>
453
+ {(width) => (
454
+ <LaneTimeline
455
+ {...timelineProps}
456
+ label={`The month at ${width}`}
457
+ range={MONTH}
458
+ />
459
+ )}
460
+ </ContainerBoard>
461
+ </div>
462
+ </div>
463
+ ),
464
+ }
465
+
466
+ /**
467
+ * The Narrow View on its own, big enough to read.
468
+ *
469
+ * Each lane keeps its name, its count and its colour, and every item
470
+ * becomes a line with its span in words underneath it. Nothing is
471
+ * behind a gesture and nothing is off the side of the box — *"no
472
+ * horizontal scroll, ever"*, the same rule `Board` settled, for the
473
+ * same reason: a pan surface hides content with no affordance and
474
+ * fights the trackpad's own back-navigation.
475
+ */
476
+ export const NarrowView: Story = {
477
+ render: (timelineProps) => (
478
+ // 20rem is a small phone, and it is deliberately a width rather
479
+ // than a device: the same layout is what a docked sidebar gets on
480
+ // a 4K monitor.
481
+ <Frame inlineSize="20rem">
482
+ <LaneTimeline
483
+ {...timelineProps}
484
+ label="The week, Narrow View"
485
+ />
486
+ </Frame>
487
+ ),
488
+ }
489
+
490
+ /**
491
+ * A month, where the tick labels **thin** rather than shrink.
492
+ *
493
+ * Type that shrinks to fit is type somebody cannot read, and the
494
+ * density axis already owns font size. So the axis drops to one
495
+ * label a week — whole weeks, never a step of two or three, because
496
+ * labels landing on Tuesday then Thursday then Saturday give the eye
497
+ * nothing to lock onto.
498
+ */
499
+ export const LongRange: Story = {
500
+ render: (timelineProps) => (
501
+ <Frame inlineSize="72rem">
502
+ <LaneTimeline
503
+ {...timelineProps}
504
+ label="The month"
505
+ range={MONTH}
506
+ />
507
+ </Frame>
508
+ ),
509
+ }
510
+
511
+ /**
512
+ * Every bar is a real control, and the keyboard path is the same one
513
+ * a pointer takes.
514
+ *
515
+ * Tab moves through the bars in **chronological** order within a
516
+ * lane and lane by lane down the page, which is the order the DOM is
517
+ * written in rather than the order the packer happened to stack
518
+ * them. Enter or Space fires the item. There is no drag and no
519
+ * resize: moving a bar is a write, and a write is a different
520
+ * component with a different decision behind it.
521
+ */
522
+ const SelectableTimeline = (): ReactNode => {
523
+ const [selectedTitle, setSelectedTitle] = useState("")
524
+
525
+ return (
526
+ <Frame inlineSize="64rem">
527
+ <div className="flex flex-col gap-3">
528
+ <LaneTimeline
529
+ label="The week, selectable"
530
+ locale="en-GB"
531
+ lanes={LANES.map((lane) => ({
532
+ ...lane,
533
+ items: lane.items.map(
534
+ ({
535
+ href: _href,
536
+ onSelect: _onSelect,
537
+ ...item
538
+ }): TimelineItem => ({
539
+ ...item,
540
+ onSelect: () => {
541
+ setSelectedTitle(item.title)
542
+ },
543
+ }),
544
+ ),
545
+ }))}
546
+ range={WEEK}
547
+ />
548
+
549
+ <p className="text-content-secondary text-sm">
550
+ {selectedTitle
551
+ ? `Picked: ${selectedTitle}`
552
+ : "Nothing picked yet."}
553
+ </p>
554
+ </div>
555
+ </Frame>
556
+ )
557
+ }
558
+
559
+ export const Interactive: Story = {
560
+ render: () => <SelectableTimeline />,
561
+ }