@sanguo-dev/calendar-ui 0.0.2 → 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.
@@ -1,79 +1,79 @@
1
- import type { CalendarDay } from "../types";
2
- import { node } from "../utils/dom";
3
-
4
- /** 周末判断:只看公历星期,与节日 / 调休数据无关 */
5
- const isWeekend = function (year: number, month: number, day: number): boolean {
6
- const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
7
- return weekday === 0 || weekday === 6;
8
- };
9
-
10
- /**
11
- * 建一个空的日期格
12
- *
13
- * 节点只建一次,之后靠 setDay / setDayState 更新,换月不再分配新节点。
14
- */
15
- export const createDayCell = function (): HTMLButtonElement {
16
- const cell = node("button", "cui__day");
17
- cell.type = "button";
18
-
19
- const tag = node("span", "cui__day-tag");
20
- tag.hidden = true;
21
- tag.setAttribute("aria-hidden", "true");
22
-
23
- cell.append(tag, node("span", "cui__day-num"), node("span", "cui__day-sub"));
24
- return cell;
25
- };
26
-
27
- /** 更新格子的日期内容;同一日期不重复写文本,只刷新状态 */
28
- export const setDay = function (cell: HTMLButtonElement, day: CalendarDay<unknown>): void {
29
- const key = `${day.year}-${day.month}-${day.day}`;
30
-
31
- if (cell.dataset.date !== key) {
32
- cell.dataset.date = key;
33
-
34
- // 结构固定为 tag → num → sub,按兄弟节点取比三次 querySelector 便宜
35
- const tag = cell.firstElementChild as HTMLElement;
36
- const num = tag.nextElementSibling as HTMLElement;
37
- const sub = num.nextElementSibling as HTMLElement;
38
- const badge = day.badge;
39
-
40
- tag.textContent = badge?.text ?? "";
41
- tag.hidden = badge === null;
42
-
43
- if (badge?.tone !== undefined) {
44
- tag.dataset.tone = badge.tone;
45
- cell.dataset.tone = badge.tone;
46
- } else {
47
- delete tag.dataset.tone;
48
- delete cell.dataset.tone;
49
- }
50
-
51
- num.textContent = String(day.day);
52
- sub.textContent = day.subtitle;
53
-
54
- // 周六 / 周日固定标红:和法定节假日同色,但不由节假日数据决定
55
- cell.classList.toggle("is-weekend", isWeekend(day.year, day.month, day.day));
56
- }
57
-
58
- setDayState(cell, day);
59
- };
60
-
61
- /** 只更新状态类,不动文本;选中、hover 预览这类高频操作走这里 */
62
- export const setDayState = function (cell: HTMLButtonElement, day: CalendarDay<unknown>): void {
63
- cell.classList.toggle("is-outside", !day.inCurrentMonth);
64
- cell.classList.toggle("is-today", day.isToday);
65
- cell.classList.toggle("is-selected", day.isSelected);
66
- cell.classList.toggle("is-range-start", day.isRangeStart);
67
- cell.classList.toggle("is-range-end", day.isRangeEnd);
68
- cell.classList.toggle("is-in-range", day.isInRange);
69
- cell.classList.toggle("is-disabled", day.isDisabled);
70
- cell.disabled = day.isDisabled;
71
- cell.tabIndex = day.inCurrentMonth && !day.isDisabled ? 0 : -1;
72
- cell.setAttribute("aria-label", day.ariaLabel);
73
-
74
- if (day.isDisabled) cell.setAttribute("aria-disabled", "true");
75
- else cell.removeAttribute("aria-disabled");
76
-
77
- if (day.isSelected) cell.setAttribute("aria-pressed", "true");
78
- else cell.removeAttribute("aria-pressed");
79
- };
1
+ import type { CalendarDay } from "../types";
2
+ import { node } from "../utils/dom";
3
+
4
+ /** 周末判断:只看公历星期,与节日 / 调休数据无关 */
5
+ const isWeekend = function (year: number, month: number, day: number): boolean {
6
+ const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay();
7
+ return weekday === 0 || weekday === 6;
8
+ };
9
+
10
+ /**
11
+ * 建一个空的日期格
12
+ *
13
+ * 节点只建一次,之后靠 setDay / setDayState 更新,换月不再分配新节点。
14
+ */
15
+ export const createDayCell = function (): HTMLButtonElement {
16
+ const cell = node("button", "cui__day");
17
+ cell.type = "button";
18
+
19
+ const tag = node("span", "cui__day-tag");
20
+ tag.hidden = true;
21
+ tag.setAttribute("aria-hidden", "true");
22
+
23
+ cell.append(tag, node("span", "cui__day-num"), node("span", "cui__day-sub"));
24
+ return cell;
25
+ };
26
+
27
+ /** 更新格子的日期内容;同一日期不重复写文本,只刷新状态 */
28
+ export const setDay = function (cell: HTMLButtonElement, day: CalendarDay<unknown>): void {
29
+ const key = `${day.year}-${day.month}-${day.day}`;
30
+
31
+ if (cell.dataset.date !== key) {
32
+ cell.dataset.date = key;
33
+
34
+ // 结构固定为 tag → num → sub,按兄弟节点取比三次 querySelector 便宜
35
+ const tag = cell.firstElementChild as HTMLElement;
36
+ const num = tag.nextElementSibling as HTMLElement;
37
+ const sub = num.nextElementSibling as HTMLElement;
38
+ const badge = day.badge;
39
+
40
+ tag.textContent = badge?.text ?? "";
41
+ tag.hidden = badge === null;
42
+
43
+ if (badge?.tone !== undefined) {
44
+ tag.dataset.tone = badge.tone;
45
+ cell.dataset.tone = badge.tone;
46
+ } else {
47
+ delete tag.dataset.tone;
48
+ delete cell.dataset.tone;
49
+ }
50
+
51
+ num.textContent = String(day.day);
52
+ sub.textContent = day.subtitle;
53
+
54
+ // 周六 / 周日固定标红:和法定节假日同色,但不由节假日数据决定
55
+ cell.classList.toggle("is-weekend", isWeekend(day.year, day.month, day.day));
56
+ }
57
+
58
+ setDayState(cell, day);
59
+ };
60
+
61
+ /** 只更新状态类,不动文本;选中、hover 预览这类高频操作走这里 */
62
+ export const setDayState = function (cell: HTMLButtonElement, day: CalendarDay<unknown>): void {
63
+ cell.classList.toggle("is-outside", !day.inCurrentMonth);
64
+ cell.classList.toggle("is-today", day.isToday);
65
+ cell.classList.toggle("is-selected", day.isSelected);
66
+ cell.classList.toggle("is-range-start", day.isRangeStart);
67
+ cell.classList.toggle("is-range-end", day.isRangeEnd);
68
+ cell.classList.toggle("is-in-range", day.isInRange);
69
+ cell.classList.toggle("is-disabled", day.isDisabled);
70
+ cell.disabled = day.isDisabled;
71
+ cell.tabIndex = day.inCurrentMonth && !day.isDisabled ? 0 : -1;
72
+ cell.setAttribute("aria-label", day.ariaLabel);
73
+
74
+ if (day.isDisabled) cell.setAttribute("aria-disabled", "true");
75
+ else cell.removeAttribute("aria-disabled");
76
+
77
+ if (day.isSelected) cell.setAttribute("aria-pressed", "true");
78
+ else cell.removeAttribute("aria-pressed");
79
+ };
@@ -1,181 +1,181 @@
1
- import type { CalendarDay, GridHandle, GridOptions } from "../types";
2
- import { node } from "../utils/dom";
3
- import { prefersReducedMotion, readCssDuration, readCssEasing } from "../utils/motion";
4
- import { createDayCell, setDay, setDayState } from "./day";
5
-
6
- /** 固定 6 行 × 7 列 */
7
- const CELL_COUNT = 42;
8
- const ROW_COUNT = 6;
9
-
10
- interface RowMetrics {
11
- height: number;
12
- opacity: number;
13
- marginBottom: number;
14
- }
15
-
16
- /** 读一行当前的视觉尺寸(动画进行中时读到的是动画值) */
17
- const readRowMetrics = function (row: HTMLElement): RowMetrics {
18
- const rect = row.getBoundingClientRect();
19
- const style = getComputedStyle(row);
20
- const margin = Number.parseFloat(style.marginBottom);
21
- return {
22
- height: rect.height,
23
- opacity: Number.parseFloat(style.opacity) || 0,
24
- marginBottom: Number.isFinite(margin) ? margin : 0,
25
- };
26
- };
27
-
28
- /**
29
- * 日期网格:42 个格子只建一次,之后按索引复用
30
- *
31
- * - render:换月时更新日期/副标题 + 状态
32
- * - updateState:只切选中/今天等状态类,不动文本
33
- * - setVisibleRows:折叠时按行动画高度,其余行收起
34
- *
35
- * 行高动画必须按真实像素走:CSS 的 max-height 过渡从 200px 收到 0,
36
- * 真正可见的变化只落在缓动的最后 20%,看起来就是「先不动、再突然合上」。
37
- *
38
- * 没有做“按日期复用 + DOM 重排”:42 个节点的移动比 42 次文本写入更贵,
39
- * 实测换月反而从 0.60ms 退化到 0.93ms。
40
- */
41
- export const createGrid = function <TData = null>(options: GridOptions<TData>): GridHandle<TData> {
42
- const el = node("div", "cui__grid");
43
- const rowEls = Array.from({ length: ROW_COUNT }, () => node("div", "cui__week"));
44
- const cells = Array.from({ length: CELL_COUNT }, () => createDayCell());
45
-
46
- rowEls.forEach((row, index) => {
47
- row.append(...cells.slice(index * 7, index * 7 + 7));
48
- });
49
- el.append(...rowEls);
50
-
51
- let days: CalendarDay<TData>[] = [];
52
- /** 上一次应用的行集合,重复渲染时直接跳过 */
53
- let visibleKey: string | null = null;
54
-
55
- /** 每一行正在跑的高度动画;再次切换时从当前可见高度接着走 */
56
- const rowAnimations = new Map<HTMLElement, Animation>();
57
-
58
- const onClick = (event: MouseEvent) => {
59
- const cell = (event.target as HTMLElement).closest<HTMLElement>(".cui__day");
60
- if (!cell) return;
61
-
62
- // 用索引取格子数据,比解析 data-date 快,也不受补位格影响
63
- const picked = days[cells.indexOf(cell as HTMLButtonElement)];
64
- if (picked && !picked.isDisabled) options.onSelect(picked);
65
- };
66
-
67
- const applyRowState = (row: HTMLElement, visible: boolean): void => {
68
- row.classList.toggle("is-collapsed-hidden", !visible);
69
- row.toggleAttribute("inert", !visible);
70
- if (visible) row.removeAttribute("aria-hidden");
71
- else row.setAttribute("aria-hidden", "true");
72
- };
73
-
74
- const cancelRowAnimations = (): void => {
75
- rowAnimations.forEach((animation) => {
76
- try {
77
- animation.cancel();
78
- } catch {
79
- // 动画已经被浏览器回收
80
- }
81
- });
82
- rowAnimations.clear();
83
- };
84
-
85
- const applyInstant = (visible: ReadonlySet<number> | null): void => {
86
- rowEls.forEach((row, index) => {
87
- applyRowState(row, visible === null || visible.has(index));
88
- });
89
- };
90
-
91
- const animateRows = (visible: ReadonlySet<number> | null): void => {
92
- const firstRow = rowEls[0] as HTMLElement;
93
- const gridStyle = getComputedStyle(el);
94
- const duration = readCssDuration(gridStyle, "--cui-collapse-duration", 200);
95
- const easing = readCssEasing(gridStyle, "--cui-collapse-easing", "ease");
96
-
97
- if (typeof firstRow.animate !== "function" || prefersReducedMotion() || duration === 0) {
98
- cancelRowAnimations();
99
- applyInstant(visible);
100
- return;
101
- }
102
-
103
- // 先读当前视觉高度(可能正被上一段动画驱动),再取消动画、落到最终状态
104
- const before = rowEls.map(readRowMetrics);
105
- cancelRowAnimations();
106
- applyInstant(visible);
107
- const after = rowEls.map(readRowMetrics);
108
-
109
- rowEls.forEach((row, index) => {
110
- const from = before[index] as RowMetrics;
111
- const to = after[index] as RowMetrics;
112
- if (Math.abs(from.height - to.height) < 0.5) return;
113
-
114
- const animation = row.animate(
115
- [
116
- {
117
- height: `${from.height}px`,
118
- opacity: from.opacity,
119
- marginBottom: `${from.marginBottom}px`,
120
- },
121
- {
122
- height: `${to.height}px`,
123
- opacity: to.opacity,
124
- marginBottom: `${to.marginBottom}px`,
125
- },
126
- ],
127
- { duration, easing, fill: "none" },
128
- );
129
-
130
- rowAnimations.set(row, animation);
131
- animation.finished
132
- .then(() => {
133
- if (rowAnimations.get(row) === animation) rowAnimations.delete(row);
134
- })
135
- .catch(() => {
136
- // cancel 会 reject finished,无需处理
137
- });
138
- });
139
- };
140
-
141
- el.addEventListener("click", onClick);
142
-
143
- return {
144
- el,
145
- render: (next: CalendarDay<TData>[]) => {
146
- days = next;
147
- for (let i = 0; i < CELL_COUNT; i++) {
148
- const day = next[i];
149
- if (day) setDay(cells[i] as HTMLButtonElement, day);
150
- }
151
- },
152
- updateState: (next: CalendarDay<TData>[]) => {
153
- days = next;
154
- for (let i = 0; i < CELL_COUNT; i++) {
155
- const day = next[i];
156
- if (day) setDayState(cells[i] as HTMLButtonElement, day);
157
- }
158
- },
159
- setVisibleRows: (rows: readonly number[] | null, animate = true) => {
160
- const visible = rows === null ? null : new Set(rows);
161
- const key = visible === null ? "*" : [...visible].sort((a, b) => a - b).join(",");
162
-
163
- // 翻月等场景要求瞬时切换:即使行集合没变,也要把进行中的动画收掉
164
- if (!animate) {
165
- cancelRowAnimations();
166
- applyInstant(visible);
167
- visibleKey = key;
168
- return;
169
- }
170
-
171
- if (key === visibleKey) return;
172
- visibleKey = key;
173
-
174
- animateRows(visible);
175
- },
176
- destroy: () => {
177
- cancelRowAnimations();
178
- el.removeEventListener("click", onClick);
179
- },
180
- };
181
- };
1
+ import type { CalendarDay, GridHandle, GridOptions } from "../types";
2
+ import { node } from "../utils/dom";
3
+ import { prefersReducedMotion, readCssDuration, readCssEasing } from "../utils/motion";
4
+ import { createDayCell, setDay, setDayState } from "./day";
5
+
6
+ /** 固定 6 行 × 7 列 */
7
+ const CELL_COUNT = 42;
8
+ const ROW_COUNT = 6;
9
+
10
+ interface RowMetrics {
11
+ height: number;
12
+ opacity: number;
13
+ marginBottom: number;
14
+ }
15
+
16
+ /** 读一行当前的视觉尺寸(动画进行中时读到的是动画值) */
17
+ const readRowMetrics = function (row: HTMLElement): RowMetrics {
18
+ const rect = row.getBoundingClientRect();
19
+ const style = getComputedStyle(row);
20
+ const margin = Number.parseFloat(style.marginBottom);
21
+ return {
22
+ height: rect.height,
23
+ opacity: Number.parseFloat(style.opacity) || 0,
24
+ marginBottom: Number.isFinite(margin) ? margin : 0,
25
+ };
26
+ };
27
+
28
+ /**
29
+ * 日期网格:42 个格子只建一次,之后按索引复用
30
+ *
31
+ * - render:换月时更新日期/副标题 + 状态
32
+ * - updateState:只切选中/今天等状态类,不动文本
33
+ * - setVisibleRows:折叠时按行动画高度,其余行收起
34
+ *
35
+ * 行高动画必须按真实像素走:CSS 的 max-height 过渡从 200px 收到 0,
36
+ * 真正可见的变化只落在缓动的最后 20%,看起来就是「先不动、再突然合上」。
37
+ *
38
+ * 没有做“按日期复用 + DOM 重排”:42 个节点的移动比 42 次文本写入更贵,
39
+ * 实测换月反而从 0.60ms 退化到 0.93ms。
40
+ */
41
+ export const createGrid = function <TData = null>(options: GridOptions<TData>): GridHandle<TData> {
42
+ const el = node("div", "cui__grid");
43
+ const rowEls = Array.from({ length: ROW_COUNT }, () => node("div", "cui__week"));
44
+ const cells = Array.from({ length: CELL_COUNT }, () => createDayCell());
45
+
46
+ rowEls.forEach((row, index) => {
47
+ row.append(...cells.slice(index * 7, index * 7 + 7));
48
+ });
49
+ el.append(...rowEls);
50
+
51
+ let days: CalendarDay<TData>[] = [];
52
+ /** 上一次应用的行集合,重复渲染时直接跳过 */
53
+ let visibleKey: string | null = null;
54
+
55
+ /** 每一行正在跑的高度动画;再次切换时从当前可见高度接着走 */
56
+ const rowAnimations = new Map<HTMLElement, Animation>();
57
+
58
+ const onClick = (event: MouseEvent) => {
59
+ const cell = (event.target as HTMLElement).closest<HTMLElement>(".cui__day");
60
+ if (!cell) return;
61
+
62
+ // 用索引取格子数据,比解析 data-date 快,也不受补位格影响
63
+ const picked = days[cells.indexOf(cell as HTMLButtonElement)];
64
+ if (picked && !picked.isDisabled) options.onSelect(picked);
65
+ };
66
+
67
+ const applyRowState = (row: HTMLElement, visible: boolean): void => {
68
+ row.classList.toggle("is-collapsed-hidden", !visible);
69
+ row.toggleAttribute("inert", !visible);
70
+ if (visible) row.removeAttribute("aria-hidden");
71
+ else row.setAttribute("aria-hidden", "true");
72
+ };
73
+
74
+ const cancelRowAnimations = (): void => {
75
+ rowAnimations.forEach((animation) => {
76
+ try {
77
+ animation.cancel();
78
+ } catch {
79
+ // 动画已经被浏览器回收
80
+ }
81
+ });
82
+ rowAnimations.clear();
83
+ };
84
+
85
+ const applyInstant = (visible: ReadonlySet<number> | null): void => {
86
+ rowEls.forEach((row, index) => {
87
+ applyRowState(row, visible === null || visible.has(index));
88
+ });
89
+ };
90
+
91
+ const animateRows = (visible: ReadonlySet<number> | null): void => {
92
+ const firstRow = rowEls[0] as HTMLElement;
93
+ const gridStyle = getComputedStyle(el);
94
+ const duration = readCssDuration(gridStyle, "--cui-collapse-duration", 200);
95
+ const easing = readCssEasing(gridStyle, "--cui-collapse-easing", "ease");
96
+
97
+ if (typeof firstRow.animate !== "function" || prefersReducedMotion() || duration === 0) {
98
+ cancelRowAnimations();
99
+ applyInstant(visible);
100
+ return;
101
+ }
102
+
103
+ // 先读当前视觉高度(可能正被上一段动画驱动),再取消动画、落到最终状态
104
+ const before = rowEls.map(readRowMetrics);
105
+ cancelRowAnimations();
106
+ applyInstant(visible);
107
+ const after = rowEls.map(readRowMetrics);
108
+
109
+ rowEls.forEach((row, index) => {
110
+ const from = before[index] as RowMetrics;
111
+ const to = after[index] as RowMetrics;
112
+ if (Math.abs(from.height - to.height) < 0.5) return;
113
+
114
+ const animation = row.animate(
115
+ [
116
+ {
117
+ height: `${from.height}px`,
118
+ opacity: from.opacity,
119
+ marginBottom: `${from.marginBottom}px`,
120
+ },
121
+ {
122
+ height: `${to.height}px`,
123
+ opacity: to.opacity,
124
+ marginBottom: `${to.marginBottom}px`,
125
+ },
126
+ ],
127
+ { duration, easing, fill: "none" },
128
+ );
129
+
130
+ rowAnimations.set(row, animation);
131
+ animation.finished
132
+ .then(() => {
133
+ if (rowAnimations.get(row) === animation) rowAnimations.delete(row);
134
+ })
135
+ .catch(() => {
136
+ // cancel 会 reject finished,无需处理
137
+ });
138
+ });
139
+ };
140
+
141
+ el.addEventListener("click", onClick);
142
+
143
+ return {
144
+ el,
145
+ render: (next: CalendarDay<TData>[]) => {
146
+ days = next;
147
+ for (let i = 0; i < CELL_COUNT; i++) {
148
+ const day = next[i];
149
+ if (day) setDay(cells[i] as HTMLButtonElement, day);
150
+ }
151
+ },
152
+ updateState: (next: CalendarDay<TData>[]) => {
153
+ days = next;
154
+ for (let i = 0; i < CELL_COUNT; i++) {
155
+ const day = next[i];
156
+ if (day) setDayState(cells[i] as HTMLButtonElement, day);
157
+ }
158
+ },
159
+ setVisibleRows: (rows: readonly number[] | null, animate = true) => {
160
+ const visible = rows === null ? null : new Set(rows);
161
+ const key = visible === null ? "*" : [...visible].sort((a, b) => a - b).join(",");
162
+
163
+ // 翻月等场景要求瞬时切换:即使行集合没变,也要把进行中的动画收掉
164
+ if (!animate) {
165
+ cancelRowAnimations();
166
+ applyInstant(visible);
167
+ visibleKey = key;
168
+ return;
169
+ }
170
+
171
+ if (key === visibleKey) return;
172
+ visibleKey = key;
173
+
174
+ animateRows(visible);
175
+ },
176
+ destroy: () => {
177
+ cancelRowAnimations();
178
+ el.removeEventListener("click", onClick);
179
+ },
180
+ };
181
+ };
@@ -1,69 +1,69 @@
1
- import type { HeaderHandle, HeaderOptions } from "../types";
2
- import { node } from "../utils/dom";
3
- import { createNav } from "./nav";
4
-
5
- /** 头部:默认是 ‹ 年月 ›,也可以交给 renderer 完全自定义 */
6
- export const createHeader = function (options: HeaderOptions): HeaderHandle {
7
- const el = node("div", "cui__header");
8
- const title = node("div", "cui__title");
9
- const prev = createNav({
10
- label: "‹",
11
- ariaLabel: options.locale.prevMonth,
12
- onClick: options.onPrev,
13
- });
14
- const next = createNav({
15
- label: "›",
16
- ariaLabel: options.locale.nextMonth,
17
- onClick: options.onNext,
18
- });
19
-
20
- let year = options.year;
21
- let month = options.month;
22
-
23
- const titleText = (): string =>
24
- options.title ? options.title(year, month) : options.locale.monthTitle(year, month);
25
-
26
- const renderDefault = (): void => {
27
- // 默认头部只建一次节点:换月时重建会把正在聚焦的翻月按钮挪出 DOM,
28
- // 焦点掉到 body,键盘用户每翻一个月就要重新找按钮。
29
- if (el.childElementCount === 0) el.append(prev.el, title, next.el);
30
- const nextTitle = titleText();
31
- if (title.textContent !== nextTitle) title.textContent = nextTitle;
32
- };
33
-
34
- const renderCustom = (): void => {
35
- el.replaceChildren(
36
- options.renderer!({
37
- year,
38
- month,
39
- locale: options.locale,
40
- prevMonth: options.onPrev,
41
- nextMonth: options.onNext,
42
- }),
43
- );
44
- };
45
-
46
- const render = (): void => {
47
- if (options.renderer) renderCustom();
48
- else renderDefault();
49
- };
50
-
51
- render();
52
-
53
- return {
54
- el,
55
- update: (nextYear: number, nextMonth: number) => {
56
- year = nextYear;
57
- month = nextMonth;
58
- render();
59
- },
60
- setNavDisabled: (prevDisabled: boolean, nextDisabled: boolean) => {
61
- prev.setDisabled(prevDisabled);
62
- next.setDisabled(nextDisabled);
63
- },
64
- destroy: () => {
65
- prev.destroy();
66
- next.destroy();
67
- },
68
- };
69
- };
1
+ import type { HeaderHandle, HeaderOptions } from "../types";
2
+ import { node } from "../utils/dom";
3
+ import { createNav } from "./nav";
4
+
5
+ /** 头部:默认是 ‹ 年月 ›,也可以交给 renderer 完全自定义 */
6
+ export const createHeader = function (options: HeaderOptions): HeaderHandle {
7
+ const el = node("div", "cui__header");
8
+ const title = node("div", "cui__title");
9
+ const prev = createNav({
10
+ label: "‹",
11
+ ariaLabel: options.locale.prevMonth,
12
+ onClick: options.onPrev,
13
+ });
14
+ const next = createNav({
15
+ label: "›",
16
+ ariaLabel: options.locale.nextMonth,
17
+ onClick: options.onNext,
18
+ });
19
+
20
+ let year = options.year;
21
+ let month = options.month;
22
+
23
+ const titleText = (): string =>
24
+ options.title ? options.title(year, month) : options.locale.monthTitle(year, month);
25
+
26
+ const renderDefault = (): void => {
27
+ // 默认头部只建一次节点:换月时重建会把正在聚焦的翻月按钮挪出 DOM,
28
+ // 焦点掉到 body,键盘用户每翻一个月就要重新找按钮。
29
+ if (el.childElementCount === 0) el.append(prev.el, title, next.el);
30
+ const nextTitle = titleText();
31
+ if (title.textContent !== nextTitle) title.textContent = nextTitle;
32
+ };
33
+
34
+ const renderCustom = (): void => {
35
+ el.replaceChildren(
36
+ options.renderer!({
37
+ year,
38
+ month,
39
+ locale: options.locale,
40
+ prevMonth: options.onPrev,
41
+ nextMonth: options.onNext,
42
+ }),
43
+ );
44
+ };
45
+
46
+ const render = (): void => {
47
+ if (options.renderer) renderCustom();
48
+ else renderDefault();
49
+ };
50
+
51
+ render();
52
+
53
+ return {
54
+ el,
55
+ update: (nextYear: number, nextMonth: number) => {
56
+ year = nextYear;
57
+ month = nextMonth;
58
+ render();
59
+ },
60
+ setNavDisabled: (prevDisabled: boolean, nextDisabled: boolean) => {
61
+ prev.setDisabled(prevDisabled);
62
+ next.setDisabled(nextDisabled);
63
+ },
64
+ destroy: () => {
65
+ prev.destroy();
66
+ next.destroy();
67
+ },
68
+ };
69
+ };