@oslokommune/punkt-elements 13.6.5 → 13.6.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,475 @@
1
+ import {
2
+ fromISOToDate,
3
+ newDate,
4
+ isValidDateRange,
5
+ sortDateStrings,
6
+ filterSelectableDates,
7
+ } from '@/utils/dateutils'
8
+ import { Ref } from 'lit/directives/ref.js'
9
+ import { PktCalendar } from '@/components/calendar/calendar'
10
+
11
+ /**
12
+ * Utility functions for PktDatepicker
13
+ */
14
+
15
+ /**
16
+ * Sleep utility function for async delays
17
+ */
18
+ export const sleep = (ms: number): Promise<void> =>
19
+ new Promise((resolve) => setTimeout(resolve, ms))
20
+
21
+ /**
22
+ * Device detection utilities
23
+ */
24
+ export const deviceDetection = {
25
+ /**
26
+ * Detects if the current device is iOS (iPhone, iPad, iPod)
27
+ */
28
+ isIOS(): boolean {
29
+ const ua = navigator.userAgent
30
+ return /iP(hone|od|ad)/.test(ua)
31
+ },
32
+
33
+ /**
34
+ * Detects if the current device is Mobile Safari
35
+ */
36
+ isMobileSafari(): boolean {
37
+ const ua = navigator.userAgent
38
+ return /iP(hone|od|ad)/.test(ua) && /Safari/.test(ua) && !/CriOS|FxiOS/.test(ua)
39
+ },
40
+ }
41
+
42
+ /**
43
+ * Value parsing and validation utilities
44
+ */
45
+ export const valueUtils = {
46
+ /**
47
+ * Parses a value (string or string array) into an array of strings
48
+ */
49
+ parseValue(value: string | string[] | null | undefined): string[] {
50
+ if (!value) return []
51
+ if (Array.isArray(value)) {
52
+ return value.filter(Boolean)
53
+ }
54
+ if (typeof value === 'string') {
55
+ return value.split(',').filter(Boolean)
56
+ }
57
+ return String(value).split(',').filter(Boolean)
58
+ },
59
+
60
+ /**
61
+ * Converts array of dates to comma-separated string
62
+ */
63
+ formatValue(values: string[]): string {
64
+ return values.join(',')
65
+ },
66
+
67
+ /**
68
+ * Ensures name attribute ends with [] for multiple/range inputs
69
+ */
70
+ normalizeNameForMultiple(
71
+ name: string | null,
72
+ isMultiple: boolean,
73
+ isRange: boolean,
74
+ ): string | null {
75
+ if (!name) return null
76
+ if ((isMultiple || isRange) && !name.endsWith('[]')) {
77
+ return name + '[]'
78
+ }
79
+ return name
80
+ },
81
+
82
+ /**
83
+ * Converts string arrays to proper arrays if they come as strings
84
+ */
85
+ normalizeStringArray(value: string | string[]): string[] {
86
+ if (typeof value === 'string') {
87
+ return value.split(',').filter(Boolean)
88
+ }
89
+ return Array.isArray(value) ? value : []
90
+ },
91
+
92
+ /**
93
+ * Validates that a range has valid order (start <= end)
94
+ */
95
+ validateRangeOrder(values: string[]): boolean {
96
+ if (!values || values.length !== 2) return true // Not a complete range
97
+ return isValidDateRange(values[0], values[1])
98
+ },
99
+
100
+ /**
101
+ * Sorts date strings chronologically
102
+ */
103
+ sortDates(dates: string[]): string[] {
104
+ return sortDateStrings(dates)
105
+ },
106
+
107
+ /**
108
+ * Filters dates to only include selectable ones based on constraints
109
+ */
110
+ filterSelectableDates(
111
+ dates: string[],
112
+ min?: string | null,
113
+ max?: string | null,
114
+ excludedDates?: string[],
115
+ excludedWeekdays?: string[],
116
+ ): string[] {
117
+ return filterSelectableDates(dates, min, max, excludedDates, excludedWeekdays)
118
+ },
119
+ }
120
+
121
+ /**
122
+ * Input type detection utilities
123
+ */
124
+ export const inputTypeUtils = {
125
+ /**
126
+ * Determines the appropriate input type based on device
127
+ * Mobile Safari does not play well with type="date" amd custom datepickers
128
+ */
129
+ getInputType(): string {
130
+ return deviceDetection.isIOS() ? 'text' : 'date'
131
+ },
132
+ }
133
+
134
+ /**
135
+ * Form and validation utilities
136
+ */
137
+ export const formUtils = {
138
+ /**
139
+ * Submits the form that contains the given element
140
+ */
141
+ submitForm(element: HTMLElement): void {
142
+ const form = (element as any).internals?.form as HTMLFormElement
143
+ if (form) {
144
+ form.requestSubmit()
145
+ }
146
+ },
147
+
148
+ /**
149
+ * Submits form if available, otherwise executes fallback action
150
+ */
151
+ submitFormOrFallback(internals: any, fallbackAction: () => void): void {
152
+ const form = internals?.form as HTMLFormElement
153
+ if (form) {
154
+ form.requestSubmit()
155
+ } else {
156
+ fallbackAction()
157
+ }
158
+ },
159
+
160
+ /**
161
+ * Validates a date input and sets validity state
162
+ */
163
+ validateDateInput(
164
+ input: HTMLInputElement,
165
+ internals: any,
166
+ min?: string | null,
167
+ max?: string | null,
168
+ strings?: any,
169
+ ): void {
170
+ const value = input.value
171
+ if (!value) return
172
+
173
+ if (min && min > value) {
174
+ internals.setValidity(
175
+ { rangeUnderflow: true },
176
+ strings?.forms?.messages?.rangeUnderflow || 'Value is below minimum',
177
+ input,
178
+ )
179
+ } else if (max && max < value) {
180
+ internals.setValidity(
181
+ { rangeOverflow: true },
182
+ strings?.forms?.messages?.rangeOverflow || 'Value is above maximum',
183
+ input,
184
+ )
185
+ }
186
+ },
187
+ }
188
+
189
+ /**
190
+ * Calendar interaction utilities
191
+ */
192
+ export const calendarUtils = {
193
+ /**
194
+ * Adds a date to selected dates if it's valid
195
+ */
196
+ addToSelected(
197
+ event: Event | KeyboardEvent,
198
+ calendarRef: Ref<PktCalendar>,
199
+ min?: string | null,
200
+ max?: string | null,
201
+ ): void {
202
+ const target = event.target as HTMLInputElement
203
+ if (!target.value) return
204
+
205
+ const minAsDate = min ? newDate(min) : null
206
+ const maxAsDate = max ? newDate(max) : null
207
+ const date = newDate(target.value.split(',')[0])
208
+
209
+ if (
210
+ date &&
211
+ !isNaN(date.getTime()) &&
212
+ (!minAsDate || date >= minAsDate) &&
213
+ (!maxAsDate || date <= maxAsDate) &&
214
+ calendarRef.value
215
+ ) {
216
+ calendarRef.value.handleDateSelect(date)
217
+ }
218
+ target.value = ''
219
+ },
220
+
221
+ /**
222
+ * Handles calendar positioning based on viewport and input position
223
+ */
224
+ handleCalendarPosition(
225
+ popupRef: Ref<HTMLDivElement>,
226
+ inputRef: Ref<HTMLInputElement>,
227
+ hasCounter: boolean = false,
228
+ ): void {
229
+ if (!popupRef.value || !inputRef.value) return
230
+
231
+ const inputRect =
232
+ inputRef.value.parentElement?.getBoundingClientRect() ||
233
+ inputRef.value.getBoundingClientRect()
234
+
235
+ const inputHeight = hasCounter ? inputRect.height + 30 : inputRect.height
236
+ const popupHeight = popupRef.value.getBoundingClientRect().height
237
+
238
+ let top = hasCounter ? 'calc(100% - 30px)' : '100%'
239
+
240
+ if (
241
+ inputRect &&
242
+ inputRect.top + popupHeight > window.innerHeight &&
243
+ inputRect.top - popupHeight > 0
244
+ ) {
245
+ top = `calc(100% - ${inputHeight}px - ${popupHeight}px)`
246
+ }
247
+
248
+ popupRef.value.style.top = top
249
+ },
250
+ }
251
+
252
+ /**
253
+ * Event handling utilities
254
+ */
255
+ export const eventUtils = {
256
+ /**
257
+ * Creates a document click listener for closing calendar
258
+ */
259
+ createDocumentClickListener(
260
+ inputRef: Ref<HTMLInputElement>,
261
+ inputRefTo: Ref<HTMLInputElement> | null,
262
+ btnRef: Ref<HTMLButtonElement>,
263
+ getCalendarOpen: () => boolean,
264
+ onBlur: () => void,
265
+ hideCalendar: () => void,
266
+ ): (e: MouseEvent) => void {
267
+ return (e: MouseEvent) => {
268
+ if (
269
+ inputRef?.value &&
270
+ btnRef?.value &&
271
+ !inputRef.value.contains(e.target as Node) &&
272
+ !(inputRefTo?.value && inputRefTo.value.contains(e.target as Node)) &&
273
+ !btnRef.value.contains(e.target as Node) &&
274
+ !(e.target as Element).closest('.pkt-calendar-popup') &&
275
+ getCalendarOpen()
276
+ ) {
277
+ onBlur()
278
+ hideCalendar()
279
+ }
280
+ }
281
+ },
282
+
283
+ /**
284
+ * Creates a document keydown listener for ESC key
285
+ */
286
+ createDocumentKeydownListener(
287
+ getCalendarOpen: () => boolean,
288
+ hideCalendar: () => void,
289
+ ): (e: KeyboardEvent) => void {
290
+ return (e: KeyboardEvent) => {
291
+ if (e.key === 'Escape' && getCalendarOpen()) {
292
+ hideCalendar()
293
+ }
294
+ }
295
+ },
296
+
297
+ /**
298
+ * Handles focus out events for calendar popup
299
+ */
300
+ handleFocusOut(
301
+ event: FocusEvent,
302
+ element: HTMLElement,
303
+ onBlur: () => void,
304
+ hideCalendar: () => void,
305
+ ): void {
306
+ if (!element.contains(event.target as Node)) {
307
+ onBlur()
308
+ hideCalendar()
309
+ }
310
+ },
311
+ }
312
+
313
+ /**
314
+ * CSS class utilities
315
+ */
316
+ export const cssUtils = {
317
+ /**
318
+ * Generates input classes for datepicker
319
+ */
320
+ getInputClasses(fullwidth: boolean, showRangeLabels: boolean, multiple: boolean, range: boolean) {
321
+ return {
322
+ 'pkt-input': true,
323
+ 'pkt-datepicker__input': true,
324
+ 'pkt-input--fullwidth': fullwidth,
325
+ 'pkt-datepicker--hasrangelabels': showRangeLabels,
326
+ 'pkt-datepicker--multiple': multiple,
327
+ 'pkt-datepicker--range': range,
328
+ }
329
+ },
330
+
331
+ /**
332
+ * Generates button classes for datepicker
333
+ */
334
+ getButtonClasses() {
335
+ return {
336
+ 'pkt-input-icon': true,
337
+ 'pkt-btn': true,
338
+ 'pkt-btn--icon-only': true,
339
+ 'pkt-btn--tertiary': true,
340
+ 'pkt-datepicker__calendar-button': true,
341
+ }
342
+ },
343
+
344
+ /**
345
+ * Generates range label classes
346
+ */
347
+ getRangeLabelClasses(showRangeLabels: boolean) {
348
+ return {
349
+ 'pkt-input-prefix': showRangeLabels,
350
+ 'pkt-hide': !showRangeLabels,
351
+ }
352
+ },
353
+ }
354
+
355
+ /**
356
+ * Date value processing utilities
357
+ */
358
+ export const dateProcessingUtils = {
359
+ /**
360
+ * Handles date selection from calendar events
361
+ */
362
+ processDateSelection(detail: any, multiple: boolean, range: boolean): string {
363
+ if (!multiple && !range) {
364
+ return detail[0] || ''
365
+ }
366
+ if (Array.isArray(detail)) {
367
+ return detail.join(',')
368
+ }
369
+ return detail
370
+ },
371
+
372
+ /**
373
+ * Updates input values after calendar selection
374
+ */
375
+ updateInputValues(
376
+ inputRef: Ref<HTMLInputElement>,
377
+ inputRefTo: Ref<HTMLInputElement> | null,
378
+ values: string[],
379
+ range: boolean,
380
+ multiple: boolean,
381
+ manageValidity: (input: HTMLInputElement) => void,
382
+ ): void {
383
+ if (!inputRef.value) return
384
+
385
+ if (range && inputRefTo?.value) {
386
+ inputRef.value.value = values[0] ?? ''
387
+ inputRefTo.value.value = values[1] ?? ''
388
+ manageValidity(inputRef.value)
389
+ manageValidity(inputRefTo.value)
390
+ } else if (!multiple) {
391
+ inputRef.value.value = values.length ? values[0] : ''
392
+ manageValidity(inputRef.value)
393
+ }
394
+ },
395
+
396
+ /**
397
+ * Processes blur events for range inputs
398
+ */
399
+ processRangeBlur(
400
+ event: Event,
401
+ values: string[],
402
+ calendarRef: Ref<PktCalendar>,
403
+ clearInputValue: () => void,
404
+ manageValidity: (input: HTMLInputElement) => void,
405
+ ): void {
406
+ const target = event.target as HTMLInputElement
407
+ if (target.value) {
408
+ manageValidity(target)
409
+ const date = fromISOToDate(target.value)
410
+ if (date) {
411
+ if (values[0] !== target.value && values[1]) {
412
+ clearInputValue()
413
+ calendarRef?.value?.handleDateSelect(date)
414
+ }
415
+ }
416
+ } else if (values[0]) {
417
+ clearInputValue()
418
+ }
419
+ },
420
+ }
421
+
422
+ /**
423
+ * Keyboard navigation utilities
424
+ */
425
+ export const keyboardUtils = {
426
+ /**
427
+ * Handles common keyboard interactions for datepicker inputs
428
+ */
429
+ handleInputKeydown(
430
+ event: KeyboardEvent,
431
+ toggleCalendar: (e: Event) => void,
432
+ submitForm?: () => void,
433
+ focusNextInput?: () => void,
434
+ blurInput?: () => void,
435
+ commaHandler?: (e: KeyboardEvent) => void,
436
+ ): void {
437
+ const { key } = event
438
+
439
+ if (key === ',') {
440
+ event.preventDefault()
441
+ if (commaHandler) {
442
+ commaHandler(event)
443
+ } else if (blurInput) {
444
+ blurInput()
445
+ }
446
+ }
447
+
448
+ if (key === 'Space' || key === ' ') {
449
+ event.preventDefault()
450
+ toggleCalendar(event)
451
+ }
452
+
453
+ if (key === 'Enter') {
454
+ event.preventDefault()
455
+ if (submitForm) {
456
+ submitForm()
457
+ } else if (focusNextInput) {
458
+ focusNextInput()
459
+ } else if (blurInput) {
460
+ blurInput()
461
+ }
462
+ }
463
+ },
464
+
465
+ /**
466
+ * Handles keyboard interactions for calendar button
467
+ */
468
+ handleButtonKeydown(event: KeyboardEvent, toggleCalendar: (e: Event) => void): void {
469
+ const { key } = event
470
+ if (key === 'Enter' || key === ' ' || key === 'Space') {
471
+ event.preventDefault()
472
+ toggleCalendar(event)
473
+ }
474
+ },
475
+ }
@@ -123,6 +123,26 @@ describe('PktDatepicker', () => {
123
123
  expect(tagTexts[1]).toContain('20')
124
124
  expect(tagTexts[2]).toContain('25')
125
125
  })
