@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.
- package/dist/calendar/day-calendar.d.ts +85 -0
- package/dist/calendar/day-calendar.js +465 -0
- package/dist/calendar/day-calendar.logic.d.ts +113 -0
- package/dist/calendar/day-calendar.logic.js +235 -0
- package/dist/components/identity-text.d.ts +9 -0
- package/dist/components/identity-text.js +72 -0
- package/dist/components/menu.d.ts +2 -1
- package/dist/components/menu.js +8 -1
- package/dist/components/scroll-area.js +1 -1
- package/dist/components/sidebar.js +2 -1
- package/dist/components/stella-mark.js +5 -4
- package/dist/components/stella-mark.native.d.ts +9 -0
- package/dist/components/stella-mark.native.js +28 -0
- package/dist/components/stella-mark.shared.d.ts +6 -0
- package/dist/components/stella-mark.shared.js +7 -0
- package/dist/components/tooltip.d.ts +11 -1
- package/dist/components/tooltip.js +13 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/styles/inline-rename.css +2 -2
- package/package.json +18 -2
|
@@ -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 {};
|