@api-client/ui 0.5.6 → 0.5.8

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 (82) hide show
  1. package/.cursor/rules/html-and-css-best-practices.mdc +63 -0
  2. package/.cursor/rules/lit-best-practices.mdc +78 -0
  3. package/.github/instructions/html-and-css-best-practices.instructions.md +70 -0
  4. package/.github/instructions/lit-best-practices.instructions.md +86 -0
  5. package/build/src/elements/currency/currency-picker.d.ts +10 -0
  6. package/build/src/elements/currency/currency-picker.d.ts.map +1 -0
  7. package/build/src/elements/currency/currency-picker.js +27 -0
  8. package/build/src/elements/currency/currency-picker.js.map +1 -0
  9. package/build/src/elements/currency/internals/Picker.d.ts +311 -0
  10. package/build/src/elements/currency/internals/Picker.d.ts.map +1 -0
  11. package/build/src/elements/currency/internals/Picker.js +857 -0
  12. package/build/src/elements/currency/internals/Picker.js.map +1 -0
  13. package/build/src/elements/currency/internals/Picker.styles.d.ts +3 -0
  14. package/build/src/elements/currency/internals/Picker.styles.d.ts.map +1 -0
  15. package/build/src/elements/currency/internals/Picker.styles.js +58 -0
  16. package/build/src/elements/currency/internals/Picker.styles.js.map +1 -0
  17. package/build/src/elements/mention-textarea/internals/MentionTextArea.d.ts +216 -0
  18. package/build/src/elements/mention-textarea/internals/MentionTextArea.d.ts.map +1 -0
  19. package/build/src/elements/mention-textarea/internals/MentionTextArea.js +1037 -0
  20. package/build/src/elements/mention-textarea/internals/MentionTextArea.js.map +1 -0
  21. package/build/src/elements/mention-textarea/internals/MentionTextArea.styles.d.ts +3 -0
  22. package/build/src/elements/mention-textarea/internals/MentionTextArea.styles.d.ts.map +1 -0
  23. package/build/src/elements/mention-textarea/internals/MentionTextArea.styles.js +274 -0
  24. package/build/src/elements/mention-textarea/internals/MentionTextArea.styles.js.map +1 -0
  25. package/build/src/elements/mention-textarea/ui-mention-textarea.d.ts +13 -0
  26. package/build/src/elements/mention-textarea/ui-mention-textarea.d.ts.map +1 -0
  27. package/build/src/elements/mention-textarea/ui-mention-textarea.js +28 -0
  28. package/build/src/elements/mention-textarea/ui-mention-textarea.js.map +1 -0
  29. package/build/src/md/button/internals/base.d.ts +1 -0
  30. package/build/src/md/button/internals/base.d.ts.map +1 -1
  31. package/build/src/md/button/internals/base.js +7 -0
  32. package/build/src/md/button/internals/base.js.map +1 -1
  33. package/build/src/md/chip/internals/Chip.styles.d.ts.map +1 -1
  34. package/build/src/md/chip/internals/Chip.styles.js +2 -0
  35. package/build/src/md/chip/internals/Chip.styles.js.map +1 -1
  36. package/build/src/md/date-picker/internals/DatePicker.styles.d.ts.map +1 -1
  37. package/build/src/md/date-picker/internals/DatePicker.styles.js +73 -0
  38. package/build/src/md/date-picker/internals/DatePicker.styles.js.map +1 -1
  39. package/build/src/md/date-picker/internals/DatePickerCalendar.d.ts +164 -51
  40. package/build/src/md/date-picker/internals/DatePickerCalendar.d.ts.map +1 -1
  41. package/build/src/md/date-picker/internals/DatePickerCalendar.js +660 -368
  42. package/build/src/md/date-picker/internals/DatePickerCalendar.js.map +1 -1
  43. package/build/src/md/date-picker/ui-date-picker-input.d.ts +65 -13
  44. package/build/src/md/date-picker/ui-date-picker-input.d.ts.map +1 -1
  45. package/build/src/md/date-picker/ui-date-picker-input.js +143 -76
  46. package/build/src/md/date-picker/ui-date-picker-input.js.map +1 -1
  47. package/build/src/md/date-picker/ui-date-picker-modal-input.d.ts +76 -17
  48. package/build/src/md/date-picker/ui-date-picker-modal-input.d.ts.map +1 -1
  49. package/build/src/md/date-picker/ui-date-picker-modal-input.js +192 -127
  50. package/build/src/md/date-picker/ui-date-picker-modal-input.js.map +1 -1
  51. package/build/src/md/date-picker/ui-date-picker-modal.d.ts +63 -15
  52. package/build/src/md/date-picker/ui-date-picker-modal.d.ts.map +1 -1
  53. package/build/src/md/date-picker/ui-date-picker-modal.js +143 -64
  54. package/build/src/md/date-picker/ui-date-picker-modal.js.map +1 -1
  55. package/demo/elements/currency/index.html +91 -0
  56. package/demo/elements/currency/index.ts +272 -0
  57. package/demo/elements/index.html +6 -0
  58. package/demo/elements/mention-textarea/index.html +19 -0
  59. package/demo/elements/mention-textarea/index.ts +205 -0
  60. package/demo/md/date-picker/date-picker.ts +138 -103
  61. package/package.json +2 -2
  62. package/src/elements/currency/currency-picker.ts +14 -0
  63. package/src/elements/currency/internals/Picker.styles.ts +58 -0
  64. package/src/elements/currency/internals/Picker.ts +846 -0
  65. package/src/elements/mention-textarea/internals/MentionTextArea.styles.ts +274 -0
  66. package/src/elements/mention-textarea/internals/MentionTextArea.ts +1036 -0
  67. package/src/elements/mention-textarea/ui-mention-textarea.ts +18 -0
  68. package/src/md/button/internals/base.ts +7 -0
  69. package/src/md/chip/internals/Chip.styles.ts +2 -0
  70. package/src/md/date-picker/internals/DatePicker.styles.ts +73 -0
  71. package/src/md/date-picker/internals/DatePickerCalendar.ts +643 -309
  72. package/src/md/date-picker/ui-date-picker-input.ts +110 -49
  73. package/src/md/date-picker/ui-date-picker-modal-input.ts +168 -99
  74. package/src/md/date-picker/ui-date-picker-modal.ts +136 -53
  75. package/test/README.md +3 -2
  76. package/test/elements/currency/CurrencyPicker.accessibility.test.ts +328 -0
  77. package/test/elements/currency/CurrencyPicker.core.test.ts +318 -0
  78. package/test/elements/currency/CurrencyPicker.integration.test.ts +482 -0
  79. package/test/elements/currency/CurrencyPicker.test.ts +486 -0
  80. package/test/elements/mention-textarea/MentionTextArea.basic.test.ts +63 -0
  81. package/test/elements/mention-textarea/MentionTextArea.test.ts +321 -0
  82. package/tsconfig.json +1 -1
@@ -11,16 +11,25 @@ import {
11
11
  getMonthNames,
12
12
  isSameDay,
13
13
  formatDate,
14
+ addDays,
14
15
  } from './DatePickerUtils.js'
