@kahitsan/ksui 0.40.1 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kahitsan/ksui",
3
- "version": "0.40.1",
3
+ "version": "0.41.0",
4
4
  "description": "ksui is a standalone set of SolidJS UI components for KahitSan/Hilinga and any SolidJS app. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with only solid-js externalized; it depends on nothing but solid-js + lucide-solid and injects its own CSS.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -0,0 +1,107 @@
1
+ import { createSignal } from "solid-js";
2
+ import { fireEvent, render, screen, within } from "@solidjs/testing-library";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import MonthCalendar from "./MonthCalendar";
5
+
6
+ function renderCalendar(overrides: Partial<Parameters<typeof MonthCalendar>[0]> = {}) {
7
+ const onMonthChange = vi.fn();
8
+ render(() => (
9
+ <MonthCalendar
10
+ month="2026-09-19"
11
+ today="2026-09-12"
12
+ onMonthChange={onMonthChange}
13
+ renderDay={({ date }) => <button type="button">Open {date}</button>}
14
+ {...overrides}
15
+ />
16
+ ));
17
+ return { onMonthChange };
18
+ }
19
+
20
+ describe("MonthCalendar", () => {
21
+ it("renders labelled grid semantics and six seven-day rows", () => {
22
+ renderCalendar();
23
+ const section = screen.getByRole("region", { name: "September 2026" });
24
+ const grid = within(section).getByRole("grid", { name: "September 2026" });
25
+ expect(within(grid).getAllByRole("columnheader").map((node) => node.textContent)).toEqual([
26
+ "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat",
27
+ ]);
28
+ expect(within(grid).getAllByRole("row")).toHaveLength(7);
29
+ expect(within(grid).getAllByRole("gridcell")).toHaveLength(42);
30
+ });
31
+
32
+ it("normalizes previous, next, and today changes to month starts", async () => {
33
+ const { onMonthChange } = renderCalendar();
34
+ await fireEvent.click(screen.getByRole("button", { name: "Previous month" }));
35
+ await fireEvent.click(screen.getByRole("button", { name: "Next month" }));
36
+ await fireEvent.click(screen.getByRole("button", { name: "Today" }));
37
+ expect(onMonthChange.mock.calls).toEqual([
38
+ ["2026-08-01"],
39
+ ["2026-10-01"],
40
+ ["2026-09-01"],
41
+ ]);
42
+ });
43
+
44
+ it("marks today and labels day cells with full dates", () => {
45
+ renderCalendar();
46
+ const today = screen.getByRole("gridcell", { name: "Saturday, September 12, 2026" });
47
+ expect(today.getAttribute("aria-current")).toBe("date");
48
+ expect(within(today).getByText("Open 2026-09-12")).toBeTruthy();
49
+ });
50
+
51
+ it("hides outside cells and skips their render slots", () => {
52
+ const renderDay = vi.fn(({ date }: { date: string }) => <span>{date}</span>);
53
+ renderCalendar({ showOutsideDays: false, renderDay });
54
+ expect(renderDay).toHaveBeenCalledTimes(30);
55
+ expect(screen.queryByText("2026-08-30")).toBeNull();
56
+ const hiddenCells = screen.getAllByRole("gridcell", { hidden: true }).filter((cell) => cell.getAttribute("aria-hidden") === "true");
57
+ expect(hiddenCells).toHaveLength(12);
58
+ });
59
+
60
+ it("supports Monday-first localized headers and custom labels", () => {
61
+ renderCalendar({
62
+ weekStartsOn: 1,
63
+ locale: "fr-FR",
64
+ labels: { previousMonth: "Mois précédent", nextMonth: "Mois suivant", today: "Aujourd’hui" },
65
+ });
66
+ expect(screen.getAllByRole("columnheader").map((node) => node.textContent)).toEqual([
67
+ "lun.", "mar.", "mer.", "jeu.", "ven.", "sam.", "dim.",
68
+ ]);
69
+ expect(screen.getByRole("button", { name: "Mois précédent" })).toBeTruthy();
70
+ expect(screen.getByRole("button", { name: "Aujourd’hui" })).toBeTruthy();
71
+ });
72
+
73
+ it("supports controlled month updates", async () => {
74
+ const [month, setMonth] = createSignal("2026-09-30");
75
+ render(() => (
76
+ <MonthCalendar
77
+ month={month()}
78
+ today="2026-09-12"
79
+ onMonthChange={setMonth}
80
+ renderDay={({ date }) => <span>{date}</span>}
81
+ />
82
+ ));
83
+ await fireEvent.click(screen.getByRole("button", { name: "Next month" }));
84
+ expect(screen.getByRole("heading", { name: "October 2026" })).toBeTruthy();
85
+ });
86
+
87
+ it("passes week metadata and stable child rendering through renderWeek", () => {
88
+ const weeks: string[] = [];
89
+ renderCalendar({
90
+ renderWeek: ({ weekIndex, days, children }) => {
91
+ weeks.push(`${weekIndex}:${days[0].date}`);
92
+ return <>{children()}</>;
93
+ },
94
+ });
95
+ expect(weeks).toEqual([
96
+ "0:2026-08-30", "1:2026-09-06", "2:2026-09-13",
97
+ "3:2026-09-20", "4:2026-09-27", "5:2026-10-04",
98
+ ]);
99
+ });
100
+
101
+ it("allows class overrides and hiding today button", () => {
102
+ renderCalendar({ classes: { root: "custom-root", cell: "custom-cell" }, showTodayButton: false });
103
+ expect(screen.getByRole("region").classList.contains("custom-root")).toBe(true);
104
+ expect(screen.getAllByRole("gridcell")[0].classList.contains("custom-cell")).toBe(true);
105
+ expect(screen.queryByRole("button", { name: "Today" })).toBeNull();
106
+ });
107
+ });
@@ -0,0 +1,399 @@
1
+ import { For, createMemo, createUniqueId, type JSX } from "solid-js";
2
+ import ChevronLeft from "lucide-solid/icons/chevron-left";
3
+ import ChevronRight from "lucide-solid/icons/chevron-right";
4
+ import { injectCSS } from "../../utils/inject-css";
5
+ import {
6
+ addCivilMonths,
7
+ buildMonthCalendarDays,
8
+ formatCivilDate,
9
+ requireCivilDate,
10
+ todayInTimeZone,
11
+ type MonthCalendarDay,
12
+ } from "../../utils/month-calendar-date";
13
+
14
+ const STYLE_ID = "ksui-month-calendar-style";
15
+ const MONTH_CALENDAR_CSS = `
16
+ .ksui-month-calendar{color:var(--ksui-month-calendar-fg,var(--ks-fg,#ffffff));font-family:var(--ksui-month-calendar-font,var(--ks-font-body,"Inter", system-ui, sans-serif));}
17
+ .ksui-month-calendar__nav{display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-bottom:.75rem;}
18
+ .ksui-month-calendar__nav-group{display:flex;align-items:center;gap:.25rem;min-width:0;}
19
+ .ksui-month-calendar__heading{margin:0 .25rem;font-size:.875rem;line-height:1.25rem;font-weight:600;white-space:nowrap;}
20
+ .ksui-month-calendar__button{display:inline-flex;align-items:center;justify-content:center;border:0;border-radius:.375rem;background:transparent;color:var(--ksui-month-calendar-muted,var(--ks-fg-muted,#a1a1aa));cursor:pointer;transition:background-color .15s ease,color .15s ease;}
21
+ .ksui-month-calendar__button:hover{background:var(--ksui-month-calendar-hover,var(--ks-surface-raised,#1a1a1a));color:var(--ksui-month-calendar-fg,var(--ks-fg,#ffffff));}
22
+ .ksui-month-calendar__button:focus-visible{outline:2px solid var(--ksui-month-calendar-focus,var(--ks-focus-ring,#c9a961));outline-offset:2px;}
23
+ .ksui-month-calendar__nav-button{width:2rem;height:2rem;padding:0;}
24
+ .ksui-month-calendar__today-button{padding:.375rem .625rem;font-size:.75rem;line-height:1rem;font-weight:500;}
25
+ .ksui-month-calendar__grid{overflow:hidden;border:1px solid var(--ksui-month-calendar-border,var(--ks-border,rgba(39,39,42,0.5)));border-radius:.5rem;background:var(--ksui-month-calendar-gap,var(--ks-border,rgba(39,39,42,0.5)));}
26
+ .ksui-month-calendar__header,.ksui-month-calendar__week{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:1px;}
27
+ .ksui-month-calendar__columnheader{padding:.5rem .25rem;background:var(--ksui-month-calendar-surface,var(--ks-surface,#0f0f0f));color:var(--ksui-month-calendar-subtle,var(--ks-fg-subtle,#71717a));font-size:.6875rem;line-height:1rem;font-weight:600;text-align:center;text-transform:uppercase;letter-spacing:.05em;}
28
+ .ksui-month-calendar__cell{min-width:0;min-height:5rem;background:var(--ksui-month-calendar-surface,var(--ks-surface,#0f0f0f));}
29
+ .ksui-month-calendar__cell--outside{color:var(--ksui-month-calendar-subtle,var(--ks-fg-subtle,#71717a));}
30
+ .ksui-month-calendar__cell--hidden{visibility:hidden;}
31
+ .ksui-month-calendar__day-label{display:inline-flex;align-items:center;justify-content:center;min-width:1.5rem;height:1.5rem;margin:.25rem;padding:0 .25rem;border-radius:9999px;color:var(--ksui-month-calendar-muted,var(--ks-fg-muted,#a1a1aa));font-size:.75rem;line-height:1rem;font-variant-numeric:tabular-nums;}
32
+ .ksui-month-calendar__cell[aria-current="date"] .ksui-month-calendar__day-label{background:var(--ksui-month-calendar-today-bg,color-mix(in srgb,var(--ks-primary,#c9a961) 20%,transparent));color:var(--ksui-month-calendar-today-fg,var(--ks-accent,#fbbf24));font-weight:600;}
33
+ `;
34
+
35
+ export interface MonthCalendarDayRenderProps extends MonthCalendarDay {
36
+ dayLabelId: string;
37
+ }
38
+
39
+ export interface MonthCalendarWeekRenderProps {
40
+ weekIndex: number;
41
+ days: readonly MonthCalendarDayRenderProps[];
42
+ children: () => JSX.Element;
43
+ }
44
+
45
+ export interface MonthCalendarLabels {
46
+ previousMonth?: string;
47
+ nextMonth?: string;
48
+ today?: string;
49
+ }
50
+
51
+ export interface MonthCalendarClasses {
52
+ root?: string;
53
+ nav?: string;
54
+ heading?: string;
55
+ grid?: string;
56
+ header?: string;
57
+ columnHeader?: string;
58
+ week?: string;
59
+ cell?: string;
60
+ dayLabel?: string;
61
+ previousButton?: string;
62
+ nextButton?: string;
63
+ todayButton?: string;
64
+ }
65
+
66
+ export interface MonthCalendarProps {
67
+ month: string;
68
+ onMonthChange: (month: string) => void;
69
+ renderDay: (props: MonthCalendarDayRenderProps) => JSX.Element;
70
+ renderWeek?: (props: MonthCalendarWeekRenderProps) => JSX.Element;
71
+ today?: string;
72
+ timeZone?: string;
73
+ locale?: string;
74
+ weekStartsOn?: number;
75
+ showOutsideDays?: boolean;
76
+ showTodayButton?: boolean;
77
+ labels?: MonthCalendarLabels;
78
+ classes?: MonthCalendarClasses;
79
+ }
80
+
81
+ interface ResolvedLabels {
82
+ previousMonth: string;
83
+ nextMonth: string;
84
+ today: string;
85
+ }
86
+
87
+ function resolveLabels(labels?: MonthCalendarLabels): ResolvedLabels {
88
+ return {
89
+ previousMonth: labels?.previousMonth ?? "Previous month",
90
+ nextMonth: labels?.nextMonth ?? "Next month",
91
+ today: labels?.today ?? "Today",
92
+ };
93
+ }
94
+
95
+ function resolveClasses(classes?: MonthCalendarClasses): MonthCalendarClasses {
96
+ return classes ?? {};
97
+ }
98
+
99
+ function joinClasses(...values: Array<string | undefined>): string {
100
+ return values.filter(Boolean).join(" ");
101
+ }
102
+
103
+
104
+ function displayDate(day: MonthCalendarDay, locale: string): string {
105
+ return new Intl.DateTimeFormat(locale, {
106
+ dateStyle: "full",
107
+ timeZone: "UTC",
108
+ }).format(new Date(Date.UTC(day.year, day.month - 1, day.day)));
109
+ }
110
+
111
+ interface DayCellProps {
112
+ day: MonthCalendarDayRenderProps;
113
+ locale: string;
114
+ showOutsideDays: boolean;
115
+ cellClass?: string;
116
+ dayLabelClass?: string;
117
+ renderDay: MonthCalendarProps["renderDay"];
118
+ }
119
+
120
+ function DayCell(props: DayCellProps) {
121
+ const hidden = () => !props.day.inMonth && !props.showOutsideDays;
122
+ return (
123
+ <div
124
+ role="gridcell"
125
+ aria-current={props.day.isToday ? "date" : undefined}
126
+ aria-labelledby={hidden() ? undefined : props.day.dayLabelId}
127
+ aria-hidden={hidden() ? "true" : undefined}
128
+ class={joinClasses(
129
+ "ksui-month-calendar__cell",
130
+ !props.day.inMonth ? "ksui-month-calendar__cell--outside" : undefined,
131
+ hidden() ? "ksui-month-calendar__cell--hidden" : undefined,
132
+ props.cellClass,
133
+ )}
134
+ >
135
+ {!hidden() && (
136
+ <>
137
+ <span
138
+ id={props.day.dayLabelId}
139
+ class={joinClasses("ksui-month-calendar__day-label", props.dayLabelClass)}
140
+ aria-label={displayDate(props.day, props.locale)}
141
+ >
142
+ {props.day.day}
143
+ </span>
144
+ {props.renderDay(props.day)}
145
+ </>
146
+ )}
147
+ </div>
148
+ );
149
+ }
150
+
151
+ interface CalendarGridProps {
152
+ headingId: string;
153
+ weekdayLabels: readonly string[];
154
+ weeks: readonly (readonly MonthCalendarDayRenderProps[])[];
155
+ locale: string;
156
+ showOutsideDays: boolean;
157
+ classes?: MonthCalendarClasses;
158
+ renderDay: MonthCalendarProps["renderDay"];
159
+ renderWeek?: MonthCalendarProps["renderWeek"];
160
+ }
161
+
162
+ interface CalendarNavigationProps {
163
+ heading: string;
164
+ headingId: string;
165
+ showTodayButton: boolean;
166
+ previousLabel: string;
167
+ nextLabel: string;
168
+ todayLabel: string;
169
+ navClass?: string;
170
+ headingClass?: string;
171
+ previousButtonClass?: string;
172
+ nextButtonClass?: string;
173
+ todayButtonClass?: string;
174
+ onPreviousMonth: () => void;
175
+ onNextMonth: () => void;
176
+ onToday: () => void;
177
+ }
178
+
179
+ interface NavigationIconButtonProps {
180
+ label: string;
181
+ class?: string;
182
+ direction: "previous" | "next";
183
+ onClick: () => void;
184
+ }
185
+
186
+ function NavigationIconButton(props: NavigationIconButtonProps) {
187
+ return (
188
+ <button
189
+ type="button"
190
+ aria-label={props.label}
191
+ class={joinClasses("ksui-month-calendar__button ksui-month-calendar__nav-button", props.class)}
192
+ onClick={props.onClick}
193
+ >
194
+ {props.direction === "previous"
195
+ ? <ChevronLeft size={16} aria-hidden="true" />
196
+ : <ChevronRight size={16} aria-hidden="true" />}
197
+ </button>
198
+ );
199
+ }
200
+
201
+ interface TodayButtonProps {
202
+ visible: boolean;
203
+ label: string;
204
+ class?: string;
205
+ onClick: () => void;
206
+ }
207
+
208
+ function TodayButton(props: TodayButtonProps) {
209
+ return props.visible ? (
210
+ <button
211
+ type="button"
212
+ class={joinClasses("ksui-month-calendar__button ksui-month-calendar__today-button", props.class)}
213
+ onClick={props.onClick}
214
+ >
215
+ {props.label}
216
+ </button>
217
+ ) : null;
218
+ }
219
+
220
+ function CalendarNavigation(props: CalendarNavigationProps) {
221
+ return (
222
+ <nav aria-label={props.heading} class={joinClasses("ksui-month-calendar__nav", props.navClass)}>
223
+ <div class="ksui-month-calendar__nav-group">
224
+ <NavigationIconButton
225
+ label={props.previousLabel}
226
+ class={props.previousButtonClass}
227
+ direction="previous"
228
+ onClick={props.onPreviousMonth}
229
+ />
230
+ <h2 id={props.headingId} class={joinClasses("ksui-month-calendar__heading", props.headingClass)}>
231
+ {props.heading}
232
+ </h2>
233
+ <NavigationIconButton
234
+ label={props.nextLabel}
235
+ class={props.nextButtonClass}
236
+ direction="next"
237
+ onClick={props.onNextMonth}
238
+ />
239
+ </div>
240
+ <TodayButton
241
+ visible={props.showTodayButton}
242
+ label={props.todayLabel}
243
+ class={props.todayButtonClass}
244
+ onClick={props.onToday}
245
+ />
246
+ </nav>
247
+ );
248
+ }
249
+
250
+ interface MonthCalendarModel {
251
+ headingId: string;
252
+ heading: () => string;
253
+ locale: () => string;
254
+ showOutsideDays: () => boolean;
255
+ showTodayButton: () => boolean;
256
+ weekdayLabels: () => string[];
257
+ weeks: () => MonthCalendarDayRenderProps[][];
258
+ previousMonth: () => void;
259
+ nextMonth: () => void;
260
+ goToToday: () => void;
261
+ }
262
+
263
+ function createMonthCalendarModel(props: MonthCalendarProps): MonthCalendarModel {
264
+ const id = createUniqueId();
265
+ const locale = () => props.locale ?? "en-US";
266
+ const timeZone = () => props.timeZone ?? "UTC";
267
+ const weekStartsOn = () => props.weekStartsOn ?? 0;
268
+ const showOutsideDays = () => props.showOutsideDays ?? true;
269
+ const showTodayButton = () => props.showTodayButton ?? true;
270
+ const today = createMemo(() => props.today ?? formatCivilDate(todayInTimeZone(timeZone())));
271
+ const month = createMemo(() => requireCivilDate(props.month, "month"));
272
+ const monthStart = createMemo(() => ({ ...month(), day: 1 }));
273
+ const headingId = `${id}-heading`;
274
+ const heading = createMemo(() =>
275
+ new Intl.DateTimeFormat(locale(), {
276
+ month: "long",
277
+ year: "numeric",
278
+ timeZone: "UTC",
279
+ }).format(new Date(Date.UTC(month().year, month().month - 1, 1))),
280
+ );
281
+ const weekdayLabels = createMemo(() => {
282
+ const formatter = new Intl.DateTimeFormat(locale(), { weekday: "short", timeZone: "UTC" });
283
+ return Array.from({ length: 7 }, (_, index) => {
284
+ const weekday = (weekStartsOn() + index) % 7;
285
+ return formatter.format(new Date(Date.UTC(2024, 0, 7 + weekday)));
286
+ });
287
+ });
288
+ const weeks = createMemo(() => {
289
+ const days = buildMonthCalendarDays(props.month, today(), weekStartsOn());
290
+ return Array.from({ length: 6 }, (_, weekIndex) =>
291
+ days.slice(weekIndex * 7, weekIndex * 7 + 7).map((day) => ({
292
+ ...day,
293
+ dayLabelId: `${id}-day-${day.date}`,
294
+ })),
295
+ );
296
+ });
297
+ const changeMonth = (offset: number) => {
298
+ props.onMonthChange(formatCivilDate(addCivilMonths(monthStart(), offset)));
299
+ };
300
+ const goToToday = () => {
301
+ const value = requireCivilDate(today(), "today");
302
+ props.onMonthChange(formatCivilDate({ ...value, day: 1 }));
303
+ };
304
+ return {
305
+ headingId,
306
+ heading,
307
+ locale,
308
+ showOutsideDays,
309
+ showTodayButton,
310
+ weekdayLabels,
311
+ weeks,
312
+ previousMonth: () => changeMonth(-1),
313
+ nextMonth: () => changeMonth(1),
314
+ goToToday,
315
+ };
316
+ }
317
+
318
+ function CalendarGrid(props: CalendarGridProps) {
319
+ const cells = (days: readonly MonthCalendarDayRenderProps[]) => (
320
+ <For each={days}>
321
+ {(day) => (
322
+ <DayCell
323
+ day={day}
324
+ locale={props.locale}
325
+ showOutsideDays={props.showOutsideDays}
326
+ cellClass={props.classes?.cell}
327
+ dayLabelClass={props.classes?.dayLabel}
328
+ renderDay={props.renderDay}
329
+ />
330
+ )}
331
+ </For>
332
+ );
333
+
334
+ return (
335
+ <div role="grid" aria-labelledby={props.headingId} class={joinClasses("ksui-month-calendar__grid", props.classes?.grid)}>
336
+ <div role="row" class={joinClasses("ksui-month-calendar__header", props.classes?.header)}>
337
+ <For each={props.weekdayLabels}>
338
+ {(label) => (
339
+ <div role="columnheader" class={joinClasses("ksui-month-calendar__columnheader", props.classes?.columnHeader)}>
340
+ {label}
341
+ </div>
342
+ )}
343
+ </For>
344
+ </div>
345
+ <For each={props.weeks}>
346
+ {(days, weekIndex) => {
347
+ const children = () => cells(days);
348
+ return (
349
+ <div role="row" class={joinClasses("ksui-month-calendar__week", props.classes?.week)}>
350
+ {props.renderWeek
351
+ ? props.renderWeek({ weekIndex: weekIndex(), days, children })
352
+ : children()}
353
+ </div>
354
+ );
355
+ }}
356
+ </For>
357
+ </div>
358
+ );
359
+ }
360
+
361
+ export default function MonthCalendar(props: MonthCalendarProps) {
362
+ injectCSS(STYLE_ID, MONTH_CALENDAR_CSS);
363
+ const model = createMonthCalendarModel(props);
364
+ const labels = () => resolveLabels(props.labels);
365
+ const classes = () => resolveClasses(props.classes);
366
+ return (
367
+ <section
368
+ aria-labelledby={model.headingId}
369
+ class={joinClasses("ksui-month-calendar", classes().root)}
370
+ >
371
+ <CalendarNavigation
372
+ heading={model.heading()}
373
+ headingId={model.headingId}
374
+ showTodayButton={model.showTodayButton()}
375
+ previousLabel={labels().previousMonth}
376
+ nextLabel={labels().nextMonth}
377
+ todayLabel={labels().today}
378
+ navClass={classes().nav}
379
+ headingClass={classes().heading}
380
+ previousButtonClass={classes().previousButton}
381
+ nextButtonClass={classes().nextButton}
382
+ todayButtonClass={classes().todayButton}
383
+ onPreviousMonth={model.previousMonth}
384
+ onNextMonth={model.nextMonth}
385
+ onToday={model.goToToday}
386
+ />
387
+ <CalendarGrid
388
+ headingId={model.headingId}
389
+ weekdayLabels={model.weekdayLabels()}
390
+ weeks={model.weeks()}
391
+ locale={model.locale()}
392
+ showOutsideDays={model.showOutsideDays()}
393
+ classes={classes()}
394
+ renderDay={props.renderDay}
395
+ renderWeek={props.renderWeek}
396
+ />
397
+ </section>
398
+ );
399
+ }
package/src/index.ts CHANGED
@@ -87,6 +87,27 @@ export { default as Modal, type ModalProps, type ModalSize, type ModalTone } fro
87
87
  // DataTable's date filter renders this picker.
