@design.estate/dees-catalog 9.6.0 → 9.7.0

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 (66) hide show
  1. package/dist_bundle/bundle.js +2957 -2685
  2. package/dist_bundle/bundle.js.map +1 -1
  3. package/dist_ts_web/00_commitinfo_data.js +1 -1
  4. package/dist_ts_web/demos/inputs.d.ts +1 -1
  5. package/dist_ts_web/demos/inputs.js +5 -4
  6. package/dist_ts_web/elements/00group-dataview/dees-table/dees-table.js +5 -4
  7. package/dist_ts_web/elements/00group-form/dees-form/dees-form.d.ts +5 -1
  8. package/dist_ts_web/elements/00group-form/dees-form/dees-form.js +27 -3
  9. package/dist_ts_web/elements/00group-harness/dees-harness-chat/dees-harness-chat.d.ts +1 -0
  10. package/dist_ts_web/elements/00group-harness/dees-harness-chat/dees-harness-chat.js +11 -2
  11. package/dist_ts_web/elements/00group-harness/dees-harness-composer/dees-harness-composer.d.ts +3 -0
  12. package/dist_ts_web/elements/00group-harness/dees-harness-composer/dees-harness-composer.demo.js +4 -1
  13. package/dist_ts_web/elements/00group-harness/dees-harness-composer/dees-harness-composer.js +51 -13
  14. package/dist_ts_web/elements/00group-input/dees-input-base/dees-input-base.d.ts +1 -8
  15. package/dist_ts_web/elements/00group-input/dees-input-base/dees-input-base.js +7 -2
  16. package/dist_ts_web/elements/00group-input/dees-input-datepicker/component.d.ts +18 -3
  17. package/dist_ts_web/elements/00group-input/dees-input-datepicker/component.js +181 -144
  18. package/dist_ts_web/elements/00group-input/dees-input-datepicker/date-validation.d.ts +13 -0
  19. package/dist_ts_web/elements/00group-input/dees-input-datepicker/date-validation.js +65 -0
  20. package/dist_ts_web/elements/00group-input/dees-input-datepicker/datepicker-popup.js +44 -56
  21. package/dist_ts_web/elements/00group-input/dees-input-datepicker/styles.js +6 -1
  22. package/dist_ts_web/elements/00group-input/dees-input-datepicker/template.js +11 -5
  23. package/dist_ts_web/elements/00group-input/dees-input-text/dees-input-text.d.ts +8 -8
  24. package/dist_ts_web/elements/00group-input/dees-input-text/dees-input-text.js +6 -53
  25. package/dist_ts_web/elements/00group-input/dees-input-textarea/dees-input-textarea.d.ts +62 -0
  26. package/dist_ts_web/elements/00group-input/dees-input-textarea/dees-input-textarea.demo.d.ts +1 -0
  27. package/dist_ts_web/elements/00group-input/dees-input-textarea/dees-input-textarea.demo.js +3 -0
  28. package/dist_ts_web/elements/00group-input/dees-input-textarea/dees-input-textarea.js +224 -0
  29. package/dist_ts_web/elements/00group-input/dees-input-textarea/index.d.ts +1 -0
  30. package/dist_ts_web/elements/00group-input/dees-input-textarea/index.js +2 -0
  31. package/dist_ts_web/elements/00group-input/index.d.ts +1 -0
  32. package/dist_ts_web/elements/00group-input/index.js +2 -1
  33. package/dist_ts_web/elements/00group-input/text-input-contextmenu.d.ts +29 -0
  34. package/dist_ts_web/elements/00group-input/text-input-contextmenu.js +29 -0
  35. package/dist_ts_web/elements/00group-overlay/dees-contextmenu/dees-contextmenu.d.ts +1 -1
  36. package/dist_ts_web/elements/00group-overlay/dees-contextmenu/dees-contextmenu.js +5 -4
  37. package/package.json +2 -2
  38. package/readme.md +78 -4
  39. package/scripts/check-bdtheme-ratchet.cjs +2 -2
  40. package/scripts/check-packed-consumer.mjs +48 -0
  41. package/ts_web/00_commitinfo_data.ts +1 -1
  42. package/ts_web/demos/inputs.ts +5 -4
  43. package/ts_web/elements/00group-dataview/dees-table/dees-table.ts +3 -2
  44. package/ts_web/elements/00group-form/dees-form/dees-form.ts +25 -2
  45. package/ts_web/elements/00group-harness/dees-harness-chat/dees-harness-chat.ts +4 -0
  46. package/ts_web/elements/00group-harness/dees-harness-composer/dees-harness-composer.demo.ts +3 -0
  47. package/ts_web/elements/00group-harness/dees-harness-composer/dees-harness-composer.ts +37 -11
  48. package/ts_web/elements/00group-input/dees-input-base/dees-input-base.ts +6 -1
  49. package/ts_web/elements/00group-input/dees-input-datepicker/component.ts +149 -148
  50. package/ts_web/elements/00group-input/dees-input-datepicker/date-validation.ts +64 -0
  51. package/ts_web/elements/00group-input/dees-input-datepicker/datepicker-popup.ts +40 -40
  52. package/ts_web/elements/00group-input/dees-input-datepicker/styles.ts +5 -0
  53. package/ts_web/elements/00group-input/dees-input-datepicker/template.ts +10 -4
  54. package/ts_web/elements/00group-input/dees-input-text/dees-input-text.ts +5 -52
  55. package/ts_web/elements/00group-input/dees-input-textarea/dees-input-textarea.demo.ts +2 -0
  56. package/ts_web/elements/00group-input/dees-input-textarea/dees-input-textarea.ts +128 -0
  57. package/ts_web/elements/00group-input/dees-input-textarea/index.ts +1 -0
  58. package/ts_web/elements/00group-input/index.ts +1 -0
  59. package/ts_web/elements/00group-input/text-input-contextmenu.ts +31 -0
  60. package/ts_web/elements/00group-overlay/dees-contextmenu/dees-contextmenu.ts +3 -3
  61. package/readme.hints.md +0 -1233
  62. package/readme.icons.md +0 -1849
  63. package/readme.info.md +0 -80
  64. package/readme.plan.md +0 -671
  65. package/readme.playbook.md +0 -820
  66. package/readme.theme-migration.md +0 -168
