@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,476 @@
1
+ /**
2
+ * Where a bar sits on the axis, and how many bars a lane stacks —
3
+ * the whole of the arithmetic, with no React and no DOM in it.
4
+ *
5
+ * It is its own module for the reason `boardMove.ts` is: every
6
+ * defect a timeline actually ships is an off-by-one, and an
7
+ * off-by-one is invisible in a screenshot. A bar one column to the
8
+ * left of where it belongs looks exactly like a bar. So the
9
+ * arithmetic is unit-tested against fixtures in Node, and the
10
+ * component below it only paints what comes out.
11
+ *
12
+ * ### Every position is a DAY NUMBER, never a `Date`
13
+ *
14
+ * Reached through `plainDate.ts` rather than reimplemented, because
15
+ * that file already argues the case at length and re-arguing it in a
16
+ * second place is how two answers appear. A timeline is the exact
17
+ * shape that bug hides in: "the roofers are here Monday to Friday"
18
+ * is true in every zone, and a `new Date("2026-04-06")` read at
19
+ * 23:30 west of Greenwich puts the bar on Sunday. The whole file is
20
+ * integer maths on days since 1970-01-01.
21
+ *
22
+ * ### The END IS INCLUSIVE, everywhere in here
23
+ *
24
+ * See `LaneTimelineItem.end` for the argument. The one thing to
25
+ * carry into this file: `columnSpan` is `end - start + 1`, so a
26
+ * single-day item is a span of **1**, not 0.
27
+ */
28
+
29
+ import type { PlainDate } from "../DatePicker/plainDate.ts"
30
+ import {
31
+ formatPlainDate,
32
+ getDayNumber,
33
+ parseIsoDate,
34
+ } from "../DatePicker/plainDate.ts"
35
+
36
+ /**
37
+ * A closed interval of calendar days, `YYYY-MM-DD` at both ends.
38
+ * Both the visible range and an item's own extent are this shape,
39
+ * deliberately — the component asks the same question of both.
40
+ */
41
+ export type TimelineSpan = {
42
+ /** The last day covered. **Inclusive.** */
43
+ end: string
44
+ /** The first day covered. */
45
+ start: string
46
+ }
47
+
48
+ /**
49
+ * The visible range, parsed once and reused for every item.
50
+ *
51
+ * Parsed once because a household week with eight lanes and two
52
+ * hundred items would otherwise re-parse the same two strings four
53
+ * hundred times — but mostly because a range that cannot be parsed
54
+ * has to fail in **one** place, rather than resolving to a
55
+ * different silent fallback inside each item's placement.
56
+ */
57
+ export type TimelineAxis = {
58
+ /** Day columns in the visible range. Never below 1. */
59
+ columnCount: number
60
+ endDay: number
61
+ startDay: number
62
+ }
63
+
64
+ export type BarPlacement = {
65
+ /** 1-based CSS grid column the bar starts in. */
66
+ columnStart: number
67
+ /**
68
+ * Day columns the bar covers **after clipping**, never below 1.
69
+ *
70
+ * A CSS `grid-column: 4 / span 0` is not an error and not a
71
+ * zero-width box — the browser treats a span of 0 as 1 in some
72
+ * engines and as invalid-and-ignored in others, so the bar lands
73
+ * either in the right place or at the end of the row with nothing
74
+ * to say which. The clamp lives here so no caller has to know
75
+ * that.
76
+ */
77
+ columnSpan: number
78
+ /**
79
+ * The days the item covers in **total**, clipping ignored — so a
80
+ * two-week thing clipped to the visible Tuesday still says
81
+ * fourteen days out loud. The number a person is owed is the
82
+ * item's own length, not the part of it that fits on screen.
83
+ */
84
+ dayCount: number
85
+ /** The item begins before the first visible day. */
86
+ hasEarlierStart: boolean
87
+ /** The item is still running after the last visible day. */
88
+ hasLaterEnd: boolean
89
+ }
90
+
91
+ export type PlacedTimelineItem<Item> = {
92
+ item: Item
93
+ placement: BarPlacement
94
+ }
95
+
96
+ export type PackedLane<Item> = {
97
+ /**
98
+ * Items that were placed and then did not fit inside
99
+ * `maxRowCount` stacked rows. Counted rather than drawn, and the
100
+ * lane says the number out loud — the same honest-overflow rule
101
+ * `Board`'s `+ n more` follows, for the same reason: a lane that
102
+ * silently drops the ninth overlapping thing is a lane that lies
103
+ * about how loaded it is, which is the one question this
104
+ * component exists to answer.
105
+ */
106
+ hiddenCount: number
107
+ /**
108
+ * Items with any part of themselves inside the visible range,
109
+ * drawn or not.
110
+ *
111
+ * The number the lane's badge shows, and it is **not**
112
+ * `items.length`: a consumer hands over a month and looks at a
113
+ * week, so counting the whole array would put "31" beside a lane
114
+ * carrying two things. It is not `rows` flattened either — that
115
+ * would drop the overflow and make a lane look lighter the more
116
+ * loaded it got, which inverts the one question this component
117
+ * answers.
118
+ */
119
+ inRangeCount: number
120
+ rows: PlacedTimelineItem<Item>[][]
121
+ }
122
+
123
+ const toDayNumber = (isoDate: string): null | number => {
124
+ const date: null | PlainDate = parseIsoDate(isoDate)
125
+
126
+ return date && getDayNumber(date)
127
+ }
128
+
129
+ /**
130
+ * `null` when either end is not an ISO calendar date.
131
+ *
132
+ * Not a thrown error and not a guessed range. A component that
133
+ * invents a range paints a confident picture of days nobody asked
134
+ * for, and the caller finds out when somebody notices the dates are
135
+ * wrong — which on a timeline is never, because there is nothing to
136
+ * compare them against.
137
+ *
138
+ * An **inverted** range (`end` before `start`) is a different case
139
+ * and is repaired rather than refused: it collapses to the single
140
+ * day at `start`. A caller flipping two variables gets one column
141
+ * and an obviously wrong picture, which is recoverable, instead of
142
+ * an empty component, which reads as "there is nothing on".
143
+ */
144
+ export const toTimelineAxis = (
145
+ range: TimelineSpan,
146
+ ): null | TimelineAxis => {
147
+ const startDay = toDayNumber(range.start)
148
+
149
+ const rawEndDay = toDayNumber(range.end)
150
+
151
+ if (startDay === null || rawEndDay === null) {
152
+ return null
153
+ }
154
+
155
+ const endDay = Math.max(startDay, rawEndDay)
156
+
157
+ return {
158
+ columnCount: endDay - startDay + 1,
159
+ endDay,
160
+ startDay,
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Where one item's bar goes, or `null` for "it is not in this
166
+ * picture at all".
167
+ *
168
+ * `null` is the right answer for an item outside the range, and it
169
+ * matters that it is distinguishable from a zero-width one: an item
170
+ * in another month must not be drawn, while an item whose own dates
171
+ * are degenerate must be. The four cases a naive implementation
172
+ * gets wrong, and what happens to each here:
173
+ *
174
+ * - **Zero width** — `end` earlier than `start`. The span folds to
175
+ * the single day at `start` and is drawn. It is not dropped,
176
+ * because a dropped row is a data fault nobody can see; and it
177
+ * is not drawn backwards, because there is no such shape.
178
+ * - **A single day** — `start` equal to `end`. One column, with
179
+ * the same bar treatment as any other. Not a dot: a dot is a
180
+ * different mark, and the eye then has two vocabularies to learn
181
+ * on one axis.
182
+ * - **Starts before the range.** Clipped to column 1, with
183
+ * `hasEarlierStart` set so the bar can drop its leading radius
184
+ * and say "starts before this range" in its name. The naive
185
+ * version computes a negative column, and CSS grid reads a
186
+ * negative `grid-column-start` as counting back from the **end**
187
+ * of the row — so the bar appears, in entirely the wrong place,
188
+ * with nothing failing.
189
+ * - **Ends after the range.** Clipped to the last column, with
190
+ * `hasLaterEnd` set. `dayCount` still reports the item's true
191
+ * length.
192
+ */
193
+ export const toBarPlacement = ({
194
+ axis,
195
+ span,
196
+ }: {
197
+ axis: TimelineAxis
198
+ span: TimelineSpan
199
+ }): null | BarPlacement => {
200
+ const startDay = toDayNumber(span.start)
201
+
202
+ if (startDay === null) {
203
+ return null
204
+ }
205
+
206
+ const parsedEndDay = toDayNumber(span.end)
207
+
208
+ // An unparseable or inverted end is the zero-width case, and both
209
+ // resolve the same way: the item covers its start day and nothing
210
+ // else.
211
+ const endDay = Math.max(
212
+ startDay,
213
+ parsedEndDay ?? startDay,
214
+ )
215
+
216
+ if (endDay < axis.startDay || startDay > axis.endDay) {
217
+ return null
218
+ }
219
+
220
+ const visibleStartDay = Math.max(startDay, axis.startDay)
221
+
222
+ const visibleEndDay = Math.min(endDay, axis.endDay)
223
+
224
+ return {
225
+ columnStart: visibleStartDay - axis.startDay + 1,
226
+ columnSpan: visibleEndDay - visibleStartDay + 1,
227
+ dayCount: endDay - startDay + 1,
228
+ hasEarlierStart: startDay < axis.startDay,
229
+ hasLaterEnd: endDay > axis.endDay,
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Stack a lane's items into as few rows as they fit in.
235
+ *
236
+ * Greedy first-fit over items sorted by start column, which is the
237
+ * standard interval-partitioning answer and is **optimal** for row
238
+ * count — the number of rows it produces equals the largest number
239
+ * of items overlapping on any one day, which is the smallest number
240
+ * any packing could use. Worth saying because the obvious
241
+ * alternative, one row per item, is what makes a hand-rolled
242
+ * timeline four screens tall.
243
+ *
244
+ * The lane getting taller is not a side effect, it is the point. A
245
+ * lane carrying three things at once is literally three times the
246
+ * height of one carrying them in sequence, so the asymmetry reads
247
+ * from across the room before anybody counts anything.
248
+ *
249
+ * Ties are broken by the **longer** item first and then by `key`,
250
+ * so the packing is stable: the same data renders the same rows on
251
+ * every machine and in every reorder of the input array. Without
252
+ * the `key` tie-break two items starting on the same day swap rows
253
+ * whenever the consumer's sort is unstable, which reads as the
254
+ * timeline flickering for no reason.
255
+ */
256
+ export const toPackedLane = <
257
+ Item extends TimelineSpan & { key: string },
258
+ >({
259
+ axis,
260
+ items,
261
+ maxRowCount,
262
+ }: {
263
+ axis: TimelineAxis
264
+ items: readonly Item[]
265
+ /**
266
+ * How tall one lane may get. Past this, items are counted rather
267
+ * than drawn.
268
+ */
269
+ maxRowCount: number
270
+ }): PackedLane<Item> => {
271
+ const placed = items.flatMap((item) => {
272
+ const placement = toBarPlacement({
273
+ axis,
274
+ span: item,
275
+ })
276
+
277
+ return placement ? [{ item, placement }] : []
278
+ })
279
+
280
+ const sorted = [...placed].sort(
281
+ (first, second) =>
282
+ first.placement.columnStart -
283
+ second.placement.columnStart ||
284
+ second.placement.columnSpan -
285
+ first.placement.columnSpan ||
286
+ (first.item.key < second.item.key ? -1 : 1),
287
+ )
288
+
289
+ const rows: PlacedTimelineItem<Item>[][] = []
290
+
291
+ let hiddenCount = 0
292
+
293
+ for (const entry of sorted) {
294
+ const row = rows.find((candidate) => {
295
+ const last = candidate.at(-1)
296
+
297
+ return (
298
+ !last ||
299
+ last.placement.columnStart +
300
+ last.placement.columnSpan <=
301
+ entry.placement.columnStart
302
+ )
303
+ })
304
+
305
+ if (row) {
306
+ row.push(entry)
307
+ } else if (rows.length < maxRowCount) {
308
+ rows.push([entry])
309
+ } else {
310
+ hiddenCount += 1
311
+ }
312
+ }
313
+
314
+ return {
315
+ hiddenCount,
316
+ inRangeCount: placed.length,
317
+ rows,
318
+ }
319
+ }
320
+
321
+ /**
322
+ * Is the axis wide enough to be worth drawing?
323
+ *
324
+ * The question a media query cannot ask, and the reason this
325
+ * component measures. The answer depends on **two** numbers and a
326
+ * breakpoint only knows one: 30 columns in 900px is a readable
327
+ * month, and 365 columns in the same 900px is 2.5px a day — a grey
328
+ * smear with bars in it. `Nav` folds its bar on measured width for
329
+ * the same reason, and `useToolbarOverflow` writes the general
330
+ * version of the argument down: a hardcoded `480px` is wrong the
331
+ * moment anything about the content changes.
332
+ *
333
+ * ⚠️ **`isMeasured` is a separate flag and not `inlineSize > 0`.**
334
+ * Before the layout effect runs there is no width, and the honest
335
+ * answer is the axis — it is what the component is for, and
336
+ * defaulting the other way would ship the fallback list to every
337
+ * non-browser render with nothing to say why. But **zero is also a
338
+ * real measurement**: the track sits in a `minmax(0, 1fr)` column
339
+ * beside a fixed label rail, so a container narrower than the rail
340
+ * resolves the track to exactly 0px. Reading that as "not measured
341
+ * yet" is how the first draft drew a thirty-column axis into 150
342
+ * pixels and reported it as fine.
343
+ */
344
+ export const getIsAxisReadable = ({
345
+ columnCount,
346
+ inlineSize,
347
+ isMeasured,
348
+ minColumnInlineSize,
349
+ }: {
350
+ columnCount: number
351
+ /** The measured inline size of the track, in CSS pixels. */
352
+ inlineSize: number
353
+ isMeasured: boolean
354
+ minColumnInlineSize: number
355
+ }): boolean =>
356
+ !isMeasured ||
357
+ columnCount <= 0 ||
358
+ inlineSize / columnCount >= minColumnInlineSize
359
+
360
+ /**
361
+ * Label every day, every week, or every four weeks.
362
+ *
363
+ * Thinning the ticks rather than shrinking the type, because type
364
+ * that shrinks to fit is type somebody cannot read and because the
365
+ * density axis already owns font size. The steps are 1, 7 and 28 —
366
+ * all whole weeks past the first, so a labelled column is the same
367
+ * weekday every time and the axis stays scannable. A step of 2 or 3
368
+ * was rejected for exactly that: labels landing on Tuesday, then
369
+ * Thursday, then Saturday give the eye nothing to lock onto.
370
+ */
371
+ const TICK_STEPS = [1, 7, 28] as const
372
+
373
+ export const chooseAxisTickStep = ({
374
+ columnCount,
375
+ inlineSize,
376
+ minTickInlineSize,
377
+ }: {
378
+ columnCount: number
379
+ inlineSize: number
380
+ /** Room one tick label needs, in CSS pixels. */
381
+ minTickInlineSize: number
382
+ }): number => {
383
+ if (inlineSize <= 0 || columnCount <= 0) {
384
+ return 1
385
+ }
386
+
387
+ const columnInlineSize = inlineSize / columnCount
388
+
389
+ return (
390
+ TICK_STEPS.find(
391
+ (step) =>
392
+ columnInlineSize * step >= minTickInlineSize,
393
+ ) ?? TICK_STEPS[TICK_STEPS.length - 1]
394
+ )
395
+ }
396
+
397
+ /**
398
+ * What a bar says when it cannot be seen.
399
+ *
400
+ * This is the load-bearing accessibility decision in the component,
401
+ * not a nicety. A bar carries its meaning in **position and
402
+ * length**, and neither of those reaches a screen reader, a
403
+ * high-contrast rendering, or anybody who cannot tell two hues
404
+ * apart. A one-day bar is also about 20px wide, so its own title
405
+ * does not fit inside it for a sighted reader either — the same
406
+ * sentence solves both.
407
+ *
408
+ * The dates are the item's **own**, never the clipped ones. A thing
409
+ * that runs from March into May reads as March to May even when the
410
+ * visible range is one week of April, because "when is this on" has
411
+ * one true answer and the viewport is not part of it.
412
+ *
413
+ * English, like `Board`'s move announcements. The library has no
414
+ * translation seam yet, and inventing one here would be a bigger
415
+ * decision than this component.
416
+ */
417
+ export const describeTimelineSpan = ({
418
+ locale,
419
+ placement,
420
+ span,
421
+ }: {
422
+ locale?: string
423
+ placement: BarPlacement
424
+ span: TimelineSpan
425
+ }): string => {
426
+ const start = parseIsoDate(span.start)
427
+
428
+ if (!start) {
429
+ return ""
430
+ }
431
+
432
+ const format = (date: PlainDate) =>
433
+ formatAxisDate({ date, locale })
434
+
435
+ const end = parseIsoDate(span.end)
436
+
437
+ const sentence =
438
+ placement.dayCount <= 1 || !end
439
+ ? format(start)
440
+ : `${format(start)} to ${format(end)}, ${placement.dayCount} days`
441
+
442
+ return [
443
+ sentence,
444
+ placement.hasEarlierStart
445
+ ? "starts before this range"
446
+ : "",
447
+ placement.hasLaterEnd
448
+ ? "continues past this range"
449
+ : "",
450
+ ]
451
+ .filter(Boolean)
452
+ .join(", ")
453
+ }
454
+
455
+ /**
456
+ * One date, spelled the way both the axis and the spoken sentence
457
+ * want it: weekday, day, month.
458
+ *
459
+ * The weekday is in there because a timeline is read in weeks. "Is
460
+ * that the weekend?" is the most common question anybody asks of
461
+ * one, and answering it from a bare day number means counting
462
+ * columns.
463
+ */
464
+ export const formatAxisDate = ({
465
+ date,
466
+ locale,
467
+ }: {
468
+ date: PlainDate
469
+ locale?: string
470
+ }): string =>
471
+ formatPlainDate(date, {
472
+ day: "numeric",
473
+ locale,
474
+ month: "short",
475
+ weekday: "short",
476
+ })
@@ -0,0 +1,165 @@
1
+ import type { RefObject } from "react"
2
+ import {
3
+ useCallback,
4
+ useLayoutEffect,
5
+ useRef,
6
+ useState,
7
+ } from "react"
8
+
9
+ /**
10
+ * The track's width, and the font size it is drawn in.
11
+ *
12
+ * ## Measured, because no breakpoint can answer this
13
+ *
14
+ * `Nav` folds its bar on a measured width and
15
+ * `useToolbarOverflow.ts` writes down the general form of the
16
+ * argument: mux-magic collapses at a hardcoded `480px`, plex-channels
17
+ * at `760px`, and neither number has a reason. A timeline has the
18
+ * same problem one level worse, because the question is not "how
19
+ * wide is the box" but **"how wide is one day"** — and that is the
20
+ * width divided by a number that lives in the data. Thirty columns
21
+ * in 900px is a readable month; three hundred and sixty five columns
22
+ * in the same 900px is a grey smear. A media query sees the first
23
+ * number and a container query sees the first number, so neither can
24
+ * decide this.
25
+ *
26
+ * ## Why the font size comes back too
27
+ *
28
+ * The minimum readable column is a multiple of the **type**, not a
29
+ * count of pixels. Every length in this token set moves with the
30
+ * density axis — `--font-size-md` is one thing on a desktop and
31
+ * another on the kiosk — so a column floor pinned at 20px is
32
+ * generous at one density and clips a two-digit date at the next.
33
+ * `Avatar` makes the same argument about a chip that stays 20px
34
+ * while the prose around it scales, and it is the same mistake.
35
+ *
36
+ * ⚠️ **The measured element must not be sized by its own contents.**
37
+ * The timeline reads its own box to decide what to draw in it, so a
38
+ * shrink-wrapping parent makes the answer its own input: fewer
39
+ * columns, narrower box, fewer columns. `LaneTimeline`'s root
40
+ * declares `container-type: inline-size`, which forbids exactly that
41
+ * — the same reason `Main` declares one — so the loop cannot form
42
+ * even when a caller drops the component into a `inline-flex` cell.
43
+ *
44
+ * ## The first paint
45
+ *
46
+ * `useLayoutEffect`, so the measurement lands **before the browser
47
+ * paints** and a timeline that has to fall back to the Narrow View
48
+ * never flashes an unreadable axis first. Same reasoning as
49
+ * `useToolbarOverflow`; the failure it prevents was visible on a
50
+ * kiosk Pi, where a slow first frame is on screen long enough to
51
+ * read.
52
+ *
53
+ * ## ⚠️ `trackRef` goes on a PROBE, never on the real track
54
+ *
55
+ * The one-way trap, and it is not hypothetical — the first draft
56
+ * had it. Attach this to the axis track itself and the Narrow View
57
+ * unmounts the element being measured, so the width never updates
58
+ * again and the timeline can never come back when the window
59
+ * widens. Hiding the track with `display: none` instead is worse: a
60
+ * hidden box measures 0, 0 reads as *unmeasured*, unmeasured shows
61
+ * the axis, the axis hides the fallback, and the component
62
+ * oscillates every frame.
63
+ *
64
+ * So the ref belongs to a zero-height `aria-hidden` cell that sits
65
+ * in the **same grid track** the axis would occupy and is rendered
66
+ * in both layouts. It answers the question actually being asked —
67
+ * *how wide would a day column be if I drew one* — in the layout
68
+ * where no day column exists.
69
+ */
70
+ export const useTrackMeasurements = (): {
71
+ fontSize: number
72
+ inlineSize: number
73
+ isMeasured: boolean
74
+ trackRef: RefObject<HTMLDivElement | null>
75
+ } => {
76
+ const trackRef = useRef<HTMLDivElement>(null)
77
+
78
+ const [measurements, setMeasurements] = useState({
79
+ fontSize: 0,
80
+ inlineSize: 0,
81
+ /**
82
+ * Its own flag rather than `inlineSize > 0`, and the reason is
83
+ * written out on `getIsAxisReadable`: **zero is a real width
84
+ * here.** The track is a `minmax(0, 1fr)` column beside a fixed
85
+ * label rail, so any container narrower than the rail resolves
86
+ * it to exactly 0px — and reading that as "not measured yet"
87
+ * puts a thirty-column axis into a phone and calls it readable.
88
+ */
89
+ isMeasured: false,
90
+ })
91
+
92
+ const measure = useCallback(() => {
93
+ const track = trackRef.current
94
+
95
+ if (!track) {
96
+ return
97
+ }
98
+
99
+ const style = getComputedStyle(track)
100
+
101
+ const next = {
102
+ fontSize: Number.parseFloat(style.fontSize) || 0,
103
+ inlineSize: track.clientWidth,
104
+ isMeasured: true,
105
+ }
106
+
107
+ setMeasurements((current) =>
108
+ current.fontSize === next.fontSize &&
109
+ current.inlineSize === next.inlineSize &&
110
+ current.isMeasured
111
+ ? current
112
+ : next,
113
+ )
114
+ }, [])
115
+
116
+ useLayoutEffect(measure)
117
+
118
+ useLayoutEffect(() => {
119
+ const track = trackRef.current
120
+
121
+ if (!track) {
122
+ return
123
+ }
124
+
125
+ /**
126
+ * Only when the **box** changed.
127
+ *
128
+ * A `ResizeObserver` delivers a record on observe and again for
129
+ * every reflow inside the frame it fired in, and measuring from
130
+ * each one is what raises chromium's "ResizeObserver loop
131
+ * completed with undelivered notifications". The same guard
132
+ * `useToolbarOverflow` carries, for the same reason: this
133
+ * callback's own `setState` can reflow the element it is
134
+ * watching.
135
+ */
136
+ let lastInlineSize = -1
137
+
138
+ const observer = new ResizeObserver(([entry]) => {
139
+ const inlineSize =
140
+ entry?.contentRect.width ??
141
+ track.getBoundingClientRect().width
142
+
143
+ if (inlineSize === lastInlineSize) {
144
+ return
145
+ }
146
+
147
+ lastInlineSize = inlineSize
148
+
149
+ measure()
150
+ })
151
+
152
+ observer.observe(track)
153
+
154
+ return () => {
155
+ observer.disconnect()
156
+ }
157
+ }, [measure])
158
+
159
+ return {
160
+ fontSize: measurements.fontSize,
161
+ inlineSize: measurements.inlineSize,
162
+ isMeasured: measurements.isMeasured,
163
+ trackRef,
164
+ }
165
+ }
package/src/index.ts CHANGED
@@ -237,6 +237,19 @@ export {
237
237
  INTENT_HOVER_CLASS,
238
238
  INTENT_SOLID_FILL_CLASS,
239
239
  } from "./intentStyles.ts"
240
+ export type { LaneTimelineProps } from "./LaneTimeline/LaneTimeline.tsx"
241
+ export { LaneTimeline } from "./LaneTimeline/LaneTimeline.tsx"
242
+ export type { TimelineItem } from "./LaneTimeline/LaneTimelineBar.tsx"
243
+ export type { TimelineLane } from "./LaneTimeline/LaneTimelineLane.tsx"
244
+ export type {
245
+ BarPlacement,
246
+ TimelineAxis,
247
+ TimelineSpan,
248
+ } from "./LaneTimeline/laneTimelineGeometry.ts"
249
+ export {
250
+ toBarPlacement,
251
+ toTimelineAxis,
252
+ } from "./LaneTimeline/laneTimelineGeometry.ts"
240
253
  export type { LightboxProps } from "./Lightbox/Lightbox.tsx"
241
254
  export { Lightbox } from "./Lightbox/Lightbox.tsx"
242
255
  export type {
@@ -233,6 +233,13 @@ test("a container-query component is never storied in a shrink-to-fit cell", ()
233
233
  // owner is in whenever he browses zoomed in.
234
234
  "DatePicker",
235
235
  "EmptyState",
236
+ // `LaneTimeline` declares one and **never queries it**, which is
237
+ // the `Main` case rather than a mistake: it wants the
238
+ // `contain: inline-size` that rides along, because the component
239
+ // measures its own box to decide whether to draw a date axis at
240
+ // all. Without the containment a shrink-wrapping parent makes
241
+ // that answer its own input.
242
+ "LaneTimeline",
236
243
  "Main",
237
244
  "MarkdownEditor",
238
245
  "MediaTile",
@@ -591,7 +598,21 @@ test("the barrel is the only place components are re-exported", async () => {
591
598
  // from `ActionTiles`' "what do you want to do" and would have
592
599
  // meant one component whose icon is sometimes a 20px glyph beside
593
600
  // a title and sometimes a 144px circle above it.
594
- expect(componentNames.length).toBe(65)
601
+ //
602
+ // `LaneTimeline` — one horizontal lane per group across a date
603
+ // axis, with a multi-day thing drawn as a bar — is +1 -> 66. It is
604
+ // the first component that folds on a **measurement of its own
605
+ // box** rather than on a container query, and the reason is in the
606
+ // arithmetic: the question is not how wide the box is but how wide
607
+ // one day is, which is the width divided by a number that lives in
608
+ // the data. `laneTimelineGeometry.ts`, `LaneTimelineBar.tsx`,
609
+ // `LaneTimelineLane.tsx` and `useTrackMeasurements.ts` are its
610
+ // members and stay out of this count by the `<Name>/<Name>.tsx`
611
+ // rule. The geometry module is still exported from the barrel — a
612
+ // consumer deciding whether an item is on screen must ask the
613
+ // question the same way the bars are placed, or the two answers
614
+ // drift by a day and nothing says so.
615
+ expect(componentNames.length).toBe(66)
595
616
 
596
617
  for (const name of componentNames) {
597
618
  expect(barrel).toContain(`export { ${name} }`)