@charcuterie/ui 4.5.1 → 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 (58) 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/Overlay/OverlayPanel.d.ts.map +1 -1
  23. package/dist/Overlay/OverlayPanel.js +54 -2
  24. package/dist/Overlay/OverlayPanel.js.map +1 -1
  25. package/dist/Overlay/OverlayStack.d.ts +5 -0
  26. package/dist/Overlay/OverlayStack.d.ts.map +1 -1
  27. package/dist/Overlay/OverlayStack.js +2 -0
  28. package/dist/Overlay/OverlayStack.js.map +1 -1
  29. package/dist/Toast/ToastRegion.d.ts +61 -0
  30. package/dist/Toast/ToastRegion.d.ts.map +1 -1
  31. package/dist/Toast/ToastRegion.js +80 -3
  32. package/dist/Toast/ToastRegion.js.map +1 -1
  33. package/dist/Toolbar/useToolbarOverflow.d.ts.map +1 -1
  34. package/dist/Toolbar/useToolbarOverflow.js +39 -0
  35. package/dist/Toolbar/useToolbarOverflow.js.map +1 -1
  36. package/dist/index.d.ts +6 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +2 -0
  39. package/dist/index.js.map +1 -1
  40. package/package.json +3 -3
  41. package/src/LaneTimeline/LaneTimeline.mdx +191 -0
  42. package/src/LaneTimeline/LaneTimeline.stories.tsx +561 -0
  43. package/src/LaneTimeline/LaneTimeline.test.tsx +330 -0
  44. package/src/LaneTimeline/LaneTimeline.tsx +381 -0
  45. package/src/LaneTimeline/LaneTimelineBar.tsx +237 -0
  46. package/src/LaneTimeline/LaneTimelineLane.tsx +332 -0
  47. package/src/LaneTimeline/laneTimelineGeometry.test.ts +429 -0
  48. package/src/LaneTimeline/laneTimelineGeometry.ts +476 -0
  49. package/src/LaneTimeline/useTrackMeasurements.ts +165 -0
  50. package/src/Overlay/OverlayPanel.tsx +63 -2
  51. package/src/Overlay/OverlayStack.tsx +7 -1
  52. package/src/Toast/Toast.stories.tsx +154 -0
  53. package/src/Toast/ToastRegion.test.tsx +172 -0
  54. package/src/Toast/ToastRegion.tsx +120 -32
  55. package/src/Toolbar/Toolbar.test.tsx +84 -0
  56. package/src/Toolbar/useToolbarOverflow.ts +43 -0
  57. package/src/index.ts +13 -0
  58. package/src/sourceRules.test.ts +22 -1
