@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="&nbsp;"
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
+ }
@@ -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.12.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.6",
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",