126
+
127
+ test('sorts multiple dates chronologically across months and years', async () => {
128
+ // Test dates that would fail with simple string sorting
129
+ const complexUnsortedDates = '2024-12-01,2023-01-15,2024-01-01,2023-12-31'
130
+ const container = await createDatepicker(`value="${complexUnsortedDates}" multiple`)
131
+
132
+ const datepicker = container.querySelector('pkt-datepicker') as PktDatepicker
133
+ await datepicker.updateComplete
134
+
135
+ const tags = datepicker.querySelectorAll('pkt-tag')
136
+ const tagTexts = Array.from(tags).map((tag) =>
137
+ tag.querySelector('time')?.getAttribute('datetime'),
138
+ )
139
+
140
+ // Should be sorted chronologically: 2023-01-15, 2023-12-31, 2024-01-01, 2024-12-01
141
+ expect(tagTexts[0]).toBe('2023-01-15')
142
+ expect(tagTexts[1]).toBe('2023-12-31')
143
+ expect(tagTexts[2]).toBe('2024-01-01')
144
+ expect(tagTexts[3]).toBe('2024-12-01')
145
+ })
126
146
  })
127
147
 
128
148
  describe('Range selection', () => {
@@ -251,8 +271,49 @@ describe('PktDatepicker', () => {
251
271
  const datepicker = container.querySelector('pkt-datepicker') as PktDatepicker
252
272
  await datepicker.updateComplete
253
273
 
254
- // Should handle invalid range gracefully
274
+ // Should reject invalid range and clear the value
275
+ expect(datepicker.value).toBe('')
276
+ expect(datepicker._value).toEqual([])
277
+
278
+ // Component should still render without errors
255
279
  expect(datepicker).toBeInTheDocument()
280
+
281
+ // Test with valid range should work
282
+ datepicker.value = '2024-06-15,2024-06-20'
283
+ await datepicker.updateComplete
284
+
285
+ expect(datepicker.value).toBe('2024-06-15,2024-06-20')
286
+ expect(datepicker._value).toEqual(['2024-06-15', '2024-06-20'])
287
+ })
288
+
289
+ test('handles range validation edge cases', async () => {
290
+ const container = await createDatepicker('range')
291
+ const datepicker = container.querySelector('pkt-datepicker') as PktDatepicker
292
+ await datepicker.updateComplete
293
+
294
+ // Same start and end date should be valid
295
+ datepicker.value = '2024-06-15,2024-06-15'
296
+ await datepicker.updateComplete
297
+ expect(datepicker.value).toBe('2024-06-15,2024-06-15')
298
+
299
+ // Single date should be valid (incomplete range)
300
+ datepicker.value = '2024-06-15'
301
+ await datepicker.updateComplete
302
+ expect(datepicker.value).toBe('2024-06-15')
303
+
304
+ // Empty value should be valid
305
+ datepicker.value = ''
306
+ await datepicker.updateComplete
307
+ expect(datepicker.value).toBe('')
308
+
309
+ // Multiple invalid attempts should be consistently rejected
310
+ datepicker.value = '2024-06-25,2024-06-10'
311
+ await datepicker.updateComplete
312
+ expect(datepicker.value).toBe('')
313
+
314
+ datepicker.value = '2024-12-31,2024-01-01'
315
+ await datepicker.updateComplete
316
+ expect(datepicker.value).toBe('')
256
317
  })
257
318
 
258
319
  test('shows range preview on hover', async () => {