@marianmeres/stuic 3.170.0 → 3.172.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.
Files changed (59) hide show
  1. package/AGENTS.md +10 -8
  2. package/API.md +90 -0
  3. package/README.md +1 -1
  4. package/dist/components/AssetsPreview/AssetsPreview.fixture.svelte +1 -1
  5. package/dist/components/Calendar/Calendar.svelte +799 -0
  6. package/dist/components/Calendar/Calendar.svelte.d.ts +135 -0
  7. package/dist/components/Calendar/README.md +294 -0
  8. package/dist/components/Calendar/calendar-i18n-sk.d.ts +20 -0
  9. package/dist/components/Calendar/calendar-i18n-sk.js +46 -0
  10. package/dist/components/Calendar/calendar-i18n.d.ts +61 -0
  11. package/dist/components/Calendar/calendar-i18n.js +73 -0
  12. package/dist/components/Calendar/index.css +307 -0
  13. package/dist/components/Calendar/index.d.ts +5 -0
  14. package/dist/components/Calendar/index.js +5 -0
  15. package/dist/components/Calendar/iso-date.d.ts +107 -0
  16. package/dist/components/Calendar/iso-date.js +247 -0
  17. package/dist/components/CommandMenu/CommandMenu.fixture.svelte +1 -1
  18. package/dist/components/ContextMenu/ContextMenu.svelte +1 -1
  19. package/dist/components/ContextMenu/ContextMenu.svelte.d.ts +1 -1
  20. package/dist/components/ContextMenu/README.md +1 -1
  21. package/dist/components/DropdownMenu/DropdownMenu.svelte +57 -3
  22. package/dist/components/DropdownMenu/DropdownMenu.svelte.d.ts +4 -2
  23. package/dist/components/DropdownMenu/README.md +1 -0
  24. package/dist/components/Input/FieldDate.svelte +349 -0
  25. package/dist/components/Input/FieldDate.svelte.d.ts +79 -0
  26. package/dist/components/Input/FieldDateRange.svelte +373 -0
  27. package/dist/components/Input/FieldDateRange.svelte.d.ts +90 -0
  28. package/dist/components/Input/README.md +149 -16
  29. package/dist/components/Input/_internal/FieldDateShell.svelte +327 -0
  30. package/dist/components/Input/_internal/FieldDateShell.svelte.d.ts +66 -0
  31. package/dist/components/Input/index.css +119 -0
  32. package/dist/components/Input/index.d.ts +2 -0
  33. package/dist/components/Input/index.js +2 -0
  34. package/dist/components/ModalDialog/ModalDialog.fixture.svelte +1 -1
  35. package/dist/components/SlidingPanels/SlidingPanels.fixture.svelte +1 -1
  36. package/dist/icons/index.d.ts +1 -0
  37. package/dist/icons/index.js +1 -0
  38. package/dist/index.css +1 -0
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.js +1 -0
  41. package/docs/_archive/README.md +10 -0
  42. package/docs/{component-testing → _archive/component-testing}/00-overview-and-roadmap.md +8 -6
  43. package/docs/{component-testing → _archive/component-testing}/01-framework-setup.md +4 -2
  44. package/docs/{component-testing → _archive/component-testing}/03-component-coverage-roadmap.md +3 -1
  45. package/docs/{component-testing → _archive/component-testing}/04-hard-cases-and-e2e.md +5 -3
  46. package/docs/{component-testing → _archive/component-testing}/05-ci.md +2 -0
  47. package/docs/{component-testing → _archive/component-testing}/PROGRESS.md +3 -1
  48. package/docs/_archive/component-testing/README.md +28 -0
  49. package/docs/{upgrading.md → _archive/upgrading.md} +2 -0
  50. package/docs/architecture.md +19 -11
  51. package/docs/domains/actions.md +4 -3
  52. package/docs/domains/components.md +22 -19
  53. package/docs/domains/utils.md +2 -1
  54. package/docs/maybe-todo.md +6 -6
  55. package/docs/tasks.md +19 -9
  56. package/docs/{component-testing/02-test-conventions.md → testing-components.md} +30 -31
  57. package/docs/testing.md +3 -3
  58. package/package.json +2 -1
  59. package/docs/component-testing/README.md +0 -38