@@ -9,6 +9,7 @@ import { demoFunc } from './demo.js';
9
9
  import { datepickerStyles } from './styles.js';
10
10
  import { renderDatepicker } from './template.js';
11
11
  import { DeesInputDatepickerPopup } from './datepicker-popup.js';
12
+ import { createCalendarDate, dateValidationMessage, parseDateFields, parseDateValue } from './date-validation.js';
12
13
  import '../../00group-utility/dees-icon/dees-icon.js';
13
14
  import '../../00group-layout/dees-label/dees-label.js';
14
15
 
@@ -78,6 +79,17 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
78
79
  @state()
79
80
  accessor selectedMinute: number = 0;
80
81
 
82
+ /** Uncommitted text stays visible even when it cannot be represented by value. */
83
+ @state()
84
+ accessor draftText: string | null = null;
85
+
86
+ @state()
87
+ accessor validationText = '';
88
+
89
+ public get validationState(): 'invalid' | 'valid' { return this.validationText ? 'invalid' : 'valid'; }
90
+
91
+ private lastCommittedValue: string | undefined;
92
+
81
93
  private popupInstance: DeesInputDatepickerPopup | null = null;
82
94
 
83
95
  public static styles = datepickerStyles;
@@ -86,42 +98,59 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
86
98
  return renderDatepicker(this);
87
99
  }
88
100
 
