@sanguo-dev/calendar-ui 0.0.0-stage → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,785 @@
1
+ import { createGrid } from "./components/grid";
2
+ import { createHeader } from "./components/header";
3
+ import { createWeekdays } from "./components/weekdays";
4
+ import { resolveLocale } from "./locales";
5
+ import type {
6
+ CalendarDateInput,
7
+ CalendarDay,
8
+ CalendarInstance,
9
+ CalendarMode,
10
+ CalendarOptions,
11
+ CalendarRange,
12
+ CalendarTheme,
13
+ } from "./types";
14
+ import { buildDays } from "./utils/days";
15
+ import {
16
+ addDays,
17
+ compareDay,
18
+ dateKey,
19
+ normalizeMonth,
20
+ parseDateInput,
21
+ sameDay,
22
+ toDateOnly,
23
+ } from "./utils/date";
24
+ import { node } from "./utils/dom";
25
+ import {
26
+ normalizeEasing,
27
+ prefersReducedMotion,
28
+ readCssDuration,
29
+ readCssEasing,
30
+ } from "./utils/motion";
31
+
32
+ /** 没有显式 min/max 时,搜索可选日期的兜底范围 */
33
+ const SEARCH_MIN = new Date(1900, 0, 1);
34
+ const SEARCH_MAX = new Date(2100, 11, 31);
35
+
36
+ const readDate = function (value: CalendarDateInput | null | undefined, path: string): Date | null {
37
+ if (value === null || value === undefined) return null;
38
+ return parseDateInput(value, path);
39
+ };
40
+
41
+ const monthIndexOf = function (year: number, month: number): number {
42
+ return year * 12 + (month - 1);
43
+ };
44
+
45
+ const monthFromIndex = function (index: number): { year: number; month: number } {
46
+ return { year: Math.floor(index / 12), month: (index % 12) + 1 };
47
+ };
48
+
49
+ /** 在 [min, max] 内找离 desired 最近的可选日期 */
50
+ const findSelectable = function (
51
+ desired: Date,
52
+ minDate: Date | null,
53
+ maxDate: Date | null,
54
+ disabled: ReadonlySet<number>,
55
+ ): Date {
56
+ const blocked = function (date: Date): boolean {
57
+ const key = dateKey(date);
58
+ return (
59
+ (minDate !== null && compareDay(date, minDate) < 0) ||
60
+ (maxDate !== null && compareDay(date, maxDate) > 0) ||
61
+ disabled.has(key)
62
+ );
63
+ };
64
+
65
+ let candidate = toDateOnly(desired);
66
+ if (minDate !== null && compareDay(candidate, minDate) < 0) candidate = minDate;
67
+ if (maxDate !== null && compareDay(candidate, maxDate) > 0) candidate = maxDate;
68
+ if (!blocked(candidate)) return candidate;
69
+
70
+ const lower = minDate ?? SEARCH_MIN;
71
+ const upper = maxDate ?? SEARCH_MAX;
72
+ const maxOffset = Math.max(0, compareDay(upper, lower));
73
+
74
+ // 同时向两侧扩展,先遇到的可选日期就是距离最近的
75
+ for (let offset = 1; offset <= maxOffset; offset++) {
76
+ const forward = addDays(candidate, offset);
77
+ if (compareDay(forward, upper) <= 0 && !blocked(forward)) return forward;
78
+
79
+ const backward = addDays(candidate, -offset);
80
+ if (compareDay(backward, lower) >= 0 && !blocked(backward)) return backward;
81
+ }
82
+
83
+ throw new RangeError("[calendar-ui] 当前日期约束下没有可选日期");
84
+ };
85
+
86
+ /**
87
+ * 在容器里创建一个日历
88
+ *
89
+ * 只依赖 DOM,不依赖任何框架;重复调用 createCalendarUI 会各自独立。
90
+ * 不传 adapter 时就是纯公历,适配器负责提供副标题、标记和透传数据。
91
+ */
92
+ export function createCalendarUI<TData = null>(
93
+ container: HTMLElement,
94
+ options: CalendarOptions<TData> = {},
95
+ ): CalendarInstance {
96
+ const weekStartsOn = options.weekStartsOn ?? 1;
97
+ const locale = resolveLocale(options.locale);
98
+ const adapter = options.adapter;
99
+ let theme = options.theme ?? "auto";
100
+ const today = new Date();
101
+
102
+ const readDuration = function (
103
+ value: number | undefined,
104
+ fallback: number,
105
+ path: string,
106
+ ): number {
107
+ const duration = value ?? fallback;
108
+ if (!Number.isFinite(duration) || duration < 0) {
109
+ throw new TypeError(`[calendar-ui] ${path} 必须是大于等于 0 的数字`);
110
+ }
111
+ return duration;
112
+ };
113
+
114
+ const readEasing = function (value: string | undefined, path: string): string {
115
+ const easing = value ?? "ease";
116
+ if (typeof easing !== "string" || easing.trim() === "") {
117
+ throw new TypeError(`[calendar-ui] ${path} 必须是非空字符串`);
118
+ }
119
+ return easing;
120
+ };
121
+
122
+ // 动画默认全关:传了 slide 才滑,传了 collapse 才有折叠箭头
123
+ const animation = options.animation;
124
+ if (animation !== undefined && (animation === null || typeof animation !== "object")) {
125
+ throw new TypeError("[calendar-ui] animation 必须是对象");
126
+ }
127
+ const slide = animation?.slide ?? null;
128
+ const collapseOptions = animation?.collapse ?? null;
129
+ const monthTransition = slide === null ? "none" : "slide";
130
+ const monthDuration = readDuration(slide?.duration, 220, "animation.slide.duration");
131
+ const monthEasing = readEasing(slide?.easing, "animation.slide.easing");
132
+ const collapsible = collapseOptions !== null;
133
+ const collapseDuration = readDuration(
134
+ collapseOptions?.duration,
135
+ 200,
136
+ "animation.collapse.duration",
137
+ );
138
+ const collapseEasing = readEasing(collapseOptions?.easing, "animation.collapse.easing");
139
+
140
+ const mode: CalendarMode = options.mode ?? "single";
141
+ if (mode !== "single" && mode !== "range") {
142
+ throw new TypeError("[calendar-ui] mode 必须是 single 或 range");
143
+ }
144
+
145
+ const requestedMonths = options.months ?? 1;
146
+ if (requestedMonths !== 1 && requestedMonths !== 2) {
147
+ throw new RangeError("[calendar-ui] months 只能是 1 或 2");
148
+ }
149
+
150
+ let minDate = readDate(options.minDate, "minDate");
151
+ let maxDate = readDate(options.maxDate, "maxDate");
152
+ if (minDate !== null && maxDate !== null && compareDay(minDate, maxDate) > 0) {
153
+ throw new RangeError("[calendar-ui] minDate 不能晚于 maxDate");
154
+ }
155
+
156
+ const disabledDates = options.disabledDates ?? [];
157
+ if (!Array.isArray(disabledDates)) {
158
+ throw new TypeError("[calendar-ui] disabledDates 必须是 Date 数组");
159
+ }
160
+ const disabledSet = new Set<number>();
161
+ disabledDates.forEach((value, index) => {
162
+ const date = readDate(value, `disabledDates[${index}]`);
163
+ if (date !== null) disabledSet.add(dateKey(date));
164
+ });
165
+
166
+ const isDisabled = function (date: Date): boolean {
167
+ const key = dateKey(date);
168
+ return (
169
+ (minDate !== null && key < dateKey(minDate)) ||
170
+ (maxDate !== null && key > dateKey(maxDate)) ||
171
+ disabledSet.has(key)
172
+ );
173
+ };
174
+
175
+ const initial = findSelectable(
176
+ readDate(options.initialDate ?? today, "initialDate") as Date,
177
+ minDate,
178
+ maxDate,
179
+ disabledSet,
180
+ );
181
+
182
+ // 双视图如果边界不足两个月,退化为单视图
183
+ let monthCount = mode === "range" ? requestedMonths : 1;
184
+ if (
185
+ monthCount === 2 &&
186
+ minDate !== null &&
187
+ maxDate !== null &&
188
+ monthIndexOf(maxDate.getFullYear(), maxDate.getMonth() + 1) -
189
+ monthIndexOf(minDate.getFullYear(), minDate.getMonth() + 1) <
190
+ 1
191
+ ) {
192
+ monthCount = 1;
193
+ }
194
+
195
+ let firstYear = initial.getFullYear();
196
+ let firstMonth = initial.getMonth() + 1;
197
+ if (monthCount === 2 && maxDate !== null) {
198
+ const maxFirst = monthIndexOf(maxDate.getFullYear(), maxDate.getMonth() + 1) - 1;
199
+ if (monthIndexOf(firstYear, firstMonth) > maxFirst) {
200
+ const clamped = monthFromIndex(maxFirst);
201
+ firstYear = clamped.year;
202
+ firstMonth = clamped.month;
203
+ }
204
+ }
205
+
206
+ const state = {
207
+ year: firstYear,
208
+ month: firstMonth,
209
+ selected: initial,
210
+ };
211
+
212
+ let rangeStart: Date | null = null;
213
+ let rangeEnd: Date | null = null;
214
+ let collapsed = collapsible && collapseOptions?.defaultCollapsed === true;
215
+ let collapseButton: HTMLButtonElement | null = null;
216
+
217
+ const root = node("div", "cui");
218
+ root.dataset.theme = theme;
219
+ root.style.setProperty("--cui-month-duration", `${monthDuration}ms`);
220
+ root.style.setProperty("--cui-month-easing", monthEasing);
221
+ root.style.setProperty("--cui-collapse-duration", `${collapseDuration}ms`);
222
+ root.style.setProperty("--cui-collapse-easing", collapseEasing);
223
+ if (monthCount === 2) root.classList.add("cui--range");
224
+
225
+ const monthOf = function (offset = 0): { year: number; month: number } {
226
+ return normalizeMonth(state.year, state.month + offset);
227
+ };
228
+
229
+ /** 按当前 state 生成某一列的 42 个格子 */
230
+ const currentDays = function (offset = 0): CalendarDay<TData>[] {
231
+ const month = monthOf(offset);
232
+ return buildDays<TData>({
233
+ year: month.year,
234
+ month: month.month,
235
+ weekStartsOn,
236
+ today,
237
+ selected: mode === "single" ? state.selected : null,
238
+ rangeStart,
239
+ rangeEnd,
240
+ locale,
241
+ adapter,
242
+ isDisabled,
243
+ subtitle: options.subtitle,
244
+ });
245
+ };
246
+
247
+ const rangeCopy = function (): CalendarRange {
248
+ return {
249
+ start: rangeStart === null ? null : new Date(rangeStart),
250
+ end: rangeEnd === null ? null : new Date(rangeEnd),
251
+ };
252
+ };
253
+
254
+ const withRangeFields = function (day: CalendarDay<TData>): CalendarDay<TData> {
255
+ const isStart = rangeStart !== null && sameDay(day.date, rangeStart);
256
+ const isEnd = rangeEnd !== null && sameDay(day.date, rangeEnd);
257
+ return {
258
+ ...day,
259
+ isSelected: isStart || isEnd,
260
+ isRangeStart: isStart,
261
+ isRangeEnd: isEnd,
262
+ isInRange:
263
+ rangeStart !== null &&
264
+ rangeEnd !== null &&
265
+ compareDay(day.date, rangeStart) >= 0 &&
266
+ compareDay(day.date, rangeEnd) <= 0,
267
+ };
268
+ };
269
+
270
+ const updateCollapseButton = function (): void {
271
+ if (collapseButton === null) return;
272
+ collapseButton.textContent = collapsed ? "⌄" : "⌃";
273
+ collapseButton.setAttribute("aria-expanded", String(!collapsed));
274
+ collapseButton.setAttribute(
275
+ "aria-label",
276
+ collapsed ? (locale.expand ?? "展开日历") : (locale.collapse ?? "收起日历"),
277
+ );
278
+ };
279
+
280
+ const updateCollapsedRows = function (
281
+ dayLists: readonly CalendarDay<TData>[][],
282
+ animate: boolean,
283
+ ): void {
284
+ if (!collapsed) {
285
+ panels.forEach((panel) => panel.grid.setVisibleRows(null, animate));
286
+ return;
287
+ }
288
+
289
+ const targets: Date[] =
290
+ mode === "range"
291
+ ? [rangeStart, rangeEnd].filter((date): date is Date => date !== null)
292
+ : [state.selected];
293
+ if (targets.length === 0) targets.push(state.selected);
294
+
295
+ panels.forEach((panel, index) => {
296
+ const rows = new Set<number>();
297
+ const days = dayLists[index] ?? [];
298
+
299
+ targets.forEach((target) => {
300
+ const dayIndex = days.findIndex(
301
+ (day) => sameDay(day.date, target) && (monthCount === 1 || day.inCurrentMonth),
302
+ );
303
+ if (dayIndex >= 0) rows.add(Math.floor(dayIndex / 7));
304
+ });
305
+
306
+ if (rows.size === 0) {
307
+ const first = days.findIndex((day) => day.inCurrentMonth);
308
+ rows.add(first >= 0 ? Math.floor(first / 7) : 0);
309
+ }
310
+
311
+ panel.grid.setVisibleRows(
312
+ [...rows].sort((a, b) => a - b),
313
+ animate,
314
+ );
315
+ });
316
+ };
317
+
318
+ function applyCollapse(next: boolean): void {
319
+ if (!collapsible) return;
320
+ collapsed = next;
321
+ updateCollapseButton();
322
+ render();
323
+ }
324
+
325
+ function toggleCollapsed(): void {
326
+ applyCollapse(!collapsed);
327
+ }
328
+
329
+ function updatePanelLabels(): void {
330
+ panelEls.forEach((el, index) => {
331
+ const month = monthOf(index);
332
+ el.setAttribute("aria-label", locale.monthTitle(month.year, month.month));
333
+ });
334
+ }
335
+
336
+ function updateNav(): void {
337
+ const first = monthIndexOf(state.year, state.month);
338
+ const last = first + monthCount - 1;
339
+ const min =
340
+ minDate === null
341
+ ? Number.NEGATIVE_INFINITY
342
+ : monthIndexOf(minDate.getFullYear(), minDate.getMonth() + 1);
343
+ const max =
344
+ maxDate === null
345
+ ? Number.POSITIVE_INFINITY
346
+ : monthIndexOf(maxDate.getFullYear(), maxDate.getMonth() + 1);
347
+
348
+ header.setNavDisabled(first <= min, last >= max);
349
+ }
350
+
351
+ function render(animateRows = true): void {
352
+ header.update(state.year, state.month);
353
+ const dayLists = panels.map((_, index) => currentDays(index));
354
+ panels.forEach((panel, index) => panel.grid.render(dayLists[index]!));
355
+ updatePanelLabels();
356
+ updateNav();
357
+ updateCollapsedRows(dayLists, animateRows);
358
+ updateCollapseButton();
359
+ }
360
+
361
+ function applyRangeClick(target: Date): void {
362
+ const date = toDateOnly(target);
363
+
364
+ // 第一次点选起点;已有完整范围时重新开始
365
+ if (rangeStart === null || rangeEnd !== null) {
366
+ rangeStart = date;
367
+ rangeEnd = null;
368
+ } else if (compareDay(date, rangeStart) < 0) {
369
+ // 反向选择自动交换
370
+ rangeEnd = rangeStart;
371
+ rangeStart = date;
372
+ } else {
373
+ rangeEnd = date;
374
+ }
375
+
376
+ render();
377
+ options.onRangeChange?.(rangeCopy());
378
+ }
379
+
380
+ /** 处理点选:补位格只改选中,不切换展示月份 */
381
+ function handleSelect(day: CalendarDay<TData>): void {
382
+ if (day.isDisabled) return;
383
+
384
+ if (mode === "range") {
385
+ applyRangeClick(day.date);
386
+ options.onSelect?.(withRangeFields(day));
387
+ return;
388
+ }
389
+
390
+ state.selected = toDateOnly(day.date);
391
+ if (day.inCurrentMonth) {
392
+ state.year = day.year;
393
+ state.month = day.month;
394
+ }
395
+
396
+ // 点选不换月,只需要刷新状态类,不必重建格子
397
+ if (collapsed) {
398
+ render();
399
+ const days = currentDays();
400
+ options.onSelect?.(days.find((item) => sameDay(item.date, day.date)) ?? day);
401
+ return;
402
+ }
403
+
404
+ const days = currentDays();
405
+ panels[0]?.grid.updateState(days);
406
+
407
+ // 回调用更新后的对象,保证 isSelected 是本次点击的结果
408
+ options.onSelect?.(days.find((item) => sameDay(item.date, day.date)) ?? day);
409
+ }
410
+
411
+ interface MonthAnimation {
412
+ token: number;
413
+ layers: HTMLElement[];
414
+ animations: Animation[];
415
+ timer: ReturnType<typeof setTimeout>;
416
+ }
417
+
418
+ let monthAnimationToken = 0;
419
+ let monthAnimation: MonthAnimation | null = null;
420
+
421
+ /** 结束翻月动画:各层留在终点,并移除旧月份的克隆层 */
422
+ function stopMonthAnimation(): void {
423
+ const active = monthAnimation;
424
+ if (active === null) return;
425
+
426
+ monthAnimation = null;
427
+ clearTimeout(active.timer);
428
+ active.animations.forEach((animation) => {
429
+ try {
430
+ animation.cancel();
431
+ } catch {
432
+ // 浏览器已经回收了这段动画
433
+ }
434
+ });
435
+ active.layers.forEach((layer) => layer.remove());
436
+ panels.forEach((panel) => {
437
+ panel.grid.el.classList.remove(
438
+ "is-month-in-next",
439
+ "is-month-in-prev",
440
+ "is-month-out-next",
441
+ "is-month-out-prev",
442
+ );
443
+ });
444
+ }
445
+
446
+ /** 克隆旧月份作为离场层;折叠行在换月后由 syncLayerRows 对齐 */
447
+ function captureMonthSnapshot(): HTMLElement[] {
448
+ return panels.map((panel) => {
449
+ const clone = panel.grid.el.cloneNode(true) as HTMLElement;
450
+ clone.classList.add("is-month-clone");
451
+ clone.classList.remove(
452
+ "is-month-in-next",
453
+ "is-month-in-prev",
454
+ "is-month-out-next",
455
+ "is-month-out-prev",
456
+ );
457
+ clone.setAttribute("aria-hidden", "true");
458
+ clone.querySelectorAll("button").forEach((button) => {
459
+ button.tabIndex = -1;
460
+ });
461
+ panel.viewport.append(clone);
462
+ return clone;
463
+ });
464
+ }
465
+
466
+ /** 离场层和新月份用同一套折叠行,两层高度一致,滑动时不会露出空隙 */
467
+ function syncLayerRows(layers: readonly HTMLElement[]): void {
468
+ layers.forEach((layer, index) => {
469
+ const sourceRows = panels[index]?.grid.el.querySelectorAll<HTMLElement>(".cui__week");
470
+ const layerRows = layer.querySelectorAll<HTMLElement>(".cui__week");
471
+ sourceRows?.forEach((source, rowIndex) => {
472
+ const target = layerRows[rowIndex];
473
+ if (target === undefined) return;
474
+
475
+ const hidden = source.classList.contains("is-collapsed-hidden");
476
+ target.classList.toggle("is-collapsed-hidden", hidden);
477
+ target.toggleAttribute("inert", hidden);
478
+ if (hidden) target.setAttribute("aria-hidden", "true");
479
+ else target.removeAttribute("aria-hidden");
480
+ });
481
+ });
482
+ }
483
+
484
+ /**
485
+ * 用 Web Animations 驱动翻月
486
+ *
487
+ * 不靠切换类名重启动画,也就不需要在点击处理里强制同步一次布局;
488
+ * 快速连点时上一段会被 stopMonthAnimation 直接停到终点,不会叠加克隆层。
489
+ */
490
+ function startMonthAnimation(
491
+ direction: 1 | -1,
492
+ layers: readonly HTMLElement[],
493
+ duration: number,
494
+ easing: string,
495
+ ): void {
496
+ const enter = `translateX(${direction * 100}%)`;
497
+ const leave = `translateX(${-direction * 100}%)`;
498
+ const inClass = direction === 1 ? "is-month-in-next" : "is-month-in-prev";
499
+ const outClass = direction === 1 ? "is-month-out-next" : "is-month-out-prev";
500
+ const animations: Animation[] = [];
501
+
502
+ panels.forEach((panel, index) => {
503
+ const grid = panel.grid.el;
504
+ const oldLayer = layers[index] ?? null;
505
+ grid.classList.add(inClass);
506
+ oldLayer?.classList.add(outClass);
507
+
508
+ if (typeof grid.animate !== "function") return;
509
+ animations.push(
510
+ grid.animate([{ transform: enter }, { transform: "translateX(0)" }], {
511
+ duration,
512
+ easing,
513
+ fill: "both",
514
+ }),
515
+ );
516
+ if (oldLayer !== null) {
517
+ animations.push(
518
+ oldLayer.animate([{ transform: "translateX(0)" }, { transform: leave }], {
519
+ duration,
520
+ easing,
521
+ fill: "both",
522
+ }),
523
+ );
524
+ }
525
+ });
526
+
527
+ const token = ++monthAnimationToken;
528
+ const timer = setTimeout(() => {
529
+ if (monthAnimation?.token === token) stopMonthAnimation();
530
+ }, duration + 80);
531
+
532
+ monthAnimation = { token, layers: [...layers], animations, timer };
533
+ }
534
+
535
+ function goTo(year: number, month: number): void {
536
+ let next = normalizeMonth(year, month);
537
+
538
+ if (minDate !== null) {
539
+ const min = monthIndexOf(minDate.getFullYear(), minDate.getMonth() + 1);
540
+ if (monthIndexOf(next.year, next.month) < min) next = monthFromIndex(min);
541
+ }
542
+ if (maxDate !== null) {
543
+ const maxFirst =
544
+ monthIndexOf(maxDate.getFullYear(), maxDate.getMonth() + 1) - (monthCount - 1);
545
+ if (monthIndexOf(next.year, next.month) > maxFirst) next = monthFromIndex(maxFirst);
546
+ }
547
+
548
+ const currentIndex = monthIndexOf(state.year, state.month);
549
+ const nextIndex = monthIndexOf(next.year, next.month);
550
+ if (nextIndex === currentIndex) return;
551
+
552
+ const direction = nextIndex > currentIndex ? 1 : -1;
553
+ const rootStyle = getComputedStyle(root);
554
+ const duration = readCssDuration(rootStyle, "--cui-month-duration", monthDuration);
555
+ const easing = normalizeEasing(readCssEasing(rootStyle, "--cui-month-easing", monthEasing));
556
+ const shouldAnimate = monthTransition === "slide" && duration > 0 && !prefersReducedMotion();
557
+
558
+ // 先把上一段停在终点:快速连点时不会让旧克隆和新动画同时存在
559
+ stopMonthAnimation();
560
+
561
+ const layers = shouldAnimate ? captureMonthSnapshot() : [];
562
+ state.year = next.year;
563
+ state.month = next.month;
564
+
565
+ // 折叠行瞬时切换:行高过渡和位移动画同时跑会让每一帧都触发布局
566
+ render(false);
567
+ syncLayerRows(layers);
568
+
569
+ if (shouldAnimate) startMonthAnimation(direction, layers, duration, easing);
570
+
571
+ options.onMonthChange?.(state.year, state.month);
572
+ }
573
+
574
+ function applyBounds(nextMin: Date | null, nextMax: Date | null): void {
575
+ if (nextMin !== null && nextMax !== null && compareDay(nextMin, nextMax) > 0) {
576
+ throw new RangeError("[calendar-ui] minDate 不能晚于 maxDate");
577
+ }
578
+
579
+ // 先确认还有可选日期,再整体替换,失败时不破坏现有状态
580
+ const nextSelected = findSelectable(state.selected, nextMin, nextMax, disabledSet);
581
+
582
+ minDate = nextMin;
583
+ maxDate = nextMax;
584
+ state.selected = nextSelected;
585
+ state.year = nextSelected.getFullYear();
586
+ state.month = nextSelected.getMonth() + 1;
587
+
588
+ if (monthCount === 2 && maxDate !== null) {
589
+ const maxFirst = monthIndexOf(maxDate.getFullYear(), maxDate.getMonth() + 1) - 1;
590
+ if (monthIndexOf(state.year, state.month) > maxFirst) {
591
+ const clamped = monthFromIndex(maxFirst);
592
+ state.year = clamped.year;
593
+ state.month = clamped.month;
594
+ }
595
+ }
596
+
597
+ render();
598
+ }
599
+
600
+ const panelEls: HTMLElement[] = [];
601
+ const panels = Array.from({ length: monthCount }, () => {
602
+ const viewport = node("div", "cui__viewport");
603
+ const grid = createGrid<TData>({ onSelect: handleSelect });
604
+ viewport.append(grid.el);
605
+ return {
606
+ weekdays: createWeekdays(weekStartsOn, locale),
607
+ viewport,
608
+ grid,
609
+ };
610
+ });
611
+
612
+ const header = createHeader({
613
+ year: state.year,
614
+ month: state.month,
615
+ locale,
616
+ title:
617
+ monthCount === 2
618
+ ? (year, month) => {
619
+ const next = normalizeMonth(year, month + 1);
620
+ return `${locale.monthTitle(year, month)} – ${locale.monthTitle(next.year, next.month)}`;
621
+ }
622
+ : undefined,
623
+ onPrev: () => goTo(state.year, state.month - 1),
624
+ onNext: () => goTo(state.year, state.month + 1),
625
+ renderer: options.renderHeader,
626
+ });
627
+ header.el.hidden = options.header === false;
628
+
629
+ if (monthCount === 1) {
630
+ root.append(header.el, panels[0]!.weekdays.el, panels[0]!.viewport);
631
+ } else {
632
+ const monthsEl = node("div", "cui__months");
633
+ panels.forEach((panel) => {
634
+ const monthEl = node("div", "cui__month");
635
+ monthEl.setAttribute("role", "group");
636
+ monthEl.append(panel.weekdays.el, panel.viewport);
637
+ monthsEl.append(monthEl);
638
+ panelEls.push(monthEl);
639
+ });
640
+ root.append(header.el, monthsEl);
641
+ }
642
+
643
+ if (collapsible) {
644
+ collapseButton = node("button", "cui__collapse");
645
+ collapseButton.type = "button";
646
+ collapseButton.addEventListener("click", toggleCollapsed);
647
+ root.append(collapseButton);
648
+ }
649
+
650
+ root.classList.add("cui--no-animation");
651
+ container.appendChild(root);
652
+ // 首次渲染不播折叠动画
653
+ render(false);
654
+ root.classList.remove("cui--no-animation");
655
+
656
+ return {
657
+ el: root,
658
+ setMonth: goTo,
659
+ getMonth: () => ({ year: state.year, month: state.month }),
660
+ prevMonth: () => goTo(state.year, state.month - 1),
661
+ nextMonth: () => goTo(state.year, state.month + 1),
662
+ select: (date: CalendarDateInput) => {
663
+ const target = readDate(date, "select(date)");
664
+ if (target === null || isDisabled(target)) return;
665
+
666
+ if (mode === "range") {
667
+ const targetIndex = monthIndexOf(target.getFullYear(), target.getMonth() + 1);
668
+ const first = monthIndexOf(state.year, state.month);
669
+ if (targetIndex < first || targetIndex > first + monthCount - 1) {
670
+ state.year = target.getFullYear();
671
+ state.month = target.getMonth() + 1;
672
+ }
673
+ if (monthCount === 2 && maxDate !== null) {
674
+ const maxFirst = monthIndexOf(maxDate.getFullYear(), maxDate.getMonth() + 1) - 1;
675
+ if (monthIndexOf(state.year, state.month) > maxFirst) {
676
+ const clamped = monthFromIndex(maxFirst);
677
+ state.year = clamped.year;
678
+ state.month = clamped.month;
679
+ }
680
+ }
681
+ applyRangeClick(target);
682
+ const days = buildDays<TData>({
683
+ year: target.getFullYear(),
684
+ month: target.getMonth() + 1,
685
+ weekStartsOn,
686
+ today,
687
+ selected: null,
688
+ rangeStart,
689
+ rangeEnd,
690
+ locale,
691
+ adapter,
692
+ isDisabled,
693
+ subtitle: options.subtitle,
694
+ });
695
+ const day = days.find((item) => sameDay(item.date, target));
696
+ if (day) options.onSelect?.(withRangeFields(day));
697
+ return;
698
+ }
699
+
700
+ state.selected = target;
701
+ state.year = target.getFullYear();
702
+ state.month = target.getMonth() + 1;
703
+ render();
704
+
705
+ const day = currentDays().find((item) => sameDay(item.date, target));
706
+ if (day) options.onSelect?.(day);
707
+ },
708
+ getSelected: () => {
709
+ if (mode === "range") return rangeStart === null ? null : new Date(rangeStart);
710
+ return new Date(state.selected);
711
+ },
712
+ getRange: rangeCopy,
713
+ setRange: (start: CalendarDateInput | null, end?: CalendarDateInput | null) => {
714
+ const nextStart = readDate(start, "setRange(start)");
715
+ const nextEnd = end === undefined ? null : readDate(end, "setRange(end)");
716
+
717
+ if (nextStart === null) {
718
+ rangeStart = null;
719
+ rangeEnd = null;
720
+ } else {
721
+ if (isDisabled(nextStart) || (nextEnd !== null && isDisabled(nextEnd))) return;
722
+
723
+ if (nextEnd === null) {
724
+ rangeStart = nextStart;
725
+ rangeEnd = null;
726
+ } else if (compareDay(nextStart, nextEnd) <= 0) {
727
+ rangeStart = nextStart;
728
+ rangeEnd = nextEnd;
729
+ } else {
730
+ rangeStart = nextEnd;
731
+ rangeEnd = nextStart;
732
+ }
733
+ }
734
+
735
+ render();
736
+ options.onRangeChange?.(rangeCopy());
737
+ },
738
+ clearRange: () => {
739
+ rangeStart = null;
740
+ rangeEnd = null;
741
+ render();
742
+ options.onRangeChange?.(rangeCopy());
743
+ },
744
+ show: () => {
745
+ root.hidden = false;
746
+ },
747
+ hide: () => {
748
+ root.hidden = true;
749
+ },
750
+ toggle: () => {
751
+ root.hidden = !root.hidden;
752
+ },
753
+ isVisible: () => !root.hidden,
754
+ showHeader: () => {
755
+ header.el.hidden = false;
756
+ },
757
+ hideHeader: () => {
758
+ header.el.hidden = true;
759
+ },
760
+ toggleHeader: () => {
761
+ header.el.hidden = !header.el.hidden;
762
+ },
763
+ isHeaderVisible: () => !header.el.hidden,
764
+ getMinDate: () => (minDate === null ? null : new Date(minDate)),
765
+ setMinDate: (date: CalendarDateInput | null) => {
766
+ applyBounds(readDate(date, "setMinDate(date)"), maxDate);
767
+ },
768
+ getMaxDate: () => (maxDate === null ? null : new Date(maxDate)),
769
+ setMaxDate: (date: CalendarDateInput | null) => {
770
+ applyBounds(minDate, readDate(date, "setMaxDate(date)"));
771
+ },
772
+ getTheme: () => theme,
773
+ setTheme: (next: CalendarTheme) => {
774
+ theme = next;
775
+ root.dataset.theme = next;
776
+ },
777
+ destroy: () => {
778
+ stopMonthAnimation();
779
+ collapseButton?.removeEventListener("click", toggleCollapsed);
780
+ header.destroy();
781
+ panels.forEach((panel) => panel.grid.destroy());
782
+ root.remove();
783
+ },
784
+ };
785
+ }