@@ -0,0 +1,109 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useUniqueId } from "@charcuterie/logic";
3
+ import { CATEGORICAL_INDEX_COUNT, CATEGORICAL_SEQUENCE, } from "@charcuterie/tokens";
4
+ import { Badge } from "../Badge/Badge.js";
5
+ import { CATEGORICAL_SOLID_FILL_CLASS } from "../categoricalStyles.js";
6
+ import { TextLink } from "../TextLink/TextLink.js";
7
+ import { toClassName } from "../toClassName.js";
8
+ import { LaneTimelineBar } from "./LaneTimelineBar.js";
9
+ import { describeTimelineSpan, toPackedLane, } from "./laneTimelineGeometry.js";
10
+ /**
11
+ * One lane: its name, how much it is carrying, and either a track of
12
+ * bars or a list of lines.
13
+ *
14
+ * ### A lane is a `group`, not a landmark
15
+ *
16
+ * The same call `BoardLaneList` makes, and it was a real axe failure
17
+ * there before it changed. A named `<section>` is a `region`, so a
18
+ * timeline of eight lanes would put eight landmarks on a page that
19
+ * has one job, and two timelines on one page would put two landmarks
20
+ * with the same name in the document — which is `landmark-unique`.
21
+ * It is a `<div role="group">` rather than a `<section role="group">`
22
+ * because axe matches `section[aria-labelledby]` on the **element**
23
+ * and audits it as a landmark even after the role is overridden.
24
+ *
25
+ * ### The count is what the lane carries IN THIS RANGE
26
+ *
27
+ * Not `items.length`. A consumer hands over a month and shows a
28
+ * week; counting the array would print "31" beside a lane with two
29
+ * bars on it. `inRangeCount` includes anything the row limit hid, so
30
+ * a lane cannot look lighter by being more loaded.
31
+ *
32
+ * ### A lane gets TALLER when it is busy, and that is the design
33
+ *
34
+ * Overlapping items stack into rows (`toPackedLane`), so a lane
35
+ * carrying three things at once is three bars tall while one
36
+ * carrying them in sequence is one. Load asymmetry then reads as
37
+ * height from across the room, before anybody counts anything —
38
+ * which is the whole reason this component exists rather than a
39
+ * month grid, where every cell is the same size no matter what is
40
+ * in it.
41
+ */
42
+ export const LaneTimelineLane = ({ axis, columnTemplate, headingLevel, lane, locale, maxRowCount, position, shape, tickStep, }) => {
43
+ const headingId = useUniqueId();
44
+ /*
45
+ * Position, not `getCategoricalIndex(lane.key)`, and `ActionTiles`
46
+ * made the same call for the same reason. That helper hashes, so a
47
+ * row keeps its colour across a list nobody controls the order of
48
+ * — right for Docket's labels, wrong here: a hash of two lane keys
49
+ * can collide, and two lanes in one hue defeats the only thing the
50
+ * colour is for. Measured on this component's own fixtures, where
51
+ * `wren` and `juno` both hashed to Pink and the screenshot showed
52
+ * one person's week twice.
53
+ *
54
+ * `CATEGORICAL_SEQUENCE` rather than the ring: the ring is
55
+ * hue-ordered for the swatch picker's sake, so walking it in order
56
+ * hands a two-lane set the two hardest colours in the family to
57
+ * tell apart.
58
+ */
59
+ const categorical = lane.categorical ??
60
+ CATEGORICAL_SEQUENCE[position % CATEGORICAL_INDEX_COUNT];
61
+ const packed = toPackedLane({
62
+ axis,
63
+ items: lane.items,
64
+ /*
65
+ * The Narrow View has no row limit, because there are no rows:
66
+ * it is a vertical list, so the thing the limit protects
67
+ * against — a lane growing past the height of the screen while
68
+ * every other lane stays one bar tall — cannot happen. Hiding
69
+ * items there would lose them for the reader who has the least
70
+ * room to spare.
71
+ */
72
+ maxRowCount: shape === "list" ? lane.items.length : maxRowCount,
73
+ });
74
+ const Heading = `h${headingLevel}`;
75
+ const header = (_jsxs("header", { className: "flex min-w-0 items-center gap-2", children: [_jsx("span", { "aria-hidden": "true", className: toClassName("h-4 w-1 shrink-0 rounded-full", CATEGORICAL_SOLID_FILL_CLASS[categorical]) }), _jsx(Heading, { className: "min-w-0 truncate font-semibold text-content-primary text-sm", id: headingId, title: lane.label, children: lane.href ? (_jsx(TextLink, { appearance: "standalone", className: "font-semibold text-inherit", href: lane.href, children: lane.label })) : (lane.label) }), _jsx(Badge, { appearance: "soft", intent: "neutral", size: "sm", children: String(packed.inRangeCount) })] }));
76
+ if (shape === "list") {
77
+ const items = packed.rows
78
+ .flat()
79
+ .sort((first, second) => first.placement.columnStart -
80
+ second.placement.columnStart);
81
+ return (_jsxs("div", { "aria-labelledby": headingId, className: "flex flex-col gap-1.5 border-border-subtle border-t py-2", role: "group", children: [header, items.length === 0 ? (_jsx("p", { className: "text-content-muted text-xs", children: "Nothing in this range." })) : (_jsx("ul", { className: "flex flex-col gap-1", children: items.map(({ item, placement }) => (_jsx("li", { className: "min-w-0", children: _jsx(LaneTimelineBar, { laneCategorical: categorical, description: describeTimelineSpan({
82
+ locale,
83
+ placement,
84
+ span: item,
85
+ }), hasEarlierStart: placement.hasEarlierStart, hasLaterEnd: placement.hasLaterEnd, item: item, shape: "row" }) }, item.key))) }))] }));
86
+ }
87
+ return (_jsxs("div", { "aria-labelledby": headingId, className: "grid grid-cols-[12rem_minmax(0,1fr)] items-start gap-x-3 border-border-subtle border-t py-1.5", role: "group", children: [header, _jsxs("div", { className: "relative min-h-6", children: [_jsx("div", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 grid", style: { gridTemplateColumns: columnTemplate }, children: Array.from({ length: axis.columnCount }, (_unused, index) => axis.startDay + index).map((dayNumber) => (_jsx("span", { className: toClassName(
88
+ // Never on the leading edge: a line down the start of
89
+ // the track reads as a frame the track does not have.
90
+ dayNumber > axis.startDay &&
91
+ (dayNumber - axis.startDay) % tickStep ===
92
+ 0 &&
93
+ "border-border-subtle border-s") }, dayNumber))) }), _jsx("ul", { className: "relative grid gap-1", style: { gridTemplateColumns: columnTemplate }, children: packed.rows.flatMap((row, rowIndex) => row.map(({ item, placement }) => (_jsx("li", { className: "min-w-0", style: {
94
+ /*
95
+ * `span`, never a start/end pair. An end line is
96
+ * one past the last column the bar covers, so
97
+ * writing it is the single easiest place to lose
98
+ * a day — and it is invisible, because a bar one
99
+ * day short still looks like a bar.
100
+ */
101
+ gridColumn: `${placement.columnStart} / span ${placement.columnSpan}`,
102
+ gridRow: rowIndex + 1,
103
+ }, children: _jsx(LaneTimelineBar, { laneCategorical: categorical, description: describeTimelineSpan({
104
+ locale,
105
+ placement,
106
+ span: item,
107
+ }), hasEarlierStart: placement.hasEarlierStart, hasLaterEnd: placement.hasLaterEnd, item: item, shape: "bar" }) }, item.key)))) }), packed.hiddenCount > 0 ? (_jsx("p", { className: "pt-1 text-content-muted text-xs", children: `+ ${packed.hiddenCount} more overlapping in ${lane.label}` })) : null] })] }));
108
+ };
109
+ //# sourceMappingURL=LaneTimelineLane.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LaneTimelineLane.js","sourceRoot":"","sources":["../../src/LaneTimeline/LaneTimelineLane.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,268 @@
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
+ import type { PlainDate } from "../DatePicker/plainDate.ts";
29
+ /**
30
+ * A closed interval of calendar days, `YYYY-MM-DD` at both ends.
31
+ * Both the visible range and an item's own extent are this shape,
32
+ * deliberately — the component asks the same question of both.
33
+ */
34
+ export type TimelineSpan = {
35
+ /** The last day covered. **Inclusive.** */
36
+ end: string;
37
+ /** The first day covered. */
38
+ start: string;
39
+ };
40
+ /**
41
+ * The visible range, parsed once and reused for every item.
42
+ *
43
+ * Parsed once because a household week with eight lanes and two
44
+ * hundred items would otherwise re-parse the same two strings four
45
+ * hundred times — but mostly because a range that cannot be parsed
46
+ * has to fail in **one** place, rather than resolving to a
47
+ * different silent fallback inside each item's placement.
48
+ */
49
+ export type TimelineAxis = {
50
+ /** Day columns in the visible range. Never below 1. */
51
+ columnCount: number;
52
+ endDay: number;
53
+ startDay: number;
54
+ };
55
+ export type BarPlacement = {
56
+ /** 1-based CSS grid column the bar starts in. */
57
+ columnStart: number;
58
+ /**
59
+ * Day columns the bar covers **after clipping**, never below 1.
60
+ *
61
+ * A CSS `grid-column: 4 / span 0` is not an error and not a
62
+ * zero-width box — the browser treats a span of 0 as 1 in some
63
+ * engines and as invalid-and-ignored in others, so the bar lands
64
+ * either in the right place or at the end of the row with nothing
65
+ * to say which. The clamp lives here so no caller has to know
66
+ * that.
67
+ */
68
+ columnSpan: number;
69
+ /**
70
+ * The days the item covers in **total**, clipping ignored — so a
71
+ * two-week thing clipped to the visible Tuesday still says
72
+ * fourteen days out loud. The number a person is owed is the
73
+ * item's own length, not the part of it that fits on screen.
74
+ */
75
+ dayCount: number;
76
+ /** The item begins before the first visible day. */
77
+ hasEarlierStart: boolean;
78
+ /** The item is still running after the last visible day. */
79
+ hasLaterEnd: boolean;
80
+ };
81
+ export type PlacedTimelineItem<Item> = {
82
+ item: Item;
83
+ placement: BarPlacement;
84
+ };
85
+ export type PackedLane<Item> = {
86
+ /**
87
+ * Items that were placed and then did not fit inside
88
+ * `maxRowCount` stacked rows. Counted rather than drawn, and the
89
+ * lane says the number out loud — the same honest-overflow rule
90
+ * `Board`'s `+ n more` follows, for the same reason: a lane that
91
+ * silently drops the ninth overlapping thing is a lane that lies
92
+ * about how loaded it is, which is the one question this
93
+ * component exists to answer.
94
+ */
95
+ hiddenCount: number;
96
+ /**
97
+ * Items with any part of themselves inside the visible range,
98
+ * drawn or not.
99
+ *
100
+ * The number the lane's badge shows, and it is **not**
101
+ * `items.length`: a consumer hands over a month and looks at a
102
+ * week, so counting the whole array would put "31" beside a lane
103
+ * carrying two things. It is not `rows` flattened either — that
104
+ * would drop the overflow and make a lane look lighter the more
105
+ * loaded it got, which inverts the one question this component
106
+ * answers.
107
+ */
108
+ inRangeCount: number;
109
+ rows: PlacedTimelineItem<Item>[][];
110
+ };
111
+ /**
112
+ * `null` when either end is not an ISO calendar date.
113
+ *
114
+ * Not a thrown error and not a guessed range. A component that
115
+ * invents a range paints a confident picture of days nobody asked
116
+ * for, and the caller finds out when somebody notices the dates are
117
+ * wrong — which on a timeline is never, because there is nothing to
118
+ * compare them against.
119
+ *
120
+ * An **inverted** range (`end` before `start`) is a different case
121
+ * and is repaired rather than refused: it collapses to the single
122
+ * day at `start`. A caller flipping two variables gets one column
123
+ * and an obviously wrong picture, which is recoverable, instead of
124
+ * an empty component, which reads as "there is nothing on".
125
+ */
126
+ export declare const toTimelineAxis: (range: TimelineSpan) => null | TimelineAxis;
127
+ /**
128
+ * Where one item's bar goes, or `null` for "it is not in this
129
+ * picture at all".
130
+ *
131
+ * `null` is the right answer for an item outside the range, and it
132
+ * matters that it is distinguishable from a zero-width one: an item
133
+ * in another month must not be drawn, while an item whose own dates
134
+ * are degenerate must be. The four cases a naive implementation
135
+ * gets wrong, and what happens to each here:
136
+ *
137
+ * - **Zero width** — `end` earlier than `start`. The span folds to
138
+ * the single day at `start` and is drawn. It is not dropped,
139
+ * because a dropped row is a data fault nobody can see; and it
140
+ * is not drawn backwards, because there is no such shape.
141
+ * - **A single day** — `start` equal to `end`. One column, with
142
+ * the same bar treatment as any other. Not a dot: a dot is a
143
+ * different mark, and the eye then has two vocabularies to learn
144
+ * on one axis.
145
+ * - **Starts before the range.** Clipped to column 1, with
146
+ * `hasEarlierStart` set so the bar can drop its leading radius
147
+ * and say "starts before this range" in its name. The naive
148
+ * version computes a negative column, and CSS grid reads a
149
+ * negative `grid-column-start` as counting back from the **end**
150
+ * of the row — so the bar appears, in entirely the wrong place,
151
+ * with nothing failing.
152
+ * - **Ends after the range.** Clipped to the last column, with
153
+ * `hasLaterEnd` set. `dayCount` still reports the item's true
154
+ * length.
155
+ */
156
+ export declare const toBarPlacement: ({ axis, span, }: {
157
+ axis: TimelineAxis;
158
+ span: TimelineSpan;
159
+ }) => null | BarPlacement;
160
+ /**
161
+ * Stack a lane's items into as few rows as they fit in.
162
+ *
163
+ * Greedy first-fit over items sorted by start column, which is the
164
+ * standard interval-partitioning answer and is **optimal** for row
165
+ * count — the number of rows it produces equals the largest number
166
+ * of items overlapping on any one day, which is the smallest number
167
+ * any packing could use. Worth saying because the obvious
168
+ * alternative, one row per item, is what makes a hand-rolled
169
+ * timeline four screens tall.
170
+ *
171
+ * The lane getting taller is not a side effect, it is the point. A
172
+ * lane carrying three things at once is literally three times the
173
+ * height of one carrying them in sequence, so the asymmetry reads
174
+ * from across the room before anybody counts anything.
175
+ *
176
+ * Ties are broken by the **longer** item first and then by `key`,
177
+ * so the packing is stable: the same data renders the same rows on
178
+ * every machine and in every reorder of the input array. Without
179
+ * the `key` tie-break two items starting on the same day swap rows
180
+ * whenever the consumer's sort is unstable, which reads as the
181
+ * timeline flickering for no reason.
182
+ */
183
+ export declare const toPackedLane: <Item extends TimelineSpan & {
184
+ key: string;
185
+ }>({ axis, items, maxRowCount, }: {
186
+ axis: TimelineAxis;
187
+ items: readonly Item[];
188
+ /**
189
+ * How tall one lane may get. Past this, items are counted rather
190
+ * than drawn.
191
+ */
192
+ maxRowCount: number;
193
+ }) => PackedLane<Item>;
194
+ /**
195
+ * Is the axis wide enough to be worth drawing?
196
+ *
197
+ * The question a media query cannot ask, and the reason this
198
+ * component measures. The answer depends on **two** numbers and a
199
+ * breakpoint only knows one: 30 columns in 900px is a readable
200
+ * month, and 365 columns in the same 900px is 2.5px a day — a grey
201
+ * smear with bars in it. `Nav` folds its bar on measured width for
202
+ * the same reason, and `useToolbarOverflow` writes the general
203
+ * version of the argument down: a hardcoded `480px` is wrong the
204
+ * moment anything about the content changes.
205
+ *
206
+ * ⚠️ **`isMeasured` is a separate flag and not `inlineSize > 0`.**
207
+ * Before the layout effect runs there is no width, and the honest
208
+ * answer is the axis — it is what the component is for, and
209
+ * defaulting the other way would ship the fallback list to every
210
+ * non-browser render with nothing to say why. But **zero is also a
211
+ * real measurement**: the track sits in a `minmax(0, 1fr)` column
212
+ * beside a fixed label rail, so a container narrower than the rail
213
+ * resolves the track to exactly 0px. Reading that as "not measured
214
+ * yet" is how the first draft drew a thirty-column axis into 150
215
+ * pixels and reported it as fine.
216
+ */
217
+ export declare const getIsAxisReadable: ({ columnCount, inlineSize, isMeasured, minColumnInlineSize, }: {
218
+ columnCount: number;
219
+ /** The measured inline size of the track, in CSS pixels. */
220
+ inlineSize: number;
221
+ isMeasured: boolean;
222
+ minColumnInlineSize: number;
223
+ }) => boolean;
224
+ export declare const chooseAxisTickStep: ({ columnCount, inlineSize, minTickInlineSize, }: {
225
+ columnCount: number;
226
+ inlineSize: number;
227
+ /** Room one tick label needs, in CSS pixels. */
228
+ minTickInlineSize: number;
229
+ }) => number;
230
+ /**
231
+ * What a bar says when it cannot be seen.
232
+ *
233
+ * This is the load-bearing accessibility decision in the component,
234
+ * not a nicety. A bar carries its meaning in **position and
235
+ * length**, and neither of those reaches a screen reader, a
236
+ * high-contrast rendering, or anybody who cannot tell two hues
237
+ * apart. A one-day bar is also about 20px wide, so its own title
238
+ * does not fit inside it for a sighted reader either — the same
239
+ * sentence solves both.
240
+ *
241
+ * The dates are the item's **own**, never the clipped ones. A thing
242
+ * that runs from March into May reads as March to May even when the
243
+ * visible range is one week of April, because "when is this on" has
244
+ * one true answer and the viewport is not part of it.
245
+ *
246
+ * English, like `Board`'s move announcements. The library has no
247
+ * translation seam yet, and inventing one here would be a bigger
248
+ * decision than this component.
249
+ */
250
+ export declare const describeTimelineSpan: ({ locale, placement, span, }: {
251
+ locale?: string;
252
+ placement: BarPlacement;
253
+ span: TimelineSpan;
254
+ }) => string;
255
+ /**
256
+ * One date, spelled the way both the axis and the spoken sentence
257
+ * want it: weekday, day, month.
258
+ *
259
+ * The weekday is in there because a timeline is read in weeks. "Is
260
+ * that the weekend?" is the most common question anybody asks of
261
+ * one, and answering it from a bare day number means counting
262
+ * columns.
263
+ */
264
+ export declare const formatAxisDate: ({ date, locale, }: {
265
+ date: PlainDate;
266
+ locale?: string;
267
+ }) => string;
268
+ //# sourceMappingURL=laneTimelineGeometry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"laneTimelineGeometry.d.ts","sourceRoot":"","sources":["../../src/LaneTimeline/laneTimelineGeometry.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,277 @@
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
+ import { formatPlainDate, getDayNumber, parseIsoDate, } from "../DatePicker/plainDate.js";
29
+ const toDayNumber = (isoDate) => {
30
+ const date = parseIsoDate(isoDate);
31
+ return date && getDayNumber(date);
32
+ };
33
+ /**
34
+ * `null` when either end is not an ISO calendar date.
35
+ *
36
+ * Not a thrown error and not a guessed range. A component that
37
+ * invents a range paints a confident picture of days nobody asked
38
+ * for, and the caller finds out when somebody notices the dates are
39
+ * wrong — which on a timeline is never, because there is nothing to
40
+ * compare them against.
41
+ *
42
+ * An **inverted** range (`end` before `start`) is a different case
43
+ * and is repaired rather than refused: it collapses to the single
44
+ * day at `start`. A caller flipping two variables gets one column
45
+ * and an obviously wrong picture, which is recoverable, instead of
46
+ * an empty component, which reads as "there is nothing on".
47
+ */
48
+ export const toTimelineAxis = (range) => {
49
+ const startDay = toDayNumber(range.start);
50
+ const rawEndDay = toDayNumber(range.end);
51
+ if (startDay === null || rawEndDay === null) {
52
+ return null;
53
+ }
54
+ const endDay = Math.max(startDay, rawEndDay);
55
+ return {
56
+ columnCount: endDay - startDay + 1,
57
+ endDay,
58
+ startDay,
59
+ };
60
+ };
61
+ /**
62
+ * Where one item's bar goes, or `null` for "it is not in this
63
+ * picture at all".
64
+ *
65
+ * `null` is the right answer for an item outside the range, and it
66
+ * matters that it is distinguishable from a zero-width one: an item
67
+ * in another month must not be drawn, while an item whose own dates
68
+ * are degenerate must be. The four cases a naive implementation
69
+ * gets wrong, and what happens to each here:
70
+ *
71
+ * - **Zero width** — `end` earlier than `start`. The span folds to
72
+ * the single day at `start` and is drawn. It is not dropped,
73
+ * because a dropped row is a data fault nobody can see; and it
74
+ * is not drawn backwards, because there is no such shape.
75
+ * - **A single day** — `start` equal to `end`. One column, with
76
+ * the same bar treatment as any other. Not a dot: a dot is a
77
+ * different mark, and the eye then has two vocabularies to learn
78
+ * on one axis.
79
+ * - **Starts before the range.** Clipped to column 1, with
80
+ * `hasEarlierStart` set so the bar can drop its leading radius
81
+ * and say "starts before this range" in its name. The naive
82
+ * version computes a negative column, and CSS grid reads a
83
+ * negative `grid-column-start` as counting back from the **end**
84
+ * of the row — so the bar appears, in entirely the wrong place,
85
+ * with nothing failing.
86
+ * - **Ends after the range.** Clipped to the last column, with
87
+ * `hasLaterEnd` set. `dayCount` still reports the item's true
88
+ * length.
89
+ */
90
+ export const toBarPlacement = ({ axis, span, }) => {
91
+ const startDay = toDayNumber(span.start);
92
+ if (startDay === null) {
93
+ return null;
94
+ }
95
+ const parsedEndDay = toDayNumber(span.end);
96
+ // An unparseable or inverted end is the zero-width case, and both
97
+ // resolve the same way: the item covers its start day and nothing
98
+ // else.
99
+ const endDay = Math.max(startDay, parsedEndDay ?? startDay);
100
+ if (endDay < axis.startDay || startDay > axis.endDay) {
101
+ return null;
102
+ }
103
+ const visibleStartDay = Math.max(startDay, axis.startDay);
104
+ const visibleEndDay = Math.min(endDay, axis.endDay);
105
+ return {
106
+ columnStart: visibleStartDay - axis.startDay + 1,
107
+ columnSpan: visibleEndDay - visibleStartDay + 1,
108
+ dayCount: endDay - startDay + 1,
109
+ hasEarlierStart: startDay < axis.startDay,
110
+ hasLaterEnd: endDay > axis.endDay,
111
+ };
112
+ };
113
+ /**
114
+ * Stack a lane's items into as few rows as they fit in.
115
+ *
116
+ * Greedy first-fit over items sorted by start column, which is the
117
+ * standard interval-partitioning answer and is **optimal** for row
118
+ * count — the number of rows it produces equals the largest number
119
+ * of items overlapping on any one day, which is the smallest number
120
+ * any packing could use. Worth saying because the obvious
121
+ * alternative, one row per item, is what makes a hand-rolled
122
+ * timeline four screens tall.
123
+ *
124
+ * The lane getting taller is not a side effect, it is the point. A
125
+ * lane carrying three things at once is literally three times the
126
+ * height of one carrying them in sequence, so the asymmetry reads
127
+ * from across the room before anybody counts anything.
128
+ *
129
+ * Ties are broken by the **longer** item first and then by `key`,
130
+ * so the packing is stable: the same data renders the same rows on
131
+ * every machine and in every reorder of the input array. Without
132
+ * the `key` tie-break two items starting on the same day swap rows
133
+ * whenever the consumer's sort is unstable, which reads as the
134
+ * timeline flickering for no reason.
135
+ */
136
+ export const toPackedLane = ({ axis, items, maxRowCount, }) => {
137
+ const placed = items.flatMap((item) => {
138
+ const placement = toBarPlacement({
139
+ axis,
140
+ span: item,
141
+ });
142
+ return placement ? [{ item, placement }] : [];
143
+ });
144
+ const sorted = [...placed].sort((first, second) => first.placement.columnStart -
145
+ second.placement.columnStart ||
146
+ second.placement.columnSpan -
147
+ first.placement.columnSpan ||
148
+ (first.item.key < second.item.key ? -1 : 1));
149
+ const rows = [];
150
+ let hiddenCount = 0;
151
+ for (const entry of sorted) {
152
+ const row = rows.find((candidate) => {
153
+ const last = candidate.at(-1);
154
+ return (!last ||
155
+ last.placement.columnStart +
156
+ last.placement.columnSpan <=
157
+ entry.placement.columnStart);
158
+ });
159
+ if (row) {
160
+ row.push(entry);
161
+ }
162
+ else if (rows.length < maxRowCount) {
163
+ rows.push([entry]);
164
+ }
165
+ else {
166
+ hiddenCount += 1;
167
+ }
168
+ }
169
+ return {
170
+ hiddenCount,
171
+ inRangeCount: placed.length,
172
+ rows,
173
+ };
174
+ };
175
+ /**
176
+ * Is the axis wide enough to be worth drawing?
177
+ *
178
+ * The question a media query cannot ask, and the reason this
179
+ * component measures. The answer depends on **two** numbers and a
180
+ * breakpoint only knows one: 30 columns in 900px is a readable
181
+ * month, and 365 columns in the same 900px is 2.5px a day — a grey
182
+ * smear with bars in it. `Nav` folds its bar on measured width for
183
+ * the same reason, and `useToolbarOverflow` writes the general
184
+ * version of the argument down: a hardcoded `480px` is wrong the
185
+ * moment anything about the content changes.
186
+ *
187
+ * ⚠️ **`isMeasured` is a separate flag and not `inlineSize > 0`.**
188
+ * Before the layout effect runs there is no width, and the honest
189
+ * answer is the axis — it is what the component is for, and
190
+ * defaulting the other way would ship the fallback list to every
191
+ * non-browser render with nothing to say why. But **zero is also a
192
+ * real measurement**: the track sits in a `minmax(0, 1fr)` column
193
+ * beside a fixed label rail, so a container narrower than the rail
194
+ * resolves the track to exactly 0px. Reading that as "not measured
195
+ * yet" is how the first draft drew a thirty-column axis into 150
196
+ * pixels and reported it as fine.
197
+ */
198
+ export const getIsAxisReadable = ({ columnCount, inlineSize, isMeasured, minColumnInlineSize, }) => !isMeasured ||
199
+ columnCount <= 0 ||
200
+ inlineSize / columnCount >= minColumnInlineSize;
201
+ /**
202
+ * Label every day, every week, or every four weeks.
203
+ *
204
+ * Thinning the ticks rather than shrinking the type, because type
205
+ * that shrinks to fit is type somebody cannot read and because the
206
+ * density axis already owns font size. The steps are 1, 7 and 28 —
207
+ * all whole weeks past the first, so a labelled column is the same
208
+ * weekday every time and the axis stays scannable. A step of 2 or 3
209
+ * was rejected for exactly that: labels landing on Tuesday, then
210
+ * Thursday, then Saturday give the eye nothing to lock onto.
211
+ */
212
+ const TICK_STEPS = [1, 7, 28];
213
+ export const chooseAxisTickStep = ({ columnCount, inlineSize, minTickInlineSize, }) => {
214
+ if (inlineSize <= 0 || columnCount <= 0) {
215
+ return 1;
216
+ }
217
+ const columnInlineSize = inlineSize / columnCount;
218
+ return (TICK_STEPS.find((step) => columnInlineSize * step >= minTickInlineSize) ?? TICK_STEPS[TICK_STEPS.length - 1]);
219
+ };
220
+ /**
221
+ * What a bar says when it cannot be seen.
222
+ *
223
+ * This is the load-bearing accessibility decision in the component,
224
+ * not a nicety. A bar carries its meaning in **position and
225
+ * length**, and neither of those reaches a screen reader, a
226
+ * high-contrast rendering, or anybody who cannot tell two hues
227
+ * apart. A one-day bar is also about 20px wide, so its own title
228
+ * does not fit inside it for a sighted reader either — the same
229
+ * sentence solves both.
230
+ *
231
+ * The dates are the item's **own**, never the clipped ones. A thing
232
+ * that runs from March into May reads as March to May even when the
233
+ * visible range is one week of April, because "when is this on" has
234
+ * one true answer and the viewport is not part of it.
235
+ *
236
+ * English, like `Board`'s move announcements. The library has no
237
+ * translation seam yet, and inventing one here would be a bigger
238
+ * decision than this component.
239
+ */
240
+ export const describeTimelineSpan = ({ locale, placement, span, }) => {
241
+ const start = parseIsoDate(span.start);
242
+ if (!start) {
243
+ return "";
244
+ }
245
+ const format = (date) => formatAxisDate({ date, locale });
246
+ const end = parseIsoDate(span.end);
247
+ const sentence = placement.dayCount <= 1 || !end
248
+ ? format(start)
249
+ : `${format(start)} to ${format(end)}, ${placement.dayCount} days`;
250
+ return [
251
+ sentence,
252
+ placement.hasEarlierStart
253
+ ? "starts before this range"
254
+ : "",
255
+ placement.hasLaterEnd
256
+ ? "continues past this range"
257
+ : "",
258
+ ]
259
+ .filter(Boolean)
260
+ .join(", ");
261
+ };
262
+ /**
263
+ * One date, spelled the way both the axis and the spoken sentence
264
+ * want it: weekday, day, month.
265
+ *
266
+ * The weekday is in there because a timeline is read in weeks. "Is
267
+ * that the weekend?" is the most common question anybody asks of
268
+ * one, and answering it from a bare day number means counting
269
+ * columns.
270
+ */
271
+ export const formatAxisDate = ({ date, locale, }) => formatPlainDate(date, {
272
+ day: "numeric",
273
+ locale,
274
+ month: "short",
275
+ weekday: "short",
276
+ });
277
+ //# sourceMappingURL=laneTimelineGeometry.js.map