@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.
- package/.cursor/rules/html-and-css-best-practices.mdc +63 -0
- package/.cursor/rules/lit-best-practices.mdc +78 -0
- package/.github/instructions/html-and-css-best-practices.instructions.md +70 -0
- package/.github/instructions/lit-best-practices.instructions.md +86 -0
- package/build/src/elements/currency/currency-picker.d.ts +10 -0
- package/build/src/elements/currency/currency-picker.d.ts.map +1 -0
- package/build/src/elements/currency/currency-picker.js +27 -0
- package/build/src/elements/currency/currency-picker.js.map +1 -0
- package/build/src/elements/currency/internals/Picker.d.ts +311 -0
- package/build/src/elements/currency/internals/Picker.d.ts.map +1 -0
- package/build/src/elements/currency/internals/Picker.js +857 -0
- package/build/src/elements/currency/internals/Picker.js.map +1 -0
- package/build/src/elements/currency/internals/Picker.styles.d.ts +3 -0
- package/build/src/elements/currency/internals/Picker.styles.d.ts.map +1 -0
- package/build/src/elements/currency/internals/Picker.styles.js +58 -0
- package/build/src/elements/currency/internals/Picker.styles.js.map +1 -0
- package/build/src/elements/highlight/MarkdownStyles.d.ts.map +1 -1
- package/build/src/elements/highlight/MarkdownStyles.js +0 -13
- package/build/src/elements/highlight/MarkdownStyles.js.map +1 -1
- package/build/src/elements/http/BodyEditor.d.ts +0 -13
- package/build/src/elements/http/BodyEditor.d.ts.map +1 -1
- package/build/src/elements/http/BodyEditor.js +0 -13
- package/build/src/elements/http/BodyEditor.js.map +1 -1
- package/build/src/elements/http/BodyTextEditor.d.ts +0 -13
- package/build/src/elements/http/BodyTextEditor.d.ts.map +1 -1
- package/build/src/elements/http/BodyTextEditor.js +0 -13
- package/build/src/elements/http/BodyTextEditor.js.map +1 -1
- package/build/src/elements/http/BodyUrlEncodedEditor.d.ts +0 -13
- package/build/src/elements/http/BodyUrlEncodedEditor.d.ts.map +1 -1
- package/build/src/elements/http/BodyUrlEncodedEditor.js +0 -13
- package/build/src/elements/http/BodyUrlEncodedEditor.js.map +1 -1
- package/build/src/elements/http/UrlInput.d.ts +0 -13
- package/build/src/elements/http/UrlInput.d.ts.map +1 -1
- package/build/src/elements/http/UrlInput.js +0 -13
- package/build/src/elements/http/UrlInput.js.map +1 -1
- package/build/src/index.d.ts +2 -0
- package/build/src/index.d.ts.map +1 -1
- package/build/src/index.js +2 -0
- package/build/src/index.js.map +1 -1
- package/build/src/md/button/internals/base.d.ts +1 -0
- package/build/src/md/button/internals/base.d.ts.map +1 -1
- package/build/src/md/button/internals/base.js +7 -0
- package/build/src/md/button/internals/base.js.map +1 -1
- package/build/src/md/button/internals/button.styles.js +1 -1
- package/build/src/md/button/internals/button.styles.js.map +1 -1
- package/build/src/md/date/internals/DateTime.d.ts +0 -13
- package/build/src/md/date/internals/DateTime.d.ts.map +1 -1
- package/build/src/md/date/internals/DateTime.js +0 -13
- package/build/src/md/date/internals/DateTime.js.map +1 -1
- package/build/src/md/date-picker/index.d.ts +13 -0
- package/build/src/md/date-picker/index.d.ts.map +1 -0
- package/build/src/md/date-picker/index.js +13 -0
- package/build/src/md/date-picker/index.js.map +1 -0
- package/build/src/md/date-picker/internals/DatePicker.styles.d.ts +4 -0
- package/build/src/md/date-picker/internals/DatePicker.styles.d.ts.map +1 -0
- package/build/src/md/date-picker/internals/DatePicker.styles.js +409 -0
- package/build/src/md/date-picker/internals/DatePicker.styles.js.map +1 -0
- package/build/src/md/date-picker/internals/DatePickerCalendar.d.ts +272 -0
- package/build/src/md/date-picker/internals/DatePickerCalendar.d.ts.map +1 -0
- package/build/src/md/date-picker/internals/DatePickerCalendar.js +1062 -0
- package/build/src/md/date-picker/internals/DatePickerCalendar.js.map +1 -0
- package/build/src/md/date-picker/internals/DatePickerUtils.d.ts +93 -0
- package/build/src/md/date-picker/internals/DatePickerUtils.d.ts.map +1 -0
- package/build/src/md/date-picker/internals/DatePickerUtils.js +221 -0
- package/build/src/md/date-picker/internals/DatePickerUtils.js.map +1 -0
- package/build/src/md/date-picker/ui-date-picker-input.d.ts +160 -0
- package/build/src/md/date-picker/ui-date-picker-input.d.ts.map +1 -0
- package/build/src/md/date-picker/ui-date-picker-input.js +464 -0
- package/build/src/md/date-picker/ui-date-picker-input.js.map +1 -0
- package/build/src/md/date-picker/ui-date-picker-modal-input.d.ts +178 -0
- package/build/src/md/date-picker/ui-date-picker-modal-input.d.ts.map +1 -0
- package/build/src/md/date-picker/ui-date-picker-modal-input.js +538 -0
- package/build/src/md/date-picker/ui-date-picker-modal-input.js.map +1 -0
- package/build/src/md/date-picker/ui-date-picker-modal.d.ts +156 -0
- package/build/src/md/date-picker/ui-date-picker-modal.d.ts.map +1 -0
- package/build/src/md/date-picker/ui-date-picker-modal.js +423 -0
- package/build/src/md/date-picker/ui-date-picker-modal.js.map +1 -0
- package/build/src/md/dialog/internals/Dialog.styles.d.ts.map +1 -1
- package/build/src/md/dialog/internals/Dialog.styles.js +1 -0
- package/build/src/md/dialog/internals/Dialog.styles.js.map +1 -1
- package/demo/elements/currency/index.html +91 -0
- package/demo/elements/currency/index.ts +272 -0
- package/demo/elements/har/har2.json +1 -1
- package/demo/elements/index.html +3 -0
- package/demo/md/date-picker/date-picker.ts +336 -0
- package/demo/md/date-picker/index.html +171 -0
- package/demo/md/index.html +2 -0
- package/package.json +1 -1
- package/src/elements/currency/currency-picker.ts +14 -0
- package/src/elements/currency/internals/Picker.styles.ts +58 -0
- package/src/elements/currency/internals/Picker.ts +846 -0
- package/src/elements/highlight/MarkdownStyles.ts +0 -13
- package/src/elements/http/BodyEditor.ts +0 -13
- package/src/elements/http/BodyTextEditor.ts +0 -13
- package/src/elements/http/BodyUrlEncodedEditor.ts +0 -13
- package/src/elements/http/UrlInput.ts +0 -13
- package/src/index.ts +17 -0
- package/src/md/button/internals/base.ts +7 -0
- package/src/md/button/internals/button.styles.ts +1 -1
- package/src/md/date/internals/DateTime.ts +0 -14
- package/src/md/date-picker/README.md +184 -0
- package/src/md/date-picker/index.ts +17 -0
- package/src/md/date-picker/internals/DatePicker.styles.ts +411 -0
- package/src/md/date-picker/internals/DatePickerCalendar.ts +1031 -0
- package/src/md/date-picker/internals/DatePickerUtils.ts +288 -0
- package/src/md/date-picker/ui-date-picker-input.ts +333 -0
- package/src/md/date-picker/ui-date-picker-modal-input.ts +440 -0
- package/src/md/date-picker/ui-date-picker-modal.ts +346 -0
- package/src/md/dialog/internals/Dialog.styles.ts +1 -0
- package/test/README.md +3 -2
- package/test/elements/currency/CurrencyPicker.accessibility.test.ts +328 -0
- package/test/elements/currency/CurrencyPicker.core.test.ts +318 -0
- package/test/elements/currency/CurrencyPicker.integration.test.ts +482 -0
- 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
|
+
}
|