@joewinke/jatui 0.41.0 → 0.42.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/CLAUDE.md +9 -3
- package/dist/components/AnimatedNumber.svelte +22 -4
- package/dist/components/BookingScheduler.svelte +412 -0
- package/dist/components/BookingScheduler.svelte.d.ts +66 -0
- package/dist/components/CalendarPicker.svelte +190 -205
- package/dist/components/CalendarPicker.svelte.d.ts +19 -1
- package/dist/components/DatePicker.svelte +419 -0
- package/dist/components/DatePicker.svelte.d.ts +44 -0
- package/dist/components/DateRangePicker.svelte +471 -5
- package/dist/components/DateRangePicker.svelte.d.ts +32 -3
- package/dist/components/EventCalendar.svelte +1324 -0
- package/dist/components/EventCalendar.svelte.d.ts +101 -0
- package/dist/components/TimeSlotPicker.svelte +174 -108
- package/dist/components/TimeSlotPicker.svelte.d.ts +27 -0
- package/dist/components/calendar/CalendarHeader.svelte +98 -0
- package/dist/components/calendar/CalendarHeader.svelte.d.ts +24 -0
- package/dist/components/calendar/MonthGrid.svelte +361 -0
- package/dist/components/calendar/MonthGrid.svelte.d.ts +55 -0
- package/dist/components/calendar/calendarGrid.d.ts +219 -0
- package/dist/components/calendar/calendarGrid.js +494 -0
- package/dist/components/calendar/createCalendarGrid.d.ts +25 -0
- package/dist/components/calendar/createCalendarGrid.js +57 -0
- package/dist/components/calendar/dayOverlap.d.ts +92 -0
- package/dist/components/calendar/dayOverlap.js +147 -0
- package/dist/components/calendar/eventCalendar.d.ts +75 -0
- package/dist/components/calendar/eventCalendar.js +187 -0
- package/dist/components/calendar/index.d.ts +32 -0
- package/dist/components/calendar/index.js +25 -0
- package/dist/components/calendar/monthPacking.d.ts +97 -0
- package/dist/components/calendar/monthPacking.js +254 -0
- package/dist/components/calendar/timeSlots.d.ts +57 -0
- package/dist/components/calendar/timeSlots.js +163 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +11 -0
- package/dist/showcase/Globe.svelte +1 -1
- package/package.json +6 -1
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calendar grid core — the pure half of every jatui date surface.
|
|
3
|
+
*
|
|
4
|
+
* CalendarPicker, DatePicker, DateRangePicker (calendar mode), BookingScheduler
|
|
5
|
+
* and EventCalendar's month switcher all render the same month panel. This
|
|
6
|
+
* module owns everything about that panel that is not markup, so it can be
|
|
7
|
+
* unit-tested without a DOM:
|
|
8
|
+
*
|
|
9
|
+
* - Local-calendar date math on plain `Date`s (always local midnight) and the
|
|
10
|
+
* ISO `YYYY-MM-DD` strings jatui uses at its edges (native `<input
|
|
11
|
+
* type="date">` values, `AvailabilityCounts` keys).
|
|
12
|
+
* - Locale-aware week start (`Intl.Locale#getWeekInfo`, with a region table
|
|
13
|
+
* fallback for engines that lack it).
|
|
14
|
+
* - The month matrix (leading/trailing days from the adjacent months).
|
|
15
|
+
* - The APG date-grid keyboard model: arrows, Home/End, PageUp/PageDown
|
|
16
|
+
* (+Shift for years), clamped to min/max, mirrored for RTL.
|
|
17
|
+
* - Range state: normalising, membership, band edges, day counts, presets.
|
|
18
|
+
* - Typed entry: parse and format in the locale's own field order, so en-US
|
|
19
|
+
* reads MM/DD/YYYY and en-GB DD/MM/YYYY (never a hardcoded order).
|
|
20
|
+
*
|
|
21
|
+
* No `@internationalized/date`: everything here is plain `Date` + `Intl`.
|
|
22
|
+
*/
|
|
23
|
+
const DAY_MS = 86_400_000;
|
|
24
|
+
// ── Date math (local calendar days) ────────────────────────────────────────
|
|
25
|
+
/** Local midnight of `d`. */
|
|
26
|
+
export function startOfDay(d) {
|
|
27
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
28
|
+
}
|
|
29
|
+
export function isSameDay(a, b) {
|
|
30
|
+
if (!a || !b)
|
|
31
|
+
return false;
|
|
32
|
+
return (a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate());
|
|
33
|
+
}
|
|
34
|
+
export function isSameMonth(a, b) {
|
|
35
|
+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth();
|
|
36
|
+
}
|
|
37
|
+
/** `d` plus `n` calendar days (DST-safe: works on the date fields, not ms). */
|
|
38
|
+
export function addDays(d, n) {
|
|
39
|
+
return new Date(d.getFullYear(), d.getMonth(), d.getDate() + n);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* `d` plus `n` months, clamping the day so Jan 31 + 1 month is Feb 28/29
|
|
43
|
+
* rather than rolling into March.
|
|
44
|
+
*/
|
|
45
|
+
export function addMonths(d, n) {
|
|
46
|
+
const target = new Date(d.getFullYear(), d.getMonth() + n, 1);
|
|
47
|
+
const last = daysInMonth(target.getFullYear(), target.getMonth());
|
|
48
|
+
return new Date(target.getFullYear(), target.getMonth(), Math.min(d.getDate(), last));
|
|
49
|
+
}
|
|
50
|
+
export function startOfMonth(d) {
|
|
51
|
+
return new Date(d.getFullYear(), d.getMonth(), 1);
|
|
52
|
+
}
|
|
53
|
+
export function endOfMonth(d) {
|
|
54
|
+
return new Date(d.getFullYear(), d.getMonth() + 1, 0);
|
|
55
|
+
}
|
|
56
|
+
export function daysInMonth(year, month) {
|
|
57
|
+
return new Date(year, month + 1, 0).getDate();
|
|
58
|
+
}
|
|
59
|
+
/** Whole calendar days from `a` to `b` (positive when `b` is later). */
|
|
60
|
+
export function dayDiff(a, b) {
|
|
61
|
+
return Math.round((startOfDay(b).getTime() - startOfDay(a).getTime()) / DAY_MS);
|
|
62
|
+
}
|
|
63
|
+
/** First day of the week containing `d`. */
|
|
64
|
+
export function startOfWeek(d, weekStart = 0) {
|
|
65
|
+
const offset = (d.getDay() - weekStart + 7) % 7;
|
|
66
|
+
return addDays(d, -offset);
|
|
67
|
+
}
|
|
68
|
+
export function endOfWeek(d, weekStart = 0) {
|
|
69
|
+
return addDays(startOfWeek(d, weekStart), 6);
|
|
70
|
+
}
|
|
71
|
+
/** Compare by calendar day: negative, 0 or positive. */
|
|
72
|
+
export function compareDays(a, b) {
|
|
73
|
+
return startOfDay(a).getTime() - startOfDay(b).getTime();
|
|
74
|
+
}
|
|
75
|
+
/** Clamp a day into [min, max] (either bound optional). */
|
|
76
|
+
export function clampDay(d, min, max) {
|
|
77
|
+
if (min && compareDays(d, min) < 0)
|
|
78
|
+
return startOfDay(min);
|
|
79
|
+
if (max && compareDays(d, max) > 0)
|
|
80
|
+
return startOfDay(max);
|
|
81
|
+
return startOfDay(d);
|
|
82
|
+
}
|
|
83
|
+
// ── ISO strings ────────────────────────────────────────────────────────────
|
|
84
|
+
/** `YYYY-MM-DD` for the LOCAL calendar day (never `toISOString`, which shifts to UTC). */
|
|
85
|
+
export function toIsoDate(d) {
|
|
86
|
+
return `${String(d.getFullYear()).padStart(4, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Parse `YYYY-MM-DD` as LOCAL midnight (`new Date('2026-03-04')` is UTC
|
|
90
|
+
* midnight, a day early west of Greenwich). Anything else, or an impossible
|
|
91
|
+
* date like 2026-02-30, returns null.
|
|
92
|
+
*/
|
|
93
|
+
export function parseIsoDate(s) {
|
|
94
|
+
if (!s)
|
|
95
|
+
return null;
|
|
96
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s.trim());
|
|
97
|
+
if (!m)
|
|
98
|
+
return null;
|
|
99
|
+
const y = Number(m[1]);
|
|
100
|
+
const mo = Number(m[2]) - 1;
|
|
101
|
+
const day = Number(m[3]);
|
|
102
|
+
const d = new Date(y, mo, day);
|
|
103
|
+
if (d.getFullYear() !== y || d.getMonth() !== mo || d.getDate() !== day)
|
|
104
|
+
return null;
|
|
105
|
+
return d;
|
|
106
|
+
}
|
|
107
|
+
/** Accept a Date, an ISO string or nothing; always hand back local midnight or null. */
|
|
108
|
+
export function toDay(v) {
|
|
109
|
+
if (v == null || v === '')
|
|
110
|
+
return null;
|
|
111
|
+
if (v instanceof Date)
|
|
112
|
+
return Number.isNaN(v.getTime()) ? null : startOfDay(v);
|
|
113
|
+
return parseIsoDate(v);
|
|
114
|
+
}
|
|
115
|
+
// ── Locale: week start + labels ────────────────────────────────────────────
|
|
116
|
+
/**
|
|
117
|
+
* Regions whose week starts on Sunday or Saturday (CLDR `firstDay`). Every
|
|
118
|
+
* other region starts on Monday. Used only when `Intl.Locale#getWeekInfo` is
|
|
119
|
+
* missing (Firefox before 2025, older Node).
|
|
120
|
+
*/
|
|
121
|
+
const SUNDAY_REGIONS = new Set([
|
|
122
|
+
'AG', 'AS', 'BD', 'BR', 'BS', 'BT', 'BW', 'BZ', 'CA', 'CN', 'CO', 'DM', 'DO', 'ET', 'GT', 'GU',
|
|
123
|
+
'HK', 'HN', 'ID', 'IL', 'IN', 'JM', 'JP', 'KE', 'KH', 'KR', 'LA', 'MH', 'MM', 'MO', 'MT', 'MX',
|
|
124
|
+
'MZ', 'NI', 'NP', 'PA', 'PE', 'PH', 'PK', 'PR', 'PT', 'PY', 'SA', 'SG', 'SV', 'TH', 'TT', 'TW',
|
|
125
|
+
'UM', 'US', 'VE', 'VI', 'WS', 'YE', 'ZA', 'ZW'
|
|
126
|
+
]);
|
|
127
|
+
const SATURDAY_REGIONS = new Set(['AE', 'AF', 'BH', 'DJ', 'DZ', 'EG', 'IQ', 'IR', 'JO', 'KW', 'LY', 'OM', 'QA', 'SD', 'SY']);
|
|
128
|
+
/** Likely region for a bare language tag ("en" → US) when the locale has none. */
|
|
129
|
+
const DEFAULT_REGION = {
|
|
130
|
+
en: 'US', ja: 'JP', zh: 'CN', ko: 'KR', he: 'IL', hi: 'IN', pt: 'BR', ar: 'EG', fa: 'IR', th: 'TH'
|
|
131
|
+
};
|
|
132
|
+
/**
|
|
133
|
+
* The first day of the week for `locale` (default: the runtime locale).
|
|
134
|
+
* `Intl.Locale#getWeekInfo` (or the older `weekInfo` getter) when the engine
|
|
135
|
+
* has it, else the CLDR region table above.
|
|
136
|
+
*/
|
|
137
|
+
export function weekStartForLocale(locale) {
|
|
138
|
+
let tag = locale;
|
|
139
|
+
try {
|
|
140
|
+
tag = tag ?? new Intl.DateTimeFormat().resolvedOptions().locale;
|
|
141
|
+
const loc = new Intl.Locale(tag);
|
|
142
|
+
const info = typeof loc.getWeekInfo === 'function' ? loc.getWeekInfo() : loc.weekInfo;
|
|
143
|
+
if (info && typeof info.firstDay === 'number')
|
|
144
|
+
return (info.firstDay % 7); // ISO: 7 = Sunday
|
|
145
|
+
const region = loc.region ?? loc.maximize?.().region ?? DEFAULT_REGION[loc.language];
|
|
146
|
+
if (region && SUNDAY_REGIONS.has(region))
|
|
147
|
+
return 0;
|
|
148
|
+
if (region && SATURDAY_REGIONS.has(region))
|
|
149
|
+
return 6;
|
|
150
|
+
return 1;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** Resolve a `weekStart` prop: a number wins, `'locale'` (or undefined) asks the locale. */
|
|
157
|
+
export function resolveWeekStart(weekStart, locale) {
|
|
158
|
+
return typeof weekStart === 'number' ? weekStart : weekStartForLocale(locale);
|
|
159
|
+
}
|
|
160
|
+
/** Weekday column labels in display order, e.g. `['Mo', 'Tu', …]` for a Monday start. */
|
|
161
|
+
export function weekdayLabels(weekStart = 0, locale, width = 'short') {
|
|
162
|
+
// 2023-01-01 was a Sunday.
|
|
163
|
+
const shortFmt = new Intl.DateTimeFormat(locale, { weekday: width });
|
|
164
|
+
const longFmt = new Intl.DateTimeFormat(locale, { weekday: 'long' });
|
|
165
|
+
return Array.from({ length: 7 }, (_, i) => {
|
|
166
|
+
const d = new Date(2023, 0, 1 + ((weekStart + i) % 7));
|
|
167
|
+
return { short: shortFmt.format(d), long: longFmt.format(d) };
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
export function formatMonthYear(d, locale) {
|
|
171
|
+
return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric' }).format(d);
|
|
172
|
+
}
|
|
173
|
+
/** "Wednesday, March 4, 2026" — the accessible name of a day cell. */
|
|
174
|
+
export function formatLongDate(d, locale) {
|
|
175
|
+
return new Intl.DateTimeFormat(locale, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }).format(d);
|
|
176
|
+
}
|
|
177
|
+
/** "Mar 4, 2026" — trigger labels and range chips. */
|
|
178
|
+
export function formatMediumDate(d, locale) {
|
|
179
|
+
return new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', year: 'numeric' }).format(d);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The weeks of `month` (any date inside it), each exactly seven cells, padded
|
|
183
|
+
* with days from the adjacent months. `fixedWeeks` always returns six rows so
|
|
184
|
+
* a panel never changes height between months.
|
|
185
|
+
*/
|
|
186
|
+
export function buildMonthMatrix(month, weekStart = 0, fixedWeeks = false) {
|
|
187
|
+
const first = startOfMonth(month);
|
|
188
|
+
const gridStart = startOfWeek(first, weekStart);
|
|
189
|
+
const total = daysInMonth(first.getFullYear(), first.getMonth());
|
|
190
|
+
const lead = dayDiff(gridStart, first);
|
|
191
|
+
const rows = fixedWeeks ? 6 : Math.ceil((lead + total) / 7);
|
|
192
|
+
const weeks = [];
|
|
193
|
+
for (let r = 0; r < rows; r++) {
|
|
194
|
+
const week = [];
|
|
195
|
+
for (let c = 0; c < 7; c++) {
|
|
196
|
+
const date = addDays(gridStart, r * 7 + c);
|
|
197
|
+
week.push({ date, iso: toIsoDate(date), inMonth: isSameMonth(date, first), column: c });
|
|
198
|
+
}
|
|
199
|
+
weeks.push(week);
|
|
200
|
+
}
|
|
201
|
+
return weeks;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The day focus moves to for `key`, or null when the key is not a grid key.
|
|
205
|
+
*
|
|
206
|
+
* | Key | Move |
|
|
207
|
+
* |---|---|
|
|
208
|
+
* | ←/→ | ∓1 day (mirrored in RTL) |
|
|
209
|
+
* | ↑/↓ | ∓1 week |
|
|
210
|
+
* | Home/End | first/last day of the week |
|
|
211
|
+
* | PageUp/PageDown | ∓1 month (Shift: ∓1 year), day clamped to the month length |
|
|
212
|
+
*
|
|
213
|
+
* The result is clamped to [min, max]. Disabled dates stay focusable (APG:
|
|
214
|
+
* they are announced as unavailable rather than skipped), so the caller only
|
|
215
|
+
* gates selection.
|
|
216
|
+
*/
|
|
217
|
+
export function nextFocusDate(current, key, opts = {}) {
|
|
218
|
+
const { weekStart = 0, min, max, rtl = false, shiftKey = false } = opts;
|
|
219
|
+
let next;
|
|
220
|
+
switch (key) {
|
|
221
|
+
case 'ArrowLeft':
|
|
222
|
+
next = addDays(current, rtl ? 1 : -1);
|
|
223
|
+
break;
|
|
224
|
+
case 'ArrowRight':
|
|
225
|
+
next = addDays(current, rtl ? -1 : 1);
|
|
226
|
+
break;
|
|
227
|
+
case 'ArrowUp':
|
|
228
|
+
next = addDays(current, -7);
|
|
229
|
+
break;
|
|
230
|
+
case 'ArrowDown':
|
|
231
|
+
next = addDays(current, 7);
|
|
232
|
+
break;
|
|
233
|
+
case 'Home':
|
|
234
|
+
next = startOfWeek(current, weekStart);
|
|
235
|
+
break;
|
|
236
|
+
case 'End':
|
|
237
|
+
next = endOfWeek(current, weekStart);
|
|
238
|
+
break;
|
|
239
|
+
case 'PageUp':
|
|
240
|
+
next = addMonths(current, shiftKey ? -12 : -1);
|
|
241
|
+
break;
|
|
242
|
+
case 'PageDown':
|
|
243
|
+
next = addMonths(current, shiftKey ? 12 : 1);
|
|
244
|
+
break;
|
|
245
|
+
default:
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
return clampDay(next, min, max);
|
|
249
|
+
}
|
|
250
|
+
/** One predicate out of min/max/weekday/custom rules. */
|
|
251
|
+
export function makeIsDisabled(rules) {
|
|
252
|
+
const min = toDay(rules.min ?? null);
|
|
253
|
+
const max = toDay(rules.max ?? null);
|
|
254
|
+
return (date) => {
|
|
255
|
+
if (min && compareDays(date, min) < 0)
|
|
256
|
+
return true;
|
|
257
|
+
if (max && compareDays(date, max) > 0)
|
|
258
|
+
return true;
|
|
259
|
+
if (rules.weekdaysOnly) {
|
|
260
|
+
const dow = date.getDay();
|
|
261
|
+
if (dow === 0 || dow === 6)
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
if (rules.isDateEnabled && !rules.isDateEnabled(date))
|
|
265
|
+
return true;
|
|
266
|
+
return false;
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
// ── Ranges ─────────────────────────────────────────────────────────────────
|
|
270
|
+
/** Order a range's ends (a range picked right-to-left is still start ≤ end). */
|
|
271
|
+
export function normalizeRange(range) {
|
|
272
|
+
const { start, end } = range;
|
|
273
|
+
if (start && end && compareDays(end, start) < 0)
|
|
274
|
+
return { start: startOfDay(end), end: startOfDay(start) };
|
|
275
|
+
return { start: start ? startOfDay(start) : null, end: end ? startOfDay(end) : null };
|
|
276
|
+
}
|
|
277
|
+
export function isInRange(d, range) {
|
|
278
|
+
if (!range?.start || !range.end)
|
|
279
|
+
return false;
|
|
280
|
+
const r = normalizeRange(range);
|
|
281
|
+
return compareDays(d, r.start) >= 0 && compareDays(d, r.end) <= 0;
|
|
282
|
+
}
|
|
283
|
+
/** Inclusive day count of a complete range, else 0. */
|
|
284
|
+
export function rangeDayCount(range) {
|
|
285
|
+
if (!range?.start || !range.end)
|
|
286
|
+
return 0;
|
|
287
|
+
return Math.abs(dayDiff(range.start, range.end)) + 1;
|
|
288
|
+
}
|
|
289
|
+
export function rangeCellState(d, column, range) {
|
|
290
|
+
const none = { inRange: false, isStart: false, isEnd: false, bandStart: false, bandEnd: false };
|
|
291
|
+
if (!range?.start)
|
|
292
|
+
return none;
|
|
293
|
+
if (!range.end) {
|
|
294
|
+
const only = isSameDay(d, range.start);
|
|
295
|
+
return { inRange: only, isStart: only, isEnd: only, bandStart: only, bandEnd: only };
|
|
296
|
+
}
|
|
297
|
+
const r = normalizeRange(range);
|
|
298
|
+
if (!isInRange(d, r))
|
|
299
|
+
return none;
|
|
300
|
+
const isStart = isSameDay(d, r.start);
|
|
301
|
+
const isEnd = isSameDay(d, r.end);
|
|
302
|
+
return {
|
|
303
|
+
inRange: true,
|
|
304
|
+
isStart,
|
|
305
|
+
isEnd,
|
|
306
|
+
bandStart: isStart || column === 0 || d.getDate() === 1,
|
|
307
|
+
bandEnd: isEnd || column === 6 || isSameDay(d, endOfMonth(d))
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* The next range after clicking `date`, picking ends in order:
|
|
312
|
+
* no start (or a complete range) → start a new range; a start → close it
|
|
313
|
+
* (swapping when the second click is earlier).
|
|
314
|
+
*/
|
|
315
|
+
export function pickRangeDate(range, date) {
|
|
316
|
+
const d = startOfDay(date);
|
|
317
|
+
if (!range.start || range.end)
|
|
318
|
+
return { start: d, end: null };
|
|
319
|
+
return normalizeRange({ start: range.start, end: d });
|
|
320
|
+
}
|
|
321
|
+
export function toIsoRange(range) {
|
|
322
|
+
return { start: range.start ? toIsoDate(range.start) : null, end: range.end ? toIsoDate(range.end) : null };
|
|
323
|
+
}
|
|
324
|
+
export function fromIsoRange(range) {
|
|
325
|
+
return { start: toDay(range?.start ?? null), end: toDay(range?.end ?? null) };
|
|
326
|
+
}
|
|
327
|
+
/** Resolve a preset's range against `now`. */
|
|
328
|
+
export function resolvePreset(preset, now = new Date()) {
|
|
329
|
+
return typeof preset.range === 'function' ? preset.range(now) : preset.range;
|
|
330
|
+
}
|
|
331
|
+
/** Today, Yesterday, Last 7 days, Last 30 days, This month, Last month, This year, Last year. */
|
|
332
|
+
export function defaultRangePresets() {
|
|
333
|
+
const iso = toIsoDate;
|
|
334
|
+
return [
|
|
335
|
+
{ id: 'today', label: 'Today', range: (n) => ({ start: iso(n), end: iso(n) }) },
|
|
336
|
+
{ id: 'yesterday', label: 'Yesterday', range: (n) => ({ start: iso(addDays(n, -1)), end: iso(addDays(n, -1)) }) },
|
|
337
|
+
{ id: 'week', label: 'Last 7 days', range: (n) => ({ start: iso(addDays(n, -6)), end: iso(n) }) },
|
|
338
|
+
{ id: 'month', label: 'Last 30 days', range: (n) => ({ start: iso(addDays(n, -29)), end: iso(n) }) },
|
|
339
|
+
{ id: 'this-month', label: 'This month', range: (n) => ({ start: iso(startOfMonth(n)), end: iso(endOfMonth(n)) }) },
|
|
340
|
+
{
|
|
341
|
+
id: 'last-month',
|
|
342
|
+
label: 'Last month',
|
|
343
|
+
range: (n) => {
|
|
344
|
+
const prev = addMonths(startOfMonth(n), -1);
|
|
345
|
+
return { start: iso(prev), end: iso(endOfMonth(prev)) };
|
|
346
|
+
}
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
id: 'this-year',
|
|
350
|
+
label: 'This year',
|
|
351
|
+
range: (n) => ({ start: iso(new Date(n.getFullYear(), 0, 1)), end: iso(new Date(n.getFullYear(), 11, 31)) })
|
|
352
|
+
},
|
|
353
|
+
{
|
|
354
|
+
id: 'last-year',
|
|
355
|
+
label: 'Last year',
|
|
356
|
+
range: (n) => ({ start: iso(new Date(n.getFullYear() - 1, 0, 1)), end: iso(new Date(n.getFullYear() - 1, 11, 31)) })
|
|
357
|
+
}
|
|
358
|
+
];
|
|
359
|
+
}
|
|
360
|
+
/** The id of the preset `range` equals, if any. */
|
|
361
|
+
export function matchPreset(range, presets, now = new Date()) {
|
|
362
|
+
for (const p of presets) {
|
|
363
|
+
const r = resolvePreset(p, now);
|
|
364
|
+
if (r.start === range.start && r.end === range.end)
|
|
365
|
+
return p.id;
|
|
366
|
+
}
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
/** The locale's numeric field order, e.g. `['month','day','year']` for en-US. */
|
|
370
|
+
export function dateFieldOrder(locale) {
|
|
371
|
+
const parts = new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date(2026, 10, 22));
|
|
372
|
+
const order = parts.map((p) => p.type).filter((t) => t === 'year' || t === 'month' || t === 'day');
|
|
373
|
+
return order.length === 3 ? order : ['year', 'month', 'day'];
|
|
374
|
+
}
|
|
375
|
+
/** The separator the locale uses between numeric date fields ("/", ".", "-"). */
|
|
376
|
+
export function dateSeparator(locale) {
|
|
377
|
+
const parts = new Intl.DateTimeFormat(locale, { year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date(2026, 10, 22));
|
|
378
|
+
const lit = parts.find((p) => p.type === 'literal');
|
|
379
|
+
return lit ? lit.value.trim() || lit.value : '/';
|
|
380
|
+
}
|
|
381
|
+
/** A placeholder hint, e.g. "MM/DD/YYYY" (en-US) or "DD.MM.YYYY" (de-DE). */
|
|
382
|
+
export function datePlaceholder(locale) {
|
|
383
|
+
const token = { year: 'YYYY', month: 'MM', day: 'DD' };
|
|
384
|
+
return dateFieldOrder(locale)
|
|
385
|
+
.map((f) => token[f])
|
|
386
|
+
.join(dateSeparator(locale));
|
|
387
|
+
}
|
|
388
|
+
/** Format for the typed-entry field: numeric, zero-padded, locale order. */
|
|
389
|
+
export function formatTypedDate(d, locale) {
|
|
390
|
+
if (!d)
|
|
391
|
+
return '';
|
|
392
|
+
const v = {
|
|
393
|
+
year: String(d.getFullYear()).padStart(4, '0'),
|
|
394
|
+
month: String(d.getMonth() + 1).padStart(2, '0'),
|
|
395
|
+
day: String(d.getDate()).padStart(2, '0')
|
|
396
|
+
};
|
|
397
|
+
return dateFieldOrder(locale)
|
|
398
|
+
.map((f) => v[f])
|
|
399
|
+
.join(dateSeparator(locale));
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Parse what a person typed. Accepts ISO `YYYY-MM-DD` anywhere, else three
|
|
403
|
+
* numeric groups in the locale's field order with any separator. Two-digit
|
|
404
|
+
* years land in 2000–2099. Impossible dates (31/02) return null.
|
|
405
|
+
*/
|
|
406
|
+
export function parseTypedDate(text, locale) {
|
|
407
|
+
const s = text.trim();
|
|
408
|
+
if (!s)
|
|
409
|
+
return null;
|
|
410
|
+
const iso = parseIsoDate(s);
|
|
411
|
+
if (iso && /^\d{4}-\d{2}-\d{2}$/.test(s))
|
|
412
|
+
return iso;
|
|
413
|
+
const groups = s.split(/[^\d]+/).filter(Boolean);
|
|
414
|
+
if (groups.length !== 3)
|
|
415
|
+
return null;
|
|
416
|
+
const order = dateFieldOrder(locale);
|
|
417
|
+
const v = { year: 0, month: 0, day: 0 };
|
|
418
|
+
order.forEach((f, i) => (v[f] = Number(groups[i])));
|
|
419
|
+
const yearRaw = groups[order.indexOf('year')];
|
|
420
|
+
if (yearRaw.length <= 2)
|
|
421
|
+
v.year += 2000;
|
|
422
|
+
else if (yearRaw.length !== 4)
|
|
423
|
+
return null;
|
|
424
|
+
if (v.month < 1 || v.month > 12 || v.day < 1)
|
|
425
|
+
return null;
|
|
426
|
+
const d = new Date(v.year, v.month - 1, v.day);
|
|
427
|
+
if (d.getFullYear() !== v.year || d.getMonth() !== v.month - 1 || d.getDate() !== v.day)
|
|
428
|
+
return null;
|
|
429
|
+
return d;
|
|
430
|
+
}
|
|
431
|
+
const ACCENTS = new Set(['primary', 'secondary', 'accent', 'info', 'success', 'warning', 'error', 'neutral']);
|
|
432
|
+
/**
|
|
433
|
+
* Inline custom properties that re-tint a month panel. `'primary'` (and
|
|
434
|
+
* anything unknown) returns '' so the jatui selection tokens apply as-is.
|
|
435
|
+
* The old `accentColor` prop interpolated `bg-${accentColor}` classes, which
|
|
436
|
+
* Tailwind's scanner never emits; this replaces it with token vars.
|
|
437
|
+
*/
|
|
438
|
+
export function calendarAccentStyle(accent) {
|
|
439
|
+
if (!accent || accent === 'primary' || !ACCENTS.has(accent))
|
|
440
|
+
return '';
|
|
441
|
+
return [
|
|
442
|
+
`--jatui-cal-accent: var(--color-${accent})`,
|
|
443
|
+
`--jatui-cal-accent-fg: var(--color-${accent}-content)`,
|
|
444
|
+
`--jatui-cal-band: color-mix(in oklab, var(--color-${accent}) 14%, transparent)`
|
|
445
|
+
].join('; ');
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* "Mar 4 – 10, 2026" via `Intl.DateTimeFormat#formatRange` (which collapses
|
|
449
|
+
* the shared month/year), "From Mar 4, 2026" / "Until …" for open ends, '' for
|
|
450
|
+
* an empty range.
|
|
451
|
+
*/
|
|
452
|
+
export function formatRangeLabel(range, locale) {
|
|
453
|
+
const fmt = new Intl.DateTimeFormat(locale, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
454
|
+
const { start, end } = normalizeRange(range);
|
|
455
|
+
if (start && end) {
|
|
456
|
+
if (isSameDay(start, end))
|
|
457
|
+
return fmt.format(start);
|
|
458
|
+
const f = fmt;
|
|
459
|
+
return typeof f.formatRange === 'function' ? f.formatRange(start, end) : `${fmt.format(start)} – ${fmt.format(end)}`;
|
|
460
|
+
}
|
|
461
|
+
if (start)
|
|
462
|
+
return `From ${fmt.format(start)}`;
|
|
463
|
+
if (end)
|
|
464
|
+
return `Until ${fmt.format(end)}`;
|
|
465
|
+
return '';
|
|
466
|
+
}
|
|
467
|
+
/** The default calendar-mode presets plus the "All time" (open) sentinel. */
|
|
468
|
+
export function defaultRangePresetsWithAll() {
|
|
469
|
+
return [...defaultRangePresets(), { id: 'all', label: 'All time', range: { start: null, end: null } }];
|
|
470
|
+
}
|
|
471
|
+
// ── Panel navigation ───────────────────────────────────────────────────────
|
|
472
|
+
/**
|
|
473
|
+
* The first visible month after focus moves to `date`, for `months` panels
|
|
474
|
+
* starting at `viewMonth`: unchanged while the day is on screen, otherwise
|
|
475
|
+
* the minimum shift that brings it back (it lands in the first panel when
|
|
476
|
+
* moving backwards, the last when moving forwards).
|
|
477
|
+
*/
|
|
478
|
+
export function followFocus(viewMonth, date, months = 1) {
|
|
479
|
+
const first = startOfMonth(viewMonth);
|
|
480
|
+
if (date < first)
|
|
481
|
+
return startOfMonth(date);
|
|
482
|
+
const afterLast = addMonths(first, months);
|
|
483
|
+
if (date >= afterLast)
|
|
484
|
+
return addMonths(startOfMonth(date), -(months - 1));
|
|
485
|
+
return first;
|
|
486
|
+
}
|
|
487
|
+
/** Whether the panels may step back / forward without leaving [min, max]. */
|
|
488
|
+
export function canStepMonth(viewMonth, dir, opts = {}) {
|
|
489
|
+
const { min, max, months = 1 } = opts;
|
|
490
|
+
const first = startOfMonth(viewMonth);
|
|
491
|
+
if (dir < 0)
|
|
492
|
+
return !min || startOfMonth(min) < first;
|
|
493
|
+
return !max || startOfMonth(max) > addMonths(first, months - 1);
|
|
494
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface CalendarGridOptions {
|
|
2
|
+
/** Initial focus day (and month). Defaults to today. */
|
|
3
|
+
initial?: () => Date | null | undefined;
|
|
4
|
+
min?: () => Date | null | undefined;
|
|
5
|
+
max?: () => Date | null | undefined;
|
|
6
|
+
/** Month panels side by side. */
|
|
7
|
+
months?: () => number;
|
|
8
|
+
}
|
|
9
|
+
export interface CalendarGrid {
|
|
10
|
+
/** First visible month (local midnight on the 1st). */
|
|
11
|
+
readonly viewMonth: Date;
|
|
12
|
+
/** Every visible month, first to last. */
|
|
13
|
+
readonly panels: Date[];
|
|
14
|
+
/** The roving-focus day. */
|
|
15
|
+
readonly focusedDate: Date;
|
|
16
|
+
readonly canPrev: boolean;
|
|
17
|
+
readonly canNext: boolean;
|
|
18
|
+
/** Move focus (keyboard or pointer); the panels follow if it leaves them. */
|
|
19
|
+
focus: (date: Date) => void;
|
|
20
|
+
/** Step the panels by `n` months; focus moves with them, clamped. */
|
|
21
|
+
shift: (n: number) => void;
|
|
22
|
+
/** Re-key to a date (e.g. when a popover opens on the current value). */
|
|
23
|
+
reset: (date?: Date | null) => void;
|
|
24
|
+
}
|
|
25
|
+
export declare function createCalendarGrid(opts?: CalendarGridOptions): CalendarGrid;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/* createCalendarGrid.svelte.js generated by Svelte v5.53.11 */
|
|
2
|
+
import * as $ from 'svelte/internal/client';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
addMonths,
|
|
6
|
+
canStepMonth,
|
|
7
|
+
clampDay,
|
|
8
|
+
followFocus,
|
|
9
|
+
startOfDay,
|
|
10
|
+
startOfMonth
|
|
11
|
+
} from './calendarGrid';
|
|
12
|
+
|
|
13
|
+
export function createCalendarGrid(opts = {}) {
|
|
14
|
+
const min = () => opts.min?.() ?? null;
|
|
15
|
+
const max = () => opts.max?.() ?? null;
|
|
16
|
+
const months = () => Math.max(1, opts.months?.() ?? 1);
|
|
17
|
+
const start = clampDay(opts.initial?.() ?? new Date(), min(), max());
|
|
18
|
+
let viewMonth = $.state($.proxy(startOfMonth(start)));
|
|
19
|
+
let focusedDate = $.state($.proxy(startOfDay(start)));
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
get viewMonth() {
|
|
23
|
+
return $.get(viewMonth);
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
get panels() {
|
|
27
|
+
return Array.from({ length: months() }, (_, i) => addMonths($.get(viewMonth), i));
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
get focusedDate() {
|
|
31
|
+
return $.get(focusedDate);
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
get canPrev() {
|
|
35
|
+
return canStepMonth($.get(viewMonth), -1, { min: min(), max: max(), months: months() });
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
get canNext() {
|
|
39
|
+
return canStepMonth($.get(viewMonth), 1, { min: min(), max: max(), months: months() });
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
focus(date) {
|
|
43
|
+
$.set(focusedDate, startOfDay(date), true);
|
|
44
|
+
$.set(viewMonth, followFocus($.get(viewMonth), $.get(focusedDate), months()), true);
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
shift(n) {
|
|
48
|
+
$.set(viewMonth, addMonths($.get(viewMonth), n), true);
|
|
49
|
+
$.set(focusedDate, clampDay(addMonths($.get(focusedDate), n), min(), max()), true);
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
reset(date) {
|
|
53
|
+
$.set(focusedDate, clampDay(date ?? new Date(), min(), max()), true);
|
|
54
|
+
$.set(viewMonth, startOfMonth($.get(focusedDate)), true);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Week/day-view overlap packing.
|
|
3
|
+
*
|
|
4
|
+
* Upstreamed from MOS `lib/components/calendar/shared/dayOverlap.ts`
|
|
5
|
+
* (jatui-lfxyy) with its tests; generic over any `PackableEvent`.
|
|
6
|
+
*
|
|
7
|
+
* Month view stacks intervals across seven discrete day columns into vertical
|
|
8
|
+
* lanes (`monthPacking.ts`). Week and day view have the same interval-graph
|
|
9
|
+
* problem on the other axis: events run over continuous time inside ONE day
|
|
10
|
+
* column, so concurrent events have to be sliced into side-by-side
|
|
11
|
+
* sub-columns. Same shape, different axis — which is why this is a sibling
|
|
12
|
+
* module rather than a mode flag on `packWeek`.
|
|
13
|
+
*
|
|
14
|
+
* The allocator hands out a horizontal slot for every timed event and the grid
|
|
15
|
+
* renders it as a percentage `left`/`width`, so two meetings at 2pm sit beside
|
|
16
|
+
* each other instead of one hiding the other.
|
|
17
|
+
*
|
|
18
|
+
* The intervals compared here are RENDERED extents, not literal ones. The grid
|
|
19
|
+
* gives an event with no end a one-hour block and never draws anything shorter
|
|
20
|
+
* than half an hour, so two five-minute events ten minutes apart really do
|
|
21
|
+
* collide on screen even though their times do not. Packing the literal times
|
|
22
|
+
* would call that pair disjoint and stack them back on top of each other.
|
|
23
|
+
*/
|
|
24
|
+
import { type PackableEvent } from "./monthPacking";
|
|
25
|
+
/** Grid block for an event with no end — matches `eventHeightPx`'s 48px. */
|
|
26
|
+
export declare const DEFAULT_DURATION_MIN = 60;
|
|
27
|
+
/** Shortest block the grid will draw — matches `eventHeightPx`'s 24px floor. */
|
|
28
|
+
export declare const MIN_DURATION_MIN = 30;
|
|
29
|
+
export interface DayOverlapOptions {
|
|
30
|
+
/** Height, in minutes, given to an event with no (or unusable) `end`. */
|
|
31
|
+
defaultDurationMin?: number;
|
|
32
|
+
/** Floor applied to every event's rendered height. */
|
|
33
|
+
minDurationMin?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Let an event widen into later sub-columns that nothing overlapping it
|
|
36
|
+
* occupies. Off makes every event in a cluster exactly `100/columns` wide.
|
|
37
|
+
*/
|
|
38
|
+
expand?: boolean;
|
|
39
|
+
}
|
|
40
|
+
export interface DayInterval {
|
|
41
|
+
/** Minutes from local midnight of the event's start. */
|
|
42
|
+
startMin: number;
|
|
43
|
+
/** Minutes from that same midnight to the BOTTOM of the rendered block. */
|
|
44
|
+
endMin: number;
|
|
45
|
+
}
|
|
46
|
+
export interface DayPlacement<E extends PackableEvent = PackableEvent> extends DayInterval {
|
|
47
|
+
ev: E;
|
|
48
|
+
/** 0-based index of the transitively-overlapping group this event is in. */
|
|
49
|
+
cluster: number;
|
|
50
|
+
/** 0-based sub-column within that cluster. */
|
|
51
|
+
column: number;
|
|
52
|
+
/** Sub-columns the cluster was split into. */
|
|
53
|
+
columns: number;
|
|
54
|
+
/** Sub-columns this event widens across (>= 1). */
|
|
55
|
+
span: number;
|
|
56
|
+
/** CSS `left`, as a percentage of the day column. */
|
|
57
|
+
leftPct: number;
|
|
58
|
+
/** CSS `width`, as a percentage of the day column. */
|
|
59
|
+
widthPct: number;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The vertical extent the grid will actually draw for `ev`, in minutes.
|
|
63
|
+
*
|
|
64
|
+
* `start` is read in local time and measured from its own midnight, so an
|
|
65
|
+
* event running past midnight simply reports an `endMin` above 1440 rather
|
|
66
|
+
* than wrapping — the comparisons below only ever need it to be monotonic.
|
|
67
|
+
*/
|
|
68
|
+
export declare function eventInterval(ev: PackableEvent, opts?: DayOverlapOptions): DayInterval;
|
|
69
|
+
/** Half-open overlap: two blocks that merely touch do NOT overlap. */
|
|
70
|
+
export declare function intervalsOverlap(a: DayInterval, b: DayInterval): boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Split one day's timed events into side-by-side sub-columns.
|
|
73
|
+
*
|
|
74
|
+
* Three passes over the events sorted by start time:
|
|
75
|
+
*
|
|
76
|
+
* 1. **Cluster.** Walk in start order carrying the furthest end seen; an event
|
|
77
|
+
* starting at or after that end can share no space with anything before it,
|
|
78
|
+
* so it opens a new cluster. This is exactly the transitive closure of
|
|
79
|
+
* "overlaps" — A and C never touching still share a cluster if B bridges
|
|
80
|
+
* them, and they must, or the two halves would be scaled differently and
|
|
81
|
+
* B would collide with one of them.
|
|
82
|
+
* 2. **Assign columns.** Greedily take the first sub-column whose last event
|
|
83
|
+
* has already ended. On an interval graph this uses exactly as many columns
|
|
84
|
+
* as the busiest instant needs — no cluster is ever sliced thinner than the
|
|
85
|
+
* real peak concurrency.
|
|
86
|
+
* 3. **Widen.** Let each event grow rightward while no event in the next
|
|
87
|
+
* sub-column overlaps it, so a lone 4pm event beside a busy 2pm stack still
|
|
88
|
+
* reads full width. This cannot reintroduce a collision: for A to widen
|
|
89
|
+
* past column c it must be disjoint from everything in column c, and any B
|
|
90
|
+
* widening out of column c is in column c.
|
|
91
|
+
*/
|
|
92
|
+
export declare function layoutDay<E extends PackableEvent>(events: E[], opts?: DayOverlapOptions): DayPlacement<E>[];
|