@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.
- 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/package.json +1 -1
- 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/index.ts +13 -0
- 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 {
|
package/src/sourceRules.test.ts
CHANGED
|
@@ -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
|
-
|
|
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} }`)
|