@sbc-connect/nuxt-base 0.12.0 → 0.13.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/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# @sbc-connect/nuxt-base
|
|
2
2
|
|
|
3
|
+
## 0.13.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#186](https://github.com/bcgov/connect-nuxt/pull/186) [`f8abf2a`](https://github.com/bcgov/connect-nuxt/commit/f8abf2ae069028f6fcda28b979723901751142c3) Thanks [@eve-git](https://github.com/eve-git)! - Add reusable Date Picker component and corresponding date validation schema
|
|
8
|
+
|
|
3
9
|
## 0.12.0
|
|
4
10
|
|
|
5
11
|
### Minor Changes
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { DateValue } from '@internationalized/date'
|
|
3
|
+
import type { DateRange } from 'reka-ui'
|
|
4
|
+
import { CalendarDate } from '@internationalized/date'
|
|
5
|
+
import { DateTime } from 'luxon'
|
|
6
|
+
import { DATE_API_INPUT_FORMAT, DATE_DISPLAY_FORMAT, DATE_INPUT_FORMATS } from '../../../utils/schemas/date'
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
label: string
|
|
10
|
+
minDate?: string
|
|
11
|
+
maxDate?: string
|
|
12
|
+
disabled?: boolean
|
|
13
|
+
// accessibility hint (sets aria-required) only — does not enforce anything.
|
|
14
|
+
// actual required/optional validation comes from getDateSchema({ required }) in
|
|
15
|
+
// utils/schemas/date.ts and must be kept in sync with this prop by the consumer
|
|
16
|
+
required?: boolean
|
|
17
|
+
// string form is announced to screen readers via the role="alert" span below;
|
|
18
|
+
// pass a boolean if you only want the red/invalid styling with no announcement
|
|
19
|
+
error?: boolean | string
|
|
20
|
+
// announced (politely, i.e. non-interrupting) via the role="status" span below
|
|
21
|
+
// whenever there is no error; pass the same text given to the wrapping
|
|
22
|
+
// UFormField's `help` prop so it reads consistently with what's shown visually
|
|
23
|
+
help?: string
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
const { locale } = useI18n()
|
|
27
|
+
|
|
28
|
+
// wires the input up to the wrapping UFormField (if any): aria-describedby/
|
|
29
|
+
// aria-invalid derived from its help/error text, and blur reported onto the
|
|
30
|
+
// form's validation bus. Calling this here (rather than leaving it to the
|
|
31
|
+
// inner UInput) makes the relationship explicit and independent of UInput's
|
|
32
|
+
// own internal wiring.
|
|
33
|
+
const { ariaAttrs, emitFormBlur } = useFormField()
|
|
34
|
+
|
|
35
|
+
const dateModel = defineModel<string>()
|
|
36
|
+
|
|
37
|
+
const inputId = `date-input-${useId()}`
|
|
38
|
+
|
|
39
|
+
const dateInput = ref(dateModel.value ?? '')
|
|
40
|
+
|
|
41
|
+
const isCalendarOpen = ref(false)
|
|
42
|
+
// tracked (rather than read directly off the DOM) so the help live-region below
|
|
43
|
+
// can gate on it, forcing a fresh content change - and therefore an announcement -
|
|
44
|
+
// on every focus, regardless of whether a given screen reader reads
|
|
45
|
+
// aria-describedby content automatically
|
|
46
|
+
const hasFocus = ref(false)
|
|
47
|
+
// tracks whether the calendar opened from input focus (suppresses auto-focus into calendar)
|
|
48
|
+
let calendarOpenedViaInput = false
|
|
49
|
+
const inputWrapperRef = useTemplateRef<HTMLElement>('inputWrapperRef')
|
|
50
|
+
const calendarContentRef = useTemplateRef<HTMLElement>('calendarContentRef')
|
|
51
|
+
let suppressCloseAutoFocus = false
|
|
52
|
+
let suppressOpenOnFocus = false
|
|
53
|
+
|
|
54
|
+
function onCloseAutoFocus(e: Event) {
|
|
55
|
+
if (suppressCloseAutoFocus) {
|
|
56
|
+
e.preventDefault()
|
|
57
|
+
suppressCloseAutoFocus = false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ignoreInputInteraction(e: Event) {
|
|
62
|
+
if (inputWrapperRef.value?.contains(e.target as Node)) {
|
|
63
|
+
e.preventDefault()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function onInputBlur(e: FocusEvent) {
|
|
68
|
+
emitFormBlur()
|
|
69
|
+
hasFocus.value = false
|
|
70
|
+
if (calendarContentRef.value?.contains(e.relatedTarget as Node)) {
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
suppressCloseAutoFocus = true
|
|
74
|
+
isCalendarOpen.value = false
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function focusCalendarGrid() {
|
|
78
|
+
nextTick(() => {
|
|
79
|
+
calendarContentRef.value?.querySelector<HTMLElement>('button:not([disabled])')?.focus()
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function onInputKeydown(event: KeyboardEvent) {
|
|
84
|
+
if (event.key !== 'ArrowDown' || !isCalendarOpen.value) {
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
event.preventDefault()
|
|
88
|
+
focusCalendarGrid()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const calendarMinValue = computed(() => toCalendarDate(props.minDate))
|
|
92
|
+
const calendarMaxValue = computed(() => toCalendarDate(props.maxDate))
|
|
93
|
+
const calendarValue = computed(() => toCalendarDate(dateInput.value, DATE_DISPLAY_FORMAT))
|
|
94
|
+
|
|
95
|
+
const isDateUnavailable = (date: DateValue): boolean => {
|
|
96
|
+
return (
|
|
97
|
+
(!!calendarMinValue.value
|
|
98
|
+
&& date.compare(calendarMinValue.value) < 0)
|
|
99
|
+
|| (!!calendarMaxValue.value
|
|
100
|
+
&& date.compare(calendarMaxValue.value) > 0)
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const activeLocale = computed(() => {
|
|
105
|
+
if (typeof locale === 'string') {
|
|
106
|
+
return locale
|
|
107
|
+
}
|
|
108
|
+
return locale.value || 'en-CA'
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
function parseDate(dateStr: string, format: string): DateTime {
|
|
112
|
+
return DateTime.fromFormat(dateStr, format, { locale: activeLocale.value })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function formatDate(date: DateTime, format: string): string {
|
|
116
|
+
return date.setLocale(activeLocale.value).toFormat(format)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function toCalendarDate(dateStr?: string, format = DATE_API_INPUT_FORMAT): CalendarDate | undefined {
|
|
120
|
+
if (!dateStr) {
|
|
121
|
+
return undefined
|
|
122
|
+
}
|
|
123
|
+
const dt = parseDate(dateStr, format)
|
|
124
|
+
if (!dt.isValid) {
|
|
125
|
+
return undefined
|
|
126
|
+
}
|
|
127
|
+
return new CalendarDate(dt.year, dt.month, dt.day)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function getInputEl(): HTMLInputElement | null | undefined {
|
|
131
|
+
return inputWrapperRef.value?.querySelector<HTMLInputElement>(`#${inputId}`)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// programmatic value changes (calendar select, clear) don't fire native
|
|
135
|
+
// input/blur events, so UInput's own form-validation wiring never sees them;
|
|
136
|
+
// a synthetic blur nudges it to revalidate against the current value
|
|
137
|
+
function notifyFormOfChange() {
|
|
138
|
+
getInputEl()?.dispatchEvent(new FocusEvent('blur', { relatedTarget: null }))
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function onDateSelect(date: DateValue | DateRange | DateValue[] | null | undefined) {
|
|
142
|
+
if (!date || Array.isArray(date) || !('year' in date)) {
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
const dt = DateTime.fromObject({ year: date.year, month: date.month, day: date.day }, { locale: activeLocale.value })
|
|
146
|
+
dateInput.value = formatDate(dt, DATE_DISPLAY_FORMAT)
|
|
147
|
+
syncModelFromLocal()
|
|
148
|
+
suppressCloseAutoFocus = true
|
|
149
|
+
suppressOpenOnFocus = true
|
|
150
|
+
isCalendarOpen.value = false
|
|
151
|
+
nextTick(() => {
|
|
152
|
+
notifyFormOfChange()
|
|
153
|
+
getInputEl()?.focus()
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function onOpenAutoFocus(e: Event) {
|
|
158
|
+
if (calendarOpenedViaInput) {
|
|
159
|
+
e.preventDefault()
|
|
160
|
+
calendarOpenedViaInput = false
|
|
161
|
+
}
|
|
162
|
+
// else: let focus enter the calendar so the screen reader announces it naturally
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function onInputFocus() {
|
|
166
|
+
hasFocus.value = true
|
|
167
|
+
if (isCalendarOpen.value) {
|
|
168
|
+
return // already open (e.g. Shift+Tab back to input)
|
|
169
|
+
}
|
|
170
|
+
calendarOpenedViaInput = true
|
|
171
|
+
if (suppressOpenOnFocus) {
|
|
172
|
+
suppressOpenOnFocus = false
|
|
173
|
+
calendarOpenedViaInput = false
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
isCalendarOpen.value = true
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function normalizeDate(input: string): string {
|
|
180
|
+
const trimmed = input.trim()
|
|
181
|
+
if (!trimmed) {
|
|
182
|
+
return input
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (parseDate(trimmed, DATE_DISPLAY_FORMAT).isValid) {
|
|
186
|
+
return trimmed
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Ensure space between letters and digits, and after commas
|
|
190
|
+
const preprocessed = trimmed
|
|
191
|
+
.replace(/([a-zA-Z])(\d)/g, '$1 $2')
|
|
192
|
+
.replace(/,(\S)/g, ', $1')
|
|
193
|
+
.replace(/\b([a-z])/g, (_, c) => c.toUpperCase())
|
|
194
|
+
|
|
195
|
+
for (const fmt of DATE_INPUT_FORMATS) {
|
|
196
|
+
const dt = parseDate(preprocessed, fmt)
|
|
197
|
+
if (dt.isValid) {
|
|
198
|
+
return formatDate(dt, DATE_DISPLAY_FORMAT)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// preserve original input (including spaces) while the user is still typing
|
|
203
|
+
return input
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function syncModelFromLocal() {
|
|
207
|
+
const trimmed = dateInput.value.trim()
|
|
208
|
+
|
|
209
|
+
if (!trimmed) {
|
|
210
|
+
dateModel.value = ''
|
|
211
|
+
return
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const parsed = parseDate(trimmed, DATE_DISPLAY_FORMAT)
|
|
215
|
+
if (!parsed.isValid) {
|
|
216
|
+
dateModel.value = trimmed
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const formattedDate = formatDate(parsed, DATE_API_INPUT_FORMAT)
|
|
221
|
+
dateModel.value = formattedDate
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
onMounted(() => {
|
|
225
|
+
const normalized = normalizeDate(dateInput.value)
|
|
226
|
+
if (normalized !== dateInput.value) {
|
|
227
|
+
dateInput.value = normalized
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
|
232
|
+
watch(dateInput, (val: string) => {
|
|
233
|
+
// sync immediately so the parent (and its schema validation) always sees the
|
|
234
|
+
// current value instead of waiting for the debounced auto-formatting below
|
|
235
|
+
syncModelFromLocal()
|
|
236
|
+
|
|
237
|
+
clearTimeout(debounceTimer)
|
|
238
|
+
if (!val) {
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
debounceTimer = setTimeout(async () => {
|
|
243
|
+
const normalized = normalizeDate(val)
|
|
244
|
+
if (normalized !== val) {
|
|
245
|
+
dateInput.value = normalized
|
|
246
|
+
await nextTick()
|
|
247
|
+
syncModelFromLocal()
|
|
248
|
+
}
|
|
249
|
+
}, 500)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
function clearDate() {
|
|
253
|
+
dateInput.value = ''
|
|
254
|
+
syncModelFromLocal()
|
|
255
|
+
notifyFormOfChange()
|
|
256
|
+
nextTick(() => {
|
|
257
|
+
inputWrapperRef.value?.querySelector<HTMLElement>(`#${inputId}`)?.focus()
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
</script>
|
|
261
|
+
|
|
262
|
+
<template>
|
|
263
|
+
<div ref="inputWrapperRef">
|
|
264
|
+
<UInput
|
|
265
|
+
:id="inputId"
|
|
266
|
+
v-model="dateInput"
|
|
267
|
+
:disabled="disabled"
|
|
268
|
+
class="w-full"
|
|
269
|
+
placeholder=" "
|
|
270
|
+
:aria-required="required"
|
|
271
|
+
v-bind="ariaAttrs"
|
|
272
|
+
@focus="onInputFocus"
|
|
273
|
+
@blur="onInputBlur"
|
|
274
|
+
@keydown="onInputKeydown"
|
|
275
|
+
>
|
|
276
|
+
<label
|
|
277
|
+
:for="inputId"
|
|
278
|
+
:class="['floating-label-input', { 'text-red-500': error }]"
|
|
279
|
+
>
|
|
280
|
+
{{ label }}
|
|
281
|
+
</label>
|
|
282
|
+
<template #trailing>
|
|
283
|
+
<UButton
|
|
284
|
+
v-if="dateInput && !disabled"
|
|
285
|
+
icon="i-mdi-close"
|
|
286
|
+
type="button"
|
|
287
|
+
:color="error ? 'error' : 'neutral'"
|
|
288
|
+
variant="ghost"
|
|
289
|
+
class="date-action-button"
|
|
290
|
+
:aria-label="$t('label.clearDate')"
|
|
291
|
+
@keydown.enter.prevent="clearDate"
|
|
292
|
+
@click="clearDate"
|
|
293
|
+
/>
|
|
294
|
+
<!-- capture Enter before Reka UI asChild trigger strips the listener -->
|
|
295
|
+
<UPopover
|
|
296
|
+
v-model:open="isCalendarOpen"
|
|
297
|
+
:reference="inputWrapperRef ?? undefined"
|
|
298
|
+
:content="{
|
|
299
|
+
side: 'top',
|
|
300
|
+
align: 'start',
|
|
301
|
+
onOpenAutoFocus,
|
|
302
|
+
onCloseAutoFocus,
|
|
303
|
+
onFocusOutside: ignoreInputInteraction,
|
|
304
|
+
onInteractOutside: ignoreInputInteraction,
|
|
305
|
+
}"
|
|
306
|
+
>
|
|
307
|
+
<UButton
|
|
308
|
+
icon="i-mdi-calendar"
|
|
309
|
+
type="button"
|
|
310
|
+
:disabled="disabled"
|
|
311
|
+
:color="error && !dateInput ? 'error' : 'neutral'"
|
|
312
|
+
variant="ghost"
|
|
313
|
+
class="date-action-button"
|
|
314
|
+
:aria-label="$t('label.openCalendar')"
|
|
315
|
+
@keydown.enter.prevent="isCalendarOpen = !isCalendarOpen"
|
|
316
|
+
/>
|
|
317
|
+
<template #content>
|
|
318
|
+
<div ref="calendarContentRef">
|
|
319
|
+
<UCalendar
|
|
320
|
+
:aria-label="$t('label.chooseDate')"
|
|
321
|
+
:model-value="calendarValue"
|
|
322
|
+
:min-value="calendarMinValue"
|
|
323
|
+
:max-value="calendarMaxValue"
|
|
324
|
+
:is-date-unavailable="isDateUnavailable"
|
|
325
|
+
@update:model-value="onDateSelect"
|
|
326
|
+
/>
|
|
327
|
+
</div>
|
|
328
|
+
</template>
|
|
329
|
+
</UPopover>
|
|
330
|
+
</template>
|
|
331
|
+
</UInput>
|
|
332
|
+
<!-- UFormField's own error text has no aria-live/role=alert, so it's only read on
|
|
333
|
+
initial focus, not when it appears/changes later (e.g. after blur). This announces
|
|
334
|
+
it independently of focus position whenever a string error is passed in. -->
|
|
335
|
+
<span role="alert" class="sr-only">{{ typeof error === 'string' ? error : '' }}</span>
|
|
336
|
+
<!-- same reasoning as the alert above, but for the help/hint text: role="status"
|
|
337
|
+
(implicit aria-live="polite") so it doesn't interrupt like the error does. Only
|
|
338
|
+
populated while focused, so the content actually changes (and therefore
|
|
339
|
+
announces) on every focus rather than sitting static since page load. -->
|
|
340
|
+
<span role="status" class="sr-only">{{ hasFocus && typeof error !== 'string' && help ? help : '' }}</span>
|
|
341
|
+
</div>
|
|
342
|
+
</template>
|
|
343
|
+
|
|
344
|
+
<style scoped>
|
|
345
|
+
:deep(.date-action-button) {
|
|
346
|
+
width: 1.75rem;
|
|
347
|
+
height: 1.75rem;
|
|
348
|
+
padding: 0;
|
|
349
|
+
display: flex;
|
|
350
|
+
align-items: center;
|
|
351
|
+
justify-content: center;
|
|
352
|
+
outline: none;
|
|
353
|
+
color: #1669BB;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
:deep(.date-action-button:focus-visible) {
|
|
357
|
+
box-shadow: 0 0 0 2px var(--ui-primary);
|
|
358
|
+
border-radius: 1px;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
:deep([data-slot="header"] button:focus-visible) {
|
|
362
|
+
outline: 2px solid #003366;
|
|
363
|
+
outline-offset: -4px;
|
|
364
|
+
border-radius: 20%;
|
|
365
|
+
background: transparent;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
:deep([data-slot="cellTrigger"]:focus-visible) {
|
|
369
|
+
outline: 2px solid #003366;
|
|
370
|
+
border-radius: 50%;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
:deep([data-slot="cellTrigger"][data-selected]) {
|
|
374
|
+
border-radius: 50%;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
:deep([data-slot="cellTrigger"][data-unavailable]) {
|
|
378
|
+
text-decoration: none;
|
|
379
|
+
color: #C3C3C3 !important;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
:deep([data-slot="cellTrigger"][data-outside-view]) {
|
|
383
|
+
color: #212529;
|
|
384
|
+
}
|
|
385
|
+
</style>
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { DateTime } from 'luxon'
|
|
3
|
+
|
|
4
|
+
export const DATE_API_INPUT_FORMAT = 'yyyy-MM-dd'
|
|
5
|
+
export const DATE_DISPLAY_FORMAT = 'MMMM d, yyyy'
|
|
6
|
+
|
|
7
|
+
export const DATE_INPUT_FORMATS = [
|
|
8
|
+
'MMMM d, yyyy',
|
|
9
|
+
'MMMM d yyyy',
|
|
10
|
+
'MMM d, yyyy',
|
|
11
|
+
'MMM d yyyy',
|
|
12
|
+
'M/d/yyyy',
|
|
13
|
+
'MM/dd/yyyy',
|
|
14
|
+
'yyyy-MM-dd',
|
|
15
|
+
'd MMMM yyyy',
|
|
16
|
+
'd MMM yyyy'
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
type DateSchemaOptions = {
|
|
20
|
+
minDate?: string
|
|
21
|
+
maxDate?: string
|
|
22
|
+
required?: boolean
|
|
23
|
+
messages?: {
|
|
24
|
+
invalidDate?: string
|
|
25
|
+
minDate?: string
|
|
26
|
+
maxDate?: string
|
|
27
|
+
dateRange?: string
|
|
28
|
+
required?: string
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getDateSchema(options: DateSchemaOptions = {}) {
|
|
33
|
+
const {
|
|
34
|
+
minDate,
|
|
35
|
+
maxDate,
|
|
36
|
+
required = true,
|
|
37
|
+
messages = {}
|
|
38
|
+
} = options
|
|
39
|
+
|
|
40
|
+
const { t } = useI18n()
|
|
41
|
+
|
|
42
|
+
function addBoundaryRefinement(
|
|
43
|
+
schema: z.ZodString,
|
|
44
|
+
boundaryDate: string,
|
|
45
|
+
compare: (entered: DateTime, boundary: DateTime) => boolean,
|
|
46
|
+
message: string
|
|
47
|
+
) {
|
|
48
|
+
const boundary = DateTime.fromFormat(
|
|
49
|
+
boundaryDate,
|
|
50
|
+
DATE_API_INPUT_FORMAT
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if (!boundary.isValid) {
|
|
54
|
+
return schema
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return schema.refine(
|
|
58
|
+
(val) => {
|
|
59
|
+
const entered = parseInputDate(val)
|
|
60
|
+
|
|
61
|
+
// Invalid formats are handled by the format refinement.
|
|
62
|
+
return !entered || compare(entered, boundary)
|
|
63
|
+
},
|
|
64
|
+
message
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const base = required
|
|
69
|
+
? z.string().min(1, messages.required ? messages.required : t('connect.validation.fieldRequired'))
|
|
70
|
+
: z.string()
|
|
71
|
+
|
|
72
|
+
const invalidDateMsg = messages.invalidDate ?? t('connect.validation.invalidDate')
|
|
73
|
+
let dateField = base.refine(
|
|
74
|
+
val => !val || !!parseInputDate(val),
|
|
75
|
+
invalidDateMsg
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
if (minDate && maxDate) {
|
|
79
|
+
const minBoundary = DateTime.fromFormat(
|
|
80
|
+
minDate,
|
|
81
|
+
DATE_API_INPUT_FORMAT
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
const maxBoundary = DateTime.fromFormat(
|
|
85
|
+
maxDate,
|
|
86
|
+
DATE_API_INPUT_FORMAT
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if (minBoundary.isValid && maxBoundary.isValid) {
|
|
90
|
+
const rangeMsg = messages.dateRange
|
|
91
|
+
? messages.dateRange
|
|
92
|
+
: t('connect.validation.dateNotInRange', {
|
|
93
|
+
minDate: minBoundary.toFormat(DATE_DISPLAY_FORMAT),
|
|
94
|
+
maxDate: maxBoundary.toFormat(DATE_DISPLAY_FORMAT)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
dateField = dateField.refine(
|
|
98
|
+
(val) => {
|
|
99
|
+
const entered = parseInputDate(val)
|
|
100
|
+
|
|
101
|
+
// Invalid formats are handled by the format refinement.
|
|
102
|
+
return !entered
|
|
103
|
+
|| (
|
|
104
|
+
entered >= minBoundary
|
|
105
|
+
&& entered <= maxBoundary
|
|
106
|
+
)
|
|
107
|
+
},
|
|
108
|
+
rangeMsg
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
} else if (minDate) {
|
|
112
|
+
const minBoundary = DateTime.fromFormat(
|
|
113
|
+
minDate,
|
|
114
|
+
DATE_API_INPUT_FORMAT
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
const minMsg = minBoundary.isValid
|
|
118
|
+
? messages.minDate
|
|
119
|
+
? messages.minDate
|
|
120
|
+
: t('connect.validation.dateNotBeforeMin', {
|
|
121
|
+
date: minBoundary.toFormat(DATE_DISPLAY_FORMAT)
|
|
122
|
+
})
|
|
123
|
+
: ''
|
|
124
|
+
|
|
125
|
+
dateField = addBoundaryRefinement(
|
|
126
|
+
dateField,
|
|
127
|
+
minDate,
|
|
128
|
+
(entered, boundary) => entered >= boundary,
|
|
129
|
+
minMsg
|
|
130
|
+
)
|
|
131
|
+
} else if (maxDate) {
|
|
132
|
+
const maxBoundary = DateTime.fromFormat(
|
|
133
|
+
maxDate,
|
|
134
|
+
DATE_API_INPUT_FORMAT
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
const maxMsg = maxBoundary.isValid
|
|
138
|
+
? messages.maxDate
|
|
139
|
+
? messages.maxDate
|
|
140
|
+
: t('connect.validation.dateNotAfterMax', {
|
|
141
|
+
date: maxBoundary.toFormat(DATE_DISPLAY_FORMAT)
|
|
142
|
+
})
|
|
143
|
+
: ''
|
|
144
|
+
|
|
145
|
+
dateField = addBoundaryRefinement(
|
|
146
|
+
dateField,
|
|
147
|
+
maxDate,
|
|
148
|
+
(entered, boundary) => entered <= boundary,
|
|
149
|
+
maxMsg
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return z.object({
|
|
154
|
+
dateInput: dateField
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Parses a user-entered date using any supported input format.
|
|
160
|
+
*/
|
|
161
|
+
export function parseInputDate(
|
|
162
|
+
dateStr?: string
|
|
163
|
+
): DateTime | undefined {
|
|
164
|
+
if (!dateStr) {
|
|
165
|
+
return undefined
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (const format of DATE_INPUT_FORMATS) {
|
|
169
|
+
const parsed = DateTime.fromFormat(
|
|
170
|
+
dateStr.trim(),
|
|
171
|
+
format
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
if (parsed.isValid) {
|
|
175
|
+
return parsed
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return undefined
|
|
180
|
+
}
|
package/i18n/locales/en-CA.ts
CHANGED
|
@@ -54,6 +54,13 @@ export default {
|
|
|
54
54
|
text: {
|
|
55
55
|
whatsNewSlideoverDescription: ' ',
|
|
56
56
|
whatsNewSlideoverEmpty: 'No new items.'
|
|
57
|
+
},
|
|
58
|
+
validation: {
|
|
59
|
+
dateNotAfterMax: 'Date must be on or before {date}',
|
|
60
|
+
dateNotBeforeMin: 'Date must be on or after {date}',
|
|
61
|
+
dateNotInRange: 'Date must be between {minDate} and {maxDate}',
|
|
62
|
+
fieldRequired: 'This field is required',
|
|
63
|
+
invalidDate: 'Invalid date'
|
|
57
64
|
}
|
|
58
65
|
},
|
|
59
66
|
test: {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sbc-connect/nuxt-base",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.13.0",
|
|
5
5
|
"repository": "github:bcgov/connect-nuxt",
|
|
6
6
|
"license": "BSD-3-Clause",
|
|
7
7
|
"main": "./nuxt.config.ts",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"@axe-core/playwright": "4.11.3",
|
|
23
23
|
"@types/luxon": "3.7.1",
|
|
24
24
|
"dotenv": "^17.4.2",
|
|
25
|
-
"nuxt": "4.4.
|
|
25
|
+
"nuxt": "4.4.8",
|
|
26
26
|
"typescript": "6.0.3",
|
|
27
27
|
"vue-tsc": "3.3.1",
|
|
28
28
|
"@sbc-connect/eslint-config": "0.0.8",
|