@redseed/redseed-ui-vue3 8.59.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 +1 -1
- package/src/components/FormField/DateRangeInternal/DateRangeMonth.vue +168 -0
- package/src/components/FormField/DateRangeInternal/DateRangePresetRail.vue +105 -0
- package/src/components/FormField/DateRangeInternal/dateRange.js +394 -0
- package/src/components/FormField/FormFieldDateRange.vue +679 -0
- package/src/components/FormField/FormFieldSlot.vue +8 -0
- package/src/components/FormField/index.js +2 -0
package/package.json
CHANGED
|
@@ -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
|
+
}
|
|
@@ -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>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import FormFieldCheckbox from './FormFieldCheckbox.vue'
|
|
2
2
|
import FormFieldCombobox from './FormFieldCombobox.vue'
|
|
3
|
+
import FormFieldDateRange from './FormFieldDateRange.vue'
|
|
3
4
|
import FormFieldEmail from './FormFieldEmail.vue'
|
|
4
5
|
import FormFieldHidden from './FormFieldHidden.vue'
|
|
5
6
|
import FormFieldNumber from './FormFieldNumber.vue'
|
|
@@ -18,6 +19,7 @@ import FormFieldUploaderWrapper from './FormFieldUploaderWrapper.vue'
|
|
|
18
19
|
export {
|
|
19
20
|
FormFieldCheckbox,
|
|
20
21
|
FormFieldCombobox,
|
|
22
|
+
FormFieldDateRange,
|
|
21
23
|
FormFieldEmail,
|
|
22
24
|
FormFieldHidden,
|
|
23
25
|
FormFieldNumber,
|