89
- async firstUpdated() {
90
- if (!this.value) {
91
- this.value = '';
101
+ public willUpdate(changed: Map<PropertyKey, unknown>): void {
102
+ super.willUpdate(changed);
103
+ if (changed.has('value') && this.value !== this.lastCommittedValue) {
104
+ this.syncValue(this.value);
92
105
  }
106
+ }
93
107
 
94
- if (this.value) {
95
- try {
96
- const date = new Date(this.value);
97
- if (!isNaN(date.getTime())) {
98
- this.selectedDate = date;
99
- this.viewDate = new Date(date);
100
- this.selectedHour = date.getHours();
101
- this.selectedMinute = date.getMinutes();
102
- }
103
- } catch {
104
- // Invalid date
108
+ public updated(changed: Map<string, unknown>): void {
109
+ super.updated(changed);
110
+ if (changed.has('disabled') && this.disabled) this.closePopup();
111
+ if (['value', 'minDate', 'maxDate', 'disabledDates', 'required', 'disabled'].some(key => changed.has(key))) {
112
+ this.updateValidation(this.currentValidationMessage());
113
+ }
114
+ if (this.popupInstance) {
115
+ this.popupInstance.minDate = this.minDate;
116
+ this.popupInstance.maxDate = this.maxDate;
117
+ this.popupInstance.disabledDates = this.disabledDates;
118
+ if (changed.has('selectedDate')) {
119
+ this.popupInstance.selectedDate = this.selectedDate;
120
+ this.popupInstance.viewDate = new Date(this.viewDate);
121
+ this.popupInstance.selectedHour = this.selectedHour;
122
+ this.popupInstance.selectedMinute = this.selectedMinute;
105
123
  }
106
- } else {
107
- const now = new Date();
108
- this.viewDate = new Date(now);
109
- this.selectedHour = now.getHours();
110
- this.selectedMinute = 0;
124
+ }
125
+ const input = this.shadowRoot?.querySelector('input');
126
+ input?.setCustomValidity(this.disabled ? '' : this.validationText);
127
+ if (input && (changed.has('draftText') || changed.has('value'))) {
128
+ const display = this.draftText ?? this.formatDate(this.value);
129
+ if (input.value !== display) input.value = display;
111
130
  }
112
131
  }
113
132
 
133
+ private syncValue(value: string | null): void {
134
+ this.value = value ?? '';
135
+ this.lastCommittedValue = this.value;
136
+ this.draftText = null;
137
+ this.selectedDate = parseDateValue(this.value);
138
+ this.viewDate = new Date(this.selectedDate ?? new Date());
139
+ this.selectedHour = this.selectedDate?.getHours() ?? 0;
140
+ this.selectedMinute = this.selectedDate?.getMinutes() ?? 0;
141
+ }
142
+
114
143
  public formatDate(isoString: string): string {
115
144
  if (!isoString) return '';
116
145
 
117
146
  try {
118
- const date = new Date(isoString);
119
- if (isNaN(date.getTime())) return '';
147
+ const date = parseDateValue(isoString);
148
+ if (!date) return isoString;
120
149
 
121
150
  let formatted = this.dateFormat;
122
151
  const day = date.getDate().toString().padStart(2, '0');
123
152
  const month = (date.getMonth() + 1).toString().padStart(2, '0');
124
- const year = date.getFullYear().toString();
153
+ const year = date.getFullYear().toString().padStart(4, '0');
125
154
 
126
155
  formatted = formatted.replace('YYYY', year);
127
156
  formatted = formatted.replace('YY', year.slice(-2));
@@ -171,7 +200,7 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
171
200
  const opensToTop = spaceBelow < 400 && spaceAbove > spaceBelow;
172
201
 
173
202
  if (!this.popupInstance) {
174
- this.popupInstance = new DeesInputDatepickerPopup();
203
+ this.popupInstance = document.createElement('dees-input-datepicker-popup');
175
204
  }
176
205
 
177
206
  // Configure popup
@@ -204,31 +233,50 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
204
233
 
205
234
  private closePopup(): void {
206
235
  this.isOpened = false;
207
- if (this.popupInstance) {
208
- this.popupInstance.removeEventListener('date-selected', this.handleDateSelected);
209
- this.popupInstance.removeEventListener('date-cleared', this.handleDateCleared);
210
- this.popupInstance.removeEventListener('close-request', this.handleCloseRequest);
211
- this.popupInstance.removeEventListener('reposition-request', this.handleRepositionRequest);
212
- this.popupInstance.hide();
236
+ const popup = this.popupInstance;
237
+ this.popupInstance = null;
238
+ if (popup) {
239
+ popup.removeEventListener('date-selected', this.handleDateSelected);
240
+ popup.removeEventListener('date-cleared', this.handleDateCleared);
241
+ popup.removeEventListener('close-request', this.handleCloseRequest);
242
+ popup.removeEventListener('reposition-request', this.handleRepositionRequest);
243
+ void popup.hide();
213
244
  }
214
245
  }
215
246
 
216
247
  private handleDateSelected = (event: Event): void => {
217
- const date = (event as CustomEvent).detail as Date;
218
- this.selectedDate = date;
219
- this.selectedHour = date.getHours();
220
- this.selectedMinute = date.getMinutes();
221
- this.viewDate = new Date(date);
222
- this.value = this.formatValueWithTimezone(date);
223
- this.changeSubject.next(this);
248
+ if (this.disabled) return;
249
+ this.commitDate((event as CustomEvent<Date>).detail, true);
224
250
  };
225
251
 
226
252
  private handleDateCleared = (): void => {
227
- this.value = '';
228
- this.selectedDate = null;
229
- this.changeSubject.next(this);
253
+ if (!this.disabled) this.commitClear();
230
254
  };
231
255
 
256
+ private commitDate(date: Date, normalizeDraft: boolean): boolean {
257
+ const message = dateValidationMessage(date, this);
258
+ if (message) { this.updateValidation(message); return false; }
259
+ const value = this.formatValueWithTimezone(date);
260
+ const changed = value !== this.value;
261
+ this.lastCommittedValue = value;
262
+ this.value = value;
263
+ this.selectedDate = new Date(date);
264
+ this.selectedHour = date.getHours();
265
+ this.selectedMinute = date.getMinutes();
266
+ this.viewDate = new Date(date);
267
+ if (normalizeDraft) this.draftText = null;
268
+ this.updateValidation('');
269
+ if (changed) this.changeSubject.next(this);
270
+ return true;
271
+ }
272
+
273
+ private commitClear(): void {
274
+ const changed = Boolean(this.value);
275
+ this.syncValue('');
276
+ this.updateValidation(this.currentValidationMessage());
277
+ if (changed) this.changeSubject.next(this);
278
+ }
279
+
232
280
  private handleCloseRequest = (): void => {
233
281
  this.closePopup();
234
282
  };
@@ -251,11 +299,17 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
251
299
  };
252
300
 
253
301
  public handleKeydown(e: KeyboardEvent): void {
254
- if (e.key === 'Enter' || e.key === ' ') {
302
+ if (this.disabled || e.isComposing) return;
303
+ if (e.key === 'Enter') {
255
304
  e.preventDefault();
256
- this.toggleCalendar();
305
+ if (this.draftText !== null) this.commitManualInput(true);
306
+ else void this.toggleCalendar();
307
+ } else if (e.key === 'ArrowDown' && e.altKey) {
308
+ e.preventDefault();
309
+ if (!this.isOpened) void this.toggleCalendar();
257
310
  } else if (e.key === 'Escape' && this.isOpened) {
258
311
  e.preventDefault();
312
+ e.stopPropagation();
259
313
  this.closePopup();
260
314
  }
261
315
  }
@@ -270,107 +324,73 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
270
324
 
271
325
  public clearValue(e: Event): void {
272
326
  e.stopPropagation();
273
- this.value = '';
274
- this.selectedDate = null;
275
- this.changeSubject.next(this);
327
+ if (!this.disabled) this.commitClear();
276
328
  }
277
329
 
278
330
  public handleManualInput(e: InputEvent): void {
279
- const input = e.target as HTMLInputElement;
280
- const inputValue = input.value.trim();
281
-
282
- if (!inputValue) {
283
- this.value = '';
284
- this.selectedDate = null;
285
- return;
286
- }
287
-
288
- const parsedDate = this.parseManualDate(inputValue);
289
- if (parsedDate && !isNaN(parsedDate.getTime())) {
290
- this.value = parsedDate.toISOString();
291
- this.selectedDate = parsedDate;
292
- this.viewDate = new Date(parsedDate);
293
- this.selectedHour = parsedDate.getHours();
294
- this.selectedMinute = parsedDate.getMinutes();
295
- this.changeSubject.next(this);
296
- }
331
+ if (this.disabled) return;
332
+ this.draftText = (e.target as HTMLInputElement).value;
333
+ if (!e.isComposing) this.updateValidation(this.currentValidationMessage());
297
334
  }
298
335
 
299
- public handleInputBlur(e: FocusEvent): void {
300
- const input = e.target as HTMLInputElement;
301
- const inputValue = input.value.trim();
302
-
303
- if (!inputValue) {
304
- this.value = '';
305
- this.selectedDate = null;
306
- this.changeSubject.next(this);
307
- return;
308
- }
336
+ public handleInputBlur(_event: FocusEvent): void {
337
+ if (!this.disabled && this.draftText !== null) this.commitManualInput(true);
338
+ }
309
339
 
310
- const parsedDate = this.parseManualDate(inputValue);
311
- if (parsedDate && !isNaN(parsedDate.getTime())) {
312
- this.value = parsedDate.toISOString();
313
- this.selectedDate = parsedDate;
314
- this.viewDate = new Date(parsedDate);
315
- this.selectedHour = parsedDate.getHours();
316
- this.selectedMinute = parsedDate.getMinutes();
317
- this.changeSubject.next(this);
318
- input.value = this.formatDate(this.value);
319
- } else {
320
- input.value = this.formatDate(this.value);
321
- }
340
+ private commitManualInput(normalizeDraft: boolean): void {
341
+ const text = this.draftText?.trim() ?? '';
342
+ if (!text) { this.commitClear(); return; }
343
+ const date = this.parseManualDate(text);
344
+ const message = dateValidationMessage(date, this);
345
+ if (message) { this.updateValidation(message); return; }
346
+ this.commitDate(date!, normalizeDraft);
322
347
  }
323
348
 
324
349
  private parseManualDate(input: string): Date | null {
325
- if (!input) return null;
326
-
327
- const parts = input.split(' ');
328
- let datePart = parts[0];
329
- let timePart = parts[1] || '';
330
- let parsedDate: Date | null = null;
331
-
332
- const isoMatch = datePart.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
333
- if (isoMatch) {
334
- const [_, year, month, day] = isoMatch;
335
- parsedDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
336
- }
337
-
338
- if (!parsedDate) {
339
- const euMatch = datePart.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/);
340
- if (euMatch) {
341
- const [_, day, month, year] = euMatch;
342
- parsedDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
343
- }
350
+ const match = /^(\S+)(?:\s+(\d{1,2}):(\d{2})(?:\s+(AM|PM))?)?$/i.exec(input.trim());
351
+ if (!match) return null;
352
+ const fields = parseDateFields(match[1]);
353
+ if (!fields) return null;
354
+ let hour = Number(match[2] ?? 0);
355
+ const minute = Number(match[3] ?? 0);
356
+ if (match[4]) {
357
+ if (hour < 1 || hour > 12) return null;
358
+ hour = hour % 12 + (match[4].toUpperCase() === 'PM' ? 12 : 0);
344
359
  }
360
+ return createCalendarDate(...fields, match[2] === undefined ? undefined : hour, minute);
361
+ }
345
362
 
346
- if (!parsedDate) {
347
- const usMatch = datePart.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
348
- if (usMatch) {
349
- const [_, month, day, year] = usMatch;
350
- parsedDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
351
- }
363
+ private currentValidationMessage(): string {
364
+ if (this.disabled) return '';
365
+ const text = this.draftText?.trim();
366
+ if (this.draftText !== null) {
367
+ if (!text) return this.required ? 'Choose a date.' : '';
368
+ return dateValidationMessage(this.parseManualDate(text), this);
352
369
  }
370
+ if (!this.value) return this.required ? 'Choose a date.' : '';
371
+ return dateValidationMessage(parseDateValue(this.value), this);
372
+ }
353
373
 
354
- if (!parsedDate || isNaN(parsedDate.getTime())) return null;
374
+ private updateValidation(message: string): void {
375
+ const changed = message !== this.validationText;
376
+ this.validationText = message;
377
+ this.shadowRoot?.querySelector('input')?.setCustomValidity(message);
378
+ if (changed) this.dispatchEvent(new CustomEvent('validation-change', {
379
+ detail: { valid: !message, message }, bubbles: true, composed: true,
380
+ }));
381
+ }
355
382
 
356
- if (timePart) {
357
- const timeMatch = timePart.match(/^(\d{1,2}):(\d{2})$/);
358
- if (timeMatch) {
359
- const [_, hours, minutes] = timeMatch;
360
- parsedDate.setHours(parseInt(hours));
361
- parsedDate.setMinutes(parseInt(minutes));
362
- }
363
- } else if (!this.enableTime) {
364
- const now = new Date();
365
- parsedDate.setHours(now.getHours());
366
- parsedDate.setMinutes(now.getMinutes());
367
- parsedDate.setSeconds(0);
368
- parsedDate.setMilliseconds(0);
369
- }
383
+ public checkValidity(): boolean { return !this.currentValidationMessage(); }
370
384
 
371
- return parsedDate;
385
+ public reportValidity(): boolean {
386
+ const message = this.currentValidationMessage();
387
+ this.updateValidation(message);
388
+ if (message) this.focus();
389
+ return !message;
372
390
  }
373
391
 
392
+ public reset(): void { this.setValue(''); }
393
+
374
394
  private formatValueWithTimezone(date: Date): string {
375
395
  if (!this.enableTimezone) return date.toISOString();
376
396
 
@@ -403,32 +423,13 @@ export class DeesInputDatepicker extends DeesInputBase<DeesInputDatepicker> {
403
423
  return this.value;
404
424
  }
405
425
 
406
- public setValue(value: string): void {
407
- this.value = value;
408
- if (value) {
409
- try {
410
- const date = new Date(value);
411
- if (!isNaN(date.getTime())) {
412
- this.selectedDate = date;
413
- this.viewDate = new Date(date);
414
- this.selectedHour = date.getHours();
415
- this.selectedMinute = date.getMinutes();
416
- }
417
- } catch {
418
- // Invalid date
419
- }
420
- }
426
+ public setValue(value: string | null): void {
427
+ this.syncValue(value);
428
+ this.updateValidation(this.currentValidationMessage());
421
429
  }
422
430
 
423
431
  async disconnectedCallback() {
432
+ this.closePopup();
424
433
  await super.disconnectedCallback();
425
- if (this.popupInstance) {
426
- this.popupInstance.removeEventListener('date-selected', this.handleDateSelected);
427
- this.popupInstance.removeEventListener('date-cleared', this.handleDateCleared);
428
- this.popupInstance.removeEventListener('close-request', this.handleCloseRequest);
429
- this.popupInstance.removeEventListener('reposition-request', this.handleRepositionRequest);
430
- this.popupInstance.hide();
431
- this.popupInstance = null;
432
- }
433
434
  }
434
435
  }
@@ -0,0 +1,64 @@
1
+ export interface IDateConstraints {
2
+ minDate: string;
3
+ maxDate: string;
4
+ disabledDates: string[];
5
+ }
6
+
7
+ /** Construct a local calendar date without accepting Date's overflow normalization. */
8
+ export const createCalendarDate = (year: number, month: number, day: number, hour?: number, minute = 0): Date | null => {
9
+ const requestedHour = hour ?? 0;
10
+ if (![year, month, day, requestedHour, minute].every(Number.isInteger)
11
+ || year < 1 || year > 9999 || month < 1 || month > 12 || day < 1 || day > 31
12
+ || requestedHour < 0 || requestedHour > 23 || minute < 0 || minute > 59) return null;
13
+ const date = new Date(0);
14
+ // setFullYear avoids the constructor's special mapping of years 0–99 to 1900–1999.
15
+ date.setFullYear(year, month - 1, day);
16
+ date.setHours(requestedHour, minute, 0, 0);
17
+ // Some zones enter daylight saving at midnight. A date-only entry still names
18
+ // that valid calendar day; an explicitly typed nonexistent time is rejected.
19
+ return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day
20
+ && (hour === undefined || date.getHours() === hour && date.getMinutes() === minute) ? date : null;
21
+ };
22
+
23
+ export const parseDateFields = (text: string): [number, number, number] | null => {
24
+ let match = /^(\d{4})-(\d{1,2})-(\d{1,2})$/.exec(text);
25
+ if (match) return [Number(match[1]), Number(match[2]), Number(match[3])];
26
+ match = /^(\d{1,2})\.(\d{1,2})\.(\d{4})$/.exec(text);
27
+ if (match) return [Number(match[3]), Number(match[2]), Number(match[1])];
28
+ match = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(text);
29
+ return match ? [Number(match[3]), Number(match[1]), Number(match[2])] : null;
30
+ };
31
+
32
+ /** Date-only constraints are local calendar days, never UTC-midnight instants. */
33
+ export const parseDateValue = (value: string): Date | null => {
34
+ if (!value) return null;
35
+ const fields = parseDateFields(value);
36
+ if (fields) return createCalendarDate(...fields);
37
+ const iso = /^(\d{4})-(\d{2})-(\d{2})T/.exec(value);
38
+ if (!iso || !createCalendarDate(Number(iso[1]), Number(iso[2]), Number(iso[3]))) return null;
39
+ const date = new Date(value);
40
+ return Number.isNaN(date.getTime()) ? null : date;
41
+ };
42
+
43
+ export const calendarDayKey = (date: Date): number =>
44
+ date.getFullYear() * 10000 + (date.getMonth() + 1) * 100 + date.getDate();
45
+
46
+ /** Shared by manual entry, calendar buttons and the final commit boundary. */
47
+ export const dateValidationMessage = (date: Date | null, constraints: IDateConstraints): string => {
48
+ if (!date || Number.isNaN(date.getTime()) || date.getFullYear() < 1 || date.getFullYear() > 9999) return 'Enter a valid date (YYYY-MM-DD, DD.MM.YYYY or MM/DD/YYYY).';
49
+ const key = calendarDayKey(date);
50
+ for (const bound of ['minDate', 'maxDate'] as const) {
51
+ if (!constraints[bound]) continue;
52
+ const limit = parseDateValue(constraints[bound]);
53
+ if (!limit) return `${bound === 'minDate' ? 'Minimum' : 'Maximum'} date is invalid.`;
54
+ if (bound === 'minDate' ? key < calendarDayKey(limit) : key > calendarDayKey(limit)) {
55
+ return `Choose a date on or ${bound === 'minDate' ? 'after' : 'before'} ${constraints[bound]}.`;
56
+ }
57
+ }
58
+ for (const value of constraints.disabledDates ?? []) {
59
+ const disabled = parseDateValue(value);
60
+ if (!disabled) return 'A disabled date is invalid.';
61
+ if (key === calendarDayKey(disabled)) return 'This date is unavailable.';
62
+ }
63
+ return '';
64
+ };
@@ -14,6 +14,7 @@ import { themeDefaultStyles } from '../../00theme.js';
14
14
  import { DeesWindowLayer } from '../../00group-overlay/dees-windowlayer/dees-windowlayer.js';
15
15
  import '../../00group-utility/dees-icon/dees-icon.js';
16
16
  import type { IDateEvent } from './types.js';
17
+ import { createCalendarDate, dateValidationMessage } from './date-validation.js';
17
18
 
18
19
  declare global {
19
20
  interface HTMLElementTagNameMap {
@@ -534,29 +535,32 @@ export class DeesInputDatepickerPopup extends DeesElement {
534
535
  const totalEventCount = dayEvents.reduce((sum, event) => sum + (event.count || 1), 0);
535
536
 
536
537
  return html`
537
- <div
538
+ <button type="button"
538
539
  class="day ${isOtherMonth ? 'other-month' : ''} ${isToday ? 'today' : ''} ${isSelected ? 'selected' : ''} ${isDayDisabled ? 'disabled' : ''} ${hasEvents ? 'has-event' : ''}"
540
+ ?disabled=${isDayDisabled}
541
+ aria-label=${`${String(day.getFullYear()).padStart(4, '0')}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`}
542
+ aria-pressed=${isSelected ? 'true' : 'false'}
539
543
  @click=${() => !isDayDisabled && this.handleSelectDate(day)}
540
544
  >
541
545
  ${day.getDate()}
542
546
  ${hasEvents ? html`
543
547
  ${totalEventCount > 3 ? html`
544
- <div class="event-count">${totalEventCount}</div>
548
+ <span class="event-count">${totalEventCount}</span>
545
549
  ` : html`
546
- <div class="event-indicator">
550
+ <span class="event-indicator">
547
551
  ${dayEvents.slice(0, 3).map(event => html`
548
- <div class="event-dot ${event.type || 'info'}"></div>
552
+ <span class="event-dot ${event.type || 'info'}"></span>
549
553
  `)}
550
- </div>
554
+ </span>
551
555
  `}
552
556
  ${dayEvents[0].title ? html`
553
- <div class="event-tooltip">
557
+ <span class="event-tooltip">
554
558
  ${dayEvents[0].title}
555
559
  ${totalEventCount > 1 ? html` (+${totalEventCount - 1} more)` : ''}
556
- </div>
560
+ </span>
557
561
  ` : ''}
558
562
  ` : ''}
559
- </div>
563
+ </button>
560
564
  `;
561
565
  })}
562
566
  </div>
@@ -601,8 +605,8 @@ export class DeesInputDatepickerPopup extends DeesElement {
601
605
  ` : ''}
602
606
 
603
607
  <div class="calendar-actions">
604
- <button class="action-button today-button" @click=${this.handleSelectToday}>Today</button>
605
- <button class="action-button clear-action-button" @click=${this.handleClear}>Clear</button>
608
+ <button type="button" class="action-button today-button" ?disabled=${this.isDayDisabled(new Date())} @click=${this.handleSelectToday}>Today</button>
609
+ <button type="button" class="action-button clear-action-button" @click=${this.handleClear}>Clear</button>
606
610
  </div>
607
611
  </div>
608
612
  `;
@@ -624,22 +628,19 @@ export class DeesInputDatepickerPopup extends DeesElement {
624
628
 
625
629
  // Calendar logic
626
630
  private getDaysInMonth(): Date[] {
627
- const year = this.viewDate.getFullYear();
628
- const month = this.viewDate.getMonth();
629
- const firstDay = new Date(year, month, 1);
630
- const lastDay = new Date(year, month + 1, 0);
631
- const days: Date[] = [];
631
+ const firstDay = new Date(this.viewDate);
632
+ firstDay.setDate(1);
633
+ firstDay.setHours(12, 0, 0, 0);
632
634
 
633
635
  const startOffset = this.weekStartsOn === 1
634
636
  ? (firstDay.getDay() === 0 ? 6 : firstDay.getDay() - 1)
635
637
  : firstDay.getDay();
636
638
 
637
- for (let i = startOffset; i > 0; i--) days.push(new Date(year, month, 1 - i));
638
- for (let i = 1; i <= lastDay.getDate(); i++) days.push(new Date(year, month, i));
639
- const remaining = 42 - days.length;
640
- for (let i = 1; i <= remaining; i++) days.push(new Date(year, month + 1, i));
641
-
642
- return days;
639
+ return Array.from({ length: 42 }, (_, index) => {
640
+ const day = new Date(firstDay);
641
+ day.setDate(1 - startOffset + index);
642
+ return day;
643
+ });
643
644
  }
644
645
 
645
646
  private isToday(date: Date): boolean {
@@ -653,15 +654,7 @@ export class DeesInputDatepickerPopup extends DeesElement {
653
654
  }
654
655
 
655
656
  private isDayDisabled(date: Date): boolean {
656
- if (this.minDate) { const min = new Date(this.minDate); if (date < min) return true; }
657
- if (this.maxDate) { const max = new Date(this.maxDate); if (date > max) return true; }
658
- if (this.disabledDates?.length) {
659
- return this.disabledDates.some(ds => {
660
- try { const d = new Date(ds); return date.getDate() === d.getDate() && date.getMonth() === d.getMonth() && date.getFullYear() === d.getFullYear(); }
661
- catch { return false; }
662
- });
663
- }
664
- return false;
657
+ return Boolean(dateValidationMessage(date, this));
665
658
  }
666
659
 
667
660
  private getEventsForDate(date: Date): IDateEvent[] {
@@ -671,16 +664,24 @@ export class DeesInputDatepickerPopup extends DeesElement {
671
664
  }
672
665
 
673
666
  private previousMonth(): void {
674
- this.viewDate = new Date(this.viewDate.getFullYear(), this.viewDate.getMonth() - 1, 1);
667
+ const date = new Date(this.viewDate);
668
+ date.setMonth(date.getMonth() - 1, 1);
669
+ this.viewDate = date;
675
670
  }
676
671
 
677
672
  private nextMonth(): void {
678
- this.viewDate = new Date(this.viewDate.getFullYear(), this.viewDate.getMonth() + 1, 1);
673
+ const date = new Date(this.viewDate);
674
+ date.setMonth(date.getMonth() + 1, 1);
675
+ this.viewDate = date;
679
676
  }
680
677
 
681
678
  // Event dispatching
682
679
  private handleSelectDate(day: Date): void {
683
- this.selectedDate = new Date(day.getFullYear(), day.getMonth(), day.getDate(), this.selectedHour, this.selectedMinute);
680
+ if (this.isDayDisabled(day)) return;
681
+ const selected = createCalendarDate(day.getFullYear(), day.getMonth() + 1, day.getDate(),
682
+ this.enableTime ? this.selectedHour : undefined, this.enableTime ? this.selectedMinute : 0);
683
+ if (!selected || this.isDayDisabled(selected)) return;
684
+ this.selectedDate = selected;
684
685
  this.dispatchEvent(new CustomEvent('date-selected', { detail: this.selectedDate }));
685
686
  if (!this.enableTime) {
686
687
  this.dispatchEvent(new CustomEvent('close-request'));
@@ -689,14 +690,11 @@ export class DeesInputDatepickerPopup extends DeesElement {
689
690
 
690
691
  private handleSelectToday(): void {
691
692
  const today = new Date();
692
- this.selectedDate = today;
693
+ if (this.isDayDisabled(today)) return;
693
694
  this.viewDate = new Date(today);
694
695
  this.selectedHour = today.getHours();
695
696
  this.selectedMinute = today.getMinutes();
696
- this.dispatchEvent(new CustomEvent('date-selected', { detail: this.selectedDate }));
697
- if (!this.enableTime) {
698
- this.dispatchEvent(new CustomEvent('close-request'));
699
- }
697
+ this.handleSelectDate(today);
700
698
  }
701
699
 
702
700
  private handleClear(): void {
@@ -722,7 +720,7 @@ export class DeesInputDatepickerPopup extends DeesElement {
722
720
  const input = e.target as HTMLInputElement;
723
721
  let value = parseInt(input.value) || 0;
724
722
  value = Math.max(0, Math.min(59, value));
725
- if (this.minuteIncrement > 1) value = Math.round(value / this.minuteIncrement) * this.minuteIncrement;
723
+ if (this.minuteIncrement > 1) value = Math.min(59, Math.round(value / this.minuteIncrement) * this.minuteIncrement);
726
724
  this.selectedMinute = value;
727
725
  this.emitTimeUpdate();
728
726
  };
@@ -740,7 +738,9 @@ export class DeesInputDatepickerPopup extends DeesElement {
740
738
 
741
739
  private emitTimeUpdate(): void {
742
740
  if (this.selectedDate) {
743
- this.selectedDate = new Date(this.selectedDate.getFullYear(), this.selectedDate.getMonth(), this.selectedDate.getDate(), this.selectedHour, this.selectedMinute);
741
+ const selected = createCalendarDate(this.selectedDate.getFullYear(), this.selectedDate.getMonth() + 1, this.selectedDate.getDate(), this.selectedHour, this.selectedMinute);
742
+ if (!selected || this.isDayDisabled(selected)) return;
743
+ this.selectedDate = selected;
744
744
  this.dispatchEvent(new CustomEvent('date-selected', { detail: this.selectedDate }));
745
745
  }
746
746
  }
@@ -38,6 +38,11 @@ export const datepickerStyles = [
38
38
  color: var(--dees-color-text-muted);
39
39
  }
40
40
 
41
+ .date-input[aria-invalid="true"] { border-color: var(--dees-color-accent-error); }
42
+ .field-messages { grid-column: 1 / -1; }
43
+ .validationText { color: var(--dees-color-text-error); font-size: var(--dees-font-footnote-size); }
44
+ .validationText:not(:empty) { margin-top: var(--dees-spacing-xs); }
45
+
41
46
  .date-input:hover:not(:disabled) {
42
47
  border-color: var(--dees-color-border-strong);
43
48
  }