88
88
  export { default as DatePicker, type DatePickerProps, type DateRangeValue } from "./components/base/DatePicker";
89
89
 
90
+ export {
91
+ default as MonthCalendar,
92
+ type MonthCalendarProps,
93
+ type MonthCalendarDayRenderProps,
94
+ type MonthCalendarWeekRenderProps,
95
+ type MonthCalendarLabels,
96
+ type MonthCalendarClasses,
97
+ } from "./components/base/MonthCalendar";
98
+ export {
99
+ addCivilDays,
100
+ addCivilMonths,
101
+ buildMonthCalendarDays,
102
+ civilWeekday,
103
+ formatCivilDate,
104
+ parseCivilDate,
105
+ requireCivilDate,
106
+ todayInTimeZone,
107
+ type CivilDate,
108
+ type MonthCalendarDay,
109
+ } from "./utils/month-calendar-date";
110
+
90
111
  export {
91
112
  default as MultiPeriodPicker,
92
113
  type MultiPeriodPickerProps,
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatShortDate } from "./formatShortDate";
3
+
4
+ describe("formatShortDate", () => {
5
+ it("preserves date-only values across runtime timezone defaults", () => {
6
+ expect(formatShortDate("2026-09-10")).toBe("Sep 10, 2026");
7
+ });
8
+
9
+ it("uses the date component from full ISO values", () => {
10
+ expect(formatShortDate("2026-09-10T00:00:00.000Z")).toBe("Sep 10, 2026");
11
+ });
12
+
13
+ it("returns a placeholder for missing or malformed values", () => {
14
+ expect(formatShortDate(null)).toBe("—");
15
+ expect(formatShortDate("not-a-date")).toBe("—");
16
+ });
17
+ });
@@ -1,17 +1,21 @@
1
1
  // Short-date formatter for plugin remotes.
