@makolabs/ripple 3.4.0 → 3.4.1
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.
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { tick } from 'svelte';
|
|
2
3
|
import { cn } from '../../helper/cls.js';
|
|
3
4
|
import { buildTestId } from '../../helper/testid.js';
|
|
4
5
|
import { Size } from '../../variants.js';
|
|
@@ -17,6 +18,7 @@
|
|
|
17
18
|
hideHeader = false,
|
|
18
19
|
disabled = false,
|
|
19
20
|
size = Size.MD,
|
|
21
|
+
scrollFlourish = 0.5,
|
|
20
22
|
class: className = '',
|
|
21
23
|
onselect,
|
|
22
24
|
testId
|
|
@@ -30,81 +32,96 @@
|
|
|
30
32
|
panel: string;
|
|
31
33
|
padding: string;
|
|
32
34
|
cell: string;
|
|
33
|
-
navBtn: string;
|
|
34
|
-
navIcon: string;
|
|
35
35
|
monthText: string;
|
|
36
36
|
dayHeaderText: string;
|
|
37
37
|
cellText: string;
|
|
38
|
+
// Height of the scrollable list — sized to exactly fit one
|
|
39
|
+
// month block (sticky month header + 6-row × 7-col day grid).
|
|
40
|
+
// All months pad to 42 slots so block height is constant, which
|
|
41
|
+
// means a single month fits perfectly at rest and scroll-snap
|
|
42
|
+
// can align month boundaries cleanly.
|
|
43
|
+
scrollH: string;
|
|
38
44
|
};
|
|
39
45
|
const calendarSize: Record<VariantSizes, CalendarDensity> = {
|
|
40
46
|
[Size.XS]: {
|
|
41
47
|
panel: 'w-48',
|
|
42
48
|
padding: 'p-2',
|
|
43
49
|
cell: 'size-6',
|
|
44
|
-
navBtn: 'size-5',
|
|
45
|
-
navIcon: 'size-3',
|
|
46
50
|
monthText: 'text-xs',
|
|
47
51
|
dayHeaderText: 'text-[9px]',
|
|
48
|
-
cellText: 'text-[10px]'
|
|
52
|
+
cellText: 'text-[10px]',
|
|
53
|
+
scrollH: 'h-[12rem]'
|
|
49
54
|
},
|
|
50
55
|
[Size.SM]: {
|
|
51
56
|
panel: 'w-56',
|
|
52
57
|
padding: 'p-2.5',
|
|
53
58
|
cell: 'size-7',
|
|
54
|
-
navBtn: 'size-6',
|
|
55
|
-
navIcon: 'size-3.5',
|
|
56
59
|
monthText: 'text-xs',
|
|
57
60
|
dayHeaderText: 'text-[10px]',
|
|
58
|
-
cellText: 'text-xs'
|
|
61
|
+
cellText: 'text-xs',
|
|
62
|
+
scrollH: 'h-[13.5rem]'
|
|
59
63
|
},
|
|
60
64
|
[Size.MD]: {
|
|
61
65
|
panel: 'w-64',
|
|
62
66
|
padding: 'p-3',
|
|
63
67
|
cell: 'size-8',
|
|
64
|
-
navBtn: 'size-7',
|
|
65
|
-
navIcon: 'size-4',
|
|
66
68
|
monthText: 'text-sm',
|
|
67
69
|
dayHeaderText: 'text-[10px]',
|
|
68
|
-
cellText: 'text-xs'
|
|
70
|
+
cellText: 'text-xs',
|
|
71
|
+
scrollH: 'h-[15rem]'
|
|
69
72
|
},
|
|
70
73
|
[Size.LG]: {
|
|
71
74
|
panel: 'w-72',
|
|
72
75
|
padding: 'p-3.5',
|
|
73
76
|
cell: 'size-9',
|
|
74
|
-
navBtn: 'size-8',
|
|
75
|
-
navIcon: 'size-4',
|
|
76
77
|
monthText: 'text-base',
|
|
77
78
|
dayHeaderText: 'text-xs',
|
|
78
|
-
cellText: 'text-sm'
|
|
79
|
+
cellText: 'text-sm',
|
|
80
|
+
scrollH: 'h-[17rem]'
|
|
79
81
|
},
|
|
80
82
|
[Size.XL]: {
|
|
81
83
|
panel: 'w-80',
|
|
82
84
|
padding: 'p-4',
|
|
83
85
|
cell: 'size-10',
|
|
84
|
-
navBtn: 'size-9',
|
|
85
|
-
navIcon: 'size-5',
|
|
86
86
|
monthText: 'text-lg',
|
|
87
87
|
dayHeaderText: 'text-xs',
|
|
88
|
-
cellText: 'text-sm'
|
|
88
|
+
cellText: 'text-sm',
|
|
89
|
+
scrollH: 'h-[19rem]'
|
|
89
90
|
},
|
|
90
91
|
// Form controls cap at xl — see `form-size.ts`.
|
|
91
92
|
[Size.XXL]: {
|
|
92
93
|
panel: 'w-80',
|
|
93
94
|
padding: 'p-4',
|
|
94
95
|
cell: 'size-10',
|
|
95
|
-
navBtn: 'size-9',
|
|
96
|
-
navIcon: 'size-5',
|
|
97
96
|
monthText: 'text-lg',
|
|
98
97
|
dayHeaderText: 'text-xs',
|
|
99
|
-
cellText: 'text-sm'
|
|
98
|
+
cellText: 'text-sm',
|
|
99
|
+
scrollH: 'h-[19rem]'
|
|
100
100
|
}
|
|
101
101
|
};
|
|
102
102
|
const density = $derived(calendarSize[size]);
|
|
103
103
|
|
|
104
|
-
const
|
|
104
|
+
const MONTH_NAMES = [
|
|
105
|
+
'January',
|
|
106
|
+
'February',
|
|
107
|
+
'March',
|
|
108
|
+
'April',
|
|
109
|
+
'May',
|
|
110
|
+
'June',
|
|
111
|
+
'July',
|
|
112
|
+
'August',
|
|
113
|
+
'September',
|
|
114
|
+
'October',
|
|
115
|
+
'November',
|
|
116
|
+
'December'
|
|
117
|
+
] as const;
|
|
105
118
|
|
|
106
|
-
|
|
107
|
-
|
|
119
|
+
function atStartOfDay(d: Date): Date {
|
|
120
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
121
|
+
const x = new Date(d);
|
|
122
|
+
x.setHours(0, 0, 0, 0);
|
|
123
|
+
return x;
|
|
124
|
+
}
|
|
108
125
|
|
|
109
126
|
function sameDay(a: Date | null | undefined, b: Date | null | undefined): boolean {
|
|
110
127
|
if (!a || !b) return false;
|
|
@@ -115,115 +132,296 @@
|
|
|
115
132
|
);
|
|
116
133
|
}
|
|
117
134
|
|
|
118
|
-
function
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const x = new Date(d);
|
|
122
|
-
x.setHours(0, 0, 0, 0);
|
|
123
|
-
return x;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function isOutOfBounds(date: Date): boolean {
|
|
127
|
-
if (minDate && date < atStartOfDay(minDate)) return true;
|
|
128
|
-
if (maxDate && date > atStartOfDay(maxDate)) return true;
|
|
135
|
+
function isOutOfBounds(d: Date): boolean {
|
|
136
|
+
if (minDate && d < atStartOfDay(minDate)) return true;
|
|
137
|
+
if (maxDate && d > atStartOfDay(maxDate)) return true;
|
|
129
138
|
return false;
|
|
130
139
|
}
|
|
131
140
|
|
|
132
|
-
function isInRange(
|
|
141
|
+
function isInRange(d: Date): boolean {
|
|
133
142
|
if (mode !== 'range' || !valueStart) return false;
|
|
134
|
-
if (!valueEnd) return sameDay(
|
|
135
|
-
return
|
|
143
|
+
if (!valueEnd) return sameDay(d, valueStart);
|
|
144
|
+
return d >= atStartOfDay(valueStart) && d <= atStartOfDay(valueEnd);
|
|
136
145
|
}
|
|
137
146
|
|
|
138
|
-
function isRangeEdge(
|
|
147
|
+
function isRangeEdge(d: Date): boolean {
|
|
139
148
|
if (mode !== 'range') return false;
|
|
140
|
-
return sameDay(
|
|
149
|
+
return sameDay(d, valueStart) || sameDay(d, valueEnd);
|
|
141
150
|
}
|
|
142
151
|
|
|
143
152
|
const today = $derived(atStartOfDay(new Date()));
|
|
144
153
|
|
|
145
|
-
const dayHeaders = $derived(() => {
|
|
154
|
+
const dayHeaders = $derived.by<readonly string[]>(() => {
|
|
146
155
|
const all = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
147
156
|
return weekStartsOn === 1 ? [...all.slice(1), all[0]] : all;
|
|
148
157
|
});
|
|
149
158
|
|
|
150
|
-
|
|
159
|
+
// Year-month index — flattens (year, month) into a single int so
|
|
160
|
+
// bound checks and lazy extension are arithmetic, not Date math.
|
|
161
|
+
function ym(d: Date): number {
|
|
162
|
+
return d.getFullYear() * 12 + d.getMonth();
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const minYM = $derived(minDate ? ym(minDate) : null);
|
|
166
|
+
const maxYM = $derived(maxDate ? ym(maxDate) : null);
|
|
167
|
+
|
|
168
|
+
// Initial pad is 0 — we render only the anchor month synchronously.
|
|
169
|
+
// The IntersectionObserver below extends in both directions on first
|
|
170
|
+
// paint (sentinels are within rootMargin) so users see a populated
|
|
171
|
+
// list almost immediately. Test environments without IO see exactly
|
|
172
|
+
// one month, which keeps assertions like "42 day cells" meaningful.
|
|
173
|
+
const EXTEND_BY = 6;
|
|
174
|
+
|
|
175
|
+
// Compute the starting window synchronously from initial prop values
|
|
176
|
+
// so the first render shows the correct anchor month — using $effect
|
|
177
|
+
// for this would leave the first frame at year 0.
|
|
178
|
+
//
|
|
179
|
+
// scrollFlourish is a fractional month distance for the scroll-in
|
|
180
|
+
// animation, so we pre-render `ceil(flourish)` months back to give
|
|
181
|
+
// the partial-month travel something to scroll *from*. The exact
|
|
182
|
+
// scroll start position is computed in pixels in the mount effect.
|
|
183
|
+
function computeInitialRange(): { f: number; l: number } {
|
|
184
|
+
const a = initialMonth ?? value ?? valueStart ?? new Date();
|
|
185
|
+
const aYM = a.getFullYear() * 12 + a.getMonth();
|
|
186
|
+
const lo = minDate ? minDate.getFullYear() * 12 + minDate.getMonth() : null;
|
|
187
|
+
const hi = maxDate ? maxDate.getFullYear() * 12 + maxDate.getMonth() : null;
|
|
188
|
+
let f = aYM - Math.ceil(Math.max(0, scrollFlourish));
|
|
189
|
+
let l = aYM;
|
|
190
|
+
if (lo !== null) f = Math.max(f, lo);
|
|
191
|
+
if (hi !== null) l = Math.min(l, hi);
|
|
192
|
+
if (f > l) {
|
|
193
|
+
f = l = aYM;
|
|
194
|
+
}
|
|
195
|
+
return { f, l };
|
|
196
|
+
}
|
|
197
|
+
const _init = computeInitialRange();
|
|
198
|
+
let firstYM = $state<number>(_init.f);
|
|
199
|
+
let lastYM = $state<number>(_init.l);
|
|
200
|
+
let extending = false;
|
|
201
|
+
|
|
202
|
+
type DayCell = {
|
|
151
203
|
date: Date;
|
|
152
|
-
inMonth: boolean;
|
|
153
204
|
disabled: boolean;
|
|
154
205
|
isToday: boolean;
|
|
155
206
|
isSelected: boolean;
|
|
156
207
|
isRangeEdge: boolean;
|
|
157
208
|
isInRange: boolean;
|
|
158
209
|
};
|
|
210
|
+
// Slots are either real days or non-clickable blanks. Padding to
|
|
211
|
+
// 42 slots keeps every month the same physical height (6 rows × 7
|
|
212
|
+
// cols), which is what lets the scroll container fit exactly one
|
|
213
|
+
// month at rest. Spillover days from adjacent months are not
|
|
214
|
+
// rendered, so each calendar day appears exactly once across the
|
|
215
|
+
// whole list.
|
|
216
|
+
type GridSlot = { kind: 'day'; cell: DayCell } | { kind: 'blank' };
|
|
217
|
+
type MonthBlock = {
|
|
218
|
+
year: number;
|
|
219
|
+
month: number;
|
|
220
|
+
key: number;
|
|
221
|
+
label: string;
|
|
222
|
+
slots: GridSlot[];
|
|
223
|
+
};
|
|
159
224
|
|
|
160
|
-
|
|
161
|
-
const first = new Date(
|
|
225
|
+
function buildMonth(year: number, month: number): MonthBlock {
|
|
226
|
+
const first = new Date(year, month, 1);
|
|
162
227
|
const offset = (first.getDay() - weekStartsOn + 7) % 7;
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
for (let i = 0; i <
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
228
|
+
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
229
|
+
const slots: GridSlot[] = [];
|
|
230
|
+
for (let i = 0; i < offset; i++) slots.push({ kind: 'blank' });
|
|
231
|
+
for (let day = 1; day <= daysInMonth; day++) {
|
|
232
|
+
const d = new Date(year, month, day);
|
|
233
|
+
slots.push({
|
|
234
|
+
kind: 'day',
|
|
235
|
+
cell: {
|
|
236
|
+
date: d,
|
|
237
|
+
disabled: disabled || isOutOfBounds(d),
|
|
238
|
+
isToday: sameDay(d, today),
|
|
239
|
+
isSelected: mode === 'single' ? sameDay(d, value) : isRangeEdge(d),
|
|
240
|
+
isRangeEdge: isRangeEdge(d),
|
|
241
|
+
isInRange: isInRange(d)
|
|
242
|
+
}
|
|
175
243
|
});
|
|
176
244
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
);
|
|
186
|
-
|
|
187
|
-
function prevMonth() {
|
|
188
|
-
if (viewMonth === 0) {
|
|
189
|
-
viewMonth = 11;
|
|
190
|
-
viewYear -= 1;
|
|
191
|
-
} else {
|
|
192
|
-
viewMonth -= 1;
|
|
193
|
-
}
|
|
245
|
+
while (slots.length < 42) slots.push({ kind: 'blank' });
|
|
246
|
+
return {
|
|
247
|
+
year,
|
|
248
|
+
month,
|
|
249
|
+
key: year * 12 + month,
|
|
250
|
+
label: `${MONTH_NAMES[month]} ${year}`,
|
|
251
|
+
slots
|
|
252
|
+
};
|
|
194
253
|
}
|
|
195
254
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
255
|
+
const months = $derived.by<MonthBlock[]>(() => {
|
|
256
|
+
const out: MonthBlock[] = [];
|
|
257
|
+
for (let i = firstYM; i <= lastYM; i++) {
|
|
258
|
+
const y = Math.floor(i / 12);
|
|
259
|
+
const m = ((i % 12) + 12) % 12;
|
|
260
|
+
out.push(buildMonth(y, m));
|
|
202
261
|
}
|
|
203
|
-
|
|
262
|
+
return out;
|
|
263
|
+
});
|
|
204
264
|
|
|
205
|
-
function pick(
|
|
206
|
-
if (
|
|
265
|
+
function pick(cell: DayCell) {
|
|
266
|
+
if (cell.disabled) return;
|
|
207
267
|
if (mode === 'single') {
|
|
208
|
-
value = date;
|
|
209
|
-
onselect?.(date);
|
|
268
|
+
value = cell.date;
|
|
269
|
+
onselect?.(cell.date);
|
|
210
270
|
return;
|
|
211
271
|
}
|
|
212
|
-
// range
|
|
213
272
|
if (!valueStart || (valueStart && valueEnd)) {
|
|
214
|
-
valueStart = date;
|
|
273
|
+
valueStart = cell.date;
|
|
215
274
|
valueEnd = null;
|
|
216
|
-
onselect?.({ from: date, to: null });
|
|
275
|
+
onselect?.({ from: cell.date, to: null });
|
|
217
276
|
} else {
|
|
218
|
-
if (date < valueStart) {
|
|
277
|
+
if (cell.date < valueStart) {
|
|
219
278
|
valueEnd = valueStart;
|
|
220
|
-
valueStart = date;
|
|
279
|
+
valueStart = cell.date;
|
|
221
280
|
} else {
|
|
222
|
-
valueEnd = date;
|
|
281
|
+
valueEnd = cell.date;
|
|
223
282
|
}
|
|
224
283
|
onselect?.({ from: valueStart, to: valueEnd });
|
|
225
284
|
}
|
|
226
285
|
}
|
|
286
|
+
|
|
287
|
+
let listEl = $state<HTMLDivElement | undefined>();
|
|
288
|
+
let topSentinelEl = $state<HTMLDivElement | undefined>();
|
|
289
|
+
let bottomSentinelEl = $state<HTMLDivElement | undefined>();
|
|
290
|
+
|
|
291
|
+
// Duration of the open-time scroll-in flourish. Slow enough to read
|
|
292
|
+
// as a deliberate UI flourish rather than a snap.
|
|
293
|
+
const FLOURISH_DURATION_MS = 750;
|
|
294
|
+
|
|
295
|
+
// Cubic ease-out — fast at the start, eases into the resting frame.
|
|
296
|
+
function easeOutCubic(t: number): number {
|
|
297
|
+
return 1 - Math.pow(1 - t, 3);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Manual rAF-driven scroll animation. We don't use scrollIntoView +
|
|
301
|
+
// CSS scroll-smooth because that ties duration to browser defaults
|
|
302
|
+
// (typically too quick). `behavior: 'auto'` per frame bypasses any
|
|
303
|
+
// inherited scroll-smooth so the rAF cadence is what drives motion.
|
|
304
|
+
function smoothScrollTo(el: HTMLElement, targetTop: number, duration: number): void {
|
|
305
|
+
const startTop = el.scrollTop;
|
|
306
|
+
const distance = targetTop - startTop;
|
|
307
|
+
if (Math.abs(distance) < 1) return;
|
|
308
|
+
const startTime = performance.now();
|
|
309
|
+
function frame(now: number) {
|
|
310
|
+
const elapsed = now - startTime;
|
|
311
|
+
const t = Math.min(elapsed / duration, 1);
|
|
312
|
+
el.scrollTo({ top: startTop + distance * easeOutCubic(t), behavior: 'auto' });
|
|
313
|
+
if (t < 1) requestAnimationFrame(frame);
|
|
314
|
+
}
|
|
315
|
+
requestAnimationFrame(frame);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function extendBackward() {
|
|
319
|
+
if (extending) return;
|
|
320
|
+
if (minYM !== null && firstYM <= minYM) return;
|
|
321
|
+
const el = listEl;
|
|
322
|
+
if (!el) return;
|
|
323
|
+
extending = true;
|
|
324
|
+
const prevHeight = el.scrollHeight;
|
|
325
|
+
const prevTop = el.scrollTop;
|
|
326
|
+
let next = firstYM - EXTEND_BY;
|
|
327
|
+
if (minYM !== null && next < minYM) next = minYM;
|
|
328
|
+
firstYM = next;
|
|
329
|
+
// Wait for the new month grids to render, then preserve the
|
|
330
|
+
// user's visual scroll position by adding the height delta.
|
|
331
|
+
// `behavior: 'auto'` is explicit so this restoration is instant
|
|
332
|
+
// even when CSS scroll-smooth is in play.
|
|
333
|
+
await tick();
|
|
334
|
+
const newHeight = el.scrollHeight;
|
|
335
|
+
el.scrollTo({ top: prevTop + (newHeight - prevHeight), behavior: 'auto' });
|
|
336
|
+
extending = false;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function extendForward() {
|
|
340
|
+
if (extending) return;
|
|
341
|
+
if (maxYM !== null && lastYM >= maxYM) return;
|
|
342
|
+
extending = true;
|
|
343
|
+
let next = lastYM + EXTEND_BY;
|
|
344
|
+
if (maxYM !== null && next > maxYM) next = maxYM;
|
|
345
|
+
lastYM = next;
|
|
346
|
+
await tick();
|
|
347
|
+
extending = false;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Wire IntersectionObserver to the sentinels. Skipped where IO isn't
|
|
351
|
+
// available (SSR / jsdom tests) — the anchor month still renders,
|
|
352
|
+
// just without lazy extension.
|
|
353
|
+
//
|
|
354
|
+
// When scrollFlourish > 0 we delay IO attachment slightly past the
|
|
355
|
+
// scroll-in animation duration. Otherwise the top sentinel — which
|
|
356
|
+
// starts visible at scrollTop=0 — would fire extendBackward as the
|
|
357
|
+
// flourish animation runs, mutating scrollTop mid-animation and
|
|
358
|
+
// causing visible jitter.
|
|
359
|
+
$effect(() => {
|
|
360
|
+
if (typeof IntersectionObserver === 'undefined') return;
|
|
361
|
+
const root = listEl;
|
|
362
|
+
const top = topSentinelEl;
|
|
363
|
+
const bottom = bottomSentinelEl;
|
|
364
|
+
if (!root || !top || !bottom) return;
|
|
365
|
+
let io: IntersectionObserver | undefined;
|
|
366
|
+
const setup = () => {
|
|
367
|
+
io = new IntersectionObserver(
|
|
368
|
+
(entries) => {
|
|
369
|
+
for (const entry of entries) {
|
|
370
|
+
if (!entry.isIntersecting) continue;
|
|
371
|
+
if (entry.target === top) extendBackward();
|
|
372
|
+
else if (entry.target === bottom) extendForward();
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
{ root, rootMargin: '200px 0px' }
|
|
376
|
+
);
|
|
377
|
+
io.observe(top);
|
|
378
|
+
io.observe(bottom);
|
|
379
|
+
};
|
|
380
|
+
const delayMs = scrollFlourish > 0 ? FLOURISH_DURATION_MS + 50 : 0;
|
|
381
|
+
const timer = setTimeout(setup, delayMs);
|
|
382
|
+
return () => {
|
|
383
|
+
clearTimeout(timer);
|
|
384
|
+
io?.disconnect();
|
|
385
|
+
};
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
// Animate the selected (or today) month into view on first paint.
|
|
389
|
+
// Runs once per mount — Calendar is re-mounted each time DatePicker's
|
|
390
|
+
// popover opens because popover content is conditionally rendered.
|
|
391
|
+
let didInitialScroll = false;
|
|
392
|
+
$effect(() => {
|
|
393
|
+
if (didInitialScroll) return;
|
|
394
|
+
if (!listEl) return;
|
|
395
|
+
didInitialScroll = true;
|
|
396
|
+
requestAnimationFrame(() => {
|
|
397
|
+
const root = listEl;
|
|
398
|
+
if (!root) return;
|
|
399
|
+
const target =
|
|
400
|
+
root.querySelector('[data-selected="true"]') ?? root.querySelector('[data-today="true"]');
|
|
401
|
+
if (!target) return;
|
|
402
|
+
// Scroll the containing month block — not the day cell — so
|
|
403
|
+
// the sticky month header lands at the top of the viewport
|
|
404
|
+
// and the full month is visible at rest.
|
|
405
|
+
const monthBlock = (target as HTMLElement).closest('[data-month-key]') as HTMLElement | null;
|
|
406
|
+
if (!monthBlock) return;
|
|
407
|
+
const targetTop = monthBlock.offsetTop;
|
|
408
|
+
// jsdom doesn't implement scrollTo — guard so unit tests don't
|
|
409
|
+
// surface unhandled errors.
|
|
410
|
+
if (typeof root.scrollTo !== 'function') return;
|
|
411
|
+
if (scrollFlourish > 0) {
|
|
412
|
+
// Position the scroll partway into the back-padded months
|
|
413
|
+
// (fractional distance) and animate from there into the
|
|
414
|
+
// anchor — gives a 1.5-month-style flourish without
|
|
415
|
+
// requiring a whole extra month of pre-render.
|
|
416
|
+
const monthH = monthBlock.offsetHeight;
|
|
417
|
+
const startTop = Math.max(0, targetTop - scrollFlourish * monthH);
|
|
418
|
+
root.scrollTo({ top: startTop, behavior: 'auto' });
|
|
419
|
+
smoothScrollTo(root, targetTop, FLOURISH_DURATION_MS);
|
|
420
|
+
} else {
|
|
421
|
+
root.scrollTo({ top: targetTop, behavior: 'auto' });
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
});
|
|
227
425
|
</script>
|
|
228
426
|
|
|
229
427
|
<div
|
|
@@ -237,87 +435,94 @@
|
|
|
237
435
|
data-testid={buildTestId('calendar', undefined, testId)}
|
|
238
436
|
>
|
|
239
437
|
{#if !hideHeader}
|
|
240
|
-
<div
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
{disabled}
|
|
251
|
-
>
|
|
252
|
-
<svg class={density.navIcon} viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
253
|
-
<path
|
|
254
|
-
fill-rule="evenodd"
|
|
255
|
-
d="M12.78 5.22a.75.75 0 0 1 0 1.06L9.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0z"
|
|
256
|
-
clip-rule="evenodd"
|
|
257
|
-
/>
|
|
258
|
-
</svg>
|
|
259
|
-
</button>
|
|
260
|
-
<span
|
|
261
|
-
class={cn('text-default-800 font-semibold', density.monthText)}
|
|
262
|
-
data-testid={buildTestId('calendar', 'month-label', testId)}>{monthLabel}</span
|
|
263
|
-
>
|
|
264
|
-
<button
|
|
265
|
-
type="button"
|
|
266
|
-
onclick={nextMonth}
|
|
267
|
-
class={cn(
|
|
268
|
-
'text-default-500 hover:bg-default-100 hover:text-default-800 flex cursor-pointer items-center justify-center rounded',
|
|
269
|
-
density.navBtn
|
|
270
|
-
)}
|
|
271
|
-
aria-label="Next month"
|
|
272
|
-
data-testid={buildTestId('calendar', 'next-month', testId)}
|
|
273
|
-
{disabled}
|
|
274
|
-
>
|
|
275
|
-
<svg class={density.navIcon} viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
|
276
|
-
<path
|
|
277
|
-
fill-rule="evenodd"
|
|
278
|
-
d="M7.22 14.78a.75.75 0 0 1 0-1.06L10.94 10 7.22 6.28a.75.75 0 0 1 1.06-1.06l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0z"
|
|
279
|
-
clip-rule="evenodd"
|
|
280
|
-
/>
|
|
281
|
-
</svg>
|
|
282
|
-
</button>
|
|
438
|
+
<div
|
|
439
|
+
class={cn(
|
|
440
|
+
'text-default-400 mb-1 grid grid-cols-7 gap-0.5 text-center font-medium',
|
|
441
|
+
density.dayHeaderText
|
|
442
|
+
)}
|
|
443
|
+
data-testid={buildTestId('calendar', 'day-headers', testId)}
|
|
444
|
+
>
|
|
445
|
+
{#each dayHeaders as d (d)}
|
|
446
|
+
<div>{d}</div>
|
|
447
|
+
{/each}
|
|
283
448
|
</div>
|
|
284
449
|
{/if}
|
|
285
450
|
|
|
286
451
|
<div
|
|
452
|
+
bind:this={listEl}
|
|
287
453
|
class={cn(
|
|
288
|
-
|
|
289
|
-
|
|
454
|
+
// No scroll-snap: once the user starts scrolling, they should
|
|
455
|
+
// glide smoothly across month boundaries. The open-time
|
|
456
|
+
// flourish still lands cleanly because that's a programmatic
|
|
457
|
+
// scroll to a specific offset, not a snap.
|
|
458
|
+
'overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
|
|
459
|
+
density.scrollH
|
|
290
460
|
)}
|
|
291
|
-
data-testid={buildTestId('calendar', '
|
|
461
|
+
data-testid={buildTestId('calendar', 'scroll', testId)}
|
|
292
462
|
>
|
|
293
|
-
{
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
463
|
+
<div bind:this={topSentinelEl} aria-hidden="true" class="h-px"></div>
|
|
464
|
+
{#each months as block (block.key)}
|
|
465
|
+
{@const monthSelected =
|
|
466
|
+
(mode === 'single' &&
|
|
467
|
+
!!value &&
|
|
468
|
+
value.getFullYear() === block.year &&
|
|
469
|
+
value.getMonth() === block.month) ||
|
|
470
|
+
(mode === 'range' &&
|
|
471
|
+
((valueStart &&
|
|
472
|
+
valueStart.getFullYear() === block.year &&
|
|
473
|
+
valueStart.getMonth() === block.month) ||
|
|
474
|
+
(valueEnd &&
|
|
475
|
+
valueEnd.getFullYear() === block.year &&
|
|
476
|
+
valueEnd.getMonth() === block.month)))}
|
|
477
|
+
<!-- One wrapper per month so the open-time auto-scroll can
|
|
478
|
+
resolve the target via closest('[data-month-key]') and
|
|
479
|
+
scroll the full month — header + grid — into view. -->
|
|
480
|
+
<div data-month-key={block.key}>
|
|
481
|
+
<div
|
|
482
|
+
class={cn(
|
|
483
|
+
'border-default-200 sticky top-0 z-10 border-b px-2 py-1 font-semibold',
|
|
484
|
+
density.monthText,
|
|
485
|
+
monthSelected ? 'bg-primary-50 text-primary-700' : 'bg-default-50 text-default-800'
|
|
486
|
+
)}
|
|
487
|
+
data-testid={buildTestId('calendar', 'month-label', testId, block.key)}
|
|
488
|
+
>
|
|
489
|
+
{block.label}
|
|
490
|
+
</div>
|
|
491
|
+
<div class="grid grid-cols-7 gap-0.5 py-1" role="grid">
|
|
492
|
+
{#each block.slots as slot, i (block.key * 100 + i)}
|
|
493
|
+
{#if slot.kind === 'day'}
|
|
494
|
+
{@const cell = slot.cell}
|
|
495
|
+
<button
|
|
496
|
+
type="button"
|
|
497
|
+
onclick={() => pick(cell)}
|
|
498
|
+
disabled={cell.disabled}
|
|
499
|
+
aria-pressed={cell.isSelected}
|
|
500
|
+
aria-label={cell.date.toLocaleDateString()}
|
|
501
|
+
data-testid={buildTestId('calendar', 'day', testId, cell.date.getDate())}
|
|
502
|
+
data-selected={cell.isSelected || undefined}
|
|
503
|
+
data-today={cell.isToday || undefined}
|
|
504
|
+
class={cn(
|
|
505
|
+
'relative flex items-center justify-center rounded transition-colors',
|
|
506
|
+
density.cell,
|
|
507
|
+
density.cellText,
|
|
508
|
+
!cell.disabled && 'text-default-700 hover:bg-default-100 cursor-pointer',
|
|
509
|
+
cell.disabled && 'text-default-200 cursor-not-allowed',
|
|
510
|
+
cell.isToday && !cell.isSelected && 'ring-default-300 font-semibold ring-1',
|
|
511
|
+
cell.isInRange &&
|
|
512
|
+
!cell.isRangeEdge &&
|
|
513
|
+
'bg-primary-50 text-primary-700 rounded-none',
|
|
514
|
+
cell.isSelected && 'bg-primary-500 hover:bg-primary-600 text-white'
|
|
515
|
+
)}
|
|
516
|
+
>
|
|
517
|
+
{cell.date.getDate()}
|
|
518
|
+
</button>
|
|
519
|
+
{:else}
|
|
520
|
+
<div class={density.cell} aria-hidden="true"></div>
|
|
521
|
+
{/if}
|
|
522
|
+
{/each}
|
|
523
|
+
</div>
|
|
524
|
+
</div>
|
|
321
525
|
{/each}
|
|
526
|
+
<div bind:this={bottomSentinelEl} aria-hidden="true" class="h-px"></div>
|
|
322
527
|
</div>
|
|
323
528
|
</div>
|