@oslokommune/punkt-elements 14.0.2 → 14.0.4

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.
Files changed (30) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/calendar-BtShW7ER.cjs +90 -0
  3. package/dist/{calendar-Bz27nuTP.js → calendar-yxjSI4wd.js} +766 -682
  4. package/dist/datepicker-D0q75U1Z.js +1463 -0
  5. package/dist/datepicker-DDV382Uu.cjs +271 -0
  6. package/dist/index.d.ts +118 -83
  7. package/dist/pkt-calendar.cjs +1 -1
  8. package/dist/pkt-calendar.js +1 -1
  9. package/dist/pkt-datepicker.cjs +1 -1
  10. package/dist/pkt-datepicker.js +2 -2
  11. package/dist/pkt-index.cjs +1 -1
  12. package/dist/pkt-index.js +3 -3
  13. package/package.json +4 -4
  14. package/src/components/calendar/calendar.ts +372 -414
  15. package/src/components/calendar/helpers/calendar-grid.ts +93 -0
  16. package/src/components/calendar/helpers/date-validation.ts +86 -0
  17. package/src/components/calendar/helpers/index.ts +49 -0
  18. package/src/components/calendar/helpers/keyboard-navigation.ts +54 -0
  19. package/src/components/calendar/helpers/selection-manager.ts +184 -0
  20. package/src/components/datepicker/datepicker-base.ts +151 -0
  21. package/src/components/datepicker/datepicker-multiple.ts +7 -114
  22. package/src/components/datepicker/datepicker-range.ts +21 -141
  23. package/src/components/datepicker/datepicker-single.ts +7 -115
  24. package/src/components/datepicker/datepicker-types.ts +56 -0
  25. package/src/components/datepicker/datepicker-utils.test.ts +730 -0
  26. package/src/components/datepicker/datepicker-utils.ts +338 -9
  27. package/src/components/datepicker/datepicker.ts +25 -1
  28. package/dist/calendar-Dz1Cnzx5.cjs +0 -115
  29. package/dist/datepicker-CnCOXI2x.cjs +0 -289
  30. package/dist/datepicker-DsqM01iU.js +0 -1355
@@ -7,25 +7,60 @@ import {
7
7
  formatReadableDate,
8
8
  parseISODateString,
9
9
  todayInTz,
10
- isDateSelectable,
11
10
  newDateFromDate,
12
11
  } from 'shared-utils/date-utils'
13
- import { getWeek, eachDayOfInterval, getISODay, addDays } from 'date-fns'
14
- import { html, nothing, PropertyValues } from 'lit'
12
+ import { html, nothing, PropertyValues, TemplateResult } from 'lit'
15
13
  import { PktElement } from '@/base-elements/element'
16
14
  import converters from '../../helpers/converters'
17
15
  import specs from 'componentSpecs/calendar.json'
18
16
  import '@/components/icon'