2
2
  //
3
3
  // Renders a YYYY-MM-DD (or full ISO) date string as the en-PH short form
4
- // (e.g. "Jan 5, 2026"). Hilinga is an Asia/Manila product, so the date part is
5
- // anchored at local midnight ("T00:00:00") to avoid the UTC drift that
6
- // toISOString would introduce. Returns an em-dash placeholder for a missing
7
- // value. Pure helper, no DOM, no fetch.
8
-
4
+ // (e.g. "Jan 5, 2026"). Date-only values are parsed as calendar components
5
+ // so runtime timezone defaults cannot shift their displayed day. Hilinga is an
6
+ // Asia/Manila product, so formatting uses the canonical Manila timezone.
7
+ // Returns an em-dash placeholder for a missing value. Pure helper, no DOM, no fetch.
9
8
  export function formatShortDate(dateStr: string | null | undefined): string {
10
9
  if (!dateStr) return "—";
11
10
  const datePart = dateStr.includes("T") ? dateStr.split("T")[0] : dateStr;
12
- return new Date(datePart + "T00:00:00").toLocaleDateString("en-PH", {
11
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(datePart);
12
+ if (!match) return "—";
13
+ const [, year, month, day] = match;
14
+ const date = new Date(Number(year), Number(month) - 1, Number(day));
15
+ return new Intl.DateTimeFormat("en-PH", {
16
+ timeZone: "Asia/Manila",
13
17
  year: "numeric",
14
18
  month: "short",
15
19
  day: "numeric",
16
- });
20
+ }).format(date);
17
21
  }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ addCivilDays,
