@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,429 @@
1
+ /**
2
+ * The arithmetic, in Node, against fixtures.
3
+ *
4
+ * Every date in here is invented. It is also all **2026 April**, for
5
+ * one reason worth stating: 2026-04-06 is a Monday, so a seven-day
6
+ * week is `06` to `12` and a reader checking this file by hand can
7
+ * see the off-by-one they are looking for.
8
+ *
9
+ * The four cases the component's own doc calls out — zero width, a
10
+ * single day, clipped at the start, clipped at the end — each get a
11
+ * test of their own, because all four are places a naive
12
+ * implementation draws nothing or draws it in the wrong place, and
13
+ * none of the four is visible in a screenshot.
14
+ */
15
+
16
+ import { expect, test } from "vitest"
17
+
18
+ import {
19
+ chooseAxisTickStep,
20
+ describeTimelineSpan,
21
+ getIsAxisReadable,
22
+ toBarPlacement,
23
+ toPackedLane,
24
+ toTimelineAxis,
25
+ } from "./laneTimelineGeometry.ts"
26
+
27
+ const WEEK = {
28
+ end: "2026-04-12",
29
+ start: "2026-04-06",
30
+ }
31
+
32
+ const axis = toTimelineAxis(WEEK)
33
+
34
+ if (!axis) {
35
+ throw new Error("the fixture week must parse")
36
+ }
37
+
38
+ test("a week is seven columns, not eight", () => {
39
+ // The inclusive end, asserted as a number rather than described in
40
+ // prose. Monday to Sunday is seven days; a half-open reading gives
41
+ // six and the bug is invisible in every screenshot.
42
+ expect(axis.columnCount).toBe(7)
43
+ })
44
+
45
+ test("an unparseable range is refused rather than guessed", () => {
46
+ expect(
47
+ toTimelineAxis({ end: "2026-04-12", start: "April 6" }),
48
+ ).toBeNull()
49
+
50
+ expect(
51
+ toTimelineAxis({ end: "", start: "2026-04-06" }),
52
+ ).toBeNull()
53
+ })
54
+
55
+ test("an inverted range collapses to its start day", () => {
56
+ // Repaired, not refused. A caller who swapped two variables gets
57
+ // one obviously-wrong column, which is recoverable; an empty
58
+ // component reads as "there is nothing on", which is not.
59
+ expect(
60
+ toTimelineAxis({
61
+ end: "2026-04-06",
62
+ start: "2026-04-12",
63
+ })?.columnCount,
64
+ ).toBe(1)
65
+ })
66
+
67
+ test("a single-day item is one column wide", () => {
68
+ expect(
69
+ toBarPlacement({
70
+ axis,
71
+ span: { end: "2026-04-08", start: "2026-04-08" },
72
+ }),
73
+ ).toEqual({
74
+ columnSpan: 1,
75
+ columnStart: 3,
76
+ dayCount: 1,
77
+ hasEarlierStart: false,
78
+ hasLaterEnd: false,
79
+ })
80
+ })
81
+
82
+ test("a zero-width item is drawn on its start day rather than dropped", () => {
83
+ // `end` before `start`. The bar is not dropped — a dropped row is
84
+ // a data fault nobody can see — and it is not drawn backwards,
85
+ // because `grid-column: 4 / span 0` is not a zero-width box, it is
86
+ // undefined behaviour that lands the bar somewhere plausible.
87
+ expect(
88
+ toBarPlacement({
89
+ axis,
90
+ span: { end: "2026-04-07", start: "2026-04-09" },
91
+ }),
92
+ ).toEqual({
93
+ columnSpan: 1,
94
+ columnStart: 4,
95
+ dayCount: 1,
96
+ hasEarlierStart: false,
97
+ hasLaterEnd: false,
98
+ })
99
+ })
100
+
101
+ test("an item starting before the range is clipped to the first column and says so", () => {
102
+ const placement = toBarPlacement({
103
+ axis,
104
+ span: { end: "2026-04-08", start: "2026-03-30" },
105
+ })
106
+
107
+ // Column **1**, never 0 and never negative. CSS grid reads a
108
+ // negative `grid-column-start` as counting back from the end of the
109
+ // row, so the naive subtraction puts the bar at the far side of the
110
+ // week with nothing failing.
111
+ expect(placement?.columnStart).toBe(1)
112
+ expect(placement?.columnSpan).toBe(3)
113
+ expect(placement?.hasEarlierStart).toBe(true)
114
+ expect(placement?.hasLaterEnd).toBe(false)
115
+
116
+ // The item's own length, not the visible part of it.
117
+ expect(placement?.dayCount).toBe(10)
118
+ })
119
+
120
+ test("an item ending after the range is clipped to the last column and says so", () => {
121
+ const placement = toBarPlacement({
122
+ axis,
123
+ span: { end: "2026-04-30", start: "2026-04-10" },
124
+ })
125
+
126
+ expect(placement?.columnStart).toBe(5)
127
+ expect(placement?.columnSpan).toBe(3)
128
+ expect(placement?.hasEarlierStart).toBe(false)
129
+ expect(placement?.hasLaterEnd).toBe(true)
130
+ expect(placement?.dayCount).toBe(21)
131
+ })
132
+
133
+ test("an item spanning the whole range is clipped at both ends", () => {
134
+ expect(
135
+ toBarPlacement({
136
+ axis,
137
+ span: { end: "2026-05-01", start: "2026-03-01" },
138
+ }),
139
+ ).toEqual({
140
+ columnSpan: 7,
141
+ columnStart: 1,
142
+ dayCount: 62,
143
+ hasEarlierStart: true,
144
+ hasLaterEnd: true,
145
+ })
146
+ })
147
+
148
+ test("an item outside the range is not placed at all", () => {
149
+ expect(
150
+ toBarPlacement({
151
+ axis,
152
+ span: { end: "2026-04-05", start: "2026-04-01" },
153
+ }),
154
+ ).toBeNull()
155
+
156
+ expect(
157
+ toBarPlacement({
158
+ axis,
159
+ span: { end: "2026-04-20", start: "2026-04-13" },
160
+ }),
161
+ ).toBeNull()
162
+ })
163
+
164
+ test("an item touching the range by one day is placed", () => {
165
+ // The boundary either side of the test above. Ending **on** the
166
+ // first visible day is inside; ending the day before is outside.
167
+ expect(
168
+ toBarPlacement({
169
+ axis,
170
+ span: { end: "2026-04-06", start: "2026-04-01" },
171
+ })?.columnSpan,
172
+ ).toBe(1)
173
+
174
+ expect(
175
+ toBarPlacement({
176
+ axis,
177
+ span: { end: "2026-04-20", start: "2026-04-12" },
178
+ })?.columnStart,
179
+ ).toBe(7)
180
+ })
181
+
182
+ test("items that do not overlap share one row", () => {
183
+ const packed = toPackedLane({
184
+ axis,
185
+ items: [
186
+ {
187
+ end: "2026-04-07",
188
+ key: "first",
189
+ start: "2026-04-06",
190
+ },
191
+ {
192
+ end: "2026-04-10",
193
+ key: "second",
194
+ start: "2026-04-08",
195
+ },
196
+ ],
197
+ maxRowCount: 6,
198
+ })
199
+
200
+ expect(packed.rows).toHaveLength(1)
201
+ expect(packed.inRangeCount).toBe(2)
202
+ expect(packed.hiddenCount).toBe(0)
203
+ })
204
+
205
+ test("a lane gets one row per overlapping item, and no more", () => {
206
+ // The optimality claim, asserted: three items overlapping on one
207
+ // day is three rows, and the fourth — which starts after all three
208
+ // have finished — goes back on the first row rather than opening a
209
+ // fourth.
210
+ const packed = toPackedLane({
211
+ axis,
212
+ items: [
213
+ {
214
+ end: "2026-04-09",
215
+ key: "a",
216
+ start: "2026-04-06",
217
+ },
218
+ {
219
+ end: "2026-04-09",
220
+ key: "b",
221
+ start: "2026-04-07",
222
+ },
223
+ {
224
+ end: "2026-04-09",
225
+ key: "c",
226
+ start: "2026-04-08",
227
+ },
228
+ {
229
+ end: "2026-04-12",
230
+ key: "d",
231
+ start: "2026-04-11",
232
+ },
233
+ ],
234
+ maxRowCount: 6,
235
+ })
236
+
237
+ expect(packed.rows).toHaveLength(3)
238
+ expect(
239
+ packed.rows[0]?.map((one) => one.item.key),
240
+ ).toEqual(["a", "d"])
241
+ })
242
+
243
+ test("the packing is stable when two items start on the same day", () => {
244
+ // Without the `key` tie-break, two items starting together swap
245
+ // rows whenever the consumer's sort is unstable — which reads as
246
+ // the timeline flickering for no reason anybody can name.
247
+ const items = [
248
+ { end: "2026-04-08", key: "zulu", start: "2026-04-06" },
249
+ {
250
+ end: "2026-04-08",
251
+ key: "alpha",
252
+ start: "2026-04-06",
253
+ },
254
+ ]
255
+
256
+ const first = toPackedLane({
257
+ axis,
258
+ items,
259
+ maxRowCount: 6,
260
+ })
261
+
262
+ const second = toPackedLane({
263
+ axis,
264
+ items: [...items].reverse(),
265
+ maxRowCount: 6,
266
+ })
267
+
268
+ expect(first.rows[0]?.[0]?.item.key).toBe("alpha")
269
+ expect(second.rows[0]?.[0]?.item.key).toBe("alpha")
270
+ })
271
+
272
+ test("a lane past its row limit counts the rest rather than dropping them", () => {
273
+ const packed = toPackedLane({
274
+ axis,
275
+ items: Array.from({ length: 5 }, (_unused, index) => ({
276
+ end: "2026-04-10",
277
+ key: `overlapping-${index}`,
278
+ start: "2026-04-06",
279
+ })),
280
+ maxRowCount: 2,
281
+ })
282
+
283
+ expect(packed.rows).toHaveLength(2)
284
+ expect(packed.hiddenCount).toBe(3)
285
+
286
+ // The badge number. It counts everything in the range, hidden
287
+ // included, so a lane cannot look lighter by being more loaded.
288
+ expect(packed.inRangeCount).toBe(5)
289
+ })
290
+
291
+ test("an item outside the range is not counted as loaded", () => {
292
+ const packed = toPackedLane({
293
+ axis,
294
+ items: [
295
+ {
296
+ end: "2026-05-04",
297
+ key: "next",
298
+ start: "2026-05-01",
299
+ },
300
+ ],
301
+ maxRowCount: 6,
302
+ })
303
+
304
+ expect(packed.inRangeCount).toBe(0)
305
+ expect(packed.rows).toHaveLength(0)
306
+ })
307
+
308
+ test("an unmeasured track shows the axis, and a measured zero does not", () => {
309
+ // The distinction that is the whole of `isMeasured`. Zero is a real
310
+ // width here — the track is a `minmax(0, 1fr)` column beside a
311
+ // fixed label rail, so any container narrower than the rail
312
+ // resolves it to exactly 0px.
313
+ expect(
314
+ getIsAxisReadable({
315
+ columnCount: 30,
316
+ inlineSize: 0,
317
+ isMeasured: false,
318
+ minColumnInlineSize: 20,
319
+ }),
320
+ ).toBe(true)
321
+
322
+ expect(
323
+ getIsAxisReadable({
324
+ columnCount: 30,
325
+ inlineSize: 0,
326
+ isMeasured: true,
327
+ minColumnInlineSize: 20,
328
+ }),
329
+ ).toBe(false)
330
+ })
331
+
332
+ test("the axis folds on the width PER COLUMN, not on the width", () => {
333
+ // One width, two answers, because the other number lives in the
334
+ // data. This is the case no media query and no container query can
335
+ // express.
336
+ expect(
337
+ getIsAxisReadable({
338
+ columnCount: 30,
339
+ inlineSize: 900,
340
+ isMeasured: true,
341
+ minColumnInlineSize: 20,
342
+ }),
343
+ ).toBe(true)
344
+
345
+ expect(
346
+ getIsAxisReadable({
347
+ columnCount: 365,
348
+ inlineSize: 900,
349
+ isMeasured: true,
350
+ minColumnInlineSize: 20,
351
+ }),
352
+ ).toBe(false)
353
+ })
354
+
355
+ test("the tick step thins in whole weeks", () => {
356
+ expect(
357
+ chooseAxisTickStep({
358
+ columnCount: 7,
359
+ inlineSize: 700,
360
+ minTickInlineSize: 56,
361
+ }),
362
+ ).toBe(1)
363
+
364
+ expect(
365
+ chooseAxisTickStep({
366
+ columnCount: 90,
367
+ inlineSize: 900,
368
+ minTickInlineSize: 56,
369
+ }),
370
+ ).toBe(7)
371
+
372
+ expect(
373
+ chooseAxisTickStep({
374
+ columnCount: 365,
375
+ inlineSize: 900,
376
+ minTickInlineSize: 56,
377
+ }),
378
+ ).toBe(28)
379
+ })
380
+
381
+ test("an unmeasured axis labels every day", () => {
382
+ expect(
383
+ chooseAxisTickStep({
384
+ columnCount: 30,
385
+ inlineSize: 0,
386
+ minTickInlineSize: 56,
387
+ }),
388
+ ).toBe(1)
389
+ })
390
+
391
+ test("a bar says its own span in words, with its own dates", () => {
392
+ const span = { end: "2026-04-30", start: "2026-03-30" }
393
+
394
+ const placement = toBarPlacement({ axis, span })
395
+
396
+ if (!placement) {
397
+ throw new Error("the fixture must be placed")
398
+ }
399
+
400
+ const sentence = describeTimelineSpan({
401
+ locale: "en-GB",
402
+ placement,
403
+ span,
404
+ })
405
+
406
+ // The item's own dates, not the clipped ones: "when is this on"
407
+ // has one true answer and the viewport is not part of it.
408
+ expect(sentence).toBe(
409
+ "Mon 30 Mar to Thu 30 Apr, 32 days, starts before this range, continues past this range",
410
+ )
411
+ })
412
+
413
+ test("a single day says the day and nothing about a span", () => {
414
+ const span = { end: "2026-04-08", start: "2026-04-08" }
415
+
416
+ const placement = toBarPlacement({ axis, span })
417
+
418
+ if (!placement) {
419
+ throw new Error("the fixture must be placed")
420
+ }
421
+
422
+ expect(
423
+ describeTimelineSpan({
424
+ locale: "en-GB",
425
+ placement,
426
+ span,
427
+ }),
428
+ ).toBe("Wed 8 Apr")
429
+ })