@redseed/redseed-ui-vue3 8.58.0 → 8.60.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": "@redseed/redseed-ui-vue3",
3
- "version": "8.58.0",
3
+ "version": "8.60.0",
4
4
  "description": "RedSeed UI Vue 3 components",
5
5
  "main": "index.js",
6
6
  "repository": "https://github.com/redseedtraining/redseed-ui",
@@ -0,0 +1,168 @@
1
+ <script setup>
2
+ import { computed } from 'vue'
3
+ import { buildMonthGrid, formatIsoDate, formatMonthCaption } from './dateRange.js'
4
+
5
+ const props = defineProps({
6
+ year: {
7
+ type: Number,
8
+ required: true,
9
+ },
10
+ monthIndex: {
11
+ type: Number,
12
+ required: true,
13
+ },
14
+ locale: {
15
+ type: String,
16
+ default: undefined,
17
+ },
18
+ weekdays: {
19
+ type: Array,
20
+ default: () => [],
21
+ },
22
+ // The bounds to paint. While a range is half-picked the parent passes the
23
+ // preview (picked bound + hovered day) rather than the committed value, so
24
+ // this component never needs to know a selection is in progress.
25
+ start: {
26
+ type: String,
27
+ default: null,
28
+ },
29
+ end: {
30
+ type: String,
31
+ default: null,
32
+ },
33
+ focusedDate: {
34
+ type: String,
35
+ default: null,
36
+ },
37
+ minDate: {
38
+ type: String,
39
+ required: true,
40
+ },
41
+ maxDate: {
42
+ type: String,
43
+ required: true,
44
+ },
45
+ referenceDate: {
46
+ type: String,
47
+ default: null,
48
+ },
49
+ })
50
+
51
+ const emit = defineEmits(['select', 'hover'])
52
+
53
+ const weeks = computed(() => buildMonthGrid(props.year, props.monthIndex))
54
+
55
+ const caption = computed(() => formatMonthCaption(props.year, props.monthIndex, props.locale))
56
+
57
+ // A plain `>`/`<` is exact for Y-m-d — fixed-width and zero-padded, so they
58
+ // sort lexicographically in date order.
59
+ function isUnavailable(iso) {
60
+ return iso < props.minDate || iso > props.maxDate
61
+ }
62
+
63
+ function isRangeStart(iso) {
64
+ return props.start !== null && iso === props.start
65
+ }
66
+
67
+ function isRangeEnd(iso) {
68
+ return props.end !== null && iso === props.end
69
+ }
70
+
71
+ function isInRange(iso) {
72
+ if (! props.start || ! props.end) {
73
+ return false
74
+ }
75
+
76
+ return iso > props.start && iso < props.end
77
+ }
78
+
79
+ /**
80
+ * Only the two ends of the window are `aria-selected`.
81
+ *
82
+ * Marking the interior too makes a 90-day range announce ninety selected
83
+ * cells, which tells a screen-reader user nothing about where the window
84
+ * begins or ends. The interior is carried by styling, as the APG date-picker
85
+ * pattern does.
86
+ */
87
+ function isEndpoint(iso) {
88
+ return isRangeStart(iso) || isRangeEnd(iso)
89
+ }
90
+
91
+ function dayClass(iso) {
92
+ return [
93
+ 'rsui-form-field-date-range__day',
94
+ {
95
+ 'rsui-form-field-date-range__day--range-start': isRangeStart(iso),
96
+ 'rsui-form-field-date-range__day--range-end': isRangeEnd(iso),
97
+ 'rsui-form-field-date-range__day--in-range': isInRange(iso),
98
+ 'rsui-form-field-date-range__day--reference': iso === props.referenceDate,
99
+ 'rsui-form-field-date-range__day--unavailable': isUnavailable(iso),
100
+ },
101
+ ]
102
+ }
103
+
104
+ // Out-of-bounds days stay focusable and keep their place in the grid — a day
105
+ // you can arrow onto and be told is unavailable beats one that silently is not
106
+ // there — so they carry aria-disabled and refuse the click here instead.
107
+ function select(iso) {
108
+ if (isUnavailable(iso)) {
109
+ return
110
+ }
111
+
112
+ emit('select', iso)
113
+ }
114
+ </script>
115
+ <template>
116
+ <div class="rsui-form-field-date-range__month">
117
+ <div class="rsui-form-field-date-range__caption" aria-hidden="true">
118
+ {{ caption }}
119
+ </div>
120
+
121
+ <table
122
+ class="rsui-form-field-date-range__table"
123
+ role="grid"
124
+ :aria-label="caption"
125
+ @mouseleave="emit('hover', null)"
126
+ >
127
+ <thead>
128
+ <tr>
129
+ <th v-for="weekday in weekdays"
130
+ :key="weekday.long"
131
+ class="rsui-form-field-date-range__weekday"
132
+ scope="col"
133
+ >
134
+ <span aria-hidden="true">{{ weekday.short }}</span>
135
+ <span class="rsui-form-field-date-range__sr-only">{{ weekday.long }}</span>
136
+ </th>
137
+ </tr>
138
+ </thead>
139
+ <tbody>
140
+ <tr v-for="(week, weekIndex) in weeks"
141
+ :key="weekIndex"
142
+ class="rsui-form-field-date-range__week"
143
+ >
144
+ <td v-for="(cell, cellIndex) in week"
145
+ :key="cellIndex"
146
+ class="rsui-form-field-date-range__cell"
147
+ role="gridcell"
148
+ :aria-selected="cell ? String(isEndpoint(cell.iso)) : undefined"
149
+ >
150
+ <button v-if="cell"
151
+ type="button"
152
+ :class="dayClass(cell.iso)"
153
+ :data-date="cell.iso"
154
+ :tabindex="cell.iso === focusedDate ? 0 : -1"
155
+ :aria-disabled="isUnavailable(cell.iso) ? 'true' : undefined"
156
+ :aria-current="cell.iso === referenceDate ? 'date' : undefined"
157
+ :aria-label="formatIsoDate(cell.iso, locale, { dateStyle: 'long' })"
158
+ @click="select(cell.iso)"
159
+ @mouseenter="emit('hover', cell.iso)"
160
+ >
161
+ {{ cell.day }}
162
+ </button>
163
+ </td>
164
+ </tr>
165
+ </tbody>
166
+ </table>
167
+ </div>
168
+ </template>
@@ -0,0 +1,105 @@
1
+ <script setup>
2
+ import { computed } from 'vue'
3
+ import { dateRangeValidationReason, isIsoDate, resolveDateRangePreset } from './dateRange.js'
4
+
5
+ const props = defineProps({
6
+ // `{ name, label }` for a window RSUI owns the arithmetic for, or
7
+ // `{ label, resolve: (referenceDate) => ({ start, end }) }` as an escape
8
+ // hatch for a genuinely bespoke one. The escape hatch is the exception:
9
+ // anything named here is resolved by RSUI so that every consumer gets the
10
+ // same window — and the same clamp fix — without writing the maths again.
11
+ presets: {
12
+ type: Array,
13
+ default: () => [],
14
+ },
15
+ referenceDate: {
16
+ type: String,
17
+ default: null,
18
+ },
19
+ // The committed range, so the rail can mark the preset it represents.
20
+ range: {
21
+ type: Object,
22
+ default: () => ({ start: null, end: null }),
23
+ },
24
+ minDate: {
25
+ type: String,
26
+ required: true,
27
+ },
28
+ maxDate: {
29
+ type: String,
30
+ required: true,
31
+ },
32
+ })
33
+
34
+ const emit = defineEmits(['select'])
35
+
36
+ /**
37
+ * Run a consumer's `resolve()` and check what it handed back.
38
+ *
39
+ * The escape hatch takes arbitrary consumer code, so a wrong return shape — a
40
+ * string, a `Date` pair, an object missing a bound — otherwise surfaces only as
41
+ * a button that is disabled forever, with nothing said about why. That is the
42
+ * hardest kind of bug to place, because the rail looks like it is working.
43
+ */
44
+ function resolveEscapeHatch(preset) {
45
+ const resolved = preset.resolve(props.referenceDate)
46
+ const isUsable = Boolean(resolved)
47
+ && isIsoDate(resolved.start)
48
+ && isIsoDate(resolved.end)
49
+
50
+ if (! isUsable) {
51
+ console.warn(
52
+ `[FormFieldDateRange] the resolve() for preset "${preset.label}" returned `
53
+ + `${JSON.stringify(resolved)}, which is not { start, end } of Y-m-d strings. `
54
+ + 'Its button stays disabled. Note the bounds must be Y-m-d strings, not Date objects.'
55
+ )
56
+
57
+ return null
58
+ }
59
+
60
+ return resolved
61
+ }
62
+
63
+ // Resolve, validate and match each preset in one pass — the rail needs all
64
+ // three for every entry, and resolving once keeps them in agreement.
65
+ const items = computed(() => props.presets.map((preset, index) => {
66
+ const range = typeof preset.resolve === 'function'
67
+ ? resolveEscapeHatch(preset)
68
+ : resolveDateRangePreset(preset.name, props.referenceDate)
69
+
70
+ // A preset whose window falls outside the bounds is disabled rather than
71
+ // clamped: a shortened window under an unchanged label would claim to cover
72
+ // rows it never asked for.
73
+ const isDisabled = ! range
74
+ || dateRangeValidationReason(range, props.minDate, props.maxDate) !== null
75
+
76
+ return {
77
+ key: preset.name ?? `preset-${index}`,
78
+ label: preset.label,
79
+ range,
80
+ isDisabled,
81
+ // No `custom` sentinel: a window no preset describes simply selects
82
+ // nothing, because picking dates directly IS custom.
83
+ isSelected: Boolean(range)
84
+ && range.start === props.range?.start
85
+ && range.end === props.range?.end,
86
+ }
87
+ }))
88
+ </script>
89
+ <template>
90
+ <div class="rsui-form-field-date-range__presets" role="group">
91
+ <button v-for="item in items"
92
+ :key="item.key"
93
+ type="button"
94
+ :class="[
95
+ 'rsui-form-field-date-range__preset',
96
+ { 'rsui-form-field-date-range__preset--selected': item.isSelected },
97
+ ]"
98
+ :aria-pressed="item.isSelected ? 'true' : 'false'"
99
+ :disabled="item.isDisabled"
100
+ @click="emit('select', item.range)"
101
+ >
102
+ {{ item.label }}
103
+ </button>
104
+ </div>
105
+ </template>
@@ -0,0 +1,394 @@
1
+ /**
2
+ * Date arithmetic for FormFieldDateRange.
3
+ *
4
+ * Ported from the LMS Meeting Report's `meetingReportDateRange.js`, which is
5
+ * where this logic was written correctly the first time. It lives in RSUI now
6
+ * because every consumer that needs a named window was otherwise writing it
7
+ * again — and the second copy did not carry the clamp fix below.
8
+ *
9
+ * Two rules hold everywhere in this module:
10
+ *
11
+ * 1. Every bound resolves against a caller-supplied `referenceDate` (a `Y-m-d`
12
+ * string), NEVER a browser clock. The app timezone is Pacific/Auckland, so
13
+ * a viewer elsewhere resolving "this month" locally would get a different
14
+ * window than the report they are reading. Nothing here calls `new Date()`
15
+ * without arguments.
16
+ *
17
+ * 2. All arithmetic runs in UTC, and values move as `Y-m-d` strings rather
18
+ * than `Date` objects. ISO dates are fixed-width and zero-padded, so they
19
+ * compare with a plain `>` in date order — no parsing, and therefore no
20
+ * timezone to get wrong.
21
+ */
22
+
23
+ const ISO_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/
24
+
25
+ export function toIsoDate(year, monthIndex, day) {
26
+ return new Date(Date.UTC(year, monthIndex, day)).toISOString().slice(0, 10)
27
+ }
28
+
29
+ /**
30
+ * A `Y-m-d` string that names a day that actually exists.
31
+ *
32
+ * The shape test alone is not enough: `2026-02-30` matches the pattern, and
33
+ * every consumer downstream then trusts it. `Date.UTC` ROLLS rather than
34
+ * rejects, so it would quietly become 2 March — a window silently shifted off
35
+ * the date the caller asked for, which is the same class of bug the preset
36
+ * clamp exists to prevent.
37
+ *
38
+ * Round-tripping through `toIsoDate` is the check: a real date formats back to
39
+ * itself, a rolled one does not. Years are held to 1000–9999 because `Date.UTC`
40
+ * maps 0–99 to 1900+n, so `0050-01-15` would round-trip as 1950 and pass.
41
+ */
42
+ export function isIsoDate(value) {
43
+ if (typeof value !== 'string' || ! ISO_DATE_PATTERN.test(value)) {
44
+ return false
45
+ }
46
+
47
+ const [year, month, day] = value.split('-').map(Number)
48
+
49
+ if (year < 1000) {
50
+ return false
51
+ }
52
+
53
+ return toIsoDate(year, month - 1, day) === value
54
+ }
55
+
56
+ // Day 0 of the following month is the last day of the one being asked about.
57
+ // A negative monthIndex rolls back into the previous year, which is what the
58
+ // three-month and last-month windows need in January through March.
59
+ export function lastDayOfMonth(year, monthIndex) {
60
+ return new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate()
61
+ }
62
+
63
+ export function parseIsoDate(iso) {
64
+ if (! isIsoDate(iso)) {
65
+ return null
66
+ }
67
+
68
+ const [year, month, day] = iso.split('-').map(Number)
69
+
70
+ return { year, monthIndex: month - 1, day }
71
+ }
72
+
73
+ /**
74
+ * Shift a year/month pair by whole months, normalising the year.
75
+ * `Date.UTC` already rolls month indices in both directions, so read the
76
+ * normalised pair back off the resulting date rather than doing it by hand.
77
+ */
78
+ export function addMonths(year, monthIndex, delta) {
79
+ const shifted = new Date(Date.UTC(year, monthIndex + delta, 1))
80
+
81
+ return { year: shifted.getUTCFullYear(), monthIndex: shifted.getUTCMonth() }
82
+ }
83
+
84
+ /**
85
+ * Shift a date by whole days. Rolling IS the wanted behaviour here — the day
86
+ * after 31 August is 1 September — which is exactly why the month arithmetic
87
+ * above has to clamp instead.
88
+ */
89
+ export function addDays(iso, delta) {
90
+ const parsed = parseIsoDate(iso)
91
+
92
+ if (! parsed) {
93
+ return null
94
+ }
95
+
96
+ return toIsoDate(parsed.year, parsed.monthIndex, parsed.day + delta)
97
+ }
98
+
99
+ /**
100
+ * Shift a date by whole months, clamping the day to the target month rather
101
+ * than letting it roll into the next one — the same trap `last_3_months`
102
+ * carries a fix for, reached here by paging the calendar with PageUp/PageDown.
103
+ */
104
+ export function shiftMonths(iso, delta) {
105
+ const parsed = parseIsoDate(iso)
106
+
107
+ if (! parsed) {
108
+ return null
109
+ }
110
+
111
+ const { year, monthIndex } = addMonths(parsed.year, parsed.monthIndex, delta)
112
+
113
+ return toIsoDate(year, monthIndex, Math.min(parsed.day, lastDayOfMonth(year, monthIndex)))
114
+ }
115
+
116
+ // Monday-first weekday index (Monday 0 ... Sunday 6). getUTCDay() is
117
+ // Sunday-first, so shift it.
118
+ export function weekdayIndex(year, monthIndex, day) {
119
+ return (new Date(Date.UTC(year, monthIndex, day)).getUTCDay() + 6) % 7
120
+ }
121
+
122
+ /**
123
+ * The preset vocabulary, in the order the names are offered.
124
+ *
125
+ * The prefix is the rule: `last_*` counts BACK from the reference date,
126
+ * `this_*` runs from the start of the current period TO it.
127
+ * Every window ends at the reference date, so none of them can breach the
128
+ * common `maxDate` of today.
129
+ *
130
+ * ⚠️ `last_month` is ROLLING — one month back from the reference date, so 14
131
+ * July to 14 August. That is a deliberate deviation from the common
132
+ * convention, recorded here so nobody "fixes" it back: GA4, Mixpanel and
133
+ * Amplitude all read "Last month" as the previous COMPLETE calendar month
134
+ * (1–31 July) and offer "Last 30 days" separately for the rolling case. It is
135
+ * rolling here so `last_*` means one thing rather than two — the alternative
136
+ * was a single calendar-aligned outlier whose name looked like its siblings and
137
+ * behaved differently. If the convention is wanted later, the clean shape is a
138
+ * calendar `last_month` alongside a rolling `last_30_days`, offering both.
139
+ *
140
+ * There is deliberately no `custom`: with an always-visible range control
141
+ * there is nothing for it to reveal, because picking dates directly IS custom.
142
+ */
143
+ export const KNOWN_PRESET_NAMES = [
144
+ 'today',
145
+ 'last_7_days',
146
+ 'last_month',
147
+ 'last_3_months',
148
+ 'this_year',
149
+ ]
150
+
151
+ export function isKnownPresetName(name) {
152
+ return KNOWN_PRESET_NAMES.includes(name)
153
+ }
154
+
155
+ /**
156
+ * Resolve a named preset to its bounds, or null for a name this module does
157
+ * not own (and for a missing or malformed reference date, since a preset
158
+ * cannot be resolved without one).
159
+ */
160
+ export function resolveDateRangePreset(name, referenceDate) {
161
+ const reference = parseIsoDate(referenceDate)
162
+
163
+ if (! reference) {
164
+ return null
165
+ }
166
+
167
+ // Only `this_year` needs a part — the rolling windows work on the string,
168
+ // because both helpers they use parse it themselves.
169
+ const { year } = reference
170
+
171
+ // A single day is still a range — both bounds are the reference date. Same
172
+ // shape as the others, so nothing downstream needs to special-case it.
173
+ if (name === 'today') {
174
+ return { start: referenceDate, end: referenceDate }
175
+ }
176
+
177
+ /*
178
+ * Every `last_*` window is the same rule: count back from the reference
179
+ * date, end at it. Only the unit and the amount differ, so they are one
180
+ * table rather than a branch each — a second implementation of the same
181
+ * arithmetic is how the two of them drifted apart in the first place.
182
+ *
183
+ * Day counts are `n - 1` because both bounds are inclusive: today plus the
184
+ * six days before it is seven days, and `-7` would quietly be eight.
185
+ *
186
+ * Month shifts go through `shiftMonths`, which clamps. That clamp is not
187
+ * optional — `Date.UTC` ROLLS an out-of-range day forward, so a naive
188
+ * "31 May minus 3 months" asks for February 31 and resolves to 3 March,
189
+ * silently starting the window days late while rendering as though it were
190
+ * intended.
191
+ */
192
+ const ROLLING_WINDOWS = {
193
+ last_7_days: (iso) => addDays(iso, -6),
194
+ last_month: (iso) => shiftMonths(iso, -1),
195
+ last_3_months: (iso) => shiftMonths(iso, -3),
196
+ }
197
+
198
+ if (Object.hasOwn(ROLLING_WINDOWS, name)) {
199
+ return {
200
+ start: ROLLING_WINDOWS[name](referenceDate),
201
+ end: referenceDate,
202
+ }
203
+ }
204
+
205
+ if (name === 'this_year') {
206
+ return {
207
+ start: toIsoDate(year, 0, 1),
208
+ end: referenceDate,
209
+ }
210
+ }
211
+
212
+ return null
213
+ }
214
+
215
+ /**
216
+ * Whether a pair of bounds describes a window that cannot exist.
217
+ *
218
+ * A half-filled range is not out of order — it is simply unfinished, which is
219
+ * the normal state between the two clicks that pick a range.
220
+ */
221
+ export function isRangeOutOfOrder(start, end) {
222
+ if (! start || ! end) {
223
+ return false
224
+ }
225
+
226
+ return start > end
227
+ }
228
+
229
+ /**
230
+ * Whether a bound is present but not a `Y-m-d` string.
231
+ *
232
+ * A `Date` object is the specific thing worth catching: it is the shape this
233
+ * API exists to keep out, and it fails SILENTLY without this check. Relational
234
+ * `<` / `>` coerce a Date with hint `number`, so every comparison against an
235
+ * ISO bound string is `NaN` — which is false in both directions, so a Date
236
+ * passes every range check below and reports as valid.
237
+ */
238
+ function isUnusableBound(value) {
239
+ return value !== null && value !== undefined && ! isIsoDate(value)
240
+ }
241
+
242
+ /**
243
+ * Why a range cannot be used, in precedence order, or null when it is fine.
244
+ *
245
+ * Reasons mirror MUI's `onError`: a consumer gating a Generate button only
246
+ * needs to know THAT the window is unusable, and which way it is unusable is
247
+ * enough to say so in its own words. The message itself stays with the
248
+ * consumer — RSUI cannot reach an app's language files.
249
+ *
250
+ * The reason vocabulary is a PUBLIC CONTRACT — consumers switch on it — so it
251
+ * is deliberately complete rather than minimal. `incomplete` in particular:
252
+ * without it, every consumer gating a Generate button has to re-derive
253
+ * emptiness itself, which is exactly the duplication this component exists to
254
+ * remove.
255
+ *
256
+ * Vocabulary: 'invalid-value' | 'incomplete' | 'invalid-range' | 'min-date' | 'max-date' | null
257
+ */
258
+ export function dateRangeValidationReason(range, minDate, maxDate) {
259
+ const start = range?.start ?? null
260
+ const end = range?.end ?? null
261
+
262
+ // First, because a bound of the wrong TYPE makes every check below
263
+ // meaningless rather than merely false.
264
+ if (isUnusableBound(range?.start) || isUnusableBound(range?.end)) {
265
+ return 'invalid-value'
266
+ }
267
+
268
+ // Ahead of the bound checks: a window missing an end is not a window, and
269
+ // saying so is more use to a consumer than "the start is fine".
270
+ if (! start || ! end) {
271
+ return 'incomplete'
272
+ }
273
+
274
+ if (isRangeOutOfOrder(start, end)) {
275
+ return 'invalid-range'
276
+ }
277
+
278
+ if (start < minDate || end < minDate) {
279
+ return 'min-date'
280
+ }
281
+
282
+ if (start > maxDate || end > maxDate) {
283
+ return 'max-date'
284
+ }
285
+
286
+ return null
287
+ }
288
+
289
+ /**
290
+ * Format a `Y-m-d` string for display in the viewer's language.
291
+ *
292
+ * Formatting is pinned to UTC so the rendered day is the day that was stored:
293
+ * reading a fixed date through a local-timezone formatter is the same class of
294
+ * mistake as resolving a preset against a browser clock.
295
+ *
296
+ * The default is zero-padded numeric rather than `dateStyle: 'medium'`. Two
297
+ * reasons, and the second is the load-bearing one:
298
+ *
299
+ * 1. Every value is the same width, so a range does not reflow as the user
300
+ * moves between months.
301
+ * 2. Separation comes from the slashes. "08/06/2026" already reads as one
302
+ * unit, so the en dash between the two bounds does not need extra spacing
303
+ * to stop the six parts running together — which is what a spelled month
304
+ * ("8 Jun 2026 – 25 Jun 2026") does need.
305
+ *
306
+ * Part ORDER still follows the locale — en-NZ renders `08/06/2026` as
307
+ * day-first, en-US the same digits as month-first. That is correct per viewer
308
+ * and is why the order is left to `Intl` rather than hardcoded.
309
+ */
310
+ /**
311
+ * Formatters are memoised on (locale, options).
312
+ *
313
+ * `Intl.DateTimeFormat` construction is the expensive part — it resolves the
314
+ * locale and builds the pattern — and the day cells call this inline for every
315
+ * `aria-label`. Two months is around 70 cells, rebuilt on every reactive change,
316
+ * which includes each mouseenter while dragging a range across the grid. The
317
+ * key set is tiny and bounded: one locale times the two options shapes this
318
+ * module uses.
319
+ */
320
+ const formatterCache = new Map()
321
+
322
+ function dateFormatter(locale, options) {
323
+ const key = `${locale ?? ''}|${JSON.stringify(options)}`
324
+
325
+ if (! formatterCache.has(key)) {
326
+ formatterCache.set(key, new Intl.DateTimeFormat(locale, { ...options, timeZone: 'UTC' }))
327
+ }
328
+
329
+ return formatterCache.get(key)
330
+ }
331
+
332
+ export function formatIsoDate(iso, locale, options = { day: '2-digit', month: '2-digit', year: 'numeric' }) {
333
+ const parsed = parseIsoDate(iso)
334
+
335
+ if (! parsed) {
336
+ return ''
337
+ }
338
+
339
+ const date = new Date(Date.UTC(parsed.year, parsed.monthIndex, parsed.day))
340
+
341
+ return dateFormatter(locale, options).format(date)
342
+ }
343
+
344
+ export function formatMonthCaption(year, monthIndex, locale) {
345
+ const date = new Date(Date.UTC(year, monthIndex, 1))
346
+
347
+ return new Intl.DateTimeFormat(locale, { month: 'long', year: 'numeric', timeZone: 'UTC' })
348
+ .format(date)
349
+ }
350
+
351
+ /**
352
+ * Monday-first weekday names in the viewer's language, short for the column
353
+ * heading and long for the screen reader. 1 January 2024 was a Monday — an
354
+ * arbitrary anchor, not a clock.
355
+ */
356
+ export function weekdayLabels(locale) {
357
+ return Array.from({ length: 7 }, (unused, index) => {
358
+ const date = new Date(Date.UTC(2024, 0, 1 + index))
359
+
360
+ return {
361
+ short: new Intl.DateTimeFormat(locale, { weekday: 'short', timeZone: 'UTC' }).format(date),
362
+ long: new Intl.DateTimeFormat(locale, { weekday: 'long', timeZone: 'UTC' }).format(date),
363
+ }
364
+ })
365
+ }
366
+
367
+ /**
368
+ * Lay a month out as Monday-first weeks of seven cells, padding both ends with
369
+ * nulls. Only the month's own days are emitted: the popover shows two months
370
+ * side by side, so borrowing days from a neighbouring month would render the
371
+ * same date twice and give it two places to be focused and selected from.
372
+ */
373
+ export function buildMonthGrid(year, monthIndex) {
374
+ const totalDays = lastDayOfMonth(year, monthIndex)
375
+ const firstWeekday = weekdayIndex(year, monthIndex, 1)
376
+
377
+ const cells = Array.from({ length: firstWeekday }, () => null)
378
+
379
+ for (let day = 1; day <= totalDays; day++) {
380
+ cells.push({ iso: toIsoDate(year, monthIndex, day), day })
381
+ }
382
+
383
+ while (cells.length % 7 !== 0) {
384
+ cells.push(null)
385
+ }
386
+
387
+ const weeks = []
388
+
389
+ for (let index = 0; index < cells.length; index += 7) {
390
+ weeks.push(cells.slice(index, index + 7))
391
+ }
392
+
393
+ return weeks
394
+ }