4
+ addCivilMonths,
5
+ buildMonthCalendarDays,
6
+ civilWeekday,
7
+ formatCivilDate,
8
+ parseCivilDate,
9
+ requireCivilDate,
10
+ todayInTimeZone,
11
+ } from "./month-calendar-date";
12
+
13
+ describe("month calendar civil dates", () => {
14
+ it("parses valid dates and rejects rollover dates", () => {
15
+ expect(parseCivilDate("2024-02-29")).toEqual({ year: 2024, month: 2, day: 29 });
16
+ expect(parseCivilDate("2023-02-29")).toBeNull();
17
+ expect(parseCivilDate("2024-2-09")).toBeNull();
18
+ expect(() => requireCivilDate("2024-13-01")).toThrow(TypeError);
19
+ });
20
+
21
+ it("uses UTC arithmetic across leap days and years", () => {
22
+ expect(formatCivilDate(addCivilDays({ year: 2024, month: 2, day: 28 }, 1))).toBe("2024-02-29");
23
+ expect(formatCivilDate(addCivilDays({ year: 2024, month: 12, day: 31 }, 1))).toBe("2025-01-01");
24
+ expect(addCivilMonths({ year: 2025, month: 1, day: 31 }, -1)).toEqual({ year: 2024, month: 12, day: 1 });
25
+ expect(civilWeekday({ year: 2026, month: 9, day: 12 })).toBe(6);
26
+ });
27
+
28
+ it("builds a stable six-week grid for any week start", () => {
29
+ const sunday = buildMonthCalendarDays("2026-09-20", "2026-09-12", 0);
30
+ expect(sunday).toHaveLength(42);
31
+ expect(sunday[0].date).toBe("2026-08-30");
32
+ expect(sunday[41].date).toBe("2026-10-10");
33
+ expect(sunday.find((day) => day.isToday)?.date).toBe("2026-09-12");
34
+
35
+ const monday = buildMonthCalendarDays("2026-09-01", "2026-09-12", 1);
36
+ expect(monday[0].date).toBe("2026-08-31");
37
+ expect(monday[6].date).toBe("2026-09-06");
38
+ });
39
+
40
+ it("rejects invalid weekStartsOn values", () => {
41
+ expect(() => buildMonthCalendarDays("2026-09-01", "2026-09-12", 7)).toThrow(RangeError);
42
+ });
43
+
44
+ it("derives today in the requested time zone", () => {
45
+ vi.useFakeTimers();
46
+ vi.setSystemTime(new Date("2026-09-12T00:30:00Z"));
47
+ expect(todayInTimeZone("America/Los_Angeles")).toEqual({ year: 2026, month: 9, day: 11 });
48
+ expect(todayInTimeZone("Asia/Manila")).toEqual({ year: 2026, month: 9, day: 12 });
49
+ vi.useRealTimers();
50
+ });
51
+ });
@@ -0,0 +1,106 @@
1
+ export interface CivilDate {
2
+ year: number;
3
+ month: number;
4
+ day: number;
5
+ }
6
+
7
+ export interface MonthCalendarDay extends CivilDate {
8
+ date: string;
9
+ inMonth: boolean;
10
+ isToday: boolean;
11
+ }
12
+
13
+ const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
14
+ const DAY_MS = 86_400_000;
15
+
16
+ export function formatCivilDate(date: CivilDate): string {
17
+ return `${String(date.year).padStart(4, "0")}-${String(date.month).padStart(2, "0")}-${String(date.day).padStart(2, "0")}`;
18
+ }
19
+
20
+ export function parseCivilDate(value: string): CivilDate | null {
21
+ const match = DATE_RE.exec(value);
22
+ if (!match) return null;
23
+ const year = Number(match[1]);
24
+ const month = Number(match[2]);
25
+ const day = Number(match[3]);
26
+ const stamp = Date.UTC(year, month - 1, day);
27
+ const parsed = new Date(stamp);
28
+ if (
29
+ parsed.getUTCFullYear() !== year ||
30
+ parsed.getUTCMonth() !== month - 1 ||
31
+ parsed.getUTCDate() !== day
32
+ ) {
33
+ return null;
34
+ }
35
+ return { year, month, day };
36
+ }
37
+
38
+ export function requireCivilDate(value: string, name = "date"): CivilDate {
39
+ const parsed = parseCivilDate(value);
40
+ if (!parsed) throw new TypeError(`${name} must be a valid YYYY-MM-DD date`);
41
+ return parsed;
42
+ }
43
+
44
+ export function addCivilDays(date: CivilDate, days: number): CivilDate {
45
+ const result = new Date(Date.UTC(date.year, date.month - 1, date.day) + days * DAY_MS);
46
+ return {
47
+ year: result.getUTCFullYear(),
48
+ month: result.getUTCMonth() + 1,
49
+ day: result.getUTCDate(),
50
+ };
51
+ }
52
+
53
+ export function addCivilMonths(date: CivilDate, months: number): CivilDate {
54
+ const monthIndex = date.year * 12 + date.month - 1 + months;
55
+ return {
56
+ year: Math.floor(monthIndex / 12),
57
+ month: ((monthIndex % 12) + 12) % 12 + 1,
58
+ day: 1,
59
+ };
60
+ }
61
+
62
+ export function civilWeekday(date: CivilDate): number {
63
+ return new Date(Date.UTC(date.year, date.month - 1, date.day)).getUTCDay();
64
+ }
65
+
66
+ export function todayInTimeZone(timeZone: string): CivilDate {
67
+ const parts = new Intl.DateTimeFormat("en-US", {
68
+ timeZone,
69
+ year: "numeric",
70
+ month: "2-digit",
71
+ day: "2-digit",
72
+ }).formatToParts(new Date());
73
+ const values = new Map(parts.map((part) => [part.type, part.value]));
74
+ return {
75
+ year: Number(values.get("year")),
76
+ month: Number(values.get("month")),
77
+ day: Number(values.get("day")),
78
+ };
79
+ }
80
+
81
+ export function buildMonthCalendarDays(
82
+ monthAnchor: string,
83
+ today: string,
84
+ weekStartsOn = 0,
85
+ ): MonthCalendarDay[] {
86
+ if (!Number.isInteger(weekStartsOn) || weekStartsOn < 0 || weekStartsOn > 6) {
87
+ throw new RangeError("weekStartsOn must be an integer from 0 through 6");
88
+ }
89
+ const month = requireCivilDate(monthAnchor, "month");
90
+ const current = { year: month.year, month: month.month, day: 1 };
91
+ const todayDate = requireCivilDate(today, "today");
92
+ const todayKey = formatCivilDate(todayDate);
93
+ const leadingDays = (civilWeekday(current) - weekStartsOn + 7) % 7;
94
+ const first = addCivilDays(current, -leadingDays);
95
+
96
+ return Array.from({ length: 42 }, (_, index) => {
97
+ const date = addCivilDays(first, index);
98
+ const key = formatCivilDate(date);
99
+ return {
100
+ ...date,
101
+ date: key,
102
+ inMonth: date.year === current.year && date.month === current.month,
103
+ isToday: key === todayKey,
104
+ };
105
+ });
106
+ }