@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.
@@ -0,0 +1,679 @@
1
+ <script setup>
2
+ import { computed, nextTick, onMounted, ref, useAttrs, useId, useSlots, watch } from 'vue'
3
+ import { onClickOutside } from '@vueuse/core'
4
+ import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/vue/24/outline'
5
+ import FormFieldSlot from './FormFieldSlot.vue'
6
+ import DateRangeMonth from './DateRangeInternal/DateRangeMonth.vue'
7
+ import DateRangePresetRail from './DateRangeInternal/DateRangePresetRail.vue'
8
+ import { useDropdownPosition } from '../../composables/useDropdownPosition.js'
9
+ import {
10
+ KNOWN_PRESET_NAMES,
11
+ addDays,
12
+ addMonths,
13
+ dateRangeValidationReason,
14
+ formatIsoDate,
15
+ formatMonthCaption,
16
+ isIsoDate,
17
+ isKnownPresetName,
18
+ parseIsoDate,
19
+ shiftMonths,
20
+ toIsoDate,
21
+ weekdayIndex,
22
+ weekdayLabels,
23
+ } from './DateRangeInternal/dateRange.js'
24
+
25
+ defineOptions({
26
+ inheritAttrs: false,
27
+ })
28
+
29
+ // `{ start, end }` of Y-m-d strings, never Date objects: ISO dates compare
30
+ // correctly with a plain `>`, so the value carries no timezone to get wrong.
31
+ const model = defineModel({
32
+ type: Object,
33
+ default: () => ({ start: null, end: null }),
34
+ })
35
+
36
+ const props = defineProps({
37
+ // Required whenever `presets` is set, and never the browser clock. The app
38
+ // timezone is Pacific/Auckland; a viewer elsewhere resolving "this month"
39
+ // locally would get a different window than the report they are reading.
40
+ referenceDate: {
41
+ type: String,
42
+ default: null,
43
+ },
44
+ presets: {
45
+ type: Array,
46
+ default: () => [],
47
+ },
48
+ minDate: {
49
+ type: String,
50
+ default: '1900-01-01',
51
+ },
52
+ maxDate: {
53
+ type: String,
54
+ default: '2099-12-31',
55
+ },
56
+ // NOTE: `disabled` is deliberately NOT a prop. It arrives as an attribute,
57
+ // matching FormFieldCombobox and FormFieldSelect — a divergence inside the
58
+ // FormField family would be permanent once this publishes, and consumers
59
+ // reasonably expect one control to behave like its siblings.
60
+ //
61
+ // Formatting only — month names, weekday headings and the value in the
62
+ // field. Left undefined it follows the browser, which is right far more
63
+ // often than hardcoded English.
64
+ locale: {
65
+ type: String,
66
+ default: undefined,
67
+ },
68
+ })
69
+
70
+ const emit = defineEmits(['error'])
71
+
72
+ const attrs = useAttrs()
73
+ const slots = useSlots()
74
+
75
+ const isOpen = ref(false)
76
+ const pendingStart = ref(null)
77
+ const hoverDate = ref(null)
78
+ const focusedDate = ref(null)
79
+ // Anchored on mount and re-anchored on every open, so the (hidden) popover
80
+ // never holds a month nobody asked for.
81
+ const leftMonth = ref({ year: 1970, monthIndex: 0 })
82
+
83
+ const fieldElement = ref(null)
84
+ const triggerElement = ref(null)
85
+ const popoverElement = ref(null)
86
+
87
+ // The a11y wiring is computed here rather than injected from FormFieldSlot:
88
+ // this component RENDERS the slot, so it is the slot's parent, and inject only
89
+ // ever walks upwards. Passing our own id down means the label's `for`, the
90
+ // help/error element ids and the control agree on one id either way.
91
+ const fallbackId = useId()
92
+ const effectiveId = computed(() => attrs.id || fallbackId)
93
+
94
+ const ariaDescribedby = computed(() => {
95
+ const ids = []
96
+
97
+ if (slots.help) {
98
+ ids.push(`${effectiveId.value}-help`)
99
+ }
100
+
101
+ if (slots.error) {
102
+ ids.push(`${effectiveId.value}-error`)
103
+ }
104
+
105
+ return ids.length > 0 ? ids.join(' ') : undefined
106
+ })
107
+
108
+ const ariaInvalid = computed(() => slots.error ? 'true' : undefined)
109
+
110
+ /**
111
+ * Whether a boolean ATTRIBUTE is present.
112
+ *
113
+ * `<FormFieldDateRange required>` arrives as the empty string, which is falsy —
114
+ * so a plain truthiness test reads the bare, most idiomatic form of the
115
+ * attribute as absent. Only an explicit `:required="false"` (or the literal
116
+ * string "false") means off.
117
+ */
118
+ function hasBooleanAttr(value) {
119
+ return value !== undefined && value !== null && value !== false && value !== 'false'
120
+ }
121
+
122
+ const isDisabled = computed(() => hasBooleanAttr(attrs.disabled))
123
+
124
+ const isRequired = computed(() => hasBooleanAttr(attrs.required))
125
+
126
+ const popoverId = computed(() => `${effectiveId.value}-popover`)
127
+
128
+ /**
129
+ * The trigger names itself as "<label>, <value>".
130
+ *
131
+ * A <button> takes its accessible name from its own subtree, so browsers
132
+ * announce the formatted range and drop the `<label for>` entirely — in a
133
+ * filter panel of four of these, every one is announced as a bare pair of
134
+ * dates. Listing the label first and the button's own id second composes both,
135
+ * in reading order. Only while a label exists: with no label the subtree name
136
+ * is already right, and pointing at a missing id would blank the name.
137
+ */
138
+ const ariaLabelledby = computed(() =>
139
+ slots.label ? `${effectiveId.value}-label ${effectiveId.value}` : undefined
140
+ )
141
+
142
+ const range = computed(() => ({
143
+ start: model.value?.start ?? null,
144
+ end: model.value?.end ?? null,
145
+ }))
146
+
147
+ const weekdays = computed(() => weekdayLabels(props.locale))
148
+
149
+ const rightMonth = computed(() => addMonths(leftMonth.value.year, leftMonth.value.monthIndex, 1))
150
+
151
+ const monthCaptions = computed(() => [
152
+ formatMonthCaption(leftMonth.value.year, leftMonth.value.monthIndex, props.locale),
153
+ formatMonthCaption(rightMonth.value.year, rightMonth.value.monthIndex, props.locale),
154
+ ])
155
+
156
+ // Names the popover after what it is showing, which is already in the viewer's
157
+ // language — no English string to hardcode and no label to translate.
158
+ const popoverLabel = computed(() => monthCaptions.value.join(' – '))
159
+
160
+ // The paging buttons are named after the month they page TO, for the same
161
+ // reason: a caption is already translated, "Previous month" would not be.
162
+ const navCaptions = computed(() => {
163
+ const previous = addMonths(leftMonth.value.year, leftMonth.value.monthIndex, -1)
164
+ const next = addMonths(rightMonth.value.year, rightMonth.value.monthIndex, 1)
165
+
166
+ return {
167
+ previous: formatMonthCaption(previous.year, previous.monthIndex, props.locale),
168
+ next: formatMonthCaption(next.year, next.monthIndex, props.locale),
169
+ }
170
+ })
171
+
172
+ const displayValue = computed(() => {
173
+ const start = formatIsoDate(range.value.start, props.locale)
174
+ const end = formatIsoDate(range.value.end, props.locale)
175
+
176
+ // Nothing usable to render, so fall through to the placeholder. Joining two
177
+ // empty strings would produce a bare " – " — truthy, so it would win the
178
+ // v-if and show an en dash with no dates beside it, which is how a Date
179
+ // object or a "14/05/2026" string used to render as though it were fine.
180
+ if (! start && ! end) {
181
+ return ''
182
+ }
183
+
184
+ // The dash stays on the side that HAS a bound, so a half-filled value reads
185
+ // as the open-ended window it is: "01/08/2026 –" is a start with no end,
186
+ // "– 01/08/2026" an end with no start. Formatting both into one string
187
+ // would render those two states identically.
188
+ return `${start} – ${end}`.trim()
189
+ })
190
+
191
+ // Presets are opt-in, and they cannot resolve without a reference date, so a
192
+ // rail that would have to guess one is not rendered at all.
193
+ const canResolvePresets = computed(() =>
194
+ props.presets.length > 0
195
+ && isIsoDate(props.referenceDate)
196
+ )
197
+
198
+ // While a range is half-picked, paint the bound already chosen against the day
199
+ // under the pointer so the window being drawn is visible before it is committed.
200
+ const previewRange = computed(() => {
201
+ if (! pendingStart.value) {
202
+ return range.value
203
+ }
204
+
205
+ // Falls back to the FOCUSED day, not just the hovered one, so a keyboard
206
+ // user arrowing toward the second bound watches the window paint exactly as
207
+ // a mouse user does. Driving this off hover alone left the keyboard path
208
+ // with no feedback at all between the two selections.
209
+ return orderRange(
210
+ pendingStart.value,
211
+ hoverDate.value ?? focusedDate.value ?? pendingStart.value,
212
+ )
213
+ })
214
+
215
+ const validationReason = computed(() =>
216
+ dateRangeValidationReason(range.value, props.minDate, props.maxDate)
217
+ )
218
+
219
+ function orderRange(one, other) {
220
+ return one > other
221
+ ? { start: other, end: one }
222
+ : { start: one, end: other }
223
+ }
224
+
225
+ /**
226
+ * Which month the left calendar opens on. Anchoring on the range keeps the
227
+ * window the user already has in view; the reference date is the next best
228
+ * anchor. Only when there is neither does this read the browser's date, and
229
+ * only to decide what to LOOK at — no value is resolved from it, so it cannot
230
+ * produce the timezone-skewed window this component exists to prevent.
231
+ */
232
+ function anchorMonth() {
233
+ const anchor = range.value.start
234
+ ?? range.value.end
235
+ ?? props.referenceDate
236
+ ?? new Date().toISOString().slice(0, 10)
237
+
238
+ const parsed = parseIsoDate(anchor) ?? parseIsoDate(new Date().toISOString().slice(0, 10))
239
+
240
+ // A range with only an end is shown on the RIGHT month, so the empty start
241
+ // side of it is the one with room to be picked.
242
+ if (! range.value.start && range.value.end) {
243
+ return addMonths(parsed.year, parsed.monthIndex, -1)
244
+ }
245
+
246
+ return { year: parsed.year, monthIndex: parsed.monthIndex }
247
+ }
248
+
249
+ function monthKey(year, monthIndex) {
250
+ return year * 12 + monthIndex
251
+ }
252
+
253
+ // Page the pair so the focused day is one of the two months on screen.
254
+ function ensureFocusVisible() {
255
+ const focused = parseIsoDate(focusedDate.value)
256
+
257
+ if (! focused) {
258
+ return
259
+ }
260
+
261
+ const focusedKey = monthKey(focused.year, focused.monthIndex)
262
+ const leftKey = monthKey(leftMonth.value.year, leftMonth.value.monthIndex)
263
+ const rightKey = monthKey(rightMonth.value.year, rightMonth.value.monthIndex)
264
+
265
+ if (focusedKey < leftKey) {
266
+ leftMonth.value = { year: focused.year, monthIndex: focused.monthIndex }
267
+ }
268
+
269
+ if (focusedKey > rightKey) {
270
+ leftMonth.value = addMonths(focused.year, focused.monthIndex, -1)
271
+ }
272
+ }
273
+
274
+ async function focusFocusedDay() {
275
+ await nextTick()
276
+
277
+ popoverElement.value?.querySelector(`[data-date="${focusedDate.value}"]`)?.focus()
278
+ }
279
+
280
+ function open() {
281
+ if (isDisabled.value || isOpen.value) {
282
+ return
283
+ }
284
+
285
+ isOpen.value = true
286
+ leftMonth.value = anchorMonth()
287
+
288
+ // Each candidate has to be a real Y-m-d, not merely non-null. `??` only
289
+ // skips null and undefined, so a Date object or a "14/05/2026" string from
290
+ // an unvalidated v-model used to land in focusedDate — where no day cell
291
+ // matches it, leaving the grid with nothing tabbable and the arrow keys
292
+ // computing from a value that cannot be parsed.
293
+ focusedDate.value = [
294
+ range.value.start,
295
+ range.value.end,
296
+ props.referenceDate,
297
+ ].find(isIsoDate)
298
+ ?? toIsoDate(leftMonth.value.year, leftMonth.value.monthIndex, 1)
299
+
300
+ ensureFocusVisible()
301
+
302
+ nextTick(() => {
303
+ calculateDropdownPosition()
304
+ focusFocusedDay()
305
+ })
306
+ }
307
+
308
+ function close({ restoreFocus = false } = {}) {
309
+ isOpen.value = false
310
+ pendingStart.value = null
311
+ hoverDate.value = null
312
+
313
+ if (restoreFocus) {
314
+ triggerElement.value?.focus()
315
+ }
316
+ }
317
+
318
+ function toggle() {
319
+ if (isOpen.value) {
320
+ close()
321
+ return
322
+ }
323
+
324
+ open()
325
+ }
326
+
327
+ // Paging carries the focused day with it. Without that, `tabindex="0"` stays on
328
+ // a day that is no longer rendered, so the grid has NO tabbable cell and cannot
329
+ // be reached by keyboard at all — and the next arrow key would page straight
330
+ // back, because `ensureFocusVisible()` would drag the months to the stale focus.
331
+ function pageMonths(delta) {
332
+ leftMonth.value = addMonths(leftMonth.value.year, leftMonth.value.monthIndex, delta)
333
+ focusedDate.value = shiftMonths(focusedDate.value, delta) ?? focusedDate.value
334
+ }
335
+
336
+ function moveFocus(iso) {
337
+ if (! iso) {
338
+ return
339
+ }
340
+
341
+ focusedDate.value = iso
342
+ ensureFocusVisible()
343
+ focusFocusedDay()
344
+ }
345
+
346
+ const DAY_DELTAS = {
347
+ ArrowLeft: -1,
348
+ ArrowRight: 1,
349
+ ArrowUp: -7,
350
+ ArrowDown: 7,
351
+ }
352
+
353
+ function handleGridKeydown(event) {
354
+ if (Object.hasOwn(DAY_DELTAS, event.key)) {
355
+ event.preventDefault()
356
+ moveFocus(addDays(focusedDate.value, DAY_DELTAS[event.key]))
357
+ return
358
+ }
359
+
360
+ const focused = parseIsoDate(focusedDate.value)
361
+
362
+ if (! focused) {
363
+ return
364
+ }
365
+
366
+ if (event.key === 'Home') {
367
+ event.preventDefault()
368
+ moveFocus(addDays(focusedDate.value, -weekdayIndex(focused.year, focused.monthIndex, focused.day)))
369
+ return
370
+ }
371
+
372
+ if (event.key === 'End') {
373
+ event.preventDefault()
374
+ moveFocus(addDays(focusedDate.value, 6 - weekdayIndex(focused.year, focused.monthIndex, focused.day)))
375
+ return
376
+ }
377
+
378
+ if (event.key === 'PageUp') {
379
+ event.preventDefault()
380
+ moveFocus(shiftMonths(focusedDate.value, -1))
381
+ return
382
+ }
383
+
384
+ if (event.key === 'PageDown') {
385
+ event.preventDefault()
386
+ moveFocus(shiftMonths(focusedDate.value, 1))
387
+ }
388
+ }
389
+
390
+ function handlePopoverKeydown(event) {
391
+ if (event.key === 'Escape') {
392
+ event.preventDefault()
393
+ close({ restoreFocus: true })
394
+ return
395
+ }
396
+
397
+ if (event.key === 'Tab') {
398
+ containTab(event)
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Keep Tab inside the open popover.
404
+ *
405
+ * Two wrong answers were tried before this one. Leaving Tab unhandled walks
406
+ * straight out of the document, because the popover is teleported to the end of
407
+ * <body> and the roving tabindex leaves every unfocused day at -1. Closing on
408
+ * Tab — which is what FormFieldSelect does — fixes that but makes the preset
409
+ * rail and the month chevrons unreachable by keyboard entirely, since focus
410
+ * opens on a day and Tab is the only way out of the grid. Presets are a
411
+ * required feature, so that trades a focus-order bug for a WCAG 2.1.1 Level A
412
+ * one.
413
+ *
414
+ * FormFieldSelect can close on Tab because its popover holds nothing but
415
+ * options reachable with the arrow keys. This one has three focusable regions,
416
+ * so it needs containment — which is also what its role="dialog" already
417
+ * implies. Escape remains the way out.
418
+ */
419
+ function containTab(event) {
420
+ // `:not([tabindex="-1"])` skips the roving-tabindex days, leaving the one
421
+ // day that is currently tabbable plus the rail and nav buttons.
422
+ const focusable = [
423
+ ...popoverElement.value?.querySelectorAll(
424
+ 'button:not([disabled]):not([tabindex="-1"])'
425
+ ) ?? [],
426
+ ]
427
+
428
+ if (focusable.length === 0) {
429
+ return
430
+ }
431
+
432
+ const first = focusable[0]
433
+ const last = focusable[focusable.length - 1]
434
+ const active = document.activeElement
435
+
436
+ // Anything not in the cycle (or a stale focus after a re-render) restarts at
437
+ // the appropriate end rather than falling through to the browser.
438
+ if (event.shiftKey && (active === first || ! focusable.includes(active))) {
439
+ event.preventDefault()
440
+ last.focus()
441
+ return
442
+ }
443
+
444
+ if (! event.shiftKey && (active === last || ! focusable.includes(active))) {
445
+ event.preventDefault()
446
+ first.focus()
447
+ }
448
+ }
449
+
450
+ function selectDay(iso) {
451
+ if (! pendingStart.value) {
452
+ pendingStart.value = iso
453
+ focusedDate.value = iso
454
+ return
455
+ }
456
+
457
+ // Ordered on commit, so a range picked backwards is a range picked, not an
458
+ // error the consumer has to catch.
459
+ model.value = orderRange(pendingStart.value, iso)
460
+ close({ restoreFocus: true })
461
+ }
462
+
463
+ function selectPreset(resolved) {
464
+ if (! resolved) {
465
+ return
466
+ }
467
+
468
+ model.value = { start: resolved.start, end: resolved.end }
469
+ close({ restoreFocus: true })
470
+ }
471
+
472
+ function handleHover(iso) {
473
+ hoverDate.value = iso
474
+ }
475
+
476
+ // Reuses the positioning FormFieldSelect/Combobox/SearchAsync share (#220), so
477
+ // the popover clamps to the viewport instead of covering its own field. 'min'
478
+ // lets the two-month panel be wider than the field it is anchored to.
479
+ const { calculateDropdownPosition } = useDropdownPosition(
480
+ popoverElement,
481
+ fieldElement,
482
+ isOpen,
483
+ { widthMode: 'min' },
484
+ )
485
+
486
+ // Restore focus only when it was inside the popover: `v-show` sets
487
+ // display:none, which drops the focused day out of the a11y tree and resets
488
+ // focus to <body>, losing the user's place in the page. A click that lands
489
+ // elsewhere has already moved focus on purpose, so leave it alone.
490
+ onClickOutside(
491
+ fieldElement,
492
+ () => close({ restoreFocus: popoverElement.value?.contains(document.activeElement) ?? false }),
493
+ { ignore: [popoverElement] },
494
+ )
495
+
496
+ // Mirrors MUI's onError: the reason only, because the message belongs to the
497
+ // consumer's language files. Emitted once on mount so a Generate button can be
498
+ // gated before the user touches anything, then on every change of reason.
499
+ watch(validationReason, (reason) => emit('error', reason, range.value))
500
+
501
+ // Both are reactive props, so a mount-only check misses every consumer whose
502
+ // labels arrive with its language files or whose reference date is fetched —
503
+ // exactly the cases where the rail silently never appears.
504
+ watch(
505
+ [() => props.presets, () => props.referenceDate],
506
+ ([presets, referenceDate]) => {
507
+ if (presets.length > 0 && ! isIsoDate(referenceDate)) {
508
+ console.warn(
509
+ '[FormFieldDateRange] `presets` needs a `referenceDate` (a Y-m-d string) to resolve against. '
510
+ + 'The preset rail is hidden: resolving against the browser clock would give a viewer outside '
511
+ + 'the app timezone a different window than the one they are reading.'
512
+ )
513
+ }
514
+
515
+ const unknown = presets
516
+ .map((preset) => preset?.name)
517
+ .filter((name) => name && ! isKnownPresetName(name))
518
+
519
+ // Without this an unknown name — a typo, or the `custom` sentinel a
520
+ // consumer migrating off a hand-rolled filter would carry over — just
521
+ // renders as a permanently disabled button with nothing in the console.
522
+ if (unknown.length > 0) {
523
+ console.warn(
524
+ `[FormFieldDateRange] unknown preset name(s): ${unknown.join(', ')}. `
525
+ + `Known names are ${KNOWN_PRESET_NAMES.join(', ')}. `
526
+ + 'An unknown name resolves to nothing, so its button stays disabled. '
527
+ + 'Note there is deliberately no `custom` preset: picking dates directly IS custom.'
528
+ )
529
+ }
530
+ },
531
+ { immediate: true, deep: true },
532
+ )
533
+
534
+ onMounted(() => {
535
+ leftMonth.value = anchorMonth()
536
+
537
+ emit('error', validationReason.value, range.value)
538
+ })
539
+
540
+ defineExpose({
541
+ focus() {
542
+ triggerElement.value?.focus()
543
+ },
544
+ open,
545
+ close,
546
+ })
547
+ </script>
548
+ <template>
549
+ <FormFieldSlot
550
+ :id="effectiveId"
551
+ :class="[$attrs.class, 'rsui-form-field-date-range']"
552
+ :required="$attrs.required"
553
+ :showAsterisk="$attrs.showAsterisk"
554
+ :compact="$attrs.compact"
555
+ :lg="$attrs.lg"
556
+ >
557
+ <template #label v-if="$slots.label">
558
+ <slot name="label"></slot>
559
+ </template>
560
+
561
+ <div ref="fieldElement" class="rsui-form-field-date-range__group">
562
+ <button
563
+ ref="triggerElement"
564
+ type="button"
565
+ class="rsui-form-field-date-range__trigger"
566
+ :id="effectiveId"
567
+ :aria-labelledby="ariaLabelledby"
568
+ :aria-describedby="ariaDescribedby"
569
+ :aria-invalid="ariaInvalid"
570
+ :aria-required="isRequired || undefined"
571
+ :aria-expanded="isOpen ? 'true' : 'false'"
572
+ aria-haspopup="dialog"
573
+ :aria-controls="isOpen ? popoverId : undefined"
574
+ :disabled="isDisabled"
575
+ :name="$attrs.name"
576
+ @click="toggle"
577
+ @keydown.down.prevent="open"
578
+ @keydown.up.prevent="open"
579
+ >
580
+ <span v-if="displayValue" class="rsui-form-field-date-range__value">
581
+ {{ displayValue }}
582
+ </span>
583
+ <span v-if="! displayValue" class="rsui-form-field-date-range__placeholder">
584
+ <slot name="placeholder"></slot>
585
+ </span>
586
+ </button>
587
+
588
+ <div class="rsui-form-field-date-range__icon" aria-hidden="true">
589
+ <CalendarIcon></CalendarIcon>
590
+ </div>
591
+ </div>
592
+
593
+ <Teleport to="body">
594
+ <div
595
+ ref="popoverElement"
596
+ v-show="isOpen"
597
+ :id="popoverId"
598
+ role="dialog"
599
+ :aria-label="popoverLabel"
600
+ :class="[
601
+ 'rsui-form-field-date-range__popover',
602
+ { 'rsui-form-field-date-range__popover--open': isOpen },
603
+ ]"
604
+ @keydown="handlePopoverKeydown"
605
+ >
606
+ <DateRangePresetRail
607
+ v-if="canResolvePresets"
608
+ :presets="presets"
609
+ :referenceDate="referenceDate"
610
+ :range="range"
611
+ :minDate="minDate"
612
+ :maxDate="maxDate"
613
+ @select="selectPreset"
614
+ ></DateRangePresetRail>
615
+
616
+ <div class="rsui-form-field-date-range__calendars">
617
+ <div class="rsui-form-field-date-range__nav">
618
+ <button
619
+ type="button"
620
+ class="rsui-form-field-date-range__nav-button rsui-form-field-date-range__nav-button--previous"
621
+ :aria-label="navCaptions.previous"
622
+ @click="pageMonths(-1)"
623
+ >
624
+ <ChevronLeftIcon aria-hidden="true"></ChevronLeftIcon>
625
+ </button>
626
+ <button
627
+ type="button"
628
+ class="rsui-form-field-date-range__nav-button rsui-form-field-date-range__nav-button--next"
629
+ :aria-label="navCaptions.next"
630
+ @click="pageMonths(1)"
631
+ >
632
+ <ChevronRightIcon aria-hidden="true"></ChevronRightIcon>
633
+ </button>
634
+ </div>
635
+
636
+ <div class="rsui-form-field-date-range__months" @keydown="handleGridKeydown">
637
+ <DateRangeMonth
638
+ :year="leftMonth.year"
639
+ :monthIndex="leftMonth.monthIndex"
640
+ :locale="locale"
641
+ :weekdays="weekdays"
642
+ :start="previewRange.start"
643
+ :end="previewRange.end"
644
+ :focusedDate="focusedDate"
645
+ :minDate="minDate"
646
+ :maxDate="maxDate"
647
+ :referenceDate="referenceDate"
648
+ @select="selectDay"
649
+ @hover="handleHover"
650
+ ></DateRangeMonth>
651
+
652
+ <DateRangeMonth
653
+ :year="rightMonth.year"
654
+ :monthIndex="rightMonth.monthIndex"
655
+ :locale="locale"
656
+ :weekdays="weekdays"
657
+ :start="previewRange.start"
658
+ :end="previewRange.end"
659
+ :focusedDate="focusedDate"
660
+ :minDate="minDate"
661
+ :maxDate="maxDate"
662
+ :referenceDate="referenceDate"
663
+ @select="selectDay"
664
+ @hover="handleHover"
665
+ ></DateRangeMonth>
666
+ </div>
667
+ </div>
668
+ </div>
669
+ </Teleport>
670
+
671
+ <template #help v-if="$slots.help">
672
+ <slot name="help"></slot>
673
+ </template>
674
+
675
+ <template #error v-if="$slots.error">
676
+ <slot name="error"></slot>
677
+ </template>
678
+ </FormFieldSlot>
679
+ </template>
@@ -65,7 +65,15 @@ const isRequired = computed(() =>
65
65
  <div v-if="$slots.label"
66
66
  :class="['rsui-form-field-slot__label', { 'rsui-form-field-slot__label--lg': lg }]"
67
67
  >
68
+ <!--
69
+ The id lets a control REFERENCE this label as well as be associated
70
+ with it. Native inputs only need `for`, but a control rendered as a
71
+ <button> takes its accessible name from its own subtree, so `for`
72
+ alone silently drops the label — such a control has to compose the
73
+ name itself with aria-labelledby, and cannot without an id to point at.
74
+ -->
68
75
  <label
76
+ :id="`${inputId}-label`"
69
77
  :for="inputId"
70
78
  >
71
79
  <slot name="label"></slot>