@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.
- 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/dist/testing/expectAgentDrivable.d.ts +1 -1
- package/dist/testing/expectAgentDrivable.js +1 -1
- package/package.json +3 -3
- 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/Toast/Toast.mdx +1 -1
- package/src/index.ts +13 -0
- package/src/sourceRules.test.ts +22 -1
- package/src/testing/expectAgentDrivable.ts +1 -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
|
+
})
|