@api-client/ui 0.5.5 → 0.5.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.
Files changed (114) 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/highlight/MarkdownStyles.d.ts.map +1 -1
  18. package/build/src/elements/highlight/MarkdownStyles.js +0 -13
  19. package/build/src/elements/highlight/MarkdownStyles.js.map +1 -1
  20. package/build/src/elements/http/BodyEditor.d.ts +0 -13
  21. package/build/src/elements/http/BodyEditor.d.ts.map +1 -1
  22. package/build/src/elements/http/BodyEditor.js +0 -13
  23. package/build/src/elements/http/BodyEditor.js.map +1 -1
  24. package/build/src/elements/http/BodyTextEditor.d.ts +0 -13
  25. package/build/src/elements/http/BodyTextEditor.d.ts.map +1 -1
  26. package/build/src/elements/http/BodyTextEditor.js +0 -13
  27. package/build/src/elements/http/BodyTextEditor.js.map +1 -1
  28. package/build/src/elements/http/BodyUrlEncodedEditor.d.ts +0 -13
  29. package/build/src/elements/http/BodyUrlEncodedEditor.d.ts.map +1 -1
  30. package/build/src/elements/http/BodyUrlEncodedEditor.js +0 -13
  31. package/build/src/elements/http/BodyUrlEncodedEditor.js.map +1 -1
  32. package/build/src/elements/http/UrlInput.d.ts +0 -13
  33. package/build/src/elements/http/UrlInput.d.ts.map +1 -1
  34. package/build/src/elements/http/UrlInput.js +0 -13
  35. package/build/src/elements/http/UrlInput.js.map +1 -1
  36. package/build/src/index.d.ts +2 -0
  37. package/build/src/index.d.ts.map +1 -1
  38. package/build/src/index.js +2 -0
  39. package/build/src/index.js.map +1 -1
  40. package/build/src/md/button/internals/base.d.ts +1 -0
  41. package/build/src/md/button/internals/base.d.ts.map +1 -1
  42. package/build/src/md/button/internals/base.js +7 -0
  43. package/build/src/md/button/internals/base.js.map +1 -1
  44. package/build/src/md/button/internals/button.styles.js +1 -1
  45. package/build/src/md/button/internals/button.styles.js.map +1 -1
  46. package/build/src/md/date/internals/DateTime.d.ts +0 -13
  47. package/build/src/md/date/internals/DateTime.d.ts.map +1 -1
  48. package/build/src/md/date/internals/DateTime.js +0 -13
  49. package/build/src/md/date/internals/DateTime.js.map +1 -1
  50. package/build/src/md/date-picker/index.d.ts +13 -0
  51. package/build/src/md/date-picker/index.d.ts.map +1 -0
  52. package/build/src/md/date-picker/index.js +13 -0
  53. package/build/src/md/date-picker/index.js.map +1 -0
  54. package/build/src/md/date-picker/internals/DatePicker.styles.d.ts +4 -0
  55. package/build/src/md/date-picker/internals/DatePicker.styles.d.ts.map +1 -0
  56. package/build/src/md/date-picker/internals/DatePicker.styles.js +409 -0
  57. package/build/src/md/date-picker/internals/DatePicker.styles.js.map +1 -0
  58. package/build/src/md/date-picker/internals/DatePickerCalendar.d.ts +272 -0
  59. package/build/src/md/date-picker/internals/DatePickerCalendar.d.ts.map +1 -0
  60. package/build/src/md/date-picker/internals/DatePickerCalendar.js +1062 -0
  61. package/build/src/md/date-picker/internals/DatePickerCalendar.js.map +1 -0
  62. package/build/src/md/date-picker/internals/DatePickerUtils.d.ts +93 -0
  63. package/build/src/md/date-picker/internals/DatePickerUtils.d.ts.map +1 -0
  64. package/build/src/md/date-picker/internals/DatePickerUtils.js +221 -0
  65. package/build/src/md/date-picker/internals/DatePickerUtils.js.map +1 -0
  66. package/build/src/md/date-picker/ui-date-picker-input.d.ts +160 -0
  67. package/build/src/md/date-picker/ui-date-picker-input.d.ts.map +1 -0
  68. package/build/src/md/date-picker/ui-date-picker-input.js +464 -0
  69. package/build/src/md/date-picker/ui-date-picker-input.js.map +1 -0
  70. package/build/src/md/date-picker/ui-date-picker-modal-input.d.ts +178 -0
  71. package/build/src/md/date-picker/ui-date-picker-modal-input.d.ts.map +1 -0
  72. package/build/src/md/date-picker/ui-date-picker-modal-input.js +538 -0
  73. package/build/src/md/date-picker/ui-date-picker-modal-input.js.map +1 -0
  74. package/build/src/md/date-picker/ui-date-picker-modal.d.ts +156 -0
  75. package/build/src/md/date-picker/ui-date-picker-modal.d.ts.map +1 -0
  76. package/build/src/md/date-picker/ui-date-picker-modal.js +423 -0
  77. package/build/src/md/date-picker/ui-date-picker-modal.js.map +1 -0
  78. package/build/src/md/dialog/internals/Dialog.styles.d.ts.map +1 -1
  79. package/build/src/md/dialog/internals/Dialog.styles.js +1 -0
  80. package/build/src/md/dialog/internals/Dialog.styles.js.map +1 -1
  81. package/demo/elements/currency/index.html +91 -0
  82. package/demo/elements/currency/index.ts +272 -0
  83. package/demo/elements/har/har2.json +1 -1
  84. package/demo/elements/index.html +3 -0
  85. package/demo/md/date-picker/date-picker.ts +336 -0
  86. package/demo/md/date-picker/index.html +171 -0
  87. package/demo/md/index.html +2 -0
  88. package/package.json +1 -1
  89. package/src/elements/currency/currency-picker.ts +14 -0
  90. package/src/elements/currency/internals/Picker.styles.ts +58 -0
  91. package/src/elements/currency/internals/Picker.ts +846 -0
  92. package/src/elements/highlight/MarkdownStyles.ts +0 -13
  93. package/src/elements/http/BodyEditor.ts +0 -13
  94. package/src/elements/http/BodyTextEditor.ts +0 -13
  95. package/src/elements/http/BodyUrlEncodedEditor.ts +0 -13
  96. package/src/elements/http/UrlInput.ts +0 -13
  97. package/src/index.ts +17 -0
  98. package/src/md/button/internals/base.ts +7 -0
  99. package/src/md/button/internals/button.styles.ts +1 -1
  100. package/src/md/date/internals/DateTime.ts +0 -14
  101. package/src/md/date-picker/README.md +184 -0
  102. package/src/md/date-picker/index.ts +17 -0
  103. package/src/md/date-picker/internals/DatePicker.styles.ts +411 -0
  104. package/src/md/date-picker/internals/DatePickerCalendar.ts +1031 -0
  105. package/src/md/date-picker/internals/DatePickerUtils.ts +288 -0
  106. package/src/md/date-picker/ui-date-picker-input.ts +333 -0
  107. package/src/md/date-picker/ui-date-picker-modal-input.ts +440 -0
  108. package/src/md/date-picker/ui-date-picker-modal.ts +346 -0
  109. package/src/md/dialog/internals/Dialog.styles.ts +1 -0
  110. package/test/README.md +3 -2
  111. package/test/elements/currency/CurrencyPicker.accessibility.test.ts +328 -0
  112. package/test/elements/currency/CurrencyPicker.core.test.ts +318 -0
  113. package/test/elements/currency/CurrencyPicker.integration.test.ts +482 -0
  114. package/test/elements/currency/CurrencyPicker.test.ts +486 -0