@@ -0,0 +1,307 @@
1
+ /* ============================================================================
2
+ CALENDAR COMPONENT TOKENS
3
+ Override globally: :root { --stuic-calendar-day-size: 3rem; }
4
+ Override locally: <Calendar style="--stuic-calendar-day-radius: 9999px;">
5
+ ============================================================================ */
6
+
7
+ /* prettier-ignore */
8
+ :root {
9
+ --stuic-calendar-bg: transparent;
10
+ --stuic-calendar-text: var(--stuic-color-foreground);
11
+ --stuic-calendar-font-family: var(--font-sans);
12
+
13
+ /* Layout */
14
+ --stuic-calendar-gap: 1.5rem; /* between months (`months > 1`) */
15
+ --stuic-calendar-header-gap: 0.25rem;
16
+ --stuic-calendar-header-margin-bottom: 0.5rem;
17
+ --stuic-calendar-footer-gap: 0.5rem;
18
+ --stuic-calendar-footer-margin-top: 0.5rem;
19
+
20
+ /* Day cell (min 44px on touch, see below) */
21
+ --stuic-calendar-day-size: 2.5rem;
22
+ --stuic-calendar-day-font-size: var(--text-sm);
23
+ --stuic-calendar-day-font-weight: var(--font-weight-normal);
24
+ --stuic-calendar-day-text: var(--stuic-color-foreground);
25
+ --stuic-calendar-day-bg-hover: var(--stuic-color-muted);
26
+ --stuic-calendar-day-text-outside: var(--stuic-color-muted-foreground);
27
+ --stuic-calendar-day-opacity-outside: 0.6;
28
+ --stuic-calendar-day-opacity-disabled: 0.35;
29
+
30
+ /* Selection */
31
+ --stuic-calendar-day-bg-selected: var(--stuic-color-primary);
32
+ --stuic-calendar-day-text-selected: var(--stuic-color-primary-foreground);
33
+ --stuic-calendar-day-bg-selected-hover: var(--stuic-color-primary-hover, var(--stuic-color-primary));
34
+ --stuic-calendar-day-bg-range: color-mix(in oklab, var(--stuic-color-primary) 15%, transparent);
35
+ --stuic-calendar-day-text-range: var(--stuic-color-foreground);
36
+ --stuic-calendar-day-bg-preview: color-mix(in oklab, var(--stuic-color-primary) 8%, transparent);
37
+
38
+ /* Today */
39
+ --stuic-calendar-today-text: var(--stuic-color-primary);
40
+ --stuic-calendar-today-font-weight: var(--font-weight-semibold);
41
+
42
+ /* Weekday header row + week-number column */
43
+ --stuic-calendar-weekday-text: var(--stuic-color-muted-foreground);
44
+ --stuic-calendar-weekday-font-size: var(--text-xs);
45
+ --stuic-calendar-weekday-font-weight: var(--font-weight-medium);
46
+ --stuic-calendar-week-number-text: var(--stuic-color-muted-foreground);
47
+ --stuic-calendar-week-number-font-size: var(--text-xs);
48
+
49
+ /* Caption (month + year) */
50
+ --stuic-calendar-caption-text: var(--stuic-color-foreground);
51
+ --stuic-calendar-caption-font-size: var(--text-base);
52
+ --stuic-calendar-caption-font-weight: var(--font-weight-semibold);
53
+
54
+ /* Caption dropdowns (`captionLayout="dropdown"`) */
55
+ --stuic-calendar-select-bg: var(--stuic-color-input, transparent);
56
+ --stuic-calendar-select-text: var(--stuic-color-foreground);
57
+ --stuic-calendar-select-border: var(--stuic-color-border);
58
+
59
+ /* Focus ring */
60
+ --stuic-calendar-ring-width: 2px;
61
+ --stuic-calendar-ring-color: var(--stuic-color-ring);
62
+ }
63
+
64
+ /* Touch: keep every day a comfortable 44px target */
65
+ @media (pointer: coarse) {
66
+ :root {
67
+ --stuic-calendar-day-size: 2.75rem;
68
+ }
69
+ }
70
+
71
+ @layer components {
72
+ /* ============================================================================
73
+ ROOT
74
+ ============================================================================ */
75
+
76
+ .stuic-calendar {
77
+ display: inline-flex;
78
+ flex-direction: column;
79
+ max-width: 100%;
80
+ background: var(--stuic-calendar-bg);
81
+ color: var(--stuic-calendar-text);
82
+ font-family: var(--stuic-calendar-font-family);
83
+ user-select: none;
84
+ -webkit-user-select: none;
85
+ }
86
+
87
+ .stuic-calendar[data-disabled] {
88
+ opacity: 0.6;
89
+ cursor: not-allowed;
90
+ }
91
+
92
+ .stuic-calendar-months {
93
+ display: flex;
94
+ flex-wrap: wrap;
95
+ gap: var(--stuic-calendar-gap);
96
+ max-width: 100%;
97
+ }
98
+
99
+ .stuic-calendar-month {
100
+ display: flex;
101
+ flex-direction: column;
102
+ max-width: 100%;
103
+ }
104
+
105
+ /* ============================================================================
106
+ HEADER (prev | caption | next)
107
+ ============================================================================ */
108
+
109
+ .stuic-calendar-header {
110
+ display: flex;
111
+ align-items: center;
112
+ justify-content: space-between;
113
+ gap: var(--stuic-calendar-header-gap);
114
+ margin-bottom: var(--stuic-calendar-header-margin-bottom);
115
+ }
116
+
117
+ .stuic-calendar-caption {
118
+ flex: 1;
119
+ min-width: 0;
120
+ display: flex;
121
+ align-items: center;
122
+ justify-content: center;
123
+ gap: 0.25rem;
124
+ text-align: center;
125
+ color: var(--stuic-calendar-caption-text);
126
+ font-size: var(--stuic-calendar-caption-font-size);
127
+ font-weight: var(--stuic-calendar-caption-font-weight);
128
+ line-height: 1.25;
129
+ }
130
+
131
+ /* keeps the caption centered on months without a prev/next button */
132
+ .stuic-calendar-nav-placeholder {
133
+ display: inline-block;
134
+ width: 2.75rem;
135
+ flex-shrink: 0;
136
+ }
137
+
138
+ .stuic-calendar-select {
139
+ appearance: auto;
140
+ font: inherit;
141
+ font-size: var(--text-sm);
142
+ font-weight: var(--stuic-calendar-caption-font-weight);
143
+ color: var(--stuic-calendar-select-text);
144
+ background: var(--stuic-calendar-select-bg);
145
+ border: var(--stuic-calendar-border-width, var(--stuic-border-width)) solid
146
+ var(--stuic-calendar-select-border);
147
+ border-radius: var(--stuic-calendar-select-radius, var(--stuic-radius));
148
+ padding: 0.25rem 0.5rem;
149
+ max-width: 100%;
150
+ min-width: 0;
151
+ cursor: pointer;
152
+ }
153
+
154
+ .stuic-calendar-select:focus-visible {
155
+ outline: var(--stuic-calendar-ring-width) solid var(--stuic-calendar-ring-color);
156
+ outline-offset: 1px;
157
+ }
158
+
159
+ /* ============================================================================
160
+ GRID
161
+ ============================================================================ */
162
+
163
+ .stuic-calendar-grid {
164
+ border-collapse: separate;
165
+ border-spacing: 0;
166
+ table-layout: fixed;
167
+ width: auto;
168
+ }
169
+
170
+ .stuic-calendar-weekday {
171
+ width: var(--stuic-calendar-day-size);
172
+ padding: 0 0 0.25rem;
173
+ text-align: center;
174
+ color: var(--stuic-calendar-weekday-text);
175
+ font-size: var(--stuic-calendar-weekday-font-size);
176
+ font-weight: var(--stuic-calendar-weekday-font-weight);
177
+ text-transform: none;
178
+ }
179
+
180
+ .stuic-calendar-week-number {
181
+ width: var(--stuic-calendar-day-size);
182
+ padding: 0 0.25rem 0 0;
183
+ text-align: center;
184
+ vertical-align: middle;
185
+ color: var(--stuic-calendar-week-number-text);
186
+ font-size: var(--stuic-calendar-week-number-font-size);
187
+ font-variant-numeric: tabular-nums;
188
+ }
189
+
190
+ .stuic-calendar-cell {
191
+ width: var(--stuic-calendar-day-size);
192
+ height: var(--stuic-calendar-day-size);
193
+ padding: 0;
194
+ text-align: center;
195
+ vertical-align: middle;
196
+ }
197
+
198
+ /* Range band: painted on the CELL so it runs edge to edge between days */
199
+ .stuic-calendar-cell[data-in-range] {
200
+ background: var(--stuic-calendar-day-bg-range);
201
+ }
202
+
203
+ .stuic-calendar-cell[data-preview]:not([data-in-range]) {
204
+ background: var(--stuic-calendar-day-bg-preview);
205
+ }
206
+
207
+ .stuic-calendar-cell[data-range-start] {
208
+ border-start-start-radius: var(--stuic-calendar-day-radius, var(--stuic-radius));
209
+ border-end-start-radius: var(--stuic-calendar-day-radius, var(--stuic-radius));
210
+ }
211
+
212
+ .stuic-calendar-cell[data-range-end] {
213
+ border-start-end-radius: var(--stuic-calendar-day-radius, var(--stuic-radius));
214
+ border-end-end-radius: var(--stuic-calendar-day-radius, var(--stuic-radius));
215
+ }
216
+
217
+ /* ============================================================================
218
+ DAY BUTTON
219
+ ============================================================================ */
220
+
221
+ .stuic-calendar-day {
222
+ position: relative;
223
+ display: inline-flex;
224
+ align-items: center;
225
+ justify-content: center;
226
+ width: 100%;
227
+ height: var(--stuic-calendar-day-size);
228
+ margin: 0;
229
+ padding: 0;
230
+ border: 0;
231
+ background: transparent;
232
+ color: var(--stuic-calendar-day-text);
233
+ font: inherit;
234
+ font-size: var(--stuic-calendar-day-font-size);
235
+ font-weight: var(--stuic-calendar-day-font-weight);
236
+ font-variant-numeric: tabular-nums;
237
+ line-height: 1;
238
+ border-radius: var(--stuic-calendar-day-radius, var(--stuic-radius));
239
+ cursor: pointer;
240
+ -webkit-tap-highlight-color: transparent;
241
+ transition:
242
+ background var(--stuic-calendar-transition, var(--stuic-transition)),
243
+ color var(--stuic-calendar-transition, var(--stuic-transition));
244
+ }
245
+
246
+ .stuic-calendar-day:hover:not([aria-disabled="true"]):not([data-selected]) {
247
+ background: var(--stuic-calendar-day-bg-hover);
248
+ }
249
+
250
+ .stuic-calendar-day:focus-visible {
251
+ outline: var(--stuic-calendar-ring-width) solid var(--stuic-calendar-ring-color);
252
+ outline-offset: -1px;
253
+ z-index: 1;
254
+ }
255
+
256
+ .stuic-calendar-day[data-outside] {
257
+ color: var(--stuic-calendar-day-text-outside);
258
+ opacity: var(--stuic-calendar-day-opacity-outside);
259
+ }
260
+
261
+ .stuic-calendar-day[data-weekend] {
262
+ color: var(--stuic-calendar-day-text-weekend, var(--stuic-calendar-day-text));
263
+ }
264
+
265
+ .stuic-calendar-day[data-today] {
266
+ color: var(--stuic-calendar-today-text);
267
+ font-weight: var(--stuic-calendar-today-font-weight);
268
+ }
269
+
270
+ .stuic-calendar-day[data-in-range] {
271
+ color: var(--stuic-calendar-day-text-range);
272
+ }
273
+
274
+ .stuic-calendar-day[data-selected] {
275
+ background: var(--stuic-calendar-day-bg-selected);
276
+ color: var(--stuic-calendar-day-text-selected);
277
+ opacity: 1;
278
+ }
279
+
280
+ .stuic-calendar-day[data-selected]:hover:not([aria-disabled="true"]) {
281
+ background: var(--stuic-calendar-day-bg-selected-hover);
282
+ }
283
+
284
+ .stuic-calendar-day[aria-disabled="true"] {
285
+ opacity: var(--stuic-calendar-day-opacity-disabled);
286
+ cursor: not-allowed;
287
+ }
288
+
289
+ /* ============================================================================
290
+ FOOTER
291
+ ============================================================================ */
292
+
293
+ .stuic-calendar-footer {
294
+ display: flex;
295
+ align-items: center;
296
+ justify-content: space-between;
297
+ flex-wrap: wrap;
298
+ gap: var(--stuic-calendar-footer-gap);
299
+ margin-top: var(--stuic-calendar-footer-margin-top);
300
+ }
301
+
302
+ .stuic-calendar-footer-actions {
303
+ display: flex;
304
+ align-items: center;
305
+ gap: var(--stuic-calendar-footer-gap);
306
+ }
307
+ }
@@ -0,0 +1,5 @@
1
+ export { default as Calendar, type Props as CalendarProps, type CalendarMode, type CalendarCaptionLayout, type CalendarDayState, type CalendarRangeChange, } from "./Calendar.svelte";
2
+ export { createCalendarT, CALENDAR_MESSAGES_EN, type CalendarMessageKey, type CalendarMessages, } from "./calendar-i18n.js";
3
+ export { CALENDAR_MESSAGES_SK } from "./calendar-i18n-sk.js";
4
+ export { type IsoDate, type IsoDateParts, type YearMonth, isIsoDate, parseIsoDate, normalizeIsoDate, toIsoDate, todayIso, addDaysIso, addMonthsIso, compareIso, daysBetweenIso, rangeLengthIso, formatIsoDate, formatIsoDateRange, DEFAULT_DATE_FORMAT, } from "./iso-date.js";
5
+ export type { DayCell as CalendarDayCell, Weekday as CalendarWeekday, } from "@marianmeres/calendar-utils";
@@ -0,0 +1,5 @@
1
+ export { default as Calendar, } from "./Calendar.svelte";
2
+ export { createCalendarT, CALENDAR_MESSAGES_EN, } from "./calendar-i18n.js";
3
+ // Opt-in locale: only bundled when imported (the components never reference it).
4
+ export { CALENDAR_MESSAGES_SK } from "./calendar-i18n-sk.js";
5
+ export { isIsoDate, parseIsoDate, normalizeIsoDate, toIsoDate, todayIso, addDaysIso, addMonthsIso, compareIso, daysBetweenIso, rangeLengthIso, formatIsoDate, formatIsoDateRange, DEFAULT_DATE_FORMAT, } from "./iso-date.js";
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Pure `YYYY-MM-DD` helpers shared by `Calendar`, `FieldDate` and `FieldDateRange`.
3
+ *
4
+ * Every date value these components bind, emit and submit is a calendar date in
5
+ * ISO `YYYY-MM-DD` form — zone-independent, lexicographically sortable, and what
6
+ * `<input type="date">` and most APIs already speak. The day arithmetic goes
7
+ * through `@marianmeres/calendar-utils` (DST-safe, any year); the formatting
8
+ * through `Intl`.
9
+ */
10
+ import { type DayCell, type GridConfig, type Weekday } from "@marianmeres/calendar-utils";
11
+ /** A calendar date as `YYYY-MM-DD` — the value type of every date component in stuic. */
12
+ export type IsoDate = string;
13
+ /** A `{ year, month }` pair, month 1–12 (the `view` of a `Calendar`). */
14
+ export interface YearMonth {
15
+ year: number;
16
+ month: number;
17
+ }
18
+ /** The three calendar fields of an `IsoDate`. */
19
+ export interface IsoDateParts {
20
+ year: number;
21
+ month: number;
22
+ day: number;
23
+ }
24
+ /** Number of days in `month` (1–12) of `year`, proleptic Gregorian. */
25
+ export declare function daysInMonth(year: number, month: number): number;
26
+ /** Zero-padded `YYYY-MM-DD` from its parts. Does not validate. */
27
+ export declare function toIsoDate(year: number, month: number, day: number): IsoDate;
28
+ /**
29
+ * Parses a strict `YYYY-MM-DD` string into its parts. Returns `null` for anything
30
+ * else, including well-formed but impossible dates (`2026-02-30`).
31
+ */
32
+ export declare function parseIsoDate(iso: unknown): IsoDateParts | null;
33
+ /** `true` for a strict, valid `YYYY-MM-DD`. */
34
+ export declare function isIsoDate(v: unknown): v is IsoDate;
35
+ /**
36
+ * Coerces the loose inputs a consumer may hand to a date field into a strict
37
+ * `IsoDate`, or `null` when that is not possible:
38
+ *
39
+ * - `"2026-09-02"` → as is
40
+ * - `"2026-09-02T10:00:00Z"` / `"2026-09-02 10:00"` → the date portion **as written**
41
+ * (never shifted through a zone — a date field has no time-of-day to shift)
42
+ * - `Date` → its **local** calendar date
43
+ * - Luxon `DateTime` → its calendar date in its own zone
44
+ * - `null` / `undefined` / `""` / anything unparseable → `null`
45
+ */
46
+ export declare function normalizeIsoDate(v: unknown): IsoDate | null;
47
+ /** Today's calendar date in `zone` (IANA, default local). */
48
+ export declare function todayIso(zone?: string): IsoDate;
49
+ /** `iso + days` calendar days (negative allowed). DST-immune. */
50
+ export declare function addDaysIso(iso: IsoDate, days: number): IsoDate;
51
+ /** `iso + months`; the day of month is clamped (Jan 31 + 1 → Feb 28/29). */
52
+ export declare function addMonthsIso(iso: IsoDate, months: number): IsoDate;
53
+ /** Sort comparator for `IsoDate`s (`-1`, `0`, `1`). */
54
+ export declare function compareIso(a: IsoDate, b: IsoDate): number;
55
+ /** Whole calendar days from `a` to `b` (positive when `b` is later). */
56
+ export declare function daysBetweenIso(a: IsoDate, b: IsoDate): number;
57
+ /** The two dates in chronological order. */
58
+ export declare function orderedRange(a: IsoDate, b: IsoDate): [IsoDate, IsoDate];
59
+ /** Inclusive day count of a range (`1` for a single-day range). */
60
+ export declare function rangeLengthIso(start: IsoDate, end: IsoDate): number;
61
+ /** Inclusive `start <= iso <= end`. */
62
+ export declare function isWithinRange(iso: IsoDate, start: IsoDate, end: IsoDate): boolean;
63
+ /** `min <= iso <= max`, each bound optional. */
64
+ export declare function isWithinBounds(iso: IsoDate, min?: IsoDate | null, max?: IsoDate | null): boolean;
65
+ /** `iso` clamped into `[min, max]` (each bound optional). */
66
+ export declare function clampIso(iso: IsoDate, min?: IsoDate | null, max?: IsoDate | null): IsoDate;
67
+ /** The `{ year, month }` an `IsoDate` falls in. */
68
+ export declare function yearMonthOf(iso: IsoDate): YearMonth;
69
+ /** Sort comparator for `YearMonth`s. */
70
+ export declare function compareYearMonth(a: YearMonth, b: YearMonth): number;
71
+ /** `ym + months` (negative allowed, crosses years). */
72
+ export declare function addMonths(ym: YearMonth, months: number): YearMonth;
73
+ /** `YYYY-MM-01` of a `YearMonth`. */
74
+ export declare function firstOfMonth(ym: YearMonth): IsoDate;
75
+ /** Stable string key of a `YearMonth` (`"2026-09"`). */
76
+ export declare function yearMonthKey(ym: YearMonth): string;
77
+ /** Column index (0–6) of `iso` in a week that starts on `weekStartsOn`. */
78
+ export declare function weekdayColumn(iso: IsoDate, weekStartsOn?: Weekday): number;
79
+ /**
80
+ * The month grid for a `YearMonth` — `createMonthGrid` from calendar-utils (5 or 6
81
+ * weeks), optionally padded to a fixed 6 weeks so the UI height never jumps while
82
+ * navigating.
83
+ */
84
+ export declare function buildMonthGrid(ym: YearMonth, config?: GridConfig, fixedWeeks?: boolean): DayCell[][];
85
+ /** Default `Intl` options of the date fields' display text. */
86
+ export declare const DEFAULT_DATE_FORMAT: Intl.DateTimeFormatOptions;
87
+ /**
88
+ * Formats an `IsoDate` for display with `Intl.DateTimeFormat` (medium date style by
89
+ * default). Invalid input renders as `""`.
90
+ */
91
+ export declare function formatIsoDate(iso: IsoDate | null | undefined, locale?: string, options?: Intl.DateTimeFormatOptions): string;
92
+ /**
93
+ * Formats an inclusive date range — via `Intl.DateTimeFormat#formatRange` where the
94
+ * runtime has it ("Sep 1 – 5, 2026"), else the two dates joined by `separator`.
95
+ */
96
+ export declare function formatIsoDateRange(start: IsoDate, end: IsoDate, locale?: string, options?: Intl.DateTimeFormatOptions, separator?: string): string;
97
+ /** "September 2026" (localized) for a `YearMonth`. */
98
+ export declare function formatYearMonth(ym: YearMonth, locale?: string, options?: Intl.DateTimeFormatOptions): string;
99
+ /** The 12 localized month names, January first. */
100
+ export declare function getMonthNames(locale?: string, format?: "long" | "short" | "narrow"): string[];
101
+ /** Full spoken form of a date ("Wednesday, September 2, 2026") — for `aria-label`s. */
102
+ export declare function formatIsoDateLong(iso: IsoDate, locale?: string): string;
103
+ /**
104
+ * A calendar-utils `DayCell` for a single date — what `isDateDisabled` receives
105
+ * when the date fields validate a value outside of any rendered grid.
106
+ */
107
+ export declare function dayCellOf(iso: IsoDate, config?: GridConfig): DayCell;
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Pure `YYYY-MM-DD` helpers shared by `Calendar`, `FieldDate` and `FieldDateRange`.
3
+ *
4
+ * Every date value these components bind, emit and submit is a calendar date in
5
+ * ISO `YYYY-MM-DD` form — zone-independent, lexicographically sortable, and what
6
+ * `<input type="date">` and most APIs already speak. The day arithmetic goes
7
+ * through `@marianmeres/calendar-utils` (DST-safe, any year); the formatting
8
+ * through `Intl`.
9
+ */
10
+ import { DateTime, addDays as addDaysDt, calendarDaysBetween, createDayCell, createMonthGrid, getAdjustedWeekday, } from "@marianmeres/calendar-utils";
11
+ const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
12
+ // `YYYY-MM-DD` optionally followed by a time part (`T…` / ` …`) — the date portion
13
+ // AS WRITTEN is used (iCalendar `VALUE=DATE` semantics, same as calendar-utils
14
+ // all-day events), never converted through a zone.
15
+ const ISO_DATE_PREFIX_RE = /^(\d{4})-(\d{2})-(\d{2})(?:[T ]|$)/;
16
+ /** Number of days in `month` (1–12) of `year`, proleptic Gregorian. */
17
+ export function daysInMonth(year, month) {
18
+ const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
19
+ return [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1] ?? 0;
20
+ }
21
+ /** Zero-padded `YYYY-MM-DD` from its parts. Does not validate. */
22
+ export function toIsoDate(year, month, day) {
23
+ const y = `${Math.abs(year)}`.padStart(4, "0");
24
+ return `${year < 0 ? "-" : ""}${y}-${`${month}`.padStart(2, "0")}-${`${day}`.padStart(2, "0")}`;
25
+ }
26
+ /**
27
+ * Parses a strict `YYYY-MM-DD` string into its parts. Returns `null` for anything
28
+ * else, including well-formed but impossible dates (`2026-02-30`).
29
+ */
30
+ export function parseIsoDate(iso) {
31
+ if (typeof iso !== "string")
32
+ return null;
33
+ const m = ISO_DATE_RE.exec(iso);
34
+ if (!m)
35
+ return null;
36
+ const year = Number(m[1]);
37
+ const month = Number(m[2]);
38
+ const day = Number(m[3]);
39
+ if (month < 1 || month > 12)
40
+ return null;
41
+ if (day < 1 || day > daysInMonth(year, month))
42
+ return null;
43
+ return { year, month, day };
44
+ }
45
+ /** `true` for a strict, valid `YYYY-MM-DD`. */
46
+ export function isIsoDate(v) {
47
+ return parseIsoDate(v) !== null;
48
+ }
49
+ /**
50
+ * Coerces the loose inputs a consumer may hand to a date field into a strict
51
+ * `IsoDate`, or `null` when that is not possible:
52
+ *
53
+ * - `"2026-09-02"` → as is
54
+ * - `"2026-09-02T10:00:00Z"` / `"2026-09-02 10:00"` → the date portion **as written**
55
+ * (never shifted through a zone — a date field has no time-of-day to shift)
56
+ * - `Date` → its **local** calendar date
57
+ * - Luxon `DateTime` → its calendar date in its own zone
58
+ * - `null` / `undefined` / `""` / anything unparseable → `null`
59
+ */
60
+ export function normalizeIsoDate(v) {
61
+ if (v === null || v === undefined || v === "")
62
+ return null;
63
+ if (v instanceof Date) {
64
+ return Number.isNaN(v.getTime())
65
+ ? null
66
+ : toIsoDate(v.getFullYear(), v.getMonth() + 1, v.getDate());
67
+ }
68
+ if (DateTime.isDateTime(v))
69
+ return v.isValid ? v.toISODate() : null;
70
+ if (typeof v !== "string")
71
+ return null;
72
+ const m = ISO_DATE_PREFIX_RE.exec(v.trim());
73
+ if (!m)
74
+ return null;
75
+ const iso = `${m[1]}-${m[2]}-${m[3]}`;
76
+ return isIsoDate(iso) ? iso : null;
77
+ }
78
+ /** Today's calendar date in `zone` (IANA, default local). */
79
+ export function todayIso(zone = "local") {
80
+ const now = DateTime.now().setZone(zone);
81
+ return (now.isValid ? now : DateTime.now()).toISODate();
82
+ }
83
+ // Internal: a DateTime for calendar arithmetic. UTC on purpose — the date has no
84
+ // zone, and UTC has no DST gaps to trip over.
85
+ const dt = (iso) => DateTime.fromISO(iso, { zone: "utc" });
86
+ /** `iso + days` calendar days (negative allowed). DST-immune. */
87
+ export function addDaysIso(iso, days) {
88
+ return addDaysDt(dt(iso), days).toISODate();
89
+ }
90
+ /** `iso + months`; the day of month is clamped (Jan 31 + 1 → Feb 28/29). */
91
+ export function addMonthsIso(iso, months) {
92
+ return dt(iso).plus({ months }).toISODate();
93
+ }
94
+ /** Sort comparator for `IsoDate`s (`-1`, `0`, `1`). */
95
+ export function compareIso(a, b) {
96
+ return a < b ? -1 : a > b ? 1 : 0;
97
+ }
98
+ /** Whole calendar days from `a` to `b` (positive when `b` is later). */
99
+ export function daysBetweenIso(a, b) {
100
+ return calendarDaysBetween(dt(a), dt(b));
101
+ }
102
+ /** The two dates in chronological order. */
103
+ export function orderedRange(a, b) {
104
+ return compareIso(a, b) <= 0 ? [a, b] : [b, a];
105
+ }
106
+ /** Inclusive day count of a range (`1` for a single-day range). */
107
+ export function rangeLengthIso(start, end) {
108
+ return Math.abs(daysBetweenIso(start, end)) + 1;
109
+ }
110
+ /** Inclusive `start <= iso <= end`. */
111
+ export function isWithinRange(iso, start, end) {
112
+ return compareIso(iso, start) >= 0 && compareIso(iso, end) <= 0;
113
+ }
114
+ /** `min <= iso <= max`, each bound optional. */
115
+ export function isWithinBounds(iso, min, max) {
116
+ if (min && compareIso(iso, min) < 0)
117
+ return false;
118
+ if (max && compareIso(iso, max) > 0)
119
+ return false;
120
+ return true;
121
+ }
122
+ /** `iso` clamped into `[min, max]` (each bound optional). */
123
+ export function clampIso(iso, min, max) {
124
+ if (min && compareIso(iso, min) < 0)
125
+ return min;
126
+ if (max && compareIso(iso, max) > 0)
127
+ return max;
128
+ return iso;
129
+ }
130
+ /** The `{ year, month }` an `IsoDate` falls in. */
131
+ export function yearMonthOf(iso) {
132
+ const p = parseIsoDate(iso);
133
+ if (!p)
134
+ throw new Error(`yearMonthOf: invalid ISO date: ${iso}`);
135
+ return { year: p.year, month: p.month };
136
+ }
137
+ /** Sort comparator for `YearMonth`s. */
138
+ export function compareYearMonth(a, b) {
139
+ return a.year - b.year || a.month - b.month;
140
+ }
141
+ /** `ym + months` (negative allowed, crosses years). */
142
+ export function addMonths(ym, months) {
143
+ const idx = ym.year * 12 + (ym.month - 1) + months;
144
+ return { year: Math.floor(idx / 12), month: (((idx % 12) + 12) % 12) + 1 };
145
+ }
146
+ /** `YYYY-MM-01` of a `YearMonth`. */
147
+ export function firstOfMonth(ym) {
148
+ return toIsoDate(ym.year, ym.month, 1);
149
+ }
150
+ /** Stable string key of a `YearMonth` (`"2026-09"`). */
151
+ export function yearMonthKey(ym) {
152
+ return `${ym.year}-${`${ym.month}`.padStart(2, "0")}`;
153
+ }
154
+ /** Column index (0–6) of `iso` in a week that starts on `weekStartsOn`. */
155
+ export function weekdayColumn(iso, weekStartsOn = 1) {
156
+ return getAdjustedWeekday(dt(iso).weekday, weekStartsOn);
157
+ }
158
+ /**
159
+ * The month grid for a `YearMonth` — `createMonthGrid` from calendar-utils (5 or 6
160
+ * weeks), optionally padded to a fixed 6 weeks so the UI height never jumps while
161
+ * navigating.
162
+ */
163
+ export function buildMonthGrid(ym, config = {}, fixedWeeks = false) {
164
+ const grid = createMonthGrid(ym.year, ym.month, config);
165
+ if (!fixedWeeks)
166
+ return grid;
167
+ const today = DateTime.now().setZone(config.zone ?? "local");
168
+ let last = grid[grid.length - 1][6].date;
169
+ while (grid.length < 6) {
170
+ const week = [];
171
+ for (let i = 0; i < 7; i++) {
172
+ last = addDaysDt(last, 1);
173
+ week.push(createDayCell(last, ym.month, today, config.weekendDays));
174
+ }
175
+ grid.push(week);
176
+ }
177
+ return grid;
178
+ }
179
+ // ------------------------------------------------------------------ formatting
180
+ // A local `Date` at noon on the given calendar date. Noon keeps a formatter from
181
+ // ever landing on the previous day around a DST switch, and `setFullYear` avoids
182
+ // the `new Date(y, …)` two-digit-year quirk.
183
+ function toLocalNoon(p) {
184
+ const d = new Date(2000, 0, 1, 12, 0, 0, 0);
185
+ d.setFullYear(p.year, p.month - 1, p.day);
186
+ return d;
187
+ }
188
+ /** Default `Intl` options of the date fields' display text. */
189
+ export const DEFAULT_DATE_FORMAT = { dateStyle: "medium" };
190
+ /**
191
+ * Formats an `IsoDate` for display with `Intl.DateTimeFormat` (medium date style by
192
+ * default). Invalid input renders as `""`.
193
+ */
194
+ export function formatIsoDate(iso, locale, options = DEFAULT_DATE_FORMAT) {
195
+ const p = iso ? parseIsoDate(iso) : null;
196
+ if (!p)
197
+ return "";
198
+ return new Intl.DateTimeFormat(locale, options).format(toLocalNoon(p));
199
+ }
200
+ /**
201
+ * Formats an inclusive date range — via `Intl.DateTimeFormat#formatRange` where the
202
+ * runtime has it ("Sep 1 – 5, 2026"), else the two dates joined by `separator`.
203
+ */
204
+ export function formatIsoDateRange(start, end, locale, options = DEFAULT_DATE_FORMAT, separator = " – ") {
205
+ const a = parseIsoDate(start);
206
+ const b = parseIsoDate(end);
207
+ if (!a || !b)
208
+ return "";
209
+ const f = new Intl.DateTimeFormat(locale, options);
210
+ const [da, db] = [toLocalNoon(a), toLocalNoon(b)];
211
+ if (typeof f.formatRange === "function") {
212
+ try {
213
+ return f.formatRange(da, db);
214
+ }
215
+ catch {
216
+ // fall through to the plain join
217
+ }
218
+ }
219
+ return `${f.format(da)}${separator}${f.format(db)}`;
220
+ }
221
+ /** "September 2026" (localized) for a `YearMonth`. */
222
+ export function formatYearMonth(ym, locale, options = { month: "long", year: "numeric" }) {
223
+ return new Intl.DateTimeFormat(locale, options).format(toLocalNoon({ year: ym.year, month: ym.month, day: 1 }));
224
+ }
225
+ /** The 12 localized month names, January first. */
226
+ export function getMonthNames(locale, format = "long") {
227
+ const f = new Intl.DateTimeFormat(locale, { month: format });
228
+ return Array.from({ length: 12 }, (_, i) => f.format(toLocalNoon({ year: 2024, month: i + 1, day: 1 })));
229
+ }
230
+ /** Full spoken form of a date ("Wednesday, September 2, 2026") — for `aria-label`s. */
231
+ export function formatIsoDateLong(iso, locale) {
232
+ return formatIsoDate(iso, locale, {
233
+ weekday: "long",
234
+ year: "numeric",
235
+ month: "long",
236
+ day: "numeric",
237
+ });
238
+ }
239
+ /**
240
+ * A calendar-utils `DayCell` for a single date — what `isDateDisabled` receives
241
+ * when the date fields validate a value outside of any rendered grid.
242
+ */
243
+ export function dayCellOf(iso, config = {}) {
244
+ const zone = config.zone ?? "local";
245
+ const day = DateTime.fromISO(iso, { zone });
246
+ return createDayCell(day, null, DateTime.now().setZone(zone), config.weekendDays);
247
+ }
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- // Conventions escape hatch (docs/component-testing/02-test-conventions.md):
2
+ // Conventions escape hatch (docs/testing-components.md):
3
3
  // CommandMenu is imperative-only (open()/close() via a ref; it builds on
4
4
  // ModalDialog which has no bindable `visible` prop), so a `.svelte.test.ts`
5
5
  // file can't drive it directly. This fixture holds the `bind:this` ref and