15
16
  import '../../../md/icons/ui-icon.js'
16
17
  import '../../../md/button/ui-button.js'
17
18
  import '../../../md/icon-button/ui-icon-button.js'
18
19
 
20
+ /**
21
+ * Event dispatched when a single date is selected in immediate mode
22
+ * or confirmed in pending mode.
23
+ */
19
24
  export interface DateSelectEvent {
20
25
  date: Date
21
26
  formattedDate: string
22
27
  }
23
28
 
29
+ /**
30
+ * Event dispatched when a date range is completed in immediate mode.
31
+ * Only fired when both start and end dates are selected.
32
+ */
24
33
  export interface DateRangeSelectEvent {
25
34
  range: DateRange
26
35
  formattedRange: {
@@ -29,6 +38,10 @@ export interface DateRangeSelectEvent {
29
38
  }
30
39
  }
31
40
 
41
+ /**
42
+ * Event dispatched when a date range selection is confirmed
43
+ * via the OK button in pending mode.
44
+ */
32
45
  export interface DateRangeConfirmEvent {
33
46
  range: DateRange | null
34
47
  formattedRange: {
@@ -37,13 +50,31 @@ export interface DateRangeConfirmEvent {
37
50
  }
38
51
  }
39
52
 
53
+ /**
54
+ * Event dispatched when user cancels a pending selection
55
+ * via the Cancel button or Escape key.
56
+ */
40
57
  export interface DateCancelEvent {
41
58
  reason?: string
42
59
  }
43
60
 
44
61
  /**
45
62
  * A calendar grid component for date selection.
46
- * Supports single date selection and date range selection.
63
+ * Supports single date selection and date range selection with full keyboard navigation.
64
+ *
65
+ * ## Features
66
+ * - Single date and date range selection
67
+ * - Keyboard navigation support (arrow keys, home, end, page up/down)
68
+ * - Configurable date restrictions (min/max dates, disabled dates)
69
+ * - Localization support for date formatting and month/day names
70
+ * - Optional action buttons for pending selections
71
+ * - Accessible design with proper ARIA attributes
72
+ *
73
+ * ## Events
74
+ * - `date-select`: Fired when a single date is selected/confirmed
75
+ * - `date-range-select`: Fired when a date range is completed (immediate mode)
76
+ * - `date-range-confirm`: Fired when a date range is confirmed (pending mode)
77
+ * - `date-cancel`: Fired when a pending selection is cancelled
47
78
  *
48
79
  * ## Usage
49
80
  *
@@ -62,11 +93,24 @@ export interface DateCancelEvent {
62
93
  * ### Date range selection
63
94
  * ```html
64
95
  * <ui-date-picker-calendar
65
- * .rangeSelection=${true}
66
- * .selectedRange=${{ start: new Date(), end: null }}
96
+ * rangeSelection
97
+ * .rangeStart=${new Date()}
98
+ * .rangeEnd=${null}
67
99
  * @date-range-select=${this.handleRangeSelect}
68
100
  * ></ui-date-picker-calendar>
69
101
  * ```
102
+ *
103
+ * ### With action buttons and restrictions
104
+ * ```html
105
+ * <ui-date-picker-calendar
106
+ * rangeSelection
107
+ * showActions
108
+ * .minDate=${new Date()}
109
+ * .maxDate=${new Date(Date.now() + 365 * 24 * 60 * 60 * 1000)}
110
+ * @date-range-confirm=${this.handleRangeConfirm}
111
+ * @date-cancel=${this.handleCancel}
112
+ * ></ui-date-picker-calendar>
113
+ * ```
70
114
  */
71
115
  @customElement('ui-date-picker-calendar')
72
116
  export class UiDatePickerCalendar extends LitElement {
@@ -78,99 +122,161 @@ export class UiDatePickerCalendar extends LitElement {
78
122
  @property({ type: Number }) accessor year = new Date().getFullYear()
79
123
 
80
124
  /**
81
- * The currently displayed month (0-indexed)
125
+ * The currently displayed month (0-indexed, where 0 = January)
82
126
  */
83
127
  @property({ type: Number }) accessor month = new Date().getMonth()
84
128
 
85
129
  /**
86
- * The currently selected date for single selection mode
130
+ * The currently selected date for single selection mode.
131
+ * Set to null for no selection.
87
132
  */
88
133
  @property({ type: Object }) accessor selectedDate: Date | null = null
89
134
 
90
135
  /**
91
- * The selected date range for range selection mode
136
+ * The start date of the selected range for range selection mode.
137
+ * Used in combination with rangeEnd to define a date range.
92
138
  */
93
- @property({ type: Object }) accessor selectedRange: DateRange | null = null
139
+ @property({ type: Object }) accessor rangeStart: Date | null = null
94
140
 
95
141
  /**
96
- * Enable range selection mode
142
+ * The end date of the selected range for range selection mode.
143
+ * Used in combination with rangeStart to define a date range.
144
+ */
145
+ @property({ type: Object }) accessor rangeEnd: Date | null = null
146
+
147
+ /**
148
+ * Enable range selection mode. When true, users can select date ranges
149
+ * instead of single dates. Affects event dispatching and UI behavior.
97
150
  */
98
151
  @property({ type: Boolean }) accessor rangeSelection = false
99
152
 
100
153
  /**
101
- * Minimum selectable date
154
+ * Minimum selectable date. Dates before this will be disabled.
155
+ * Set to undefined for no minimum restriction.
102
156
  */
103
157
  @property({ type: Object }) accessor minDate: Date | undefined = undefined
104
158
 
105
159
  /**
106
- * Maximum selectable date
160
+ * Maximum selectable date. Dates after this will be disabled.
161
+ * Set to undefined for no maximum restriction.
107
162
  */
108
163
  @property({ type: Object }) accessor maxDate: Date | undefined = undefined
109
164
 
110
165
  /**
111
- * Array of disabled dates
166
+ * Array of specific dates to disable. These dates will not be selectable
167
+ * regardless of minDate and maxDate settings.
112
168
  */
113
169
  @property({ type: Array }) accessor disabledDates: Date[] | undefined = undefined
114
170
 
115
171
  /**
116
- * Locale for date formatting and month/day names
172
+ * Locale for date formatting and month/day names (e.g., 'en-US', 'fr-FR').
173
+ * Defaults to browser locale if not specified.
117
174
  */
118
175
  @property({ type: String }) accessor locale: string | undefined = undefined
119
176
 
120
177
  /**
121
- * Whether to show navigation controls
178
+ * Whether to show navigation controls (previous/next month and year buttons).
179
+ * When false, users can only navigate using keyboard or programmatically.
122
180
  */
123
181
  @property({ type: Boolean }) accessor showNavigation = true
124
182
 
125
183
  /**
126
- * Whether to show action buttons (OK/Cancel)
184
+ * Whether to show action buttons (OK/Cancel). When true, selections are pending
185
+ * until confirmed with the OK button. When false, selections are immediate.
127
186
  */
128
187
  @property({ type: Boolean }) accessor showActions = false
129
188
 
130
189
  /**
131
- * Text for the OK button
190
+ * Text label for the OK/confirm button. Only visible when showActions is true.
132
191
  */
133
192
  @property({ type: String }) accessor okButtonText = 'OK'
134
193
 
135
194
  /**
136
- * Text for the Cancel button
195
+ * Text label for the Cancel button. Only visible when showActions is true.
137
196
  */
138
197
  @property({ type: String }) accessor cancelButtonText = 'Cancel'
139
198
 
140
- @state() private accessor _calendarData: CalendarMonth | undefined = undefined
199
+ @state() private accessor calendarData: CalendarMonth | undefined = undefined
200
+
201
+ @state() private accessor monthNames: string[] = []
141
202
 
142
- @state() private accessor _rangeStart: Date | undefined = undefined
203
+ @state() private accessor showMonthDropdown = false
143
204
 
144
- @state() private accessor _monthNames: string[] = []
205
+ @state() private accessor showYearDropdown = false
145
206
 
146
- @state() private accessor _showMonthDropdown = false
207
+ @state() private accessor pendingDate: Date | null = null
147
208
 
148
- @state() private accessor _showYearDropdown = false
209
+ @state() private accessor pendingRangeStart: Date | null = null
149
210
 
150
- @state() private accessor _pendingDate: Date | null = null
211
+ @state() private accessor pendingRangeEnd: Date | null = null
151
212
 
152
- @state() private accessor _pendingRange: DateRange | null = null
213
+ @state() private accessor focusedDate: Date | null = null
153
214
 
154
215
  override connectedCallback(): void {
155
216
  super.connectedCallback()
156
- this._updateCalendar()
157
- this._updateMonthNames()
158
- document.addEventListener('keydown', this._handleDocumentKeyDown.bind(this))
217
+ this.updateCalendar()
218
+ this.updateMonthNames()
219
+ this.initializeFocusedDate()
220
+ }
221
+
222
+ override firstUpdated(): void {
223
+ // Set initial focus to the calendar container
224
+ this.updateFocus()
159
225
  }
160
226
 
161
227
  override disconnectedCallback(): void {
162
228
  super.disconnectedCallback()
163
- document.removeEventListener('keydown', this._handleDocumentKeyDown.bind(this))
229
+ }
230
+
231
+ override willUpdate(changedProperties: Map<string | number | symbol, unknown>): void {
232
+ if (
233
+ changedProperties.has('year') ||
234
+ changedProperties.has('month') ||
235
+ changedProperties.has('selectedDate') ||
236
+ changedProperties.has('rangeStart') ||
237
+ changedProperties.has('rangeEnd') ||
238
+ changedProperties.has('disabledDates') ||
239
+ changedProperties.has('locale')
240
+ ) {
241
+ this.updateCalendar()
242
+ }
243
+
244
+ if (changedProperties.has('locale')) {
245
+ this.updateMonthNames()
246
+ }
247
+
248
+ // Update focused date when month/year changes via navigation
249
+ if (changedProperties.has('year') || changedProperties.has('month')) {
250
+ this.updateFocusedDateForMonthChange()
251
+ }
164
252
  }
165
253
 
166
254
  override updated(changedProperties: Map<string | number | symbol, unknown>): void {
167
- if (changedProperties.has('_showYearDropdown') && this._showYearDropdown) {
255
+ if (changedProperties.has('showYearDropdown') && this.showYearDropdown) {
168
256
  // Scroll selected year into view
169
- this._scrollSelectedYearIntoView()
257
+ this.scrollSelectedYearIntoView()
258
+ }
259
+
260
+ if (changedProperties.has('focusedDate')) {
261
+ this.updateFocus()
170
262
  }
171
263
  }
172
264
 
173
- private _scrollSelectedYearIntoView(): void {
265
+ private updateFocus(): void {
266
+ if (!this.focusedDate) return
267
+
268
+ // Find the button for the focused date and set focus
269
+ const dateString = this.focusedDate.toISOString().split('T')[0]
270
+ const focusedButton = this.shadowRoot?.querySelector(`[data-date="${dateString}"]`) as HTMLElement
271
+ if (focusedButton && !focusedButton.hasAttribute('disabled')) {
272
+ // Use requestAnimationFrame to ensure DOM is updated
273
+ requestAnimationFrame(() => {
274
+ focusedButton.focus()
275
+ })
276
+ }
277
+ }
278
+
279
+ private scrollSelectedYearIntoView(): void {
174
280
  // Wait for next frame to ensure DOM is updated
175
281
  requestAnimationFrame(() => {
176
282
  const selectedYearButton = this.shadowRoot?.querySelector('.year-option.selected') as HTMLElement
@@ -183,398 +289,526 @@ export class UiDatePickerCalendar extends LitElement {
183
289
  })
184
290
  }
185
291
 
186
- override willUpdate(changedProperties: Map<string | number | symbol, unknown>): void {
187
- if (
188
- changedProperties.has('year') ||
189
- changedProperties.has('month') ||
190
- changedProperties.has('selectedDate') ||
191
- changedProperties.has('selectedRange') ||
192
- changedProperties.has('disabledDates') ||
193
- changedProperties.has('locale')
194
- ) {
195
- this._updateCalendar()
292
+ private updateFocusedDateForMonthChange(): void {
293
+ if (!this.focusedDate) {
294
+ this.initializeFocusedDate()
295
+ return
196
296
  }
197
297
 
198
- if (changedProperties.has('locale')) {
199
- this._updateMonthNames()
298
+ // Keep the same day of month if possible
299
+ const targetDay = this.focusedDate.getDate()
300
+ const newDate = new Date(this.year, this.month, targetDay)
301
+
302
+ // Check if the target date exists in the new month and is not disabled
303
+ if (newDate.getMonth() === this.month && !this.isDateDisabled(newDate)) {
304
+ this.focusedDate = newDate
305
+ } else {
306
+ // Find the closest available date
307
+ this.focusedDate = this.findFirstAvailableDate()
200
308
  }
201
309
  }
202
310
 
203
- private _updateCalendar(): void {
204
- this._calendarData = generateCalendarMonth(
311
+ private updateCalendar(): void {
312
+ const selectedRange = this.rangeStart || this.rangeEnd ? { start: this.rangeStart, end: this.rangeEnd } : null
313
+ this.calendarData = generateCalendarMonth(
205
314
  this.year,
206
315
  this.month,
207
316
  this.selectedDate,
208
- this.selectedRange,
317
+ selectedRange,
209
318
  this.disabledDates,
210
319
  this.locale
211
320
  )
212
321
  }
213
322
 
214
- private _updateMonthNames(): void {
215
- this._monthNames = getMonthNames(this.locale)
323
+ private updateMonthNames(): void {
324
+ this.monthNames = getMonthNames(this.locale)
216
325
  }
217
326
 
218
- private _navigateMonth(delta: number): void {
327
+ private navigateMonth(delta: number): void {
219
328
  const newDate = addMonths(new Date(this.year, this.month), delta)
220
329
  this.year = newDate.getFullYear()
221
330
  this.month = newDate.getMonth()
222
331
  }
223
332
 
224
- private _handlePrevMonth(): void {
225
- this._navigateMonth(-1)
333
+ private handlePrevMonth(): void {
334
+ this.navigateMonth(-1)
226
335
  }
227
336
 
228
- private _handleNextMonth(): void {
229
- this._navigateMonth(1)
337
+ private handleNextMonth(): void {
338
+ this.navigateMonth(1)
230
339
  }
231
340
 
232
- private _handlePrevYear(): void {
341
+ private handlePrevYear(): void {
233
342
  this.year = this.year - 1
234
343
  }
235
344
 
236
- private _handleNextYear(): void {
345
+ private handleNextYear(): void {
237
346
  this.year = this.year + 1
238
347
  }
239
348
 
240
- private _handleMonthClick(): void {
241
- this._showMonthDropdown = !this._showMonthDropdown
242
- this._showYearDropdown = false
349
+ private handleMonthClick(): void {
350
+ this.showMonthDropdown = !this.showMonthDropdown
351
+ this.showYearDropdown = false
243
352
  }
244
353
 
245
- private _handleYearClick(): void {
246
- this._showYearDropdown = !this._showYearDropdown
247
- this._showMonthDropdown = false
354
+ private handleYearClick(): void {
355
+ this.showYearDropdown = !this.showYearDropdown
356
+ this.showMonthDropdown = false
248
357
  }
249
358
 
250
- private _handleMonthSelect(selectedMonth: number): void {
359
+ private handleMonthSelect(selectedMonth: number): void {
251
360
  this.month = selectedMonth
252
- this._showMonthDropdown = false
361
+ this.showMonthDropdown = false
253
362
  }
254
363
 
255
- private _handleYearSelect(selectedYear: number): void {
364
+ private handleYearSelect(selectedYear: number): void {
256
365
  this.year = selectedYear
257
- this._showYearDropdown = false
366
+ this.showYearDropdown = false
367
+ }
368
+
369
+ private closeDropdowns(): void {
370
+ this.showMonthDropdown = false
371
+ this.showYearDropdown = false
372
+ }
373
+
374
+ private navigateDate(delta: number): void {
375
+ if (!this.focusedDate) return
376
+
377
+ const newDate = addDays(this.focusedDate, delta)
378
+
379
+ // Check if new date is in current month or if we should navigate to next/previous month
380
+ if (newDate.getMonth() !== this.month || newDate.getFullYear() !== this.year) {
381
+ // Navigate to next/previous month
382
+ if (delta > 0) {
383
+ this.navigateMonth(1)
384
+ } else {
385
+ this.navigateMonth(-1)
386
+ }
387
+ // Set focused date to the target date in the new month
388
+ this.focusedDate = newDate
389
+ return
390
+ }
391
+
392
+ // Check if new date is disabled
393
+ if (this.isDateDisabled(newDate)) {
394
+ // Try to find next available date
395
+ const availableDate = this.findNextAvailableDate(newDate, delta > 0)
396
+ if (availableDate) {
397
+ this.focusedDate = availableDate
398
+ }
399
+ } else {
400
+ this.focusedDate = newDate
401
+ }
258
402
  }
259
403
 
260
- private _closeDropdowns(): void {
261
- this._showMonthDropdown = false
262
- this._showYearDropdown = false
404
+ private findNextAvailableDate(startDate: Date, forward: boolean): Date | null {
405
+ const direction = forward ? 1 : -1
406
+ for (let i = 1; i <= 31; i++) {
407
+ const date = addDays(startDate, i * direction)
408
+ if (date.getMonth() !== this.month) break // Out of current month
409
+ if (!this.isDateDisabled(date)) {
410
+ return date
411
+ }
412
+ }
413
+ return null
263
414
  }
264
415
 
265
- private _handleDocumentKeyDown(event: KeyboardEvent): void {
266
- if (event.key === 'Escape') {
267
- this._closeDropdowns()
416
+ private focusFirstDayOfMonth(): void {
417
+ const firstDay = new Date(this.year, this.month, 1)
418
+ if (!this.isDateDisabled(firstDay)) {
419
+ this.focusedDate = firstDay
420
+ } else {
421
+ this.focusedDate = this.findFirstAvailableDate()
268
422
  }
269
423
  }
270
424
 
271
- private _handleDayClick(day: CalendarDay): void {
425
+ private focusLastDayOfMonth(): void {
426
+ const lastDay = new Date(this.year, this.month + 1, 0)
427
+ if (!this.isDateDisabled(lastDay)) {
428
+ this.focusedDate = lastDay
429
+ } else {
430
+ // Find last available date in month
431
+ for (let i = lastDay.getDate(); i >= 1; i--) {
432
+ const date = new Date(this.year, this.month, i)
433
+ if (!this.isDateDisabled(date)) {
434
+ this.focusedDate = date
435
+ break
436
+ }
437
+ }
438
+ }
439
+ }
440
+
441
+ private selectFocusedDate(): void {
442
+ if (!this.focusedDate || this.isDateDisabled(this.focusedDate)) return
443
+
444
+ if (this.rangeSelection) {
445
+ this.handleRangeSelection(this.focusedDate)
446
+ } else {
447
+ this.handleSingleSelection(this.focusedDate)
448
+ }
449
+ }
450
+
451
+ private handleDayClick(day: CalendarDay): void {
272
452
  if (day.isDisabled) return
273
453
 
454
+ // Update focused date to clicked date
455
+ this.focusedDate = day.date
456
+
274
457
  if (this.rangeSelection) {
275
- this._handleRangeSelection(day.date)
458
+ this.handleRangeSelection(day.date)
276
459
  } else {
277
- this._handleSingleSelection(day.date)
460
+ this.handleSingleSelection(day.date)
278
461
  }
279
462
  }
280
463
 
281
- private _handleSingleSelection(date: Date): void {
464
+ private handleSingleSelection(date: Date): void {
282
465
  if (this.showActions) {
283
466
  // Use pending state when actions are enabled
284
- this._pendingDate = date
467
+ this.pendingDate = date
285
468
  } else {
286
469
  // Immediate selection when no actions
287
470
  this.selectedDate = date
471
+ this.dispatchDateEvent(date)
472
+ }
473
+ }
474
+
475
+ private handleRangeSelection(date: Date): void {
476
+ const isImmediate = !this.showActions
477
+ const { start, end } = this.getCurrentRange()
478
+
479
+ // If we have a complete range, start a new one
480
+ if (start && end) {
481
+ this.setRangeValues(date, null, isImmediate)
482
+ return
483
+ }
484
+
485
+ // If we have a start but no end, complete the range
486
+ if (start && !end) {
487
+ const sortedRange = start <= date ? { start, end: date } : { start: date, end: start }
488
+ this.setRangeValues(sortedRange.start, sortedRange.end, isImmediate)
288
489
 
289
- const event: DateSelectEvent = {
290
- date,
291
- formattedDate: formatDate(date, this.locale),
490
+ if (isImmediate) {
491
+ this.dispatchRangeEvent(sortedRange)
292
492
  }
493
+ return
494
+ }
293
495
 
294
- this.dispatchEvent(
295
- new CustomEvent('date-select', {
296
- detail: event,
297
- bubbles: true,
298
- composed: true,
299
- })
300
- )
496
+ // Start new range
497
+ this.setRangeValues(date, null, isImmediate)
498
+ }
499
+
500
+ /**
501
+ * Helper to get the current range state (either immediate or pending)
502
+ */
503
+ private getCurrentRange(): { start: Date | null; end: Date | null } {
504
+ const isImmediate = !this.showActions
505
+ return {
506
+ start: isImmediate ? this.rangeStart : this.pendingRangeStart,
507
+ end: isImmediate ? this.rangeEnd : this.pendingRangeEnd,
301
508
  }
302
509
  }
303
510
 
304
- private _handleRangeSelection(date: Date): void {
305
- if (!this._rangeStart || (this.selectedRange?.start && this.selectedRange?.end)) {
306
- // Start new range
307
- this._rangeStart = date
308
- const newRange = { start: date, end: null }
511
+ /**
512
+ * Helper to check if we have a complete range in the current mode
513
+ */
514
+ private hasCompleteRange(): boolean {
515
+ const { start, end } = this.getCurrentRange()
516
+ return !!(start && end)
517
+ }
309
518
 
310
- if (this.showActions) {
311
- // Use pending state when actions are enabled
312
- this._pendingRange = newRange
313
- } else {
314
- this.selectedRange = newRange
315
- }
519
+ private setRangeValues(start: Date | null, end: Date | null, isImmediate: boolean): void {
520
+ if (isImmediate) {
521
+ this.rangeStart = start
522
+ this.rangeEnd = end
316
523
  } else {
317
- // Complete range
318
- const start = this._rangeStart
319
- const end = date
524
+ this.pendingRangeStart = start
525
+ this.pendingRangeEnd = end
526
+ }
527
+ }
320
528
 
321
- // Ensure start is before end
322
- const sortedRange: DateRange = start <= end ? { start, end } : { start: end, end: start }
529
+ /**
530
+ * Helper to dispatch date selection events
531
+ */
532
+ private dispatchDateEvent(date: Date): void {
533
+ const event: DateSelectEvent = {
534
+ date,
535
+ formattedDate: formatDate(date, this.locale),
536
+ }
323
537
 
324
- this._rangeStart = undefined
538
+ this.dispatchEvent(
539
+ new CustomEvent('date-select', {
540
+ detail: event,
541
+ bubbles: true,
542
+ composed: true,
543
+ })
544
+ )
545
+ }
325
546
 
326
- if (this.showActions) {
327
- // Use pending state when actions are enabled
328
- this._pendingRange = sortedRange
329
- } else {
330
- // Immediate selection when no actions
331
- this.selectedRange = sortedRange
332
-
333
- const event: DateRangeSelectEvent = {
334
- range: sortedRange,
335
- formattedRange: {
336
- start: sortedRange.start ? formatDate(sortedRange.start, this.locale) : null,
337
- end: sortedRange.end ? formatDate(sortedRange.end, this.locale) : null,
338
- },
339
- }
547
+ /**
548
+ * Helper to dispatch range selection events
549
+ */
550
+ private dispatchRangeEvent(range: { start: Date; end: Date }): void {
551
+ const event: DateRangeSelectEvent = {
552
+ range,
553
+ formattedRange: {
554
+ start: formatDate(range.start, this.locale),
555
+ end: formatDate(range.end, this.locale),
556
+ },
557
+ }
340
558
 
341
- this.dispatchEvent(
342
- new CustomEvent('date-range-select', {
343
- detail: event,
344
- bubbles: true,
345
- composed: true,
346
- })
347
- )
348
- }
559
+ this.dispatchEvent(
560
+ new CustomEvent('date-range-select', {
561
+ detail: event,
562
+ bubbles: true,
563
+ composed: true,
564
+ })
565
+ )
566
+ }
567
+
568
+ /**
569
+ * Helper to dispatch range confirmation events
570
+ */
571
+ private dispatchRangeConfirmEvent(range: DateRange | null): void {
572
+ const event: DateRangeConfirmEvent = {
573
+ range,
574
+ formattedRange: {
575
+ start: range?.start ? formatDate(range.start, this.locale) : null,
576
+ end: range?.end ? formatDate(range.end, this.locale) : null,
577
+ },
349
578
  }
579
+
580
+ this.dispatchEvent(
581
+ new CustomEvent('date-range-confirm', {
582
+ detail: event,
583
+ bubbles: true,
584
+ composed: true,
585
+ })
586
+ )
350
587
  }
351
588
 
352
- private _isDateDisabled(date: Date): boolean {
589
+ /**
590
+ * Helper to dispatch cancel events
591
+ */
592
+ private dispatchCancelEvent(reason = 'user_cancelled'): void {
593
+ const event: DateCancelEvent = { reason }
594
+
595
+ this.dispatchEvent(
596
+ new CustomEvent('date-cancel', {
597
+ detail: event,
598
+ bubbles: true,
599
+ composed: true,
600
+ })
601
+ )
602
+ }
603
+
604
+ private isDateDisabled(date: Date): boolean {
353
605
  if (this.minDate && date < this.minDate) return true
354
606
  if (this.maxDate && date > this.maxDate) return true
355
607
  if (this.disabledDates?.some((disabledDate) => isSameDay(date, disabledDate))) return true
356
608
  return false
357
609
  }
358
610
 
359
- private _handleConfirm(): void {
611
+ private handleConfirm(): void {
360
612
  if (this.rangeSelection) {
361
- if (this._pendingRange) {
362
- this.selectedRange = this._pendingRange
363
-
364
- const event: DateRangeConfirmEvent = {
365
- range: this._pendingRange,
366
- formattedRange: {
367
- start: this._pendingRange.start ? formatDate(this._pendingRange.start, this.locale) : null,
368
- end: this._pendingRange.end ? formatDate(this._pendingRange.end, this.locale) : null,
369
- },
370
- }
613
+ if (this.pendingRangeStart || this.pendingRangeEnd) {
614
+ this.rangeStart = this.pendingRangeStart
615
+ this.rangeEnd = this.pendingRangeEnd
616
+
617
+ const range = this.rangeStart || this.rangeEnd ? { start: this.rangeStart, end: this.rangeEnd } : null
618
+
619
+ // Reset pending state after confirmation
620
+ this.pendingRangeStart = null
621
+ this.pendingRangeEnd = null
371
622
 
372
- this.dispatchEvent(
373
- new CustomEvent('date-range-confirm', {
374
- detail: event,
375
- bubbles: true,
376
- composed: true,
377
- })
378
- )
623
+ this.dispatchRangeConfirmEvent(range)
379
624
  }
380
625
  } else {
381
- if (this._pendingDate) {
382
- this.selectedDate = this._pendingDate
626
+ if (this.pendingDate) {
627
+ this.selectedDate = this.pendingDate
383
628
 
384
- const event: DateSelectEvent = {
385
- date: this._pendingDate,
386
- formattedDate: formatDate(this._pendingDate, this.locale),
387
- }
629
+ // Reset pending state after confirmation
630
+ this.pendingDate = null
388
631
 
389
- this.dispatchEvent(
390
- new CustomEvent('date-select', {
391
- detail: event,
392
- bubbles: true,
393
- composed: true,
394
- })
395
- )
632
+ this.dispatchDateEvent(this.selectedDate)
396
633
  }
397
634
  }
398
635
  }
399
636
 
400
- private _handleCancel(): void {
637
+ private handleCancel(): void {
401
638
  // Reset pending state
402
- this._pendingDate = null
403
- this._pendingRange = null
404
- this._rangeStart = undefined
639
+ this.pendingDate = null
640
+ this.pendingRangeStart = null
641
+ this.pendingRangeEnd = null
642
+
643
+ this.dispatchCancelEvent()
644
+ }
405
645
 
406
- const event: DateCancelEvent = {
407
- reason: 'user_cancelled',
646
+ /**
647
+ * Helper to render navigation buttons
648
+ */
649
+ private renderNavButton(
650
+ direction: 'prev' | 'next',
651
+ onClick: () => void,
652
+ ariaLabel: string,
653
+ icon: 'chevronLeft' | 'chevronRight'
654
+ ): TemplateResult | typeof nothing {
655
+ if (!this.showNavigation || this.showMonthDropdown || this.showYearDropdown) {
656
+ return nothing
408
657
  }
409
658
 
410
- this.dispatchEvent(
411
- new CustomEvent('date-cancel', {
412
- detail: event,
413
- bubbles: true,
414
- composed: true,
415
- })
416
- )
659
+ return html`<ui-icon-button
660
+ class="nav-button"
661
+ size="xs"
662
+ @click=${onClick}
663
+ aria-label=${ariaLabel}
664
+ title=${ariaLabel}
665
+ >
666
+ <ui-icon icon=${icon}></ui-icon>
667
+ </ui-icon-button>`
417
668
  }
418
669
 
419
- private _renderNavigation(): TemplateResult {
420
- const monthName = this._monthNames[this.month] || ''
670
+ private renderNavigation(): TemplateResult {
671
+ const monthName = this.monthNames[this.month] || ''
421
672
 
422
673
  return html`
423
674
  <div class="header">
424
675
  <div class="month-year">
425
676
  <div class="month-selector">
426
- ${this.showNavigation && !this._showMonthDropdown && !this._showYearDropdown
427
- ? html`
428
- <ui-icon-button
429
- class="nav-button month-nav"
430
- size="xs"
431
- @click=${this._handlePrevMonth}
432
- aria-label="Previous month"
433
- title="Previous month"
434
- >
435
- <ui-icon icon="chevronLeft"></ui-icon>
436
- </ui-icon-button>
437
- `
438
- : ''}
677
+ ${this.renderNavButton('prev', this.handlePrevMonth, 'Previous month', 'chevronLeft')}
439
678
  <ui-button
440
679
  class="month-button"
441
680
  color="text"
442
681
  size="xs"
443
- @click=${this._handleMonthClick}
682
+ @click=${this.handleMonthClick}
444
683
  aria-label="Select month"
445
- aria-expanded=${this._showMonthDropdown}
684
+ aria-expanded=${this.showMonthDropdown}
446
685
  trailingIcon
447
686
  >
448
687
  ${monthName}
449
688
  <ui-icon icon="arrowDropDown" slot="icon"></ui-icon>
450
689
  </ui-button>
451
- ${this.showNavigation && !this._showMonthDropdown && !this._showYearDropdown
452
- ? html`
453
- <ui-icon-button
454
- class="nav-button month-nav"
455
- size="xs"
456
- @click=${this._handleNextMonth}
457
- aria-label="Next month"
458
- title="Next month"
459
- >
460
- <ui-icon icon="chevronRight"></ui-icon>
461
- </ui-icon-button>
462
- `
463
- : ''}
690
+ ${this.renderNavButton('next', this.handleNextMonth, 'Next month', 'chevronRight')}
464
691
  </div>
465
692
  <div class="year-selector">
466
- ${this.showNavigation && !this._showMonthDropdown && !this._showYearDropdown
467
- ? html`
468
- <ui-icon-button
469
- class="nav-button year-nav"
470
- size="xs"
471
- @click=${this._handlePrevYear}
472
- aria-label="Previous year"
473
- title="Previous year"
474
- >
475
- <ui-icon icon="chevronLeft"></ui-icon>
476
- </ui-icon-button>
477
- `
478
- : ''}
693
+ ${this.renderNavButton('prev', this.handlePrevYear, 'Previous year', 'chevronLeft')}
479
694
  <ui-button
480
695
  class="year-button"
481
696
  color="text"
482
697
  size="xs"
483
- @click=${this._handleYearClick}
698
+ @click=${this.handleYearClick}
484
699
  aria-label="Select year"
485
- aria-expanded=${this._showYearDropdown}
700
+ aria-expanded=${this.showYearDropdown}
486
701
  trailingIcon
487
702
  >
488
703
  ${this.year}
489
704
  <ui-icon icon="arrowDropDown" slot="icon"></ui-icon>
490
705
  </ui-button>
491
- ${this.showNavigation && !this._showMonthDropdown && !this._showYearDropdown
492
- ? html`
493
- <ui-icon-button
494
- class="nav-button year-nav"
495
- size="xs"
496
- @click=${this._handleNextYear}
497
- aria-label="Next year"
498
- title="Next year"
499
- >
500
- <ui-icon icon="chevronRight"></ui-icon>
501
- </ui-icon-button>
502
- `
503
- : ''}
706
+ ${this.renderNavButton('next', this.handleNextYear, 'Next year', 'chevronRight')}
504
707
  </div>
505
708
  </div>
506
709
  </div>
507
710
  `
508
711
  }
509
712
 
510
- private _renderWeekdays(): TemplateResult {
511
- if (!this._calendarData) return html``
713
+ private renderWeekdays(): TemplateResult {
714
+ if (!this.calendarData) return html``
512
715
 
513
716
  return html`
514
- <div class="weekdays">
515
- ${this._calendarData.weekdays.map((weekday) => html`<div class="weekday">${weekday}</div>`)}
717
+ <div class="weekdays" role="row">
718
+ ${this.calendarData.weekdays.map((weekday) => html`<div class="weekday" role="columnheader">${weekday}</div>`)}
516
719
  </div>
517
720
  `
518
721
  }
519
722
 
520
- private _renderDay(day: CalendarDay): TemplateResult {
521
- const isPendingSelected = this.showActions && this._pendingDate && isSameDay(day.date, this._pendingDate)
522
- const isPendingRangeStart =
523
- this.showActions && this._pendingRange?.start && isSameDay(day.date, this._pendingRange.start)
524
- const isPendingRangeEnd = this.showActions && this._pendingRange?.end && isSameDay(day.date, this._pendingRange.end)
525
- const isPendingInRange =
526
- this.showActions &&
527
- this._pendingRange?.start &&
528
- this._pendingRange?.end &&
529
- day.date >= this._pendingRange.start &&
530
- day.date <= this._pendingRange.end &&
531
- !isPendingRangeStart &&
532
- !isPendingRangeEnd
533
-
534
- // Determine button color based on selection state
535
- let color: 'elevated' | 'filled' | 'outlined' | 'text' | 'tonal' = 'text'
536
-
723
+ /**
724
+ * Helper to determine day selection state for rendering
725
+ */
726
+ private getDaySelectionState(day: CalendarDay): {
727
+ isSelected: boolean
728
+ isRangeStart: boolean
729
+ isRangeEnd: boolean
730
+ isInRange: boolean
731
+ } {
537
732
  if (this.showActions) {
538
- if (isPendingRangeStart || isPendingRangeEnd || isPendingSelected) {
539
- color = 'filled'
540
- } else if (isPendingInRange) {
541
- color = 'text'
733
+ // Use pending state
734
+ const isPendingSelected = this.pendingDate && isSameDay(day.date, this.pendingDate)
735
+ const isPendingRangeStart = this.pendingRangeStart && isSameDay(day.date, this.pendingRangeStart)
736
+ const isPendingRangeEnd = this.pendingRangeEnd && isSameDay(day.date, this.pendingRangeEnd)
737
+ const isPendingInRange =
738
+ this.pendingRangeStart &&
739
+ this.pendingRangeEnd &&
740
+ day.date >= this.pendingRangeStart &&
741
+ day.date <= this.pendingRangeEnd &&
742
+ !isPendingRangeStart &&
743
+ !isPendingRangeEnd
744
+
745
+ return {
746
+ isSelected: !!isPendingSelected,
747
+ isRangeStart: !!isPendingRangeStart,
748
+ isRangeEnd: !!isPendingRangeEnd,
749
+ isInRange: !!isPendingInRange,
542
750
  }
543
751
  } else {
544
- if (day.isRangeStart || day.isRangeEnd || day.isSelected) {
545
- color = 'filled'
546
- } else if (day.isInRange) {
547
- color = 'text'
752
+ // Use immediate state
753
+ return {
754
+ isSelected: day.isSelected,
755
+ isRangeStart: day.isRangeStart,
756
+ isRangeEnd: day.isRangeEnd,
757
+ isInRange: day.isInRange,
548
758
  }
549
759
  }
760
+ }
761
+
762
+ /**
763
+ * Helper to determine button color for a day
764
+ */
765
+ private getDayButtonColor(
766
+ day: CalendarDay,
767
+ selectionState: ReturnType<typeof this.getDaySelectionState>
768
+ ): 'elevated' | 'filled' | 'outlined' | 'text' | 'tonal' {
769
+ if (selectionState.isRangeStart || selectionState.isRangeEnd || selectionState.isSelected) {
770
+ return 'filled'
771
+ }
772
+
773
+ if (selectionState.isInRange) {
774
+ return 'text'
775
+ }
550
776
 
551
- if (day.isToday && color === 'text') {
552
- color = 'outlined'
777
+ if (day.isToday) {
778
+ return 'outlined'
553
779
  }
554
780
 
781
+ return 'text'
782
+ }
783
+
784
+ private renderDay(day: CalendarDay): TemplateResult {
785
+ const selectionState = this.getDaySelectionState(day)
786
+ const color = this.getDayButtonColor(day, selectionState)
787
+
555
788
  const classes = {
556
789
  'day-cell': true,
557
790
  'other-month': !day.isCurrentMonth,
558
791
  'today': day.isToday,
559
- 'in-range': this.showActions ? !!isPendingInRange : day.isInRange,
560
- 'range-start': this.showActions ? !!isPendingRangeStart : day.isRangeStart,
561
- 'range-end': this.showActions ? !!isPendingRangeEnd : day.isRangeEnd,
562
- 'has-complete-range': this.showActions
563
- ? !!(this._pendingRange?.start && this._pendingRange?.end)
564
- : !!(this.selectedRange?.start && this.selectedRange?.end),
792
+ 'in-range': selectionState.isInRange,
793
+ 'range-start': selectionState.isRangeStart,
794
+ 'range-end': selectionState.isRangeEnd,
795
+ 'has-complete-range': this.hasCompleteRange(),
565
796
  }
566
797
 
798
+ const isFocused = this.focusedDate && isSameDay(day.date, this.focusedDate)
799
+
567
800
  return html`
568
- <div class=${classMap(classes)}>
801
+ <div class=${classMap(classes)} role="gridcell">
569
802
  <ui-button
570
803
  class="day-button"
571
804
  color=${color}
572
805
  size="s"
573
806
  data-date=${day.date.toISOString().split('T')[0]}
574
- tabindex=${day.isToday && !(day.isDisabled || this._isDateDisabled(day.date)) ? '0' : '-1'}
807
+ tabindex=${isFocused && !(day.isDisabled || this.isDateDisabled(day.date)) ? '0' : '-1'}
575
808
  aria-label=${formatDate(day.date, this.locale)}
576
- @click=${() => this._handleDayClick(day)}
577
- ?disabled=${day.isDisabled || this._isDateDisabled(day.date)}
809
+ aria-selected=${selectionState.isSelected || selectionState.isRangeStart || selectionState.isRangeEnd}
810
+ @click=${() => this.handleDayClick(day)}
811
+ ?disabled=${day.isDisabled || this.isDateDisabled(day.date)}
578
812
  >
579
813
  ${day.date.getDate()}
580
814
  </ui-button>
@@ -582,50 +816,63 @@ export class UiDatePickerCalendar extends LitElement {
582
816
  `
583
817
  }
584
818
 
585
- private _renderDays(): TemplateResult {
586
- if (!this._calendarData) return html``
819
+ private renderDays(): TemplateResult {
820
+ if (!this.calendarData) return html``
587
821
 
588
- return html`<div class="days">${this._calendarData.days.map((day) => this._renderDay(day))}</div>`
822
+ return html`<div class="days">${this.calendarData.days.map((day) => this.renderDay(day))}</div>`
589
823
  }
590
824
 
591
- private _renderActions(): TemplateResult | typeof nothing {
825
+ private renderActions(): TemplateResult | typeof nothing {
592
826
  if (!this.showActions) return nothing
593
827
 
594
- const hasSelection = this.rangeSelection ? this._pendingRange?.start : this._pendingDate
828
+ const hasSelection = this.rangeSelection ? this.pendingRangeStart : this.pendingDate
595
829
 
596
830
  return html`
597
831
  <div class="actions">
598
- <ui-button size="s" color="text" @click=${this._handleCancel}>${this.cancelButtonText}</ui-button>
599
- <ui-button size="s" color="text" @click=${this._handleConfirm} ?disabled=${!hasSelection}>
832
+ <ui-button size="s" color="text" @click=${this.handleCancel}>${this.cancelButtonText}</ui-button>
833
+ <ui-button size="s" color="text" @click=${this.handleConfirm} ?disabled=${!hasSelection}>
600
834
  ${this.okButtonText}
601
835
  </ui-button>
602
836
  </div>
603
837
  `
604
838
  }
605
839
 
606
- private _renderMonthDropdown(): TemplateResult {
840
+ /**
841
+ * Helper to render dropdown option buttons
842
+ */
843
+ private renderDropdownOption(
844
+ value: string | number,
845
+ label: string,
846
+ isSelected: boolean,
847
+ onClick: () => void,
848
+ className = ''
849
+ ): TemplateResult {
850
+ return html`
851
+ <ui-button
852
+ class="dropdown-option ${className} ${isSelected ? 'selected' : ''}"
853
+ color=${isSelected ? 'filled' : 'text'}
854
+ size="s"
855
+ @click=${onClick}
856
+ aria-label=${label}
857
+ >
858
+ ${label}
859
+ </ui-button>
860
+ `
861
+ }
862
+
863
+ private renderMonthDropdown(): TemplateResult {
607
864
  return html`
608
865
  <div class="dropdown-view">
609
866
  <div class="month-list">
610
- ${this._monthNames.map(
611
- (monthName, index) => html`
612
- <ui-button
613
- class="month-option ${index === this.month ? 'selected' : ''}"
614
- color=${index === this.month ? 'filled' : 'text'}
615
- size="s"
616
- @click=${() => this._handleMonthSelect(index)}
617
- aria-label=${monthName}
618
- >
619
- ${monthName}
620
- </ui-button>
621
- `
867
+ ${this.monthNames.map((monthName, index) =>
868
+ this.renderDropdownOption(index, monthName, index === this.month, () => this.handleMonthSelect(index))
622
869
  )}
623
870
  </div>
624
871
  </div>
625
872
  `
626
873
  }
627
874
 
628
- private _renderYearDropdown(): TemplateResult {
875
+ private renderYearDropdown(): TemplateResult {
629
876
  const currentYear = this.year
630
877
  const startYear = currentYear - 50
631
878
  const endYear = currentYear + 50
@@ -638,46 +885,133 @@ export class UiDatePickerCalendar extends LitElement {
638
885
  return html`
639
886
  <div class="dropdown-view">
640
887
  <div class="year-grid">
641
- ${years.map(
642
- (year) => html`
643
- <ui-button
644
- class="year-option ${year === this.year ? 'selected' : ''}"
645
- color=${year === this.year ? 'filled' : 'text'}
646
- size="s"
647
- @click=${() => this._handleYearSelect(year)}
648
- aria-label=${year.toString()}
649
- >
650
- ${year}
651
- </ui-button>
652
- `
888
+ ${years.map((year) =>
889
+ this.renderDropdownOption(
890
+ year,
891
+ year.toString(),
892
+ year === this.year,
893
+ () => this.handleYearSelect(year),
894
+ 'year-option'
895
+ )
653
896
  )}
654
897
  </div>
655
898
  </div>
656
899
  `
657
900
  }
658
901
 
902
+ private findFirstAvailableDate(): Date {
903
+ const firstDayOfMonth = new Date(this.year, this.month, 1)
904
+ for (let i = 0; i < 31; i++) {
905
+ const date = addDays(firstDayOfMonth, i)
906
+ if (date.getMonth() !== this.month) break // Next month
907
+ if (!this.isDateDisabled(date)) {
908
+ return date
909
+ }
910
+ }
911
+ return firstDayOfMonth // Fallback
912
+ }
913
+
914
+ private handleKeyDown(event: KeyboardEvent): void {
915
+ // Prevent default behavior for navigation keys
916
+ const navigationKeys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown', ' ']
917
+ if (navigationKeys.includes(event.key)) {
918
+ event.preventDefault()
919
+ }
920
+
921
+ switch (event.key) {
922
+ case 'ArrowLeft':
923
+ this.navigateDate(-1)
924
+ break
925
+ case 'ArrowRight':
926
+ this.navigateDate(1)
927
+ break
928
+ case 'ArrowUp':
929
+ this.navigateDate(-7)
930
+ break
931
+ case 'ArrowDown':
932
+ this.navigateDate(7)
933
+ break
934
+ case 'Enter':
935
+ case ' ':
936
+ this.selectFocusedDate()
937
+ break
938
+ case 'Home':
939
+ this.focusFirstDayOfMonth()
940
+ break
941
+ case 'End':
942
+ this.focusLastDayOfMonth()
943
+ break
944
+ case 'PageUp':
945
+ if (event.shiftKey) {
946
+ this.handlePrevYear()
947
+ } else {
948
+ this.navigateMonth(-1)
949
+ }
950
+ break
951
+ case 'PageDown':
952
+ if (event.shiftKey) {
953
+ this.handleNextYear()
954
+ } else {
955
+ this.navigateMonth(1)
956
+ }
957
+ break
958
+ case 'Escape':
959
+ this.closeDropdowns()
960
+ break
961
+ }
962
+ }
963
+
964
+ /**
965
+ * Helper to check if a date is in the current month and not disabled
966
+ */
967
+ private isDateAvailable(date: Date): boolean {
968
+ return date.getMonth() === this.month && date.getFullYear() === this.year && !this.isDateDisabled(date)
969
+ }
970
+
971
+ private initializeFocusedDate(): void {
972
+ // Priority: selectedDate, rangeStart, today, first available date
973
+ const candidates = [this.selectedDate, this.rangeStart, new Date()].filter(Boolean) as Date[]
974
+
975
+ for (const candidate of candidates) {
976
+ if (this.isDateAvailable(candidate)) {
977
+ this.focusedDate = candidate
978
+ return
979
+ }
980
+ }
981
+
982
+ // Fallback to first available date in current month
983
+ this.focusedDate = this.findFirstAvailableDate()
984
+ }
985
+
659
986
  override render(): TemplateResult {
660
987
  // Show dropdown views instead of calendar when dropdowns are open
661
- if (this._showMonthDropdown) {
988
+ if (this.showMonthDropdown) {
662
989
  return html`
663
- <div class="calendar" role="grid" aria-label="Calendar">
664
- ${this._renderNavigation()} ${this._renderMonthDropdown()}
990
+ <div class="calendar" role="grid" aria-label="Month selection for ${this.year}">
991
+ ${this.renderNavigation()} ${this.renderMonthDropdown()}
665
992
  </div>
666
993
  `
667
994
  }
668
995
 
669
- if (this._showYearDropdown) {
996
+ if (this.showYearDropdown) {
670
997
  return html`
671
- <div class="calendar" role="grid" aria-label="Calendar">
672
- ${this._renderNavigation()} ${this._renderYearDropdown()}
998
+ <div class="calendar" role="grid" aria-label="Year selection">
999
+ ${this.renderNavigation()} ${this.renderYearDropdown()}
673
1000
  </div>
674
1001
  `
675
1002
  }
676
1003
 
677
1004
  // Default calendar view
678
1005
  return html`
679
- <div class="calendar" role="grid" aria-label="Calendar">
680
- ${this._renderNavigation()} ${this._renderWeekdays()} ${this._renderDays()} ${this._renderActions()}
1006
+ <div
1007
+ class="calendar"
1008
+ role="grid"
1009
+ aria-label="Calendar for ${this.monthNames[this.month]} ${this.year}"
1010
+ aria-roledescription="Calendar grid"
1011
+ tabindex="0"
1012
+ @keydown=${this.handleKeyDown}
1013
+ >
1014
+ ${this.renderNavigation()} ${this.renderWeekdays()} ${this.renderDays()} ${this.renderActions()}
681
1015
  </div>
682
1016
  `
683
1017
  }