@stll/ui 0.57.0 → 0.59.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.
@@ -0,0 +1,85 @@
1
+ import { DayCalendarGranularity, DayCalendarRange } from "./day-calendar.logic.js";
2
+ import { ReactElement, ReactNode } from "react";
3
+ //#region src/calendar/day-calendar.d.ts
4
+ /** A block paints in its matter's colour, or in the neutral surface tone. */
5
+ export type DayCalendarTone = {
6
+ color: string | null;
7
+ matterId: string;
8
+ type: "matter";
9
+ } | {
10
+ type: "neutral";
11
+ };
12
+ /** Whether an entry is still provisional (drawn dashed) or final. */
13
+ export type DayCalendarEntryState = "draft" | "final";
14
+ type DayCalendarBlockFields = DayCalendarRange & {
15
+ accessibleLabel: string;
16
+ detail?: ReactNode;
17
+ id: string;
18
+ title: ReactNode;
19
+ tone: DayCalendarTone;
20
+ };
21
+ export type DayCalendarBlock = (DayCalendarBlockFields & {
22
+ kind: "entry";
23
+ state: DayCalendarEntryState;
24
+ }) | (DayCalendarBlockFields & {
25
+ kind: "mixed";
26
+ }) | (DayCalendarBlockFields & {
27
+ kind: "segment";
28
+ });
29
+ export type DayCalendarRangeSelection = {
30
+ label: string;
31
+ type: "enabled";
32
+ } | {
33
+ type: "disabled";
34
+ };
35
+ type DayCalendarColumnHeader = {
36
+ badge?: ReactNode;
37
+ id: string;
38
+ label: ReactNode;
39
+ };
40
+ /**
41
+ * A `locked` column has no blocks field, so caller data cannot reach it: it
42
+ * paints synthetic placeholder bars behind the caller's `action`.
43
+ */
44
+ export type DayCalendarColumn = (DayCalendarColumnHeader & {
45
+ blocks: readonly DayCalendarBlock[];
46
+ rangeSelection: DayCalendarRangeSelection;
47
+ type: "blocks";
48
+ }) | (DayCalendarColumnHeader & {
49
+ action: ReactNode;
50
+ type: "locked";
51
+ });
52
+ /**
53
+ * The committed range the caller is acting on. `panel` anchors caller content
54
+ * (an entry form) at the range's start, in the column it names.
55
+ */
56
+ export type DayCalendarSelection = {
57
+ columnId: string;
58
+ panel?: {
59
+ columnId: string;
60
+ content: ReactNode;
61
+ };
62
+ range: DayCalendarRange;
63
+ };
64
+ type DayCalendarProps = {
65
+ ariaLabel: string;
66
+ columns: readonly DayCalendarColumn[];
67
+ /** The calendar day, `YYYY-MM-DD`, in `timeZone`. */
68
+ date: string;
69
+ granularity: DayCalendarGranularity;
70
+ /** Whole wall-clock hours the view covers, `0 <= startHour < endHour <= 24`. */
71
+ hours: {
72
+ endHour: number;
73
+ startHour: number;
74
+ };
75
+ /** The reader's formatting locale; it decides 12- or 24-hour labels. */
76
+ locale: string;
77
+ /** The current instant; the now-line shows only while it falls inside the day. */
78
+ nowMs: number;
79
+ onSelectRange: (columnId: string, range: DayCalendarRange) => void;
80
+ selection: DayCalendarSelection | null;
81
+ timeZone: string;
82
+ };
83
+ export declare const DayCalendar: ({ ariaLabel, columns, date, granularity, hours, locale, nowMs, onSelectRange, selection, timeZone }: DayCalendarProps) => ReactElement;
84
+ //#endregion
85
+ export {};
@@ -0,0 +1,465 @@
1
+ import { cn } from "../lib/utils.js";
2
+ import { DAY_CALENDAR_SLOT_HEIGHT_PX, boundsHeight, extendRange, getDayBounds, getHourTicks, isRepeatedWallTime, layoutOverlaps, moveRange, offsetToTime, rangeFromDrag, slotAt, timeToOffset } from "./day-calendar.logic.js";
3
+ import { resolveMatterColor } from "../lib/matter-colors.js";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ import { useState } from "react";
6
+ import { panic } from "better-result";
7
+ import { Temporal } from "temporal-polyfill/full";
8
+ //#region src/calendar/day-calendar.tsx
9
+ const isSameDraftView = (left, right) => left.date === right.date && left.endHour === right.endHour && left.granularity === right.granularity && left.startHour === right.startHour && left.timeZone === right.timeZone;
10
+ /**
11
+ * A draft's range belongs to the day and grid it was made on; once they
12
+ * change, committing it would select a stale range, so it is discarded.
13
+ */
14
+ const useDraftSelection = (view) => {
15
+ const [stored, setDraft] = useState(null);
16
+ const draft = stored !== null && isSameDraftView(stored.view, view) ? stored : null;
17
+ if (stored !== null && draft === null) setDraft(null);
18
+ return [draft, setDraft];
19
+ };
20
+ const GUTTER_WIDTH = "4rem";
21
+ /**
22
+ * Two instants on different days. They differ in a field a time-only format
23
+ * lacks, so formatting them as a range falls back to the locale's generic
24
+ * interval pattern ("{0} – {1}", "{0}~{1}"), whose only shared part is the
25
+ * separator.
26
+ */
27
+ const SEPARATOR_PROBE = {
28
+ endMs: Temporal.Instant.from("2000-01-03T00:00:00Z").epochMilliseconds,
29
+ startMs: Temporal.Instant.from("2000-01-01T00:00:00Z").epochMilliseconds
30
+ };
31
+ /**
32
+ * A range touching a fall-back day's repeated hour labels its ends with
33
+ * their UTC offsets, or the two runs of that hour read the same. Interval
34
+ * formats drop the zone name, so each end is formatted on its own.
35
+ */
36
+ const createTimeLabels = ({ locale, timeZone }) => {
37
+ const options = {
38
+ hour: "numeric",
39
+ minute: "2-digit",
40
+ timeZone
41
+ };
42
+ const formatTime = new Intl.DateTimeFormat(locale, options);
43
+ const formatOffsetTime = new Intl.DateTimeFormat(locale, {
44
+ ...options,
45
+ timeZoneName: "shortOffset"
46
+ });
47
+ const touchesRepeatedHour = (range) => isRepeatedWallTime(range.startMs, timeZone) || isRepeatedWallTime(range.endMs, timeZone);
48
+ const rangeSeparator = () => formatTime.formatRangeToParts(SEPARATOR_PROBE.startMs, SEPARATOR_PROBE.endMs).filter(({ source }) => source === "shared").map(({ value }) => value).join("");
49
+ return {
50
+ formatRange: (range) => touchesRepeatedHour(range) ? [formatOffsetTime.format(range.startMs), formatOffsetTime.format(range.endMs)].join(rangeSeparator()) : formatTime.formatRange(range.startMs, range.endMs),
51
+ formatTime,
52
+ timeFormatFor: (range) => touchesRepeatedHour(range) ? formatOffsetTime : formatTime
53
+ };
54
+ };
55
+ const DayCalendar = ({ ariaLabel, columns, date, granularity, hours, locale, nowMs, onSelectRange, selection, timeZone }) => {
56
+ assertDayCalendarContract(columns);
57
+ const view = {
58
+ date,
59
+ endHour: hours.endHour,
60
+ granularity,
61
+ startHour: hours.startHour,
62
+ timeZone
63
+ };
64
+ const [draft, setDraft] = useDraftSelection(view);
65
+ const bounds = getDayBounds({
66
+ date,
67
+ ...hours,
68
+ timeZone
69
+ });
70
+ const grid = {
71
+ granularity,
72
+ bounds
73
+ };
74
+ const height = boundsHeight(grid);
75
+ const { formatRange, formatTime, timeFormatFor } = createTimeLabels({
76
+ locale,
77
+ timeZone
78
+ });
79
+ const minutesFromStart = (ms) => Math.round((ms - bounds.startMs) / 6e4);
80
+ const nowVisible = nowMs >= bounds.startMs && nowMs < bounds.endMs;
81
+ const timeAt = (event) => offsetToTime(event.clientY - event.currentTarget.getBoundingClientRect().top, grid);
82
+ const handlePointerDown = (columnId, event) => {
83
+ if (event.button !== 0) return;
84
+ event.currentTarget.setPointerCapture(event.pointerId);
85
+ const anchorMs = timeAt(event);
86
+ setDraft({
87
+ anchorMs,
88
+ columnId,
89
+ pointerId: event.pointerId,
90
+ range: rangeFromDrag({
91
+ anchorMs,
92
+ pointerMs: anchorMs
93
+ }, grid),
94
+ type: "pointer",
95
+ view
96
+ });
97
+ };
98
+ const handlePointerMove = (event) => {
99
+ if (draft?.type !== "pointer" || draft.pointerId !== event.pointerId) return;
100
+ setDraft({
101
+ anchorMs: draft.anchorMs,
102
+ columnId: draft.columnId,
103
+ pointerId: draft.pointerId,
104
+ range: rangeFromDrag({
105
+ anchorMs: draft.anchorMs,
106
+ pointerMs: timeAt(event)
107
+ }, grid),
108
+ type: "pointer",
109
+ view
110
+ });
111
+ };
112
+ const handlePointerUp = (event) => {
113
+ if (draft?.type !== "pointer" || draft.pointerId !== event.pointerId) return;
114
+ const range = rangeFromDrag({
115
+ anchorMs: draft.anchorMs,
116
+ pointerMs: timeAt(event)
117
+ }, grid);
118
+ setDraft(null);
119
+ onSelectRange(draft.columnId, range);
120
+ };
121
+ const handlePointerCancel = (event) => {
122
+ if (draft?.type === "pointer" && draft.pointerId === event.pointerId) setDraft(null);
123
+ };
124
+ const initialKeyboardRange = (columnId) => {
125
+ if (selection?.columnId === columnId) return selection.range;
126
+ return slotAt(nowVisible ? nowMs : bounds.startMs, grid);
127
+ };
128
+ const handleKeyDown = (columnId, event) => {
129
+ const current = draft?.columnId === columnId ? draft : null;
130
+ switch (event.key) {
131
+ case "ArrowDown":
132
+ case "ArrowUp": {
133
+ event.preventDefault();
134
+ const slots = event.key === "ArrowDown" ? 1 : -1;
135
+ if (current === null) {
136
+ setDraft({
137
+ columnId,
138
+ range: initialKeyboardRange(columnId),
139
+ type: "keyboard",
140
+ view
141
+ });
142
+ return;
143
+ }
144
+ setDraft({
145
+ columnId,
146
+ range: event.shiftKey ? extendRange(current.range, slots, grid) : moveRange(current.range, slots, grid),
147
+ type: "keyboard",
148
+ view
149
+ });
150
+ return;
151
+ }
152
+ case "Enter":
153
+ if (current === null) return;
154
+ event.preventDefault();
155
+ setDraft(null);
156
+ onSelectRange(columnId, current.range);
157
+ return;
158
+ case "Escape":
159
+ if (current === null) return;
160
+ event.preventDefault();
161
+ event.stopPropagation();
162
+ setDraft(null);
163
+ return;
164
+ }
165
+ };
166
+ const gridStyle = { gridTemplateColumns: `${GUTTER_WIDTH} repeat(${String(columns.length)}, minmax(0, 1fr))` };
167
+ const bodyStyle = { blockSize: height };
168
+ const slotHeight = DAY_CALENDAR_SLOT_HEIGHT_PX[granularity];
169
+ const slotLinesStyle = {
170
+ backgroundImage: ["linear-gradient(to bottom, var(--border) 1px, transparent 1px)", "linear-gradient(to bottom, color-mix(in srgb, var(--border) 45%, transparent) 1px, transparent 1px)"].join(", "),
171
+ backgroundSize: `100% ${String(slotHeight * 60 / granularity)}px, 100% ${String(slotHeight)}px`
172
+ };
173
+ const hourTicks = getHourTicks(bounds);
174
+ return /* @__PURE__ */ jsxs("section", {
175
+ "aria-label": ariaLabel,
176
+ className: "bg-card border-border grid rounded-xl border",
177
+ "data-granularity": granularity,
178
+ "data-slot": "day-calendar",
179
+ style: gridStyle,
180
+ children: [
181
+ /* @__PURE__ */ jsx("div", {
182
+ "aria-hidden": "true",
183
+ className: "bg-card border-border sticky top-0 z-30 rounded-ss-xl border-b"
184
+ }),
185
+ columns.map((column, index) => /* @__PURE__ */ jsxs("header", {
186
+ className: cn("bg-card border-border text-muted-foreground sticky top-0 z-30 flex min-w-0 items-center gap-2 border-s border-b px-3 py-2.5 text-xs font-medium", index === columns.length - 1 && "rounded-se-xl"),
187
+ "data-slot": "day-calendar-column-header",
188
+ children: [/* @__PURE__ */ jsx("span", {
189
+ className: "min-w-0 shrink-0 truncate",
190
+ children: column.label
191
+ }), column.badge === void 0 ? null : /* @__PURE__ */ jsx("span", {
192
+ className: "bg-muted text-muted-foreground text-2xs min-w-0 truncate rounded-full px-2 py-0.5",
193
+ children: column.badge
194
+ })]
195
+ }, column.id)),
196
+ /* @__PURE__ */ jsx("div", {
197
+ "aria-hidden": "true",
198
+ className: "text-muted-foreground text-2xs relative tabular-nums",
199
+ style: bodyStyle,
200
+ children: hourTicks.map((tick) => /* @__PURE__ */ jsx("span", {
201
+ className: "absolute end-2 pt-0.5",
202
+ "data-slot": "day-calendar-hour",
203
+ style: { insetBlockStart: timeToOffset(tick, grid) },
204
+ children: formatTime.format(tick)
205
+ }, tick))
206
+ }),
207
+ columns.map((column) => {
208
+ const columnSelection = selection?.columnId === column.id ? selection.range : null;
209
+ const columnDraft = draft?.columnId === column.id ? draft.range : null;
210
+ const panel = selection?.panel?.columnId === column.id ? {
211
+ content: selection.panel.content,
212
+ range: selection.range
213
+ } : null;
214
+ return /* @__PURE__ */ jsxs("div", {
215
+ className: "border-border relative min-w-0 border-s",
216
+ "data-column-id": column.id,
217
+ "data-slot": "day-calendar-column",
218
+ "data-type": column.type,
219
+ style: bodyStyle,
220
+ children: [
221
+ column.type === "locked" ? /* @__PURE__ */ jsx(LockedColumnBody, { action: column.action }) : null,
222
+ column.type === "blocks" && column.rangeSelection.type === "enabled" ? /* @__PURE__ */ jsxs("div", {
223
+ "aria-keyshortcuts": "ArrowUp ArrowDown Shift+ArrowUp Shift+ArrowDown Enter Escape",
224
+ "aria-label": column.rangeSelection.label,
225
+ "aria-orientation": "vertical",
226
+ "aria-valuemax": minutesFromStart(bounds.endMs),
227
+ "aria-valuemin": 0,
228
+ "aria-valuenow": minutesFromStart((columnDraft ?? initialKeyboardRange(column.id)).startMs),
229
+ "aria-valuetext": formatRange(columnDraft ?? initialKeyboardRange(column.id)),
230
+ className: "focus-visible:ring-ring absolute inset-0 touch-none outline-none select-none focus-visible:ring-2 focus-visible:ring-inset",
231
+ "data-slot": "day-calendar-surface",
232
+ onKeyDown: (event) => handleKeyDown(column.id, event),
233
+ onPointerCancel: handlePointerCancel,
234
+ onPointerDown: (event) => handlePointerDown(column.id, event),
235
+ onPointerMove: handlePointerMove,
236
+ onPointerUp: handlePointerUp,
237
+ role: "slider",
238
+ style: slotLinesStyle,
239
+ tabIndex: 0,
240
+ children: [/* @__PURE__ */ jsx(ColumnBlocks, {
241
+ blocks: column.blocks,
242
+ grid
243
+ }), /* @__PURE__ */ jsx(RangeOutline, {
244
+ committed: columnSelection,
245
+ draft: columnDraft,
246
+ grid,
247
+ timeFormatFor
248
+ })]
249
+ }) : null,
250
+ column.type === "blocks" && column.rangeSelection.type === "disabled" ? /* @__PURE__ */ jsxs("div", {
251
+ className: "absolute inset-0",
252
+ "data-slot": "day-calendar-surface",
253
+ style: slotLinesStyle,
254
+ children: [/* @__PURE__ */ jsx(ColumnBlocks, {
255
+ blocks: column.blocks,
256
+ grid
257
+ }), /* @__PURE__ */ jsx(RangeOutline, {
258
+ committed: columnSelection,
259
+ draft: null,
260
+ grid,
261
+ timeFormatFor
262
+ })]
263
+ }) : null,
264
+ nowVisible && column.type === "blocks" ? /* @__PURE__ */ jsx("div", {
265
+ "aria-hidden": "true",
266
+ className: "bg-destructive pointer-events-none absolute inset-x-0 z-20 h-0.5",
267
+ "data-slot": "day-calendar-now",
268
+ style: { insetBlockStart: timeToOffset(nowMs, grid) }
269
+ }) : null,
270
+ panel === null ? null : /* @__PURE__ */ jsx("div", {
271
+ className: "absolute inset-x-2 z-40",
272
+ "data-slot": "day-calendar-panel",
273
+ style: { insetBlockStart: timeToOffset(panel.range.startMs, grid) },
274
+ children: panel.content
275
+ })
276
+ ]
277
+ }, column.id);
278
+ })
279
+ ]
280
+ });
281
+ };
282
+ const ColumnBlocks = ({ blocks, grid }) => {
283
+ const visible = blocks.flatMap((block) => {
284
+ const startMs = Math.max(block.startMs, grid.bounds.startMs);
285
+ const endMs = Math.min(block.endMs, grid.bounds.endMs);
286
+ return endMs > startMs ? [{
287
+ block,
288
+ endMs,
289
+ id: block.id,
290
+ startMs
291
+ }] : [];
292
+ });
293
+ const lanes = layoutOverlaps(visible);
294
+ return visible.map(({ block, endMs, startMs }) => {
295
+ const lane = lanes.get(block.id);
296
+ if (lane === void 0) panic(`Day calendar layout lost block ${block.id}`);
297
+ return /* @__PURE__ */ jsx(DayCalendarBlockView, {
298
+ block,
299
+ lane,
300
+ range: {
301
+ endMs,
302
+ startMs
303
+ },
304
+ grid
305
+ }, block.id);
306
+ });
307
+ };
308
+ /** Below this height a block puts its title and detail on one line. */
309
+ const TWO_LINE_BLOCK_MIN_PX = 40;
310
+ /** A brief block paints at least this tall, unless the grid ends sooner. */
311
+ const BLOCK_MIN_PX = 6;
312
+ const BLOCK_KIND_CLASSES = {
313
+ entry: "font-medium",
314
+ mixed: "text-muted-foreground",
315
+ segment: ""
316
+ };
317
+ const ENTRY_STATE_CLASSES = {
318
+ draft: "border border-dashed border-current/40",
319
+ final: ""
320
+ };
321
+ const DayCalendarBlockView = ({ block, grid, lane, range }) => {
322
+ const top = timeToOffset(range.startMs, grid);
323
+ const blockSize = Math.min(Math.max(timeToOffset(range.endMs, grid) - top, BLOCK_MIN_PX), boundsHeight(grid) - top);
324
+ const placement = {
325
+ blockSize,
326
+ inlineSize: `${String(100 / lane.laneCount)}%`,
327
+ insetBlockStart: top,
328
+ insetInlineStart: `${String(lane.lane * 100 / lane.laneCount)}%`
329
+ };
330
+ return /* @__PURE__ */ jsx("div", {
331
+ className: "pointer-events-none absolute px-1.5 py-px",
332
+ style: placement,
333
+ children: /* @__PURE__ */ jsxs("article", {
334
+ "aria-label": block.accessibleLabel,
335
+ className: cn("text-foreground flex h-full min-w-0 overflow-hidden rounded-md px-2.5 text-start text-xs", blockSize < TWO_LINE_BLOCK_MIN_PX ? "flex-row items-center gap-2" : "flex-col gap-0.5 py-1", BLOCK_KIND_CLASSES[block.kind], block.kind === "entry" && ENTRY_STATE_CLASSES[block.state], block.tone.type === "matter" && "border-s-[3px]", block.tone.type === "neutral" && "bg-muted"),
336
+ "data-kind": block.kind,
337
+ "data-slot": "day-calendar-block",
338
+ style: blockSurfaceStyle(block),
339
+ children: [/* @__PURE__ */ jsx("span", {
340
+ className: "min-w-0 shrink-0 truncate font-medium",
341
+ children: block.title
342
+ }), block.detail === void 0 ? null : /* @__PURE__ */ jsx("span", {
343
+ className: "text-muted-foreground min-w-0 truncate tabular-nums",
344
+ children: block.detail
345
+ })]
346
+ })
347
+ });
348
+ };
349
+ const MIXED_STRIPES = "repeating-linear-gradient(135deg, var(--muted) 0 6px, transparent 6px 12px)";
350
+ function blockSurfaceStyle(block) {
351
+ const tone = block.tone;
352
+ const stripes = block.kind === "mixed" ? MIXED_STRIPES : void 0;
353
+ switch (tone.type) {
354
+ case "neutral": return { backgroundImage: stripes };
355
+ case "matter": {
356
+ const color = resolveMatterColor(tone.matterId, tone.color);
357
+ return {
358
+ backgroundColor: `color-mix(in srgb, ${color} 16%, var(--card))`,
359
+ backgroundImage: stripes,
360
+ borderInlineStartColor: color
361
+ };
362
+ }
363
+ default: return panic("Unhandled day calendar tone");
364
+ }
365
+ }
366
+ const RangeOutline = ({ committed, draft, grid, timeFormatFor }) => {
367
+ const range = draft ?? committed;
368
+ if (range === null) return null;
369
+ const formatTime = timeFormatFor(range);
370
+ const top = timeToOffset(range.startMs, grid);
371
+ return /* @__PURE__ */ jsxs("div", {
372
+ className: "border-info bg-info/10 pointer-events-none absolute inset-x-1 z-10 rounded-lg border-2",
373
+ "data-slot": "day-calendar-selection",
374
+ "data-state": draft === null ? "committed" : "draft",
375
+ style: {
376
+ blockSize: timeToOffset(range.endMs, grid) - top,
377
+ insetBlockStart: top
378
+ },
379
+ children: [/* @__PURE__ */ jsx("span", {
380
+ className: "text-info-foreground text-2xs absolute end-1 bottom-full mb-0.5 tabular-nums",
381
+ children: formatTime.format(range.startMs)
382
+ }), /* @__PURE__ */ jsx("span", {
383
+ className: "text-info-foreground text-2xs absolute end-1 top-full mt-0.5 tabular-nums",
384
+ children: formatTime.format(range.endMs)
385
+ })]
386
+ });
387
+ };
388
+ /**
389
+ * Placeholder geometry for a locked column, as fractions of the column. It is
390
+ * fixed so that nothing about the hidden data (count, timing, length) leaks.
391
+ */
392
+ const LOCKED_PLACEHOLDER_BARS = [
393
+ {
394
+ blockSize: "7%",
395
+ inlineEnd: "30%",
396
+ start: "1%",
397
+ tone: "tinted"
398
+ },
399
+ {
400
+ blockSize: "5%",
401
+ inlineEnd: "12%",
402
+ start: "9%",
403
+ tone: "neutral"
404
+ },
405
+ {
406
+ blockSize: "5%",
407
+ inlineEnd: "40%",
408
+ start: "15%",
409
+ tone: "neutral"
410
+ },
411
+ {
412
+ blockSize: "8%",
413
+ inlineEnd: "18%",
414
+ start: "21%",
415
+ tone: "tinted"
416
+ },
417
+ {
418
+ blockSize: "7%",
419
+ inlineEnd: "25%",
420
+ start: "30%",
421
+ tone: "neutral"
422
+ },
423
+ {
424
+ blockSize: "8%",
425
+ inlineEnd: "35%",
426
+ start: "42%",
427
+ tone: "tinted"
428
+ },
429
+ {
430
+ blockSize: "6%",
431
+ inlineEnd: "22%",
432
+ start: "55%",
433
+ tone: "neutral"
434
+ }
435
+ ];
436
+ const LOCKED_BAR_TONES = {
437
+ neutral: "bg-muted-foreground/15",
438
+ tinted: "bg-muted-foreground/25"
439
+ };
440
+ const LockedColumnBody = ({ action }) => /* @__PURE__ */ jsxs("div", {
441
+ className: "absolute inset-0 overflow-hidden",
442
+ children: [/* @__PURE__ */ jsx("div", {
443
+ "aria-hidden": "true",
444
+ className: "absolute inset-0 opacity-70 blur-sm",
445
+ "data-slot": "day-calendar-locked-placeholder",
446
+ inert: true,
447
+ children: LOCKED_PLACEHOLDER_BARS.map((bar) => /* @__PURE__ */ jsx("div", {
448
+ className: cn("absolute start-2.5 rounded-lg", LOCKED_BAR_TONES[bar.tone]),
449
+ style: {
450
+ blockSize: bar.blockSize,
451
+ insetBlockStart: bar.start,
452
+ insetInlineEnd: bar.inlineEnd
453
+ }
454
+ }, bar.start))
455
+ }), /* @__PURE__ */ jsx("div", {
456
+ className: "absolute inset-x-3 top-16 flex justify-center",
457
+ "data-slot": "day-calendar-locked-action",
458
+ children: action
459
+ })]
460
+ });
461
+ function assertDayCalendarContract(columns) {
462
+ if (new Set(columns.map(({ id }) => id)).size !== columns.length) panic("Day calendar column ids must be unique");
463
+ }
464
+ //#endregion
465
+ export { DayCalendar };
@@ -0,0 +1,113 @@
1
+ //#region src/calendar/day-calendar.logic.d.ts
2
+ export declare const DAY_CALENDAR_GRANULARITIES: readonly [15, 30, 60];
3
+ export type DayCalendarGranularity = (typeof DAY_CALENDAR_GRANULARITIES)[number];
4
+ /**
5
+ * Height of one grid slot. Coarser grids get taller slots, but not
6
+ * proportionally: an hour stays tall enough for a two-line block.
7
+ */
8
+ export declare const DAY_CALENDAR_SLOT_HEIGHT_PX: {
9
+ readonly 15: 22;
10
+ readonly 30: 32;
11
+ readonly 60: 44;
12
+ };
13
+ /** A half-open instant range, `[startMs, endMs)`, in epoch milliseconds. */
14
+ export type DayCalendarRange = {
15
+ endMs: number;
16
+ startMs: number;
17
+ };
18
+ export declare const granularityMs: (granularity: DayCalendarGranularity) => number;
19
+ type DayBoundsOptions = {
20
+ date: string;
21
+ endHour: number;
22
+ startHour: number;
23
+ timeZone: string;
24
+ };
25
+ /**
26
+ * The instants a day view covers, from `startHour` to `endHour` wall-clock
27
+ * time in `timeZone`. A daylight-saving day is an hour shorter or longer;
28
+ * the view stays linear in real time. Hours wholly inside a daylight-saving
29
+ * gap resolve both ends to the same instant; the view then covers the hour
30
+ * after the gap rather than collapsing to zero height.
31
+ */
32
+ export declare const getDayBounds: ({ date, endHour, startHour, timeZone }: DayBoundsOptions) => DayCalendarRange;
33
+ /**
34
+ * Whether the wall-clock time at `ms` occurs twice in `timeZone`, as in the
35
+ * repeated hour of a fall-back day; only its UTC offset tells it apart from
36
+ * its twin. A wall time read from an instant is never in a gap, so the two
37
+ * resolutions differ only for a repeated one.
38
+ */
39
+ export declare const isRepeatedWallTime: (ms: number, timeZone: string) => boolean;
40
+ /** One instant per elapsed hour from the day's start, for hour labels. */
41
+ export declare const getHourTicks: (bounds: DayCalendarRange) => number[];
42
+ type OffsetOptions = {
43
+ granularity: DayCalendarGranularity;
44
+ bounds: DayCalendarRange;
45
+ };
46
+ export declare const timeToOffset: (ms: number, { granularity, bounds }: OffsetOptions) => number;
47
+ export declare const offsetToTime: (offsetPx: number, { granularity, bounds }: OffsetOptions) => number;
48
+ export declare const boundsHeight: (options: OffsetOptions) => number;
49
+ export declare const clampToBounds: (ms: number, bounds: DayCalendarRange) => number;
50
+ export type SnapDirection = "ceil" | "floor" | "nearest";
51
+ /**
52
+ * Snaps to the slot grid, which starts at the day's first hour (a whole
53
+ * wall-clock hour), then clamps into the day. Idempotent: a snapped
54
+ * instant is a grid point or a day edge, and both snap to themselves.
55
+ */
56
+ export declare const snapToGrid: (ms: number, { direction, granularity, bounds }: OffsetOptions & {
57
+ direction: SnapDirection;
58
+ }) => number;
59
+ /**
60
+ * The snapped range between a drag's anchor and the pointer, in either
61
+ * order: the start floors and the end ceils, so the range always covers
62
+ * both instants.
63
+ */
64
+ export declare const rangeFromDrag: ({ anchorMs, pointerMs }: {
65
+ anchorMs: number;
66
+ pointerMs: number;
67
+ }, options: OffsetOptions) => DayCalendarRange;
68
+ /** Moves a range by whole slots, keeping its duration inside the day. */
69
+ export declare const moveRange: (range: DayCalendarRange, slots: number, { granularity, bounds }: OffsetOptions) => DayCalendarRange;
70
+ /** Grows or shrinks a range's end by whole slots; it never drops below one slot. */
71
+ export declare const extendRange: (range: DayCalendarRange, slots: number, options: OffsetOptions) => DayCalendarRange;
72
+ /** A one-slot range at the snapped instant, used to start a keyboard selection. */
73
+ export declare const slotAt: (ms: number, options: OffsetOptions) => DayCalendarRange;
74
+ export type DayCalendarLanePlacement = {
75
+ lane: number;
76
+ laneCount: number;
77
+ };
78
+ /**
79
+ * Places overlapping ranges side by side. Ranges that overlap transitively
80
+ * form a cluster; each takes the first lane free at its start, and every
81
+ * member of a cluster shares the cluster's lane count, so their widths match.
82
+ */
83
+ export declare const layoutOverlaps: (ranges: readonly (DayCalendarRange & {
84
+ id: string;
85
+ })[]) => Map<string, DayCalendarLanePlacement>;
86
+ export type MergedSegment<Segment extends DayCalendarRange & {
87
+ id: string;
88
+ }> = {
89
+ segment: Segment;
90
+ type: "single";
91
+ } | {
92
+ endMs: number;
93
+ id: string;
94
+ sources: Segment[];
95
+ startMs: number;
96
+ type: "mixed";
97
+ };
98
+ /**
99
+ * Collapses runs of rapid switching. Consecutive segments shorter than
100
+ * `thresholdMs` that touch or overlap form one `mixed` entry listing its
101
+ * sources in time order; a lone short segment stays as it is. Every input
102
+ * segment lands in exactly one output entry, and the time the output covers
103
+ * equals the time the input covers. Output ids are unique: a `mixed` id
104
+ * derives from its first source and takes a numeric suffix when an input or
105
+ * an earlier entry already uses it.
106
+ */
107
+ export declare const mergeShortSegments: <Segment extends DayCalendarRange & {
108
+ id: string;
109
+ }>(segments: readonly Segment[], { thresholdMs }: {
110
+ thresholdMs: number;
111
+ }) => MergedSegment<Segment>[];
112
+ //#endregion
113
+ export {};