19
-
20
- type DatesInRange = {
21
- [key: string]: boolean
17
+ import {
18
+ isDateExcluded,
19
+ isDayDisabled as checkDayDisabled,
20
+ isPrevMonthAllowed as checkPrevMonthAllowed,
21
+ isNextMonthAllowed as checkNextMonthAllowed,
22
+ type IDateConstraints,
23
+ } from './helpers/date-validation'
24
+ import {
25
+ DAYS_PER_WEEK,
26
+ calculateCalendarDimensions,
27
+ getCellType,
28
+ getDayNumber,
29
+ } from './helpers/calendar-grid'
30
+ import {
31
+ convertSelectedToDates,
32
+ updateRangeMap as calculateRangeMap,
33
+ isRangeAllowed as checkRangeAllowed,
34
+ addToSelection,
35
+ removeFromSelection,
36
+ toggleSelection,
37
+ handleRangeSelection,
38
+ type TDateRangeMap,
39
+ } from './helpers/selection-manager'
40
+ import {
41
+ shouldIgnoreKeyboardEvent,
42
+ findNextSelectableDate as findNextDate,
43
+ getKeyDirection,
44
+ } from './helpers/keyboard-navigation'
45
+
46
+ // Types
47
+
48
+ type TDayViewData = {
49
+ currentDate: Date
50
+ currentDateISO: string
51
+ isToday: boolean
52
+ isSelected: boolean
53
+ isDisabled: boolean
54
+ ariaLabel: string
55
+ tabindex: string
22
56
  }
23
57
 
24
58
  @customElement('pkt-calendar')
25
59
  export class PktCalendar extends PktElement {
26
- /**
27
- * Element attributes
28
- */
60
+ // Selection properties
61
+ @property({ converter: converters.csvToArray })
62
+ selected: string | string[] = []
63
+
29
64
  @property({ type: Boolean })
30
65
  multiple: boolean = specs.props.multiple.default
31
66
 
@@ -35,15 +70,7 @@ export class PktCalendar extends PktElement {
35
70
  @property({ type: Boolean })
36
71
  range: boolean = specs.props.range.default
37
72
 
38
- @property({ type: Boolean })
39
- weeknumbers: boolean = specs.props.weeknumbers.default
40
-
41
- @property({ type: Boolean })
42
- withcontrols: boolean = specs.props.withcontrols.default
43
-
44
- @property({ converter: converters.csvToArray })
45
- selected: string | string[] = []
46
-
73
+ // Date constraints
47
74
  @property({ type: String })
48
75
  earliest: string | null = specs.props.earliest.default
49
76
 
@@ -56,12 +83,17 @@ export class PktCalendar extends PktElement {
56
83
  @property({ converter: converters.csvToArray })
57
84
  excludeweekdays: string[] = []
58
85
 
86
+ // Display options
87
+ @property({ type: Boolean })
88
+ weeknumbers: boolean = specs.props.weeknumbers.default
89
+
90
+ @property({ type: Boolean })
91
+ withcontrols: boolean = specs.props.withcontrols.default
92
+
59
93
  @property({ converter: converters.stringToDate })
60
94
  currentmonth: Date | null = null
61
95
 
62
- /**
63
- * Strings
64
- */
96
+ // Localization strings
65
97
  @property({ type: Array }) dayStrings: string[] = this.strings.dates.daysShort
66
98
  @property({ type: Array }) dayStringsLong: string[] = this.strings.dates.days
67
99
  @property({ type: Array }) monthStrings: string[] = this.strings.dates.months
@@ -69,29 +101,31 @@ export class PktCalendar extends PktElement {
69
101
  @property({ type: String }) prevMonthString: string = this.strings.dates.prevMonth
70
102
  @property({ type: String }) nextMonthString: string = this.strings.dates.nextMonth
71
103
 
72
- /**
73
- * Private properties
74
- */
104
+ // Internal state - selection tracking
75
105
  @property({ type: Array }) private _selected: Date[] = []
106
+ @state() private inRange: TDateRangeMap = {}
107
+ @property({ type: Date }) private rangeHovered: Date | null = null
108
+
109
+ // Internal state - navigation and display
76
110
  @property({ type: Number }) private year: number = 0
77
111
  @property({ type: Number }) private month: number = 0
78
112
  @property({ type: Number }) private week: number = 0
79
- @property({ type: Date }) private rangeHovered: Date | null = null
113
+ @state() private currentmonthtouched: boolean = false
80
114
 
81
- @state() private inRange: DatesInRange = {}
115
+ // Internal state - keyboard navigation and focus management
82
116
  @state() private focusedDate: string | null = null
83
117
  @state() private selectableDates: {
84
118
  currentDateISO: string
85
119
  isDisabled: boolean
86
120
  tabindex: string
87
121
  }[] = []
88
- @state() private currentmonthtouched: boolean = false
89
122
  @state() private tabIndexSet: number = 0
123
+
90
124
  /**
91
- * Runs on mount, used to set up various values and whatever you need to run
125
+ * Lifecycle methods
92
126
  */
93
- connectedCallback() {
94
- super.connectedCallback()
127
+ protected firstUpdated(_changedProperties: PropertyValues): void {
128
+ this.addEventListener('keydown', this.handleKeydown)
95
129
  }
96
130
 
97
131
  disconnectedCallback(): void {
@@ -99,13 +133,6 @@ export class PktCalendar extends PktElement {
99
133
  super.disconnectedCallback()
100
134
  }
101
135
 
102
- attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
103
- if (name === 'selected' && value) {
104
- this.convertSelected()
105
- }
106
- super.attributeChangedCallback(name, _old, value)
107
- }
108
-
109
136
  updated(changedProperties: PropertyValues): void {
110
137
  super.updated(changedProperties)
111
138
  if (changedProperties.has('selected')) {
@@ -113,11 +140,10 @@ export class PktCalendar extends PktElement {
113
140
  }
114
141
  }
115
142
 
116
- protected firstUpdated(_changedProperties: PropertyValues): void {
117
- this.addEventListener('keydown', this.handleKeydown)
118
- }
119
-
120
- convertSelected() {
143
+ /**
144
+ * Date and selection management
145
+ */
146
+ private convertSelected() {
121
147
  if (typeof this.selected === 'string') {
122
148
  this.selected = this.selected.split(',')
123
149
  }
@@ -125,28 +151,16 @@ export class PktCalendar extends PktElement {
125
151
  this.selected = []
126
152
  }
127
153
 
128
- this._selected = this.selected.map((d: string) => parseISODateString(d))
129
- if (this.range && this.selected.length === 2) {
130
- const days = eachDayOfInterval({
131
- start: this._selected[0],
132
- end: this._selected[1],
133
- })
154
+ this._selected = convertSelectedToDates(this.selected)
134
155
 
135
- this.inRange = {}
136
- if (Array.isArray(days) && days.length) {
137
- const inRange: DatesInRange = {}
138
- for (let i = 0; i < days.length; i++) {
139
- const day = days[i]
140
- const isInRange = day > this._selected[0] && day < this._selected[1]
141
- inRange[formatISODate(day)] = isInRange
142
- }
143
- this.inRange = inRange
144
- }
156
+ if (this.range && this.selected.length === 2) {
157
+ this.inRange = calculateRangeMap(this._selected[0], this._selected[1])
145
158
  }
159
+
146
160
  this.setCurrentMonth()
147
161
  }
148
162
 
149
- setCurrentMonth() {
163
+ private setCurrentMonth() {
150
164
  if (this.currentmonth === null && !this.currentmonthtouched) {
151
165
  this.currentmonthtouched = true
152
166
  }
@@ -163,64 +177,40 @@ export class PktCalendar extends PktElement {
163
177
  this.month = this.currentmonth.getMonth()
164
178
  }
165
179
 
166
- handleKeydown(e: KeyboardEvent) {
167
- switch (e.key) {
168
- case 'ArrowLeft':
169
- this.handleArrowKey(e, -1)
170
- break
171
- case 'ArrowRight':
172
- this.handleArrowKey(e, 1)
173
- break
174
- case 'ArrowUp':
175
- this.handleArrowKey(e, -7)
176
- break
177
- case 'ArrowDown':
178
- this.handleArrowKey(e, 7)
179
- break
180
- default:
181
- break
180
+ /**
181
+ * Keyboard navigation
182
+ */
183
+ private handleKeydown(e: KeyboardEvent) {
184
+ const direction = getKeyDirection(e.key)
185
+ if (direction !== null) {
186
+ this.handleArrowKey(e, direction)
182
187
  }
183
188
  }
184
189
 
185
- handleArrowKey(e: KeyboardEvent, direction: number) {
186
- if ((e.target as HTMLElement)?.nodeName === 'INPUT') return
187
- if ((e.target as HTMLElement)?.nodeName === 'SELECT') return
188
- if (
189
- (e.target as HTMLElement)?.nodeName === 'BUTTON' &&
190
- !(e.target as HTMLElement)?.dataset?.date
191
- )
192
- return
190
+ private handleArrowKey(e: KeyboardEvent, direction: number) {
191
+ const target = e.target as HTMLElement
192
+ if (shouldIgnoreKeyboardEvent(target)) return
193
+
193
194
  e.preventDefault()
195
+
194
196
  if (!this.focusedDate) {
195
197
  this.focusOnCurrentDate()
198
+ return
196
199
  }
200
+
197
201
  const date = this.focusedDate ? newDate(this.focusedDate) : newDateYMD(this.year, this.month, 1)
198
- let nextDate = addDays(date, direction)
202
+ const nextDate = findNextDate(date, direction, this.querySelector.bind(this))
203
+
199
204
  if (nextDate) {
200
- let el = this.querySelector(`button[data-date="${formatISODate(nextDate)}"]`)
201
- if (el instanceof HTMLButtonElement) {
202
- if (el.dataset.disabled) {
203
- nextDate = addDays(nextDate, direction)
204
- let nextElement = this.querySelector(`button[data-date="${formatISODate(nextDate)}"]`)
205
- while (
206
- nextElement &&
207
- nextElement instanceof HTMLButtonElement &&
208
- nextElement.dataset.disabled
209
- ) {
210
- nextDate = addDays(nextDate, direction)
211
- nextElement = this.querySelector(`button[data-date="${formatISODate(nextDate)}"]`)
212
- }
213
- el = nextElement
214
- }
215
- if (el instanceof HTMLButtonElement && !el.dataset.disabled) {
216
- this.focusedDate = formatISODate(nextDate)
217
- el.focus()
218
- }
205
+ const el = this.querySelector(`button[data-date="${formatISODate(nextDate)}"]`)
206
+ if (el instanceof HTMLButtonElement && !el.dataset.disabled) {
207
+ this.focusedDate = formatISODate(nextDate)
208
+ el.focus()
219
209
  }
220
210
  }
221
211
  }
222
212
  /**
223
- * Component functionality and render
213
+ * Rendering methods
224
214
  */
225
215
  render() {
226
216
  return html`
@@ -235,51 +225,9 @@ export class PktCalendar extends PktElement {
235
225
  }}
236
226
  >
237
227
  <nav class="pkt-cal-month-nav">
238
- <div>
239
- <button
240
- type="button"
241
- aria-label="${this.prevMonthString}"
242
- @click=${() => this.isPrevMonthAllowed() && this.prevMonth()}
243
- @keydown=${(e: KeyboardEvent) => {
244
- if (e.key === 'Enter' || e.key === ' ') {
245
- e.preventDefault()
246
- this.isPrevMonthAllowed() && this.prevMonth()
247
- }
248
- }}
249
- class="pkt-btn pkt-btn--tertiary pkt-btn--small pkt-btn--icon-only pkt-calendar__prev-month ${this.isPrevMonthAllowed()
250
- ? ''
251
- : 'pkt-hide'}"
252
- .data-disabled=${!this.isPrevMonthAllowed() ? 'disabled' : nothing}
253
- ?aria-disabled=${!this.isPrevMonthAllowed()}
254
- tabindex=${this.isPrevMonthAllowed() ? '0' : '-1'}
255
- >
256
- <pkt-icon class="pkt-btn__icon" name="chevron-thin-left"></pkt-icon>
257
- <span class="pkt-btn__text">${this.prevMonthString}</span>
258
- </button>
259
- </div>
228
+ ${this.renderMonthNavButton('prev')}
260
229
  ${this.renderMonthNav()}
261
- <div>
262
- <button
263
- type="button"
264
- aria-label="${this.nextMonthString}"
265
- @click=${() => this.isNextMonthAllowed() && this.nextMonth()}
266
- @keydown=${(e: KeyboardEvent) => {
267
- if (e.key === 'Enter' || e.key === ' ') {
268
- e.preventDefault()
269
- this.isNextMonthAllowed() && this.nextMonth()
270
- }
271
- }}
272
- class="pkt-btn pkt-btn--tertiary pkt-btn--small pkt-btn--icon-only pkt-calendar__next-month ${this.isNextMonthAllowed()
273
- ? ''
274
- : 'pkt-hide'}"
275
- .data-disabled=${!this.isNextMonthAllowed() ? 'disabled' : nothing}
276
- ?aria-disabled=${!this.isNextMonthAllowed()}
277
- tabindex=${this.isNextMonthAllowed() ? '0' : '-1'}
278
- >
279
- <pkt-icon class="pkt-btn__icon" name="chevron-thin-right"></pkt-icon>
280
- <span class="pkt-btn__text">${this.nextMonthString}</span>
281
- </button>
282
- </div>
230
+ ${this.renderMonthNavButton('next')}
283
231
  </nav>
284
232
  <table
285
233
  class="pkt-cal-days pkt-txt-12-medium pkt-calendar__body"
@@ -297,11 +245,43 @@ export class PktCalendar extends PktElement {
297
245
  `
298
246
  }
299
247
 
300
- private renderDayNames() {
301
- const days: any[] = []
248
+ private renderMonthNavButton(direction: 'prev' | 'next'): TemplateResult {
249
+ const isPrev = direction === 'prev'
250
+ const isAllowed = isPrev ? this.isPrevMonthAllowed() : this.isNextMonthAllowed()
251
+ const label = isPrev ? this.prevMonthString : this.nextMonthString
252
+ const iconName = isPrev ? 'chevron-thin-left' : 'chevron-thin-right'
253
+ const className = isPrev ? 'pkt-calendar__prev-month' : 'pkt-calendar__next-month'
254
+ const onClick = isPrev ? () => this.prevMonth() : () => this.nextMonth()
255
+
256
+ return html`<div>
257
+ <button
258
+ type="button"
259
+ aria-label="${label}"
260
+ @click=${() => isAllowed && onClick()}
261
+ @keydown=${(e: KeyboardEvent) => {
262
+ if (e.key === 'Enter' || e.key === ' ') {
263
+ e.preventDefault()
264
+ isAllowed && onClick()
265
+ }
266
+ }}
267
+ class="pkt-btn pkt-btn--tertiary pkt-btn--small pkt-btn--icon-only ${className} ${isAllowed ? '' : 'pkt-hide'}"
268
+ .data-disabled=${!isAllowed ? 'disabled' : nothing}
269
+ ?aria-disabled=${!isAllowed}
270
+ tabindex=${isAllowed ? '0' : '-1'}
271
+ >
272
+ <pkt-icon class="pkt-btn__icon" name="${iconName}"></pkt-icon>
273
+ <span class="pkt-btn__text">${label}</span>
274
+ </button>
275
+ </div>`
276
+ }
277
+
278
+ private renderDayNames(): TemplateResult {
279
+ const days: TemplateResult[] = []
280
+
302
281
  if (this.weeknumbers) {
303
282
  days.push(html`<th><div class="pkt-calendar__week-number">${this.weekString}</div></th>`)
304
283
  }
284
+
305
285
  for (let i = 0; i < this.dayStrings.length; i++) {
306
286
  days.push(
307
287
  html`<th>
@@ -311,101 +291,128 @@ export class PktCalendar extends PktElement {
311
291
  </th>`,
312
292
  )
313
293
  }
314
- return html`<tr class="pkt-cal-week-row">
315
- ${days}
316
- </tr>`
294
+
295
+ return html`<tr class="pkt-cal-week-row">${days}</tr>`
317
296
  }
318
297
 
319
- private renderMonthNav() {
320
- let monthView: any[] = []
298
+ private renderMonthNav(): TemplateResult {
321
299
  if (this.withcontrols) {
322
- monthView.push(
323
- html`<div class="pkt-cal-month-picker">
324
- <label for="${this.id}-monthnav" class="pkt-hide">${this.strings.dates.month}</label>
325
- <select
326
- aria-label="${this.strings.dates.month}"
327
- class="pkt-input pkt-input-compact"
328
- id="${this.id}-monthnav"
329
- @change=${(e: any) => {
330
- e.stopImmediatePropagation()
331
- this.changeMonth(this.year, e.target.value)
332
- }}
333
- >
334
- ${this.monthStrings.map(
335
- (month, index) =>
336
- html`<option value=${index} ?selected=${this.month === index}>${month}</option>`,
337
- )}
338
- </select>
339
- <label for="${this.id}-yearnav" class="pkt-hide">${this.strings.dates.year}</label>
340
- <input
341
- aria-label="${this.strings.dates.year}"
342
- class="pkt-input pkt-cal-input-year pkt-input-compact"
343
- id="${this.id}-yearnav"
344
- type="number"
345
- size="4"
346
- placeholder="0000"
347
- @change=${(e: any) => {
348
- e.stopImmediatePropagation()
349
- this.changeMonth(e.target.value, this.month)
350
- }}
351
- .value=${this.year}
352
- />
353
- </div> `,
354
- )
355
- } else {
356
- monthView.push(
357
- html`<div class="pkt-txt-16-medium pkt-calendar__month-title" aria-live="polite">
358
- ${this.monthStrings[this.month]} ${this.year}
359
- </div>`,
360
- )
300
+ return html`<div class="pkt-cal-month-picker">
301
+ <label for="${this.id}-monthnav" class="pkt-hide">${this.strings.dates.month}</label>
302
+ <select
303
+ aria-label="${this.strings.dates.month}"
304
+ class="pkt-input pkt-input-compact"
305
+ id="${this.id}-monthnav"
306
+ @change=${(e: Event) => {
307
+ e.stopImmediatePropagation()
308
+ const target = e.target as HTMLSelectElement
309
+ this.changeMonth(this.year, parseInt(target.value))
310
+ }}
311
+ >
312
+ ${this.monthStrings.map(
313
+ (month, index) =>
314
+ html`<option value=${index} ?selected=${this.month === index}>${month}</option>`,
315
+ )}
316
+ </select>
317
+ <label for="${this.id}-yearnav" class="pkt-hide">${this.strings.dates.year}</label>
318
+ <input
319
+ aria-label="${this.strings.dates.year}"
320
+ class="pkt-input pkt-cal-input-year pkt-input-compact"
321
+ id="${this.id}-yearnav"
322
+ type="number"
323
+ size="4"
324
+ placeholder="0000"
325
+ @change=${(e: Event) => {
326
+ e.stopImmediatePropagation()
327
+ const target = e.target as HTMLInputElement
328
+ this.changeMonth(parseInt(target.value), this.month)
329
+ }}
330
+ .value=${this.year}
331
+ />
332
+ </div>`
361
333
  }
362
- return monthView
334
+
335
+ return html`<div class="pkt-txt-16-medium pkt-calendar__month-title" aria-live="polite">
336
+ ${this.monthStrings[this.month]} ${this.year}
337
+ </div>`
363
338
  }
364
339
 
365
- private renderDayView(dayCounter: number, today: Date) {
340
+ private getDayViewData(dayCounter: number, today: Date): TDayViewData {
366
341
  const currentDate = newDateYMD(this.year, this.month, dayCounter)
367
342
  const currentDateISO = formatISODate(currentDate)
368
343
  const isToday = currentDateISO === formatISODate(today)
369
344
  const isSelected = this.selected.includes(currentDateISO)
370
- const ariaLabel = formatReadableDate(currentDate)
371
- const isDisabled =
372
- this.isExcluded(currentDate) ||
373
- (!isSelected &&
374
- this.multiple &&
375
- this.maxMultiple > 0 &&
376
- this.selected.length >= this.maxMultiple)
377
- const tabindex = this.focusedDate
378
- ? this.focusedDate === currentDateISO && !isDisabled
379
- ? '0'
380
- : '-1'
381
- : !isDisabled && this.tabIndexSet === 0
382
- ? '0'
383
- : this.tabIndexSet === dayCounter
384
- ? '0'
385
- : '-1'
386
-
387
- if (tabindex === '0') {
345
+ const isDisabled = this.isDayDisabled(currentDate, isSelected)
346
+ const tabindex = this.calculateTabIndex(currentDateISO, isDisabled, dayCounter)
347
+
348
+ return {
349
+ currentDate,
350
+ currentDateISO,
351
+ isToday,
352
+ isSelected,
353
+ isDisabled,
354
+ ariaLabel: formatReadableDate(currentDate),
355
+ tabindex,
356
+ }
357
+ }
358
+
359
+ private getDateConstraints(): IDateConstraints {
360
+ return {
361
+ earliest: this.earliest,
362
+ latest: this.latest,
363
+ excludedates: this.excludedates,
364
+ excludeweekdays: this.excludeweekdays,
365
+ }
366
+ }
367
+
368
+ private isDayDisabled(date: Date, isSelected: boolean): boolean {
369
+ return checkDayDisabled(date, isSelected, this.getDateConstraints(), {
370
+ multiple: this.multiple,
371
+ maxMultiple: this.maxMultiple,
372
+ selectedCount: this.selected.length,
373
+ })
374
+ }
375
+
376
+ private calculateTabIndex(dateISO: string, isDisabled: boolean, dayCounter: number): string {
377
+ if (this.focusedDate) {
378
+ return this.focusedDate === dateISO && !isDisabled ? '0' : '-1'
379
+ }
380
+
381
+ if (!isDisabled && this.tabIndexSet === 0) {
388
382
  this.tabIndexSet = dayCounter
383
+ return '0'
389
384
  }
390
385
 
391
- this.selectableDates.push({ currentDateISO, isDisabled, tabindex })
386
+ return this.tabIndexSet === dayCounter ? '0' : '-1'
387
+ }
392
388
 
393
- const classes = {
389
+ private getDayCellClasses(data: TDayViewData) {
390
+ const { currentDateISO, isToday, isSelected } = data
391
+ const isRangeStart = this.range &&
392
+ (this.selected.length === 2 || this.rangeHovered !== null) &&
393
+ currentDateISO === this.selected[0]
394
+ const isRangeEnd = this.range && this.selected.length === 2 && currentDateISO === this.selected[1]
395
+
396
+ return {
394
397
  'pkt-cal-today': isToday,
395
398
  'pkt-cal-selected': isSelected,
396
399
  'pkt-cal-in-range': this.inRange[currentDateISO],
397
- 'pkt-cal-excluded': this.isExcluded(currentDate),
398
- 'pkt-cal-in-range-first':
399
- this.range &&
400
- (this.selected.length === 2 || this.rangeHovered !== null) &&
401
- currentDateISO === this.selected[0],
402
- 'pkt-cal-in-range-last':
403
- this.range && this.selected.length === 2 && currentDateISO === this.selected[1],
400
+ 'pkt-cal-excluded': this.isExcluded(data.currentDate),
401
+ 'pkt-cal-in-range-first': isRangeStart,
402
+ 'pkt-cal-in-range-last': isRangeEnd,
404
403
  'pkt-cal-range-hover':
405
404
  this.rangeHovered !== null && currentDateISO === formatISODate(this.rangeHovered),
406
405
  }
406
+ }
407
+
408
+ private getDayButtonClasses(data: TDayViewData) {
409
+ const { currentDateISO, isToday, isSelected, isDisabled } = data
410
+ const isRangeStart = this.range &&
411
+ (this.selected.length === 2 || this.rangeHovered !== null) &&
412
+ currentDateISO === this.selected[0]
413
+ const isRangeEnd = this.range && this.selected.length === 2 && currentDateISO === this.selected[1]
407
414
 
408
- const buttonClasses = {
415
+ return {
409
416
  'pkt-calendar__date': true,
410
417
  'pkt-calendar__date--today': isToday,
411
418
  'pkt-calendar__date--selected': isSelected,
@@ -413,30 +420,38 @@ export class PktCalendar extends PktElement {
413
420
  'pkt-calendar__date--in-range': this.inRange[currentDateISO],
414
421
  'pkt-calendar__date--in-range-hover':
415
422
  this.rangeHovered !== null && currentDateISO === formatISODate(this.rangeHovered),
416
- 'pkt-calendar__date--range-start':
417
- this.range &&
418
- (this.selected.length === 2 || this.rangeHovered !== null) &&
419
- currentDateISO === this.selected[0],
420
- 'pkt-calendar__date--range-end':
421
- this.range && this.selected.length === 2 && currentDateISO === this.selected[1],
423
+ 'pkt-calendar__date--range-start': isRangeStart,
424
+ 'pkt-calendar__date--range-end': isRangeEnd,
422
425
  }
426
+ }
427
+
428
+ private handleDayFocus(date: Date, dateISO: string): void {
429
+ if (this.range && !this.isExcluded(date)) {
430
+ this.handleRangeHover(date)
431
+ }
432
+ this.focusedDate = dateISO
433
+ }
434
+
435
+ private renderDayView(dayCounter: number, today: Date) {
436
+ const data = this.getDayViewData(dayCounter, today)
437
+ const { currentDate, currentDateISO, isSelected, isDisabled, ariaLabel, tabindex } = data
423
438
 
424
- return html`<td class=${classMap(classes)}>
439
+ // Track selectable dates for keyboard navigation
440
+ this.selectableDates.push({ currentDateISO, isDisabled, tabindex })
441
+
442
+ const cellClasses = this.getDayCellClasses(data)
443
+ const buttonClasses = this.getDayButtonClasses(data)
444
+
445
+ return html`<td class=${classMap(cellClasses)}>
425
446
  <button
426
447
  type="button"
427
448
  aria-pressed=${isSelected ? 'true' : 'false'}
428
449
  ?disabled=${isDisabled}
429
- class="pkt-btn pkt-btn--tertiary pkt-btn--small pkt-btn--label-only ${classMap(
430
- buttonClasses,
431
- )}"
432
- @mouseover=${() =>
433
- this.range && !this.isExcluded(currentDate) && this.handleRangeHover(currentDate)}
434
- @focus=${() => {
435
- this.range && !this.isExcluded(currentDate) && this.handleRangeHover(currentDate)
436
- this.focusedDate = currentDateISO
437
- }}
450
+ class="pkt-btn pkt-btn--tertiary pkt-btn--small pkt-btn--label-only ${classMap(buttonClasses)}"
451
+ @mouseover=${() => this.range && !this.isExcluded(currentDate) && this.handleRangeHover(currentDate)}
452
+ @focus=${() => this.handleDayFocus(currentDate, currentDateISO)}
438
453
  aria-label="${ariaLabel}"
439
- tabindex=${this.selectableDates.find((x) => x.currentDateISO === currentDateISO)?.tabindex}
454
+ tabindex=${tabindex}
440
455
  data-disabled=${isDisabled ? 'disabled' : nothing}
441
456
  data-date=${currentDateISO}
442
457
  @keydown=${(e: KeyboardEvent) => {
@@ -457,86 +472,73 @@ export class PktCalendar extends PktElement {
457
472
  </td>`
458
473
  }
459
474
 
475
+ private renderEmptyDayCell(day: number): TemplateResult {
476
+ return html`<td class="pkt-cal-other">
477
+ <div
478
+ class="pkt-btn pkt-btn--tertiary pkt-btn--small pkt-btn--label-only"
479
+ data-disabled="disabled"
480
+ >
481
+ <span class="pkt-btn__text pkt-txt-14-light">${day}</span>
482
+ </div>
483
+ </td>`
484
+ }
485
+
486
+ private renderWeekRow(cells: TemplateResult[]): TemplateResult {
487
+ return html`<tr class="pkt-cal-week-row" role="row">${cells}</tr>`
488
+ }
489
+
460
490
  private renderCalendarBody() {
461
491
  const today = todayInTz()
462
- const firstDayOfMonth = newDateYMD(this.year, this.month, 1)
463
- const lastDayOfMonth = newDateYMD(this.year, this.month + 1, 0)
464
- const startingDay = (firstDayOfMonth.getDay() + 6) % 7
465
- const numDays = lastDayOfMonth.getDate()
466
- const numRows = Math.ceil((numDays + startingDay) / 7)
467
- const lastDayOfPrevMonth = newDateYMD(this.year, this.month, 0)
468
- const numDaysPrevMonth = lastDayOfPrevMonth.getDate()
492
+ const dimensions = calculateCalendarDimensions(this.year, this.month)
469
493
 
470
494
  let dayCounter = 1
471
- this.week = getWeek(newDateYMD(this.year, this.month, 1))
495
+ this.week = dimensions.initialWeek
472
496
 
473
- const rows: any[] = []
497
+ const rows: TemplateResult[] = []
474
498
 
475
- for (let i = 0; i < numRows; i++) {
476
- const cells: any[] = []
499
+ for (let i = 0; i < dimensions.numRows; i++) {
500
+ const cells: TemplateResult[] = []
477
501
 
478
- this.weeknumbers && cells.push(html`<td class="pkt-cal-week">${this.week}</td>`)
502
+ // Add week number if enabled
503
+ if (this.weeknumbers) {
504
+ cells.push(html`<td class="pkt-cal-week">${this.week}</td>`)
505
+ }
479
506
  this.week++
480
507
 
481
- for (let j = 1; j < 8; j++) {
482
- if (i === 0 && j < startingDay + 1) {
483
- const dayFromPrevMonth = numDaysPrevMonth - (startingDay - j)
484
- cells.push(
485
- html`<td class="pkt-cal-other">
486
- <div
487
- class="pkt-btn pkt-btn--tertiary pkt-btn--small pkt-btn--label-only"
488
- data-disabled="disabled"
489
- >
490
- <span class="pkt-btn__text pkt-txt-14-light">${dayFromPrevMonth}</span>
491
- </div>
492
- </td>`,
493
- )
494
- } else if (dayCounter > numDays) {
495
- cells.push(
496
- html`<td class="pkt-cal-other">
497
- <div
498
- class="pkt-btn pkt-btn--tertiary pkt-btn--small pkt-btn--label-only"
499
- data-disabled="disabled"
500
- >
501
- <span class="pkt-btn__text pkt-txt-14-light">${dayCounter - numDays}</span>
502
- </div>
503
- </td>`,
504
- )
505
- dayCounter++
506
- } else {
508
+ // Render each day in the week
509
+ for (let j = 0; j < DAYS_PER_WEEK; j++) {
510
+ const cellType = getCellType(i, j, dayCounter, dimensions)
511
+
512
+ if (cellType === 'current-month') {
507
513
  cells.push(this.renderDayView(dayCounter, today))
508
514
  dayCounter++
515
+ } else {
516
+ const dayNumber = getDayNumber(cellType, j, dayCounter, dimensions)
517
+ cells.push(this.renderEmptyDayCell(dayNumber))
518
+ if (cellType === 'next-month') {
519
+ dayCounter++
520
+ }
509
521
  }
510
522
  }
511
523
 
512
- rows.push(
513
- html`<tr class="pkt-cal-week-row" role="row">
514
- ${cells}
515
- </tr>`,
516
- )
524
+ rows.push(this.renderWeekRow(cells))
517
525
  }
518
526
 
519
527
  return rows
520
528
  }
521
529
 
522
- private isExcluded(date: Date) {
523
- const excludedDatesStrings = this.excludedates.map((d) =>
524
- typeof d === 'string' ? d : formatISODate(d),
525
- )
526
-
527
- return !isDateSelectable(
528
- date,
529
- this.earliest,
530
- this.latest,
531
- excludedDatesStrings,
532
- this.excludeweekdays,
533
- )
530
+ /**
531
+ * Date validation
532
+ */
533
+ private isExcluded(date: Date): boolean {
534
+ return isDateExcluded(date, this.getDateConstraints())
534
535
  }
535
536
 
536
- isPrevMonthAllowed() {
537
- const prevMonth = newDateYMD(this.year, this.month, 0)
538
- if (this.earliest && newDate(this.earliest) > prevMonth) return false
539
- return true
537
+ /**
538
+ * Month navigation
539
+ */
540
+ isPrevMonthAllowed(): boolean {
541
+ return checkPrevMonthAllowed(this.year, this.month, this.earliest)
540
542
  }
541
543
 
542
544
  private prevMonth() {
@@ -545,14 +547,8 @@ export class PktCalendar extends PktElement {
545
547
  this.changeMonth(newYear, newMonth)
546
548
  }
547
549
 
548
- isNextMonthAllowed() {
549
- const nextMonth = newDateYMD(
550
- this.month === 11 ? this.year + 1 : this.year,
551
- this.month === 11 ? 0 : this.month + 1,
552
- 1,
553
- )
554
- if (this.latest && newDate(this.latest) < nextMonth) return false
555
- return true
550
+ isNextMonthAllowed(): boolean {
551
+ return checkNextMonthAllowed(this.year, this.month, this.latest)
556
552
  }
557
553
 
558
554
  private nextMonth() {
@@ -561,7 +557,7 @@ export class PktCalendar extends PktElement {
561
557
  this.changeMonth(newYear, newMonth)
562
558
  }
563
559
 
564
- private changeMonth(year: number, month: number) {
560
+ private changeMonth(year: number, month: number): void {
565
561
  this.year = typeof year === 'string' ? parseInt(year) : year
566
562
  this.month = typeof month === 'string' ? parseInt(month) : month
567
563
  this.currentmonth = newDateFromDate(new Date(this.year, this.month, 1))
@@ -570,40 +566,25 @@ export class PktCalendar extends PktElement {
570
566
  this.selectableDates = []
571
567
  }
572
568
 
573
- private isRangeAllowed(date: Date) {
574
- let allowed = true
575
- if (this._selected.length === 1) {
576
- const days = eachDayOfInterval({
577
- start: this._selected[0],
578
- end: date,
579
- })
580
-
581
- if (Array.isArray(days) && days.length) {
582
- for (let i = 0; i < days.length; i++) {
583
- this.excludedates.forEach((d: Date) => {
584
- if (d > this._selected[0] && d < date) {
585
- allowed = false
586
- }
587
- })
588
- if (this.excludeweekdays.includes(getISODay(days[i]).toString())) {
589
- allowed = false
590
- }
591
- }
592
- }
593
- }
594
- return allowed
595
- }
596
-
597
- private emptySelected() {
569
+ /**
570
+ * Date selection logic
571
+ */
572
+ private emptySelected(): void {
598
573
  this.selected = []
599
574
  this._selected = []
600
575
  this.inRange = {}
601
576
  }
602
577
 
603
- public addToSelected(selectedDate: Date) {
604
- if (this.selected.includes(formatISODate(selectedDate))) return
605
- this.selected = [...this.selected, formatISODate(selectedDate)]
606
- this._selected = [...this._selected, selectedDate]
578
+ private normalizeSelected(): string[] {
579
+ if (typeof this.selected === 'string') {
580
+ return this.selected.split(',')
581
+ }
582
+ return this.selected
583
+ }
584
+
585
+ public addToSelected(selectedDate: Date): void {
586
+ this.selected = addToSelection(selectedDate, this.normalizeSelected())
587
+ this._selected = convertSelectedToDates(this.selected)
607
588
 
608
589
  if (this.range && this.selected.length === 2) {
609
590
  this.convertSelected()
@@ -611,83 +592,54 @@ export class PktCalendar extends PktElement {
611
592
  }
612
593
  }
613
594
 
614
- public removeFromSelected(selectedDate: Date) {
615
- if (this.selected.length === 1) {
616
- this.emptySelected()
617
- } else {
618
- const selectedDateIndex = this.selected.indexOf(formatISODate(selectedDate))
619
- const selectedCopy = [...this.selected]
620
- const _selectedCopy = [...this._selected]
621
- selectedCopy.splice(selectedDateIndex, 1)
622
- _selectedCopy.splice(selectedDateIndex, 1)
623
- this.selected = selectedCopy
624
- this._selected = _selectedCopy
625
- }
595
+ public removeFromSelected(selectedDate: Date): void {
596
+ this.selected = removeFromSelection(selectedDate, this.normalizeSelected())
597
+ this._selected = convertSelectedToDates(this.selected)
626
598
  }
627
599
 
628
- public toggleSelected(selectedDate: Date) {
629
- const selectedDateISO = formatISODate(selectedDate)
630
- this.selected.includes(selectedDateISO)
631
- ? this.removeFromSelected(selectedDate)
632
- : !(this.maxMultiple && this.selected.length >= this.maxMultiple)
633
- ? this.addToSelected(selectedDate)
634
- : null
600
+ public toggleSelected(selectedDate: Date): void {
601
+ this.selected = toggleSelection(selectedDate, this.normalizeSelected(), this.maxMultiple)
602
+ this._selected = convertSelectedToDates(this.selected)
635
603
  }
636
604
 
637
- private handleRangeSelect(selectedDate: Date) {
638
- const selectedDateISO = formatISODate(selectedDate)
639
- if (this.selected.includes(selectedDateISO)) {
640
- if (this.selected.indexOf(selectedDateISO) === 0) {
641
- this.emptySelected()
642
- } else {
643
- this.removeFromSelected(selectedDate)
644
- }
645
- } else {
646
- if (this.selected.length > 1) {
647
- this.emptySelected()
648
- this.addToSelected(selectedDate)
649
- } else {
650
- if (this.selected.length === 1 && !this.isRangeAllowed(selectedDate)) {
651
- this.emptySelected()
652
- }
653
- if (this.selected.length === 1 && this._selected[0] > selectedDate) {
654
- this.emptySelected()
655
- }
656
- this.addToSelected(selectedDate)
657
- }
605
+ private isRangeAllowed(date: Date): boolean {
606
+ return checkRangeAllowed(date, this._selected, this.excludedates, this.excludeweekdays)
607
+ }
608
+
609
+ private handleRangeSelect(selectedDate: Date): Promise<void> {
610
+ this.selected = handleRangeSelection(selectedDate, this.normalizeSelected(), {
611
+ excludedates: this.excludedates,
612
+ excludeweekdays: this.excludeweekdays,
613
+ })
614
+ this._selected = convertSelectedToDates(this.selected)
615
+
616
+ if (this.selected.length === 2) {
617
+ this.convertSelected()
618
+ } else if (this.selected.length === 1) {
619
+ // Clear inRange markers when starting a new range
620
+ this.inRange = {}
658
621
  }
622
+
659
623
  return Promise.resolve()
660
624
  }
661
625
 
662
- private handleRangeHover(date: Date) {
626
+ private handleRangeHover(date: Date): void {
663
627
  if (
664
- this.range &&
665
- this._selected.length === 1 &&
666
- this.isRangeAllowed(date) &&
667
- this._selected[0] < date
628
+ !this.range ||
629
+ this._selected.length !== 1 ||
630
+ !this.isRangeAllowed(date) ||
631
+ this._selected[0] >= date
668
632
  ) {
669
- this.rangeHovered = date
670
-
671
- this.inRange = {}
672
- const days = eachDayOfInterval({
673
- start: this._selected[0],
674
- end: date,
675
- })
676
-
677
- if (Array.isArray(days) && days.length) {
678
- for (let i = 0; i < days.length; i++) {
679
- const day = days[i]
680
- const isInRange = day > this._selected[0] && day < date
681
- this.inRange[formatISODate(day)] = isInRange
682
- }
683
- }
684
- } else {
685
633
  this.rangeHovered = null
634
+ return
686
635
  }
636
+
637
+ this.rangeHovered = date
638
+ this.inRange = calculateRangeMap(this._selected[0], date)
687
639
  }
688
640
 
689
- public handleDateSelect(selectedDate: Date | null) {
690
- if (!selectedDate) return
641
+ public handleDateSelect(selectedDate: Date | null): Promise<void> {
642
+ if (!selectedDate) return Promise.resolve()
691
643
  if (this.range) {
692
644
  this.handleRangeSelect(selectedDate)
693
645
  } else if (this.multiple) {
@@ -711,27 +663,33 @@ export class PktCalendar extends PktElement {
711
663
  return Promise.resolve()
712
664
  }
713
665
 
714
- public focusOnCurrentDate() {
666
+ /**
667
+ * Focus management and event handlers
668
+ */
669
+ public focusOnCurrentDate(): void {
715
670
  const currentDateISO = formatISODate(newDateFromDate(new Date()))
716
671
  const el = this.querySelector(`button[data-date="${currentDateISO}"]`)
672
+
717
673
  if (el instanceof HTMLButtonElement) {
718
674
  this.focusedDate = currentDateISO
719
675
  el.focus()
720
- } else {
721
- const firstSelectable = this.selectableDates.find((x) => !x.isDisabled)
722
- if (firstSelectable) {
723
- const firstSelectableEl = this.querySelector(
724
- `button[data-date="${firstSelectable.currentDateISO}"]`,
725
- )
726
- if (firstSelectableEl instanceof HTMLButtonElement) {
727
- this.focusedDate = firstSelectable.currentDateISO
728
- firstSelectableEl.focus()
729
- }
676
+ return
677
+ }
678
+
679
+ // Fall back to first selectable date
680
+ const firstSelectable = this.selectableDates.find((x) => !x.isDisabled)
681
+ if (firstSelectable) {
682
+ const firstSelectableEl = this.querySelector(
683
+ `button[data-date="${firstSelectable.currentDateISO}"]`,
684
+ )
685
+ if (firstSelectableEl instanceof HTMLButtonElement) {
686
+ this.focusedDate = firstSelectable.currentDateISO
687
+ firstSelectableEl.focus()
730
688
  }
731
689
  }
732
690
  }
733
691
 
734
- public closeEvent(e: FocusEvent) {
692
+ public closeEvent(e: FocusEvent): void {
735
693
  if (
736
694
  !this.contains(e.relatedTarget as Node) &&
737
695
  !(e.target as Element).classList.contains('pkt-hide')
@@ -740,7 +698,7 @@ export class PktCalendar extends PktElement {
740
698
  }
741
699
  }
742
700
 
743
- public close() {
701
+ public close(): void {
744
702
  this.dispatchEvent(
745
703
  new CustomEvent('close', {
746
704
  detail: true,