@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 anchor = $derived(initialMonth ?? value ?? valueStart ?? new Date());
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
- let viewYear = $state(anchor.getFullYear());
107
- let viewMonth = $state(anchor.getMonth());
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 atStartOfDay(d: Date): Date {
119
- // Pure function over plain Date no reactivity needed.
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
- }
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(date: Date): boolean {
141
+ function isInRange(d: Date): boolean {
133
142
  if (mode !== 'range' || !valueStart) return false;
134
- if (!valueEnd) return sameDay(date, valueStart);
135
- return date >= atStartOfDay(valueStart) && date <= atStartOfDay(valueEnd);
143
+ if (!valueEnd) return sameDay(d, valueStart);
144
+ return d >= atStartOfDay(valueStart) && d <= atStartOfDay(valueEnd);
136
145
  }
137
146
 
138
- function isRangeEdge(date: Date): boolean {
147
+ function isRangeEdge(d: Date): boolean {
139
148
  if (mode !== 'range') return false;
140
- return sameDay(date, valueStart) || sameDay(date, valueEnd);
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
- type Cell = {
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
- const cells = $derived.by<Cell[]>(() => {
161
- const first = new Date(viewYear, viewMonth, 1);
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 start = new Date(viewYear, viewMonth, 1 - offset);
164
- const out: Cell[] = [];
165
- for (let i = 0; i < 42; i++) {
166
- const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
167
- out.push({
168
- date: d,
169
- inMonth: d.getMonth() === viewMonth,
170
- disabled: disabled || isOutOfBounds(d),
171
- isToday: sameDay(d, today),
172
- isSelected: mode === 'single' ? sameDay(d, value ?? undefined) : isRangeEdge(d),
173
- isRangeEdge: isRangeEdge(d),
174
- isInRange: isInRange(d)
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
- return out;
178
- });
179
-
180
- const monthLabel = $derived(
181
- new Date(viewYear, viewMonth, 1).toLocaleDateString(undefined, {
182
- month: 'long',
183
- year: 'numeric'
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
- function nextMonth() {
197
- if (viewMonth === 11) {
198
- viewMonth = 0;
199
- viewYear += 1;
200
- } else {
201
- viewMonth += 1;
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(date: Date, cellDisabled: boolean) {
206
- if (cellDisabled) return;
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 class="mb-2 flex items-center justify-between">
241
- <button
242
- type="button"
243
- onclick={prevMonth}
244
- class={cn(
245
- 'text-default-500 hover:bg-default-100 hover:text-default-800 flex cursor-pointer items-center justify-center rounded',
246
- density.navBtn
247
- )}
248
- aria-label="Previous month"
249
- data-testid={buildTestId('calendar', 'prev-month', testId)}
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
- 'text-default-400 mb-1 grid grid-cols-7 gap-0.5 text-center font-medium',
289
- density.dayHeaderText
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', 'day-headers', testId)}
461
+ data-testid={buildTestId('calendar', 'scroll', testId)}
292
462
  >
293
- {#each dayHeaders() as d (d)}
294
- <div>{d}</div>
295
- {/each}
296
- </div>
297
-
298
- <div class="grid grid-cols-7 gap-0.5" role="grid">
299
- {#each cells as cell (cell.date.toISOString())}
300
- <button
301
- type="button"
302
- onclick={() => pick(cell.date, cell.disabled)}
303
- disabled={cell.disabled}
304
- aria-pressed={cell.isSelected}
305
- aria-label={cell.date.toLocaleDateString()}
306
- data-testid={buildTestId('calendar', 'day', testId, cell.date.getDate())}
307
- class={cn(
308
- 'relative flex items-center justify-center rounded transition-colors',
309
- density.cell,
310
- density.cellText,
311
- !cell.inMonth && 'text-default-300',
312
- cell.inMonth && !cell.disabled && 'text-default-700 hover:bg-default-100 cursor-pointer',
313
- cell.disabled && 'text-default-200 cursor-not-allowed',
314
- cell.isToday && !cell.isSelected && 'ring-default-300 font-semibold ring-1',
315
- cell.isInRange && !cell.isRangeEdge && 'bg-primary-50 text-primary-700 rounded-none',
316
- cell.isSelected && 'bg-primary-500 hover:bg-primary-600 text-white'
317
- )}
318
- >
319
- {cell.date.getDate()}
320
- </button>
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>