@@ -0,0 +1,1031 @@
1
+ import { LitElement, html, TemplateResult, nothing } from 'lit'
2
+ import { customElement, property, state } from 'lit/decorators.js'
3
+ import { classMap } from 'lit/directives/class-map.js'
4
+ import { calendarStyles } from './DatePicker.styles.js'
5
+ import {
6
+ CalendarMonth,
7
+ CalendarDay,
8
+ DateRange,
9
+ generateCalendarMonth,
10
+ addMonths,
11
+ getMonthNames,
12
+ isSameDay,
13
+ formatDate,
14
+ addDays,
15
+ } from './DatePickerUtils.js'
16
+ import '../../../md/icons/ui-icon.js'
17
+ import '../../../md/button/ui-button.js'
18
+ import '../../../md/icon-button/ui-icon-button.js'
19
+
20
+ /**
21
+ * Event dispatched when a single date is selected in immediate mode
22
+ * or confirmed in pending mode.
23
+ */
24
+ export interface DateSelectEvent {
25
+ date: Date
26
+ formattedDate: string
27
+ }
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
+ */
33
+ export interface DateRangeSelectEvent {
34
+ range: DateRange
35
+ formattedRange: {
36
+ start: string | null
37
+ end: string | null
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Event dispatched when a date range selection is confirmed
43
+ * via the OK button in pending mode.
44
+ */
45
+ export interface DateRangeConfirmEvent {
46
+ range: DateRange | null
47
+ formattedRange: {
48
+ start: string | null
49
+ end: string | null
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Event dispatched when user cancels a pending selection
55
+ * via the Cancel button or Escape key.
56
+ */
57
+ export interface DateCancelEvent {
58
+ reason?: string
59
+ }
60
+
61
+ /**
62
+ * A calendar grid component for date 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
78
+ *
79
+ * ## Usage
80
+ *
81
+ * ```html
82
+ * <ui-date-picker-calendar></ui-date-picker-calendar>
83
+ * ```
84
+ *
85
+ * ### Single date selection
86
+ * ```html
87
+ * <ui-date-picker-calendar
88
+ * .selectedDate=${new Date()}
89
+ * @date-select=${this.handleDateSelect}
90
+ * ></ui-date-picker-calendar>
91
+ * ```
92
+ *
93
+ * ### Date range selection
94
+ * ```html
95
+ * <ui-date-picker-calendar
96
+ * rangeSelection
97
+ * .rangeStart=${new Date()}
98
+ * .rangeEnd=${null}
99
+ * @date-range-select=${this.handleRangeSelect}
100
+ * ></ui-date-picker-calendar>
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
+ * ```
114
+ */
115
+ @customElement('ui-date-picker-calendar')
116
+ export class UiDatePickerCalendar extends LitElement {
117
+ static override styles = calendarStyles
118
+
119
+ /**
120
+ * The currently displayed year
121
+ */
122
+ @property({ type: Number }) accessor year = new Date().getFullYear()
123
+
124
+ /**
125
+ * The currently displayed month (0-indexed, where 0 = January)
126
+ */
127
+ @property({ type: Number }) accessor month = new Date().getMonth()
128
+
129
+ /**
130
+ * The currently selected date for single selection mode.
131
+ * Set to null for no selection.
132
+ */
133
+ @property({ type: Object }) accessor selectedDate: Date | null = null
134
+
135
+ /**
136
+ * The start date of the selected range for range selection mode.
137
+ * Used in combination with rangeEnd to define a date range.
138
+ */
139
+ @property({ type: Object }) accessor rangeStart: Date | null = null
140
+
141
+ /**
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.
150
+ */
151
+ @property({ type: Boolean }) accessor rangeSelection = false
152
+
153
+ /**
154
+ * Minimum selectable date. Dates before this will be disabled.
155
+ * Set to undefined for no minimum restriction.
156
+ */
157
+ @property({ type: Object }) accessor minDate: Date | undefined = undefined
158
+
159
+ /**
160
+ * Maximum selectable date. Dates after this will be disabled.
161
+ * Set to undefined for no maximum restriction.
162
+ */
163
+ @property({ type: Object }) accessor maxDate: Date | undefined = undefined
164
+
165
+ /**
166
+ * Array of specific dates to disable. These dates will not be selectable
167
+ * regardless of minDate and maxDate settings.
168
+ */
169
+ @property({ type: Array }) accessor disabledDates: Date[] | undefined = undefined
170
+
171
+ /**
172
+ * Locale for date formatting and month/day names (e.g., 'en-US', 'fr-FR').
173
+ * Defaults to browser locale if not specified.
174
+ */
175
+ @property({ type: String }) accessor locale: string | undefined = undefined
176
+
177
+ /**
178
+ * Whether to show navigation controls (previous/next month and year buttons).
179
+ * When false, users can only navigate using keyboard or programmatically.
180
+ */
181
+ @property({ type: Boolean }) accessor showNavigation = true
182
+
183
+ /**
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.
186
+ */
187
+ @property({ type: Boolean }) accessor showActions = false
188
+
189
+ /**
190
+ * Text label for the OK/confirm button. Only visible when showActions is true.
191
+ */
192
+ @property({ type: String }) accessor okButtonText = 'OK'
193
+
194
+ /**
195
+ * Text label for the Cancel button. Only visible when showActions is true.
196
+ */
197
+ @property({ type: String }) accessor cancelButtonText = 'Cancel'
198
+
199
+ @state() private accessor calendarData: CalendarMonth | undefined = undefined
200
+
201
+ @state() private accessor monthNames: string[] = []
202
+
203
+ @state() private accessor showMonthDropdown = false
204
+
205
+ @state() private accessor showYearDropdown = false
206
+
207
+ @state() private accessor pendingDate: Date | null = null
208
+
209
+ @state() private accessor pendingRangeStart: Date | null = null
210
+
211
+ @state() private accessor pendingRangeEnd: Date | null = null
212
+
213
+ @state() private accessor focusedDate: Date | null = null
214
+
215
+ override connectedCallback(): void {
216
+ super.connectedCallback()
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()
225
+ }
226
+
227
+ override disconnectedCallback(): void {
228
+ super.disconnectedCallback()
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
+ }
252
+ }
253
+
254
+ override updated(changedProperties: Map<string | number | symbol, unknown>): void {
255
+ if (changedProperties.has('showYearDropdown') && this.showYearDropdown) {
256
+ // Scroll selected year into view
257
+ this.scrollSelectedYearIntoView()
258
+ }
259
+
260
+ if (changedProperties.has('focusedDate')) {
261
+ this.updateFocus()
262
+ }
263
+ }
264
+
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 {
280
+ // Wait for next frame to ensure DOM is updated
281
+ requestAnimationFrame(() => {
282
+ const selectedYearButton = this.shadowRoot?.querySelector('.year-option.selected') as HTMLElement
283
+ if (selectedYearButton) {
284
+ selectedYearButton.scrollIntoView({
285
+ behavior: 'auto',
286
+ block: 'center',
287
+ })
288
+ }
289
+ })
290
+ }
291
+
292
+ private updateFocusedDateForMonthChange(): void {
293
+ if (!this.focusedDate) {
294
+ this.initializeFocusedDate()
295
+ return
296
+ }
297
+
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()
308
+ }
309
+ }
310
+
311
+ private updateCalendar(): void {
312
+ const selectedRange = this.rangeStart || this.rangeEnd ? { start: this.rangeStart, end: this.rangeEnd } : null
313
+ this.calendarData = generateCalendarMonth(
314
+ this.year,
315
+ this.month,
316
+ this.selectedDate,
317
+ selectedRange,
318
+ this.disabledDates,
319
+ this.locale
320
+ )
321
+ }
322
+
323
+ private updateMonthNames(): void {
324
+ this.monthNames = getMonthNames(this.locale)
325
+ }
326
+
327
+ private navigateMonth(delta: number): void {
328
+ const newDate = addMonths(new Date(this.year, this.month), delta)
329
+ this.year = newDate.getFullYear()
330
+ this.month = newDate.getMonth()
331
+ }
332
+
333
+ private handlePrevMonth(): void {
334
+ this.navigateMonth(-1)
335
+ }
336
+
337
+ private handleNextMonth(): void {
338
+ this.navigateMonth(1)
339
+ }
340
+
341
+ private handlePrevYear(): void {
342
+ this.year = this.year - 1
343
+ }
344
+
345
+ private handleNextYear(): void {
346
+ this.year = this.year + 1
347
+ }
348
+
349
+ private handleMonthClick(): void {
350
+ this.showMonthDropdown = !this.showMonthDropdown
351
+ this.showYearDropdown = false
352
+ }
353
+
354
+ private handleYearClick(): void {
355
+ this.showYearDropdown = !this.showYearDropdown
356
+ this.showMonthDropdown = false
357
+ }
358
+
359
+ private handleMonthSelect(selectedMonth: number): void {
360
+ this.month = selectedMonth
361
+ this.showMonthDropdown = false
362
+ }
363
+
364
+ private handleYearSelect(selectedYear: number): void {
365
+ this.year = selectedYear
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
+ }
402
+ }
403
+
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
414
+ }
415
+
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()
422
+ }
423
+ }
424
+
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 {
452
+ if (day.isDisabled) return
453
+
454
+ // Update focused date to clicked date
455
+ this.focusedDate = day.date
456
+
457
+ if (this.rangeSelection) {
458
+ this.handleRangeSelection(day.date)
459
+ } else {
460
+ this.handleSingleSelection(day.date)
461
+ }
462
+ }
463
+
464
+ private handleSingleSelection(date: Date): void {
465
+ if (this.showActions) {
466
+ // Use pending state when actions are enabled
467
+ this.pendingDate = date
468
+ } else {
469
+ // Immediate selection when no actions
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)
489
+
490
+ if (isImmediate) {
491
+ this.dispatchRangeEvent(sortedRange)
492
+ }
493
+ return
494
+ }
495
+
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,
508
+ }
509
+ }
510
+
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
+ }
518
+
519
+ private setRangeValues(start: Date | null, end: Date | null, isImmediate: boolean): void {
520
+ if (isImmediate) {
521
+ this.rangeStart = start
522
+ this.rangeEnd = end
523
+ } else {
524
+ this.pendingRangeStart = start
525
+ this.pendingRangeEnd = end
526
+ }
527
+ }
528
+
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
+ }
537
+
538
+ this.dispatchEvent(
539
+ new CustomEvent('date-select', {
540
+ detail: event,
541
+ bubbles: true,
542
+ composed: true,
543
+ })
544
+ )
545
+ }
546
+
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
+ }
558
+
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
+ },
578
+ }
579
+
580
+ this.dispatchEvent(
581
+ new CustomEvent('date-range-confirm', {
582
+ detail: event,
583
+ bubbles: true,
584
+ composed: true,
585
+ })
586
+ )
587
+ }
588
+
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 {
605
+ if (this.minDate && date < this.minDate) return true
606
+ if (this.maxDate && date > this.maxDate) return true
607
+ if (this.disabledDates?.some((disabledDate) => isSameDay(date, disabledDate))) return true
608
+ return false
609
+ }
610
+
611
+ private handleConfirm(): void {
612
+ if (this.rangeSelection) {
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
622
+
623
+ this.dispatchRangeConfirmEvent(range)
624
+ }
625
+ } else {
626
+ if (this.pendingDate) {
627
+ this.selectedDate = this.pendingDate
628
+
629
+ // Reset pending state after confirmation
630
+ this.pendingDate = null
631
+
632
+ this.dispatchDateEvent(this.selectedDate)
633
+ }
634
+ }
635
+ }
636
+
637
+ private handleCancel(): void {
638
+ // Reset pending state
639
+ this.pendingDate = null
640
+ this.pendingRangeStart = null
641
+ this.pendingRangeEnd = null
642
+
643
+ this.dispatchCancelEvent()
644
+ }
645
+
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
657
+ }
658
+
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>`
668
+ }
669
+
670
+ private renderNavigation(): TemplateResult {
671
+ const monthName = this.monthNames[this.month] || ''
672
+
673
+ return html`
674
+ <div class="header">
675
+ <div class="month-year">
676
+ <div class="month-selector">
677
+ ${this.renderNavButton('prev', this.handlePrevMonth, 'Previous month', 'chevronLeft')}
678
+ <ui-button
679
+ class="month-button"
680
+ color="text"
681
+ size="xs"
682
+ @click=${this.handleMonthClick}
683
+ aria-label="Select month"
684
+ aria-expanded=${this.showMonthDropdown}
685
+ trailingIcon
686
+ >
687
+ ${monthName}
688
+ <ui-icon icon="arrowDropDown" slot="icon"></ui-icon>
689
+ </ui-button>
690
+ ${this.renderNavButton('next', this.handleNextMonth, 'Next month', 'chevronRight')}
691
+ </div>
692
+ <div class="year-selector">
693
+ ${this.renderNavButton('prev', this.handlePrevYear, 'Previous year', 'chevronLeft')}
694
+ <ui-button
695
+ class="year-button"
696
+ color="text"
697
+ size="xs"
698
+ @click=${this.handleYearClick}
699
+ aria-label="Select year"
700
+ aria-expanded=${this.showYearDropdown}
701
+ trailingIcon
702
+ >
703
+ ${this.year}
704
+ <ui-icon icon="arrowDropDown" slot="icon"></ui-icon>
705
+ </ui-button>
706
+ ${this.renderNavButton('next', this.handleNextYear, 'Next year', 'chevronRight')}
707
+ </div>
708
+ </div>
709
+ </div>
710
+ `
711
+ }
712
+
713
+ private renderWeekdays(): TemplateResult {
714
+ if (!this.calendarData) return html``
715
+
716
+ return html`
717
+ <div class="weekdays" role="row">
718
+ ${this.calendarData.weekdays.map((weekday) => html`<div class="weekday" role="columnheader">${weekday}</div>`)}
719
+ </div>
720
+ `
721
+ }
722
+
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
+ } {
732
+ if (this.showActions) {
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,
750
+ }
751
+ } else {
752
+ // Use immediate state
753
+ return {
754
+ isSelected: day.isSelected,
755
+ isRangeStart: day.isRangeStart,
756
+ isRangeEnd: day.isRangeEnd,
757
+ isInRange: day.isInRange,
758
+ }
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
+ }
776
+
777
+ if (day.isToday) {
778
+ return 'outlined'
779
+ }
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
+
788
+ const classes = {
789
+ 'day-cell': true,
790
+ 'other-month': !day.isCurrentMonth,
791
+ 'today': day.isToday,
792
+ 'in-range': selectionState.isInRange,
793
+ 'range-start': selectionState.isRangeStart,
794
+ 'range-end': selectionState.isRangeEnd,
795
+ 'has-complete-range': this.hasCompleteRange(),
796
+ }
797
+
798
+ const isFocused = this.focusedDate && isSameDay(day.date, this.focusedDate)
799
+
800
+ return html`
801
+ <div class=${classMap(classes)} role="gridcell">
802
+ <ui-button
803
+ class="day-button"
804
+ color=${color}
805
+ size="s"
806
+ data-date=${day.date.toISOString().split('T')[0]}
807
+ tabindex=${isFocused && !(day.isDisabled || this.isDateDisabled(day.date)) ? '0' : '-1'}
808
+ aria-label=${formatDate(day.date, this.locale)}
809
+ aria-selected=${selectionState.isSelected || selectionState.isRangeStart || selectionState.isRangeEnd}
810
+ @click=${() => this.handleDayClick(day)}
811
+ ?disabled=${day.isDisabled || this.isDateDisabled(day.date)}
812
+ >
813
+ ${day.date.getDate()}
814
+ </ui-button>
815
+ </div>
816
+ `
817
+ }
818
+
819
+ private renderDays(): TemplateResult {
820
+ if (!this.calendarData) return html``
821
+
822
+ return html`<div class="days">${this.calendarData.days.map((day) => this.renderDay(day))}</div>`
823
+ }
824
+
825
+ private renderActions(): TemplateResult | typeof nothing {
826
+ if (!this.showActions) return nothing
827
+
828
+ const hasSelection = this.rangeSelection ? this.pendingRangeStart : this.pendingDate
829
+
830
+ return html`
831
+ <div class="actions">
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}>
834
+ ${this.okButtonText}
835
+ </ui-button>
836
+ </div>
837
+ `
838
+ }
839
+
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 {
864
+ return html`
865
+ <div class="dropdown-view">
866
+ <div class="month-list">
867
+ ${this.monthNames.map((monthName, index) =>
868
+ this.renderDropdownOption(index, monthName, index === this.month, () => this.handleMonthSelect(index))
869
+ )}
870
+ </div>
871
+ </div>
872
+ `
873
+ }
874
+
875
+ private renderYearDropdown(): TemplateResult {
876
+ const currentYear = this.year
877
+ const startYear = currentYear - 50
878
+ const endYear = currentYear + 50
879
+ const years: number[] = []
880
+
881
+ for (let year = startYear; year <= endYear; year++) {
882
+ years.push(year)
883
+ }
884
+
885
+ return html`
886
+ <div class="dropdown-view">
887
+ <div class="year-grid">
888
+ ${years.map((year) =>
889
+ this.renderDropdownOption(
890
+ year,
891
+ year.toString(),
892
+ year === this.year,
893
+ () => this.handleYearSelect(year),
894
+ 'year-option'
895
+ )
896
+ )}
897
+ </div>
898
+ </div>
899
+ `
900
+ }
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
+
986
+ override render(): TemplateResult {
987
+ // Show dropdown views instead of calendar when dropdowns are open
988
+ if (this.showMonthDropdown) {
989
+ return html`
990
+ <div class="calendar" role="grid" aria-label="Month selection for ${this.year}">
991
+ ${this.renderNavigation()} ${this.renderMonthDropdown()}
992
+ </div>
993
+ `
994
+ }
995
+
996
+ if (this.showYearDropdown) {
997
+ return html`
998
+ <div class="calendar" role="grid" aria-label="Year selection">
999
+ ${this.renderNavigation()} ${this.renderYearDropdown()}
1000
+ </div>
1001
+ `
1002
+ }
1003
+
1004
+ // Default calendar view
1005
+ return html`
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()}
1015
+ </div>
1016
+ `
1017
+ }
1018
+ }
1019
+
1020
+ declare global {
1021
+ interface HTMLElementTagNameMap {
1022
+ 'ui-date-picker-calendar': UiDatePickerCalendar
1023
+ }
1024
+
1025
+ interface HTMLElementEventMap {
1026
+ 'date-select': CustomEvent<DateSelectEvent>
1027
+ 'date-range-select': CustomEvent<DateRangeSelectEvent>
1028
+ 'date-range-confirm': CustomEvent<DateRangeConfirmEvent>
1029
+ 'date-cancel': CustomEvent<DateCancelEvent>
1030
+ }
1031
+ }