@reliability-design/web 1.4.4 → 1.4.6

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 (58) hide show
  1. package/dist/components/rel-accordion/rel-accordion.ts +13 -1
  2. package/dist/components/rel-alert/rel-alert.ts +1 -1
  3. package/dist/components/rel-autocomplete/rel-autocomplete.ts +33 -2
  4. package/dist/components/rel-badge/rel-badge.ts +6 -1
  5. package/dist/components/rel-button/rel-button.ts +18 -2
  6. package/dist/components/rel-calendar/rel-calendar.ts +86 -18
  7. package/dist/components/rel-carousel/rel-carousel.ts +45 -2
  8. package/dist/components/rel-checkbox/rel-checkbox.ts +4 -1
  9. package/dist/components/rel-chip/rel-chip.ts +59 -19
  10. package/dist/components/rel-date-picker/date-parse.test.ts +116 -0
  11. package/dist/components/rel-date-picker/date-parse.ts +178 -0
  12. package/dist/components/rel-date-picker/rel-date-picker.ts +675 -93
  13. package/dist/components/rel-drawer/rel-drawer.ts +28 -61
  14. package/dist/components/rel-menu/rel-menu.ts +130 -15
  15. package/dist/components/rel-menubar/rel-menubar.ts +221 -63
  16. package/dist/components/rel-nav-menu/rel-nav-menu.ts +39 -3
  17. package/dist/components/rel-otp/rel-otp.ts +14 -4
  18. package/dist/components/rel-pagination/rel-pagination.ts +13 -0
  19. package/dist/components/rel-popover/rel-popover.ts +64 -5
  20. package/dist/components/rel-progress/rel-progress.ts +13 -4
  21. package/dist/components/rel-radio/README.md +6 -3
  22. package/dist/components/rel-radio/rel-radio.ts +236 -60
  23. package/dist/components/rel-range-picker/rel-range-picker.ts +581 -91
  24. package/dist/components/rel-resizable/rel-resizable-group.ts +20 -0
  25. package/dist/components/rel-segmented-button/rel-segmented-button.ts +55 -13
  26. package/dist/components/rel-select/rel-select.ts +203 -24
  27. package/dist/components/rel-sheet/rel-sheet.ts +184 -18
  28. package/dist/components/rel-sidebar/rel-sidebar.ts +50 -40
  29. package/dist/components/rel-skeleton/rel-skeleton.ts +9 -0
  30. package/dist/components/rel-slider/rel-slider.ts +6 -3
  31. package/dist/components/rel-snackbar/rel-snackbar.ts +20 -16
  32. package/dist/components/rel-stepper/rel-stepper.ts +47 -2
  33. package/dist/components/rel-switch/rel-switch.ts +3 -1
  34. package/dist/components/rel-tab/rel-tab.ts +32 -7
  35. package/dist/components/rel-textfield/rel-textfield.ts +263 -75
  36. package/dist/components/rel-time-picker/rel-time-picker.ts +749 -261
  37. package/dist/components/rel-time-picker/time-parse.test.ts +67 -0
  38. package/dist/components/rel-time-picker/time-parse.ts +85 -0
  39. package/dist/components/rel-toast/rel-toast.ts +4 -2
  40. package/dist/components/rel-tooltip/README.md +13 -5
  41. package/dist/components/rel-tooltip/rel-tooltip.ts +33 -0
  42. package/dist/components/rel-topbar/rel-topbar-actions.ts +1 -1
  43. package/dist/components/rel-topbar/rel-topbar-brand.ts +1 -1
  44. package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +1 -1
  45. package/dist/components/rel-topbar/rel-topbar-dropdown.ts +1 -1
  46. package/dist/components/rel-topbar/rel-topbar-item.ts +318 -319
  47. package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +1 -1
  48. package/dist/components/rel-topbar/rel-topbar-profile.ts +224 -224
  49. package/dist/components/rel-topbar/rel-topbar-utilities.ts +1 -1
  50. package/dist/components/rel-topbar/rel-topbar.ts +440 -440
  51. package/dist/components/rel-upload/rel-upload.ts +25 -0
  52. package/dist/index.d.mts +461 -23
  53. package/dist/index.d.ts +461 -23
  54. package/dist/index.js +3906 -807
  55. package/dist/index.js.map +1 -1
  56. package/dist/index.mjs +4143 -1055
  57. package/dist/index.mjs.map +1 -1
  58. package/package.json +2 -2
@@ -1,6 +1,10 @@
1
1
  import { LitElement, html, css, nothing } from "lit";
2
2
  import { customElement, property, state, query } from "lit/decorators.js";
3
+ import { live } from "lit/directives/live.js";
3
4
  import { formatDatePattern } from "./date-format";
5
+ import { parseDateInput, isDayFirstPattern, isDayFirstLocale } from "./date-parse";
6
+ import { getDeepActiveElement, trapTabKey } from "../../utils/focus.js";
7
+ import { announce } from "../../utils/announce";
4
8
 
5
9
  interface CalendarDay {
6
10
  date: Date;
@@ -74,8 +78,20 @@ export class RelDatePicker extends LitElement {
74
78
  @state() private focusedDate: Date | null = null;
75
79
  @state() private yearDropdownOpen = false;
76
80
  @state() private monthDropdownOpen = false;
81
+ /** Uncommitted typed text; null → the input shows the committed value. */
82
+ @state() private inputText: string | null = null;
83
+ /** Parse/range error message for the typed text (cleared on next input). */
84
+ @state() private inputError: string | null = null;
77
85
 
78
- @query(".trigger-button") private triggerButton?: HTMLButtonElement;
86
+ /**
87
+ * Whether the next popover open should move focus into the calendar grid.
88
+ * Icon-click / ArrowDown / external `open` do (previous behavior); opening
89
+ * by clicking into the text input must NOT, or typing would be impossible.
90
+ */
91
+ private focusPanelOnOpen = true;
92
+
93
+ @query(".trigger-input") private triggerInput?: HTMLInputElement;
94
+ @query(".trigger-button") private triggerButton?: HTMLElement;
79
95
  @query(".popover") private popoverElement?: HTMLElement;
80
96
  @query(".calendar-grid") private calendarGrid?: HTMLElement;
81
97
 
@@ -152,9 +168,14 @@ export class RelDatePicker extends LitElement {
152
168
  color: var(--rel-text-danger);
153
169
  }
154
170
 
171
+ /* Persistent live region — out of flow while empty so it adds no gap. */
172
+ .date-picker-support.empty {
173
+ position: absolute;
174
+ }
175
+
155
176
  .trigger-button {
156
177
  width: 100%;
157
- min-width: 200px;
178
+ min-width: min(200px, 100%);
158
179
  display: flex;
159
180
  align-items: center;
160
181
  justify-content: space-between;
@@ -174,28 +195,48 @@ export class RelDatePicker extends LitElement {
174
195
  letter-spacing: var(--rel-body-md-regular-letter-spacing);
175
196
  }
176
197
 
177
- .trigger-button:hover:not(:disabled) {
198
+ :host(:not([disabled])) .trigger-button:hover {
178
199
  border-color: var(--rel-border-strong);
179
200
  }
180
201
 
181
- .trigger-button:focus-visible {
202
+ .trigger-button:focus-within {
182
203
  outline: 2px solid var(--rel-border-focus);
183
204
  outline-offset: 2px;
184
205
  border-color: var(--rel-border-focus);
185
206
  }
186
207
 
187
- .trigger-button:disabled {
208
+ :host([disabled]) .trigger-button {
188
209
  cursor: not-allowed;
189
210
  background: var(--rel-bg-surface-elevated);
190
211
  }
191
212
 
213
+ /* The editable field itself — the wrapper carries the box styles. */
214
+ .trigger-input {
215
+ flex: 1;
216
+ min-width: 0;
217
+ border: none;
218
+ background: transparent;
219
+ padding: 0;
220
+ margin: 0;
221
+ outline: none;
222
+ color: var(--rel-text-default);
223
+ font: inherit;
224
+ letter-spacing: inherit;
225
+ cursor: inherit;
226
+ }
227
+
228
+ .trigger-input::placeholder {
229
+ color: var(--rel-text-placeholder);
230
+ opacity: 1;
231
+ }
232
+
192
233
  /* Error state */
193
234
  .trigger-button.error,
194
- .trigger-button.error:hover:not(:disabled) {
235
+ :host(:not([disabled])) .trigger-button.error:hover {
195
236
  border-color: var(--rel-border-danger);
196
237
  }
197
238
 
198
- .trigger-button.error:focus-visible {
239
+ .trigger-button.error:focus-within {
199
240
  outline-color: var(--rel-border-danger);
200
241
  border-color: var(--rel-border-danger);
201
242
  }
@@ -219,15 +260,6 @@ export class RelDatePicker extends LitElement {
219
260
  letter-spacing: var(--rel-body-lg-regular-letter-spacing);
220
261
  }
221
262
 
222
- .trigger-content {
223
- flex: 1;
224
- color: var(--rel-text-default);
225
- }
226
-
227
- .trigger-content.placeholder {
228
- color: var(--rel-text-placeholder);
229
- }
230
-
231
263
  .trigger-icons {
232
264
  display: flex;
233
265
  align-items: center;
@@ -258,16 +290,34 @@ export class RelDatePicker extends LitElement {
258
290
  }
259
291
 
260
292
  .popover {
293
+ /* Reset native [popover] UA styles first (the panel is promoted to
294
+ the top layer so a drawer/sheet/modal ancestor — transformed,
295
+ overflow:hidden, or an equal-z-index sibling panel — can't clip it
296
+ or paint over it). While in the top layer, JS overrides the
297
+ anchored position below with inline fixed coordinates; the CSS
298
+ anchoring is the no-Popover-API fallback. */
299
+ margin: 0;
300
+ width: auto;
301
+ height: auto;
302
+ inset: auto;
303
+ color: inherit;
261
304
  position: absolute;
262
305
  top: calc(100% + 4px);
263
306
  left: 0;
264
- z-index: 1000;
307
+ z-index: 2147483647;
265
308
  background: var(--rel-bg-surface);
266
309
  border: 1px solid var(--rel-border-default);
267
310
  border-radius: var(--rel-border-radius-sm);
268
311
  box-shadow: var(--rel-shadows-shadow-300);
269
312
  padding: var(--rel-spacing-md);
270
- min-width: 280px;
313
+ /* Never wider or taller than the viewport (small screens). border-box
314
+ so the caps include border+padding; 314px = the previous 280px
315
+ content-box min-width, keeping desktop rendering unchanged. */
316
+ box-sizing: border-box;
317
+ min-width: min(314px, calc(100vw - 16px));
318
+ max-width: calc(100vw - 16px);
319
+ max-height: min(600px, calc(100vh - 16px));
320
+ overflow-y: auto;
271
321
  display: none;
272
322
  }
273
323
 
@@ -275,6 +325,56 @@ export class RelDatePicker extends LitElement {
275
325
  display: block;
276
326
  }
277
327
 
328
+ /* Typed-shortcut hints (T → Today, …); each chip applies its value. */
329
+ .shortcut-row {
330
+ display: flex;
331
+ flex-wrap: wrap;
332
+ gap: var(--rel-spacing-2xs);
333
+ padding-bottom: var(--rel-spacing-xs);
334
+ margin-bottom: var(--rel-spacing-xs);
335
+ border-bottom: 1px solid var(--rel-border-subtle);
336
+ }
337
+
338
+ .shortcut-chip {
339
+ display: inline-flex;
340
+ align-items: center;
341
+ gap: var(--rel-spacing-2xs);
342
+ border: none;
343
+ background: transparent;
344
+ border-radius: var(--rel-border-radius-xs);
345
+ padding: var(--rel-spacing-3xs) var(--rel-spacing-2xs);
346
+ cursor: pointer;
347
+ color: var(--rel-text-default);
348
+ transition: background 0.15s ease;
349
+ font-family: var(--rel-body-xs-regular-font-family);
350
+ font-weight: var(--rel-body-xs-regular-font-weight);
351
+ font-size: var(--rel-body-xs-regular-font-size);
352
+ line-height: var(--rel-body-xs-regular-line-height);
353
+ letter-spacing: var(--rel-body-xs-regular-letter-spacing);
354
+ }
355
+
356
+ .shortcut-chip:hover {
357
+ background: var(--rel-bg-neutral-elevated-1);
358
+ }
359
+
360
+ .shortcut-chip:focus-visible {
361
+ outline: 2px solid var(--rel-border-focus);
362
+ outline-offset: -2px;
363
+ }
364
+
365
+ .shortcut-chip kbd {
366
+ border: 1px solid var(--rel-border-default);
367
+ border-bottom-width: 2px;
368
+ border-radius: var(--rel-border-radius-xs);
369
+ background: var(--rel-bg-surface-elevated);
370
+ padding: 0 var(--rel-spacing-3xs);
371
+ min-width: 18px;
372
+ text-align: center;
373
+ font-family: inherit;
374
+ font-size: inherit;
375
+ color: var(--rel-text-label);
376
+ }
377
+
278
378
  .calendar-header {
279
379
  display: flex;
280
380
  align-items: center;
@@ -357,6 +457,11 @@ export class RelDatePicker extends LitElement {
357
457
  }
358
458
 
359
459
  .dropdown-item {
460
+ display: block;
461
+ width: 100%;
462
+ border: none;
463
+ background: transparent;
464
+ text-align: left;
360
465
  padding: var(--rel-spacing-xs) var(--rel-spacing-sm);
361
466
  cursor: pointer;
362
467
  color: var(--rel-text-default);
@@ -368,6 +473,11 @@ export class RelDatePicker extends LitElement {
368
473
  letter-spacing: var(--rel-body-sm-regular-letter-spacing);
369
474
  }
370
475
 
476
+ .dropdown-item:focus-visible {
477
+ outline: 2px solid var(--rel-border-focus);
478
+ outline-offset: -2px;
479
+ }
480
+
371
481
  .dropdown-item:hover {
372
482
  background: var(--rel-bg-surface-elevated);
373
483
  }
@@ -403,6 +513,13 @@ export class RelDatePicker extends LitElement {
403
513
  gap: var(--rel-spacing-3xs);
404
514
  }
405
515
 
516
+ .calendar-week {
517
+ grid-column: 1 / -1;
518
+ display: grid;
519
+ grid-template-columns: repeat(7, 1fr);
520
+ gap: var(--rel-spacing-3xs);
521
+ }
522
+
406
523
  .day-button {
407
524
  aspect-ratio: 1;
408
525
  border: none;
@@ -485,6 +602,9 @@ export class RelDatePicker extends LitElement {
485
602
  super.disconnectedCallback();
486
603
  document.removeEventListener("click", this.handleClickOutside);
487
604
  document.removeEventListener("keydown", this.handleEscapeKey);
605
+ window.removeEventListener("resize", this.onReposition);
606
+ window.removeEventListener("scroll", this.onReposition, true);
607
+ this.hideTopLayer();
488
608
  }
489
609
 
490
610
  private handleClickOutside = (e: MouseEvent) => {
@@ -492,13 +612,21 @@ export class RelDatePicker extends LitElement {
492
612
 
493
613
  const path = e.composedPath();
494
614
  if (!path.includes(this)) {
495
- this.closeCalendarPopover();
615
+ // Don't refocus the field — the user clicked somewhere else on purpose.
616
+ this.closeCalendarPopover(false);
496
617
  }
497
618
  };
498
619
 
499
620
  private handleEscapeKey = (e: KeyboardEvent) => {
500
- if (e.key === "Escape" && this.open) {
621
+ if (!this.open) return;
622
+ if (e.key === "Escape") {
501
623
  this.closeCalendarPopover();
624
+ return;
625
+ }
626
+ // The popover announces itself as a dialog — keep Tab inside it.
627
+ if (e.key === "Tab") {
628
+ const popover = this.renderRoot.querySelector<HTMLElement>(".popover");
629
+ if (popover) trapTabKey(e, popover);
502
630
  }
503
631
  };
504
632
 
@@ -511,15 +639,116 @@ export class RelDatePicker extends LitElement {
511
639
  }
512
640
  }
513
641
 
514
- if (changedProperties.has("open") && this.open) {
515
- // Focus first day when opening
516
- setTimeout(() => {
517
- const firstDay = this.calendarGrid?.querySelector<HTMLButtonElement>(
518
- ".day-button:not(:disabled)"
519
- );
520
- firstDay?.focus();
521
- }, 100);
642
+ // `open` is a public reflected property (consumers may set it directly,
643
+ // not only via the trigger), so top-layer promotion syncs here rather
644
+ // than inside toggle/close.
645
+ if (changedProperties.has("open")) {
646
+ if (!this.open) {
647
+ window.removeEventListener("resize", this.onReposition);
648
+ window.removeEventListener("scroll", this.onReposition, true);
649
+ this.hideTopLayer();
650
+ this.focusPanelOnOpen = true;
651
+ return;
652
+ }
653
+ this.showTopLayer();
654
+ window.addEventListener("resize", this.onReposition);
655
+ window.addEventListener("scroll", this.onReposition, true);
656
+ // Focus the roving cell (selected day, else today, else first
657
+ // selectable) when opening — not blindly the first of the month —
658
+ // unless the open came from clicking into the text input (typing must
659
+ // keep working).
660
+ const focusPanel = this.focusPanelOnOpen;
661
+ this.focusPanelOnOpen = true;
662
+ if (focusPanel) {
663
+ setTimeout(() => this.focusPanelSelection(), 100);
664
+ }
665
+ }
666
+ }
667
+
668
+ /* A drawer/sheet/modal ancestor breaks the anchored panel two ways: a
669
+ * transform makes it the containing block for position:fixed descendants
670
+ * (and overflow:hidden clips absolute ones), and an equal-z-index panel
671
+ * later in the DOM paints over the calendar. Promoting the panel to the
672
+ * browser top layer sidesteps both — same fix as rel-popover /
673
+ * rel-autocomplete / rel-select / rel-range-picker. `manual` keeps
674
+ * outside-click and Escape handling under this component's own control.
675
+ * While in the top layer the CSS anchoring is meaningless, so the panel
676
+ * gets inline fixed coordinates from updatePopoverPosition(). */
677
+
678
+ private showTopLayer() {
679
+ const el = this.popoverElement;
680
+ if (el && typeof el.showPopover === "function") {
681
+ try {
682
+ if (!el.matches(":popover-open")) el.showPopover();
683
+ } catch {
684
+ /* older engines / detached element — CSS fallback applies */
685
+ }
522
686
  }
687
+ requestAnimationFrame(() => this.updatePopoverPosition());
688
+ }
689
+
690
+ private hideTopLayer() {
691
+ const el = this.popoverElement;
692
+ if (!el) return;
693
+ if (typeof el.hidePopover === "function") {
694
+ try {
695
+ if (el.matches(":popover-open")) el.hidePopover();
696
+ } catch {
697
+ /* already hidden or unsupported — nothing to do */
698
+ }
699
+ }
700
+ el.style.position = "";
701
+ el.style.top = "";
702
+ el.style.left = "";
703
+ }
704
+
705
+ private onReposition = () => {
706
+ if (this.open) this.updatePopoverPosition();
707
+ };
708
+
709
+ private updatePopoverPosition = () => {
710
+ const panel = this.popoverElement;
711
+ const anchor = this.triggerButton;
712
+ if (!panel || !anchor || !this.open) return;
713
+
714
+ const t = anchor.getBoundingClientRect();
715
+ const c = panel.getBoundingClientRect();
716
+
717
+ // Below the trigger; flip above when there's no room, clamp to the
718
+ // viewport with an 8px margin on every edge.
719
+ let top = t.bottom + 4;
720
+ if (top + c.height > window.innerHeight - 8) {
721
+ top = Math.max(8, t.top - c.height - 4);
722
+ }
723
+ let left = t.left;
724
+ if (left + c.width > window.innerWidth - 8) {
725
+ left = window.innerWidth - c.width - 8;
726
+ }
727
+ left = Math.max(8, left);
728
+
729
+ // Without top-layer support a transformed ancestor may be the containing
730
+ // block for position:fixed, shifting the panel away from the viewport
731
+ // coordinates computed above. Apply, re-measure, and cancel the offset.
732
+ panel.style.position = "fixed";
733
+ panel.style.top = `${top}px`;
734
+ panel.style.left = `${left}px`;
735
+ const applied = panel.getBoundingClientRect();
736
+ top -= applied.top - top;
737
+ left -= applied.left - left;
738
+ panel.style.top = `${top}px`;
739
+ panel.style.left = `${left}px`;
740
+ };
741
+
742
+ /** Focus the roving day cell (selected day, else today, else first). */
743
+ private focusPanelSelection() {
744
+ const target =
745
+ this.calendarGrid?.querySelector<HTMLButtonElement>(
746
+ '.day-button[tabindex="0"]:not(:disabled)'
747
+ ) ??
748
+ this.calendarGrid?.querySelector<HTMLButtonElement>(
749
+ ".day-button:not(:disabled)"
750
+ );
751
+ target?.focus();
523
752
  }
524
753
 
525
754
  private parseISODate(iso: string): Date | null {
@@ -665,6 +894,26 @@ export class RelDatePicker extends LitElement {
665
894
  return formatter.format(new Date(this.viewYear, this.viewMonth, 1));
666
895
  }
667
896
 
897
+ private dayLabelFormatter: Intl.DateTimeFormat | null = null;
898
+ private dayLabelLocale = "";
899
+
900
+ private formatDayLabel(date: Date): string {
901
+ if (!this.dayLabelFormatter || this.dayLabelLocale !== this.locale) {
902
+ this.dayLabelLocale = this.locale;
903
+ this.dayLabelFormatter = new Intl.DateTimeFormat(this.locale, {
904
+ weekday: "long",
905
+ month: "long",
906
+ day: "numeric",
907
+ year: "numeric",
908
+ });
909
+ }
910
+ return this.dayLabelFormatter.format(date);
911
+ }
912
+
913
+ private announceMonth() {
914
+ announce(`${this.getMonthName()} ${this.viewYear}`);
915
+ }
916
+
668
917
  private getMonthsList(): { value: number; label: string }[] {
669
918
  const months = [];
670
919
  for (let i = 0; i < 12; i++) {
@@ -709,11 +958,11 @@ export class RelDatePicker extends LitElement {
709
958
  );
710
959
  }
711
960
 
712
- private closeCalendarPopover() {
961
+ private closeCalendarPopover(refocus = true) {
713
962
  this.open = false;
714
963
  this.monthDropdownOpen = false;
715
964
  this.yearDropdownOpen = false;
716
- this.triggerButton?.focus();
965
+ if (refocus) this.triggerInput?.focus();
717
966
  this.dispatchEvent(
718
967
  new CustomEvent("rel-open-change", {
719
968
  detail: { open: false },
@@ -730,6 +979,7 @@ export class RelDatePicker extends LitElement {
730
979
  } else {
731
980
  this.viewMonth--;
732
981
  }
982
+ this.announceMonth();
733
983
  }
734
984
 
735
985
  private nextMonth() {
@@ -739,33 +989,40 @@ export class RelDatePicker extends LitElement {
739
989
  } else {
740
990
  this.viewMonth++;
741
991
  }
992
+ this.announceMonth();
742
993
  }
743
994
 
744
995
  private selectDay(day: CalendarDay) {
745
996
  if (day.isDisabled || this.disabled || this.readonly) return;
997
+ this.commitDate(day.date);
998
+ if (this.closeOnSelect) {
999
+ this.closeCalendarPopover();
1000
+ }
1001
+ }
746
1002
 
747
- const newValue = this.formatISODate(day.date);
1003
+ /** Commit a date: set value, reset typed text/error, fire rel-change. */
1004
+ private commitDate(date: Date) {
1005
+ const newValue = this.formatISODate(date);
748
1006
  this.value = newValue;
749
-
1007
+ this.inputText = null;
1008
+ this.inputError = null;
750
1009
  this.dispatchEvent(
751
1010
  new CustomEvent("rel-change", {
752
1011
  detail: {
753
1012
  value: newValue,
754
- date: new Date(day.date),
1013
+ date: new Date(date),
755
1014
  },
756
1015
  bubbles: true,
757
1016
  composed: true,
758
1017
  })
759
1018
  );
760
-
761
- if (this.closeOnSelect) {
762
- this.closeCalendarPopover();
763
- }
764
1019
  }
765
1020
 
766
1021
  private clearValue(e: Event) {
767
1022
  e.stopPropagation();
768
1023
  this.value = null;
1024
+ this.inputText = null;
1025
+ this.inputError = null;
769
1026
 
770
1027
  this.dispatchEvent(
771
1028
  new CustomEvent("rel-change", {
@@ -779,15 +1036,148 @@ export class RelDatePicker extends LitElement {
779
1036
  );
780
1037
  }
781
1038
 
782
- private handleTriggerKeydown(e: KeyboardEvent) {
783
- if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
1039
+ // ── Typed keyboard entry ──
1040
+
1041
+ /** Ambiguous "a/b" day/month order: display format wins, then locale. */
1042
+ private get dayFirst(): boolean {
1043
+ if (this.displayFormat) {
1044
+ const fromPattern = isDayFirstPattern(this.displayFormat);
1045
+ if (fromPattern !== null) return fromPattern;
1046
+ }
1047
+ return isDayFirstLocale(this.locale);
1048
+ }
1049
+
1050
+ /**
1051
+ * Parse and commit the typed text (Enter / blur). Invalid or out-of-range
1052
+ * text keeps the field in an error state with the text preserved for
1053
+ * correction; the committed value is untouched (Escape reverts the text).
1054
+ */
1055
+ private commitTypedValue() {
1056
+ if (this.inputText === null) return; // nothing typed since last commit
1057
+ const text = this.inputText.trim();
1058
+
1059
+ if (!text) {
1060
+ // Field cleared by keyboard — behaves like the clear button.
1061
+ this.inputText = null;
1062
+ this.inputError = null;
1063
+ if (this.value !== null) {
1064
+ this.value = null;
1065
+ this.dispatchEvent(
1066
+ new CustomEvent("rel-change", {
1067
+ detail: { value: null, date: null },
1068
+ bubbles: true,
1069
+ composed: true,
1070
+ })
1071
+ );
1072
+ }
1073
+ if (this.open) this.closeCalendarPopover();
1074
+ return;
1075
+ }
1076
+
1077
+ const parsed = parseDateInput(text, {
1078
+ referenceDate: new Date(),
1079
+ dayFirst: this.dayFirst,
1080
+ locale: this.locale,
1081
+ });
1082
+ if (!parsed) {
1083
+ this.inputError = "Invalid date";
1084
+ return;
1085
+ }
1086
+ // Midnight-anchored like the calendar grid, so min/max compares match.
1087
+ const date = new Date(parsed.getFullYear(), parsed.getMonth(), parsed.getDate());
1088
+ if (this.isDateDisabledInternal(date)) {
1089
+ this.inputError = "Date not available";
1090
+ return;
1091
+ }
1092
+
1093
+ this.commitDate(date);
1094
+ if (this.open) this.closeCalendarPopover();
1095
+ }
1096
+
1097
+ /** Today ± offset days, committed directly (T shortcuts / popover chips). */
1098
+ private applyDayOffsetShortcut(offsetDays: number) {
1099
+ const now = new Date();
1100
+ const date = new Date(now.getFullYear(), now.getMonth(), now.getDate() + offsetDays);
1101
+ if (this.isDateDisabledInternal(date)) {
1102
+ this.inputError = "Date not available";
1103
+ return;
1104
+ }
1105
+ this.commitDate(date);
1106
+ if (this.open) this.closeCalendarPopover();
1107
+ }
1108
+
1109
+ private handleInputInput(e: Event) {
1110
+ this.inputText = (e.target as HTMLInputElement).value;
1111
+ if (this.inputError) this.inputError = null;
1112
+ }
1113
+
1114
+ private handleInputKeydown(e: KeyboardEvent) {
1115
+ if (e.key === "Enter") {
1116
+ e.preventDefault();
1117
+ if (this.inputText === null) {
1118
+ // Nothing typed — Enter opens the popover (previous trigger behavior).
1119
+ if (!this.open) this.toggleCalendarPopover();
1120
+ } else {
1121
+ this.commitTypedValue();
1122
+ }
1123
+ return;
1124
+ }
1125
+ if (e.key === "ArrowDown") {
784
1126
  e.preventDefault();
785
1127
  if (!this.open) {
786
1128
  this.toggleCalendarPopover();
1129
+ } else {
1130
+ this.focusPanelSelection();
787
1131
  }
1132
+ return;
1133
+ }
1134
+ if (e.key === "Escape" && this.inputText !== null) {
1135
+ // Revert the typed text; the document listener closes the popover.
1136
+ this.inputText = null;
1137
+ this.inputError = null;
788
1138
  }
789
1139
  }
790
1140
 
1141
+ /** Clicking anywhere on the field opens the popover, keeping focus typed-in. */
1142
+ private handleFieldClick() {
1143
+ if (this.open || this.disabled || this.readonly) return;
1144
+ this.focusPanelOnOpen = false;
1145
+ this.toggleCalendarPopover();
1146
+ }
1147
+
1148
+ /** The calendar icon toggles the popover with the previous focus behavior. */
1149
+ private handleIconClick(e: Event) {
1150
+ e.stopPropagation();
1151
+ if (this.open) {
1152
+ this.closeCalendarPopover();
1153
+ } else {
1154
+ this.toggleCalendarPopover();
1155
+ }
1156
+ }
1157
+
1158
+ /** Commit typed text when focus leaves the component entirely. */
1159
+ private handleInputBlur = (e: FocusEvent) => {
1160
+ this.dispatchEvent(new CustomEvent("rel-blur", { bubbles: true, composed: true }));
1161
+ const related = e.relatedTarget as Node | null;
1162
+ if (related && (this.renderRoot.contains(related) || this.contains(related))) {
1163
+ return; // focus moved into the panel/icons — their own handlers commit
1164
+ }
1165
+ this.commitTypedValue();
1166
+ };
1167
+
1168
+ /** T inside the panel (grid/header focus) applies Today directly. */
1169
+ private handlePanelKeydown = (e: KeyboardEvent) => {
1170
+ if (
1171
+ (e.key === "t" || e.key === "T") &&
1172
+ !e.ctrlKey &&
1173
+ !e.metaKey &&
1174
+ !e.altKey
1175
+ ) {
1176
+ e.preventDefault();
1177
+ this.applyDayOffsetShortcut(0);
1178
+ }
1179
+ };
1180
+
791
1181
  private handleDayKeydown(e: KeyboardEvent, day: CalendarDay, index: number) {
792
1182
  const days = this.getCalendarDays();
793
1183
  let targetIndex = index;
@@ -821,6 +1211,7 @@ export class RelDatePicker extends LitElement {
821
1211
  e.preventDefault();
822
1212
  if (e.shiftKey) {
823
1213
  this.viewYear--;
1214
+ this.announceMonth();
824
1215
  } else {
825
1216
  this.previousMonth();
826
1217
  }
@@ -829,6 +1220,7 @@ export class RelDatePicker extends LitElement {
829
1220
  e.preventDefault();
830
1221
  if (e.shiftKey) {
831
1222
  this.viewYear++;
1223
+ this.announceMonth();
832
1224
  } else {
833
1225
  this.nextMonth();
834
1226
  }
@@ -865,24 +1257,127 @@ export class RelDatePicker extends LitElement {
865
1257
  e.stopPropagation();
866
1258
  this.monthDropdownOpen = !this.monthDropdownOpen;
867
1259
  this.yearDropdownOpen = false;
1260
+ if (this.monthDropdownOpen) this.focusOpenDropdownSelection();
868
1261
  }
869
1262
 
870
1263
  private toggleYearDropdown(e: Event) {
871
1264
  e.stopPropagation();
872
1265
  this.yearDropdownOpen = !this.yearDropdownOpen;
873
1266
  this.monthDropdownOpen = false;
1267
+ if (this.yearDropdownOpen) this.focusOpenDropdownSelection();
874
1268
  }
875
1269
 
1270
+ private handleDropdownTriggerKeydown(e: KeyboardEvent, kind: "month" | "year") {
1271
+ if (e.key !== "ArrowDown") return;
1272
+ e.preventDefault();
1273
+ e.stopPropagation();
1274
+ this.monthDropdownOpen = kind === "month";
1275
+ this.yearDropdownOpen = kind === "year";
1276
+ this.focusOpenDropdownSelection();
1277
+ }
1278
+
1279
+ private async focusOpenDropdownSelection() {
1280
+ await this.updateComplete;
1281
+ const menu = this.renderRoot.querySelector<HTMLElement>(".dropdown-menu.open");
1282
+ if (!menu) return;
1283
+ const target =
1284
+ menu.querySelector<HTMLElement>(".dropdown-item.selected") ??
1285
+ menu.querySelector<HTMLElement>(".dropdown-item");
1286
+ target?.focus();
1287
+ target?.scrollIntoView({ block: "nearest" });
1288
+ }
1289
+
1290
+ private handleDropdownKeydown = (e: KeyboardEvent) => {
1291
+ const menu = e.currentTarget as HTMLElement;
1292
+ const items = Array.from(
1293
+ menu.querySelectorAll<HTMLElement>(".dropdown-item")
1294
+ );
1295
+ if (items.length === 0) return;
1296
+
1297
+ if (e.key === "Escape") {
1298
+ // Close only the dropdown, not the whole calendar popover behind it.
1299
+ e.preventDefault();
1300
+ e.stopPropagation();
1301
+ const trigger = menu
1302
+ .closest(".dropdown-container")
1303
+ ?.querySelector<HTMLElement>(".dropdown-trigger");
1304
+ this.monthDropdownOpen = false;
1305
+ this.yearDropdownOpen = false;
1306
+ trigger?.focus();
1307
+ return;
1308
+ }
1309
+
1310
+ if (e.key === "Tab") {
1311
+ this.monthDropdownOpen = false;
1312
+ this.yearDropdownOpen = false;
1313
+ return;
1314
+ }
1315
+
1316
+ const idx = items.indexOf(getDeepActiveElement() as HTMLElement);
1317
+ let target: number;
1318
+ switch (e.key) {
1319
+ case "ArrowDown":
1320
+ target = Math.min(items.length - 1, idx + 1);
1321
+ break;
1322
+ case "ArrowUp":
1323
+ target = Math.max(0, idx - 1);
1324
+ break;
1325
+ case "Home":
1326
+ target = 0;
1327
+ break;
1328
+ case "End":
1329
+ target = items.length - 1;
1330
+ break;
1331
+ default:
1332
+ return;
1333
+ }
1334
+ e.preventDefault();
1335
+ e.stopPropagation();
1336
+ items[target]?.focus();
1337
+ items[target]?.scrollIntoView({ block: "nearest" });
1338
+ };
1339
+
876
1340
  private selectMonth(month: number, e: Event) {
877
1341
  e.stopPropagation();
1342
+ const trigger = (e.target as HTMLElement)
1343
+ .closest(".dropdown-container")
1344
+ ?.querySelector<HTMLElement>(".dropdown-trigger");
878
1345
  this.viewMonth = month;
879
1346
  this.monthDropdownOpen = false;
1347
+ this.announceMonth();
1348
+ // The focused item just went display:none — keep keyboard users anchored.
1349
+ this.updateComplete.then(() => trigger?.focus());
880
1350
  }
881
1351
 
882
1352
  private selectYear(year: number, e: Event) {
883
1353
  e.stopPropagation();
1354
+ const trigger = (e.target as HTMLElement)
1355
+ .closest(".dropdown-container")
1356
+ ?.querySelector<HTMLElement>(".dropdown-trigger");
884
1357
  this.viewYear = year;
885
1358
  this.yearDropdownOpen = false;
1359
+ this.announceMonth();
1360
+ this.updateComplete.then(() => trigger?.focus());
1361
+ }
1362
+
1363
+ /**
1364
+ * Index of the single grid cell carrying tabindex="0". The old expression
1365
+ * (`isSelected || (index === 0 && !value)`) left the grid with ZERO tab
1366
+ * stops once the user arrowed around without a selection.
1367
+ */
1368
+ private getRovingIndex(days: CalendarDay[]): number {
1369
+ const candidates = [
1370
+ this.focusedDate
1371
+ ? days.findIndex(
1372
+ (d) => d.isCurrentMonth && this.isSameDay(d.date, this.focusedDate!)
1373
+ )
1374
+ : -1,
1375
+ days.findIndex((d) => d.isSelected),
1376
+ days.findIndex((d) => d.isCurrentMonth && d.isToday),
1377
+ days.findIndex((d) => d.isCurrentMonth && !d.isDisabled),
1378
+ ];
1379
+ const found = candidates.find((i) => i >= 0);
1380
+ return found ?? 0;
886
1381
  }
887
1382
 
888
1383
  render() {
@@ -890,10 +1385,18 @@ export class RelDatePicker extends LitElement {
890
1385
  const weekdayLabels = this.getWeekdayLabels();
891
1386
  const months = this.getMonthsList();
892
1387
  const years = this.getYearsList();
1388
+ const rovingIndex = this.getRovingIndex(days);
1389
+ const weeks: CalendarDay[][] = [];
1390
+ for (let i = 0; i < days.length; i += 7) {
1391
+ weeks.push(days.slice(i, i + 7));
1392
+ }
893
1393
 
894
- const isError = this.error;
895
- const belowText = isError && this.errorMessage ? this.errorMessage : this.helperText;
1394
+ const isError = this.error || this.inputError !== null;
1395
+ const belowText = isError
1396
+ ? this.errorMessage ?? this.inputError ?? this.helperText
1397
+ : this.helperText;
896
1398
  const describedById = belowText ? "date-picker-desc" : undefined;
1399
+ const displayText = this.value ? this.formatDisplayDate(this.value) : "";
897
1400
  const wrapperClasses = [
898
1401
  "date-picker-wrapper",
899
1402
  this.size,
@@ -917,24 +1420,35 @@ export class RelDatePicker extends LitElement {
917
1420
  ? html`<input type="hidden" name=${this.name} .value=${this.value ?? ""} />`
918
1421
  : nothing}
919
1422
 
920
- <button
1423
+ <div
1424
+ part="trigger"
921
1425
  class="trigger-button ${isError ? "error" : ""}"
922
- type="button"
923
- ?disabled=${this.disabled}
924
- @click=${this.toggleCalendarPopover}
925
- @keydown=${this.handleTriggerKeydown}
926
- aria-label=${this.label || "Select date"}
927
- aria-haspopup="dialog"
928
- aria-expanded=${this.open}
929
- aria-required=${this.required}
930
- aria-invalid=${isError}
931
- aria-readonly=${this.readonly}
932
- aria-describedby=${describedById ?? nothing}
933
- aria-labelledby=${this.label ? "date-picker-label" : nothing}
1426
+ @click=${this.handleFieldClick}
934
1427
  >
935
- <span class="trigger-content ${this.value ? "" : "placeholder"}">
936
- ${this.value ? this.formatDisplayDate(this.value) : this.placeholder}
937
- </span>
1428
+ <input
1429
+ part="value"
1430
+ class="trigger-input"
1431
+ type="text"
1432
+ .value=${live(this.inputText ?? displayText)}
1433
+ placeholder=${this.placeholder}
1434
+ ?disabled=${this.disabled}
1435
+ ?readonly=${this.readonly}
1436
+ autocomplete="off"
1437
+ spellcheck="false"
1438
+ enterkeyhint="done"
1439
+ @input=${this.handleInputInput}
1440
+ @keydown=${this.handleInputKeydown}
1441
+ @blur=${this.handleInputBlur}
1442
+ role="combobox"
1443
+ aria-label=${this.label || "Select date"}
1444
+ aria-haspopup="dialog"
1445
+ aria-expanded=${this.open}
1446
+ aria-controls="date-picker-panel"
1447
+ aria-required=${this.required}
1448
+ aria-invalid=${isError}
1449
+ aria-describedby=${describedById ?? nothing}
1450
+ aria-labelledby=${this.label ? "date-picker-label" : nothing}
1451
+ />
938
1452
  <span class="trigger-icons">
939
1453
  ${this.clearable && this.value
940
1454
  ? html`
@@ -955,15 +1469,57 @@ export class RelDatePicker extends LitElement {
955
1469
  </button>
956
1470
  `
957
1471
  : nothing}
958
- <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
959
- <path
960
- d="M4 2h1V1h2v1h2V1h2v1h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm8 3H4v7h8V5z"
961
- />
962
- </svg>
1472
+ <button
1473
+ class="icon-button"
1474
+ type="button"
1475
+ aria-label="Open calendar"
1476
+ aria-expanded=${this.open}
1477
+ ?disabled=${this.disabled}
1478
+ @click=${this.handleIconClick}
1479
+ >
1480
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
1481
+ <path
1482
+ d="M4 2h1V1h2v1h2V1h2v1h1a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm8 3H4v7h8V5z"
1483
+ />
1484
+ </svg>
1485
+ </button>
963
1486
  </span>
964
- </button>
1487
+ </div>
965
1488
 
966
- <div class="popover ${this.open ? "open" : ""}" role="dialog" aria-label="Choose date">
1489
+ <div
1490
+ id="date-picker-panel"
1491
+ class="popover ${this.open ? "open" : ""}"
1492
+ popover="manual"
1493
+ role="dialog"
1494
+ aria-label="Choose date"
1495
+ @keydown=${this.handlePanelKeydown}
1496
+ >
1497
+ <div part="shortcuts" class="shortcut-row">
1498
+ <button
1499
+ class="shortcut-chip"
1500
+ type="button"
1501
+ title="Type T in the field for today"
1502
+ @click=${() => this.applyDayOffsetShortcut(0)}
1503
+ >
1504
+ <kbd aria-hidden="true">T</kbd><span>Today</span>
1505
+ </button>
1506
+ <button
1507
+ class="shortcut-chip"
1508
+ type="button"
1509
+ title="Type T+1 in the field for tomorrow"
1510
+ @click=${() => this.applyDayOffsetShortcut(1)}
1511
+ >
1512
+ <kbd aria-hidden="true">T+1</kbd><span>Tomorrow</span>
1513
+ </button>
1514
+ <button
1515
+ class="shortcut-chip"
1516
+ type="button"
1517
+ title="Type T-1 in the field for yesterday"
1518
+ @click=${() => this.applyDayOffsetShortcut(-1)}
1519
+ >
1520
+ <kbd aria-hidden="true">T-1</kbd><span>Yesterday</span>
1521
+ </button>
1522
+ </div>
967
1523
  <div class="calendar-header">
968
1524
  <button
969
1525
  class="nav-button"
@@ -988,22 +1544,31 @@ export class RelDatePicker extends LitElement {
988
1544
  class="dropdown-trigger"
989
1545
  type="button"
990
1546
  @click=${this.toggleMonthDropdown}
1547
+ @keydown=${(e: KeyboardEvent) => this.handleDropdownTriggerKeydown(e, "month")}
991
1548
  aria-label="Select month"
1549
+ aria-haspopup="listbox"
992
1550
  aria-expanded=${this.monthDropdownOpen}
993
1551
  >
994
1552
  ${this.getMonthName()}
995
1553
  </button>
996
- <div class="dropdown-menu ${this.monthDropdownOpen ? "open" : ""}">
1554
+ <div
1555
+ class="dropdown-menu ${this.monthDropdownOpen ? "open" : ""}"
1556
+ role="listbox"
1557
+ aria-label="Month"
1558
+ @keydown=${this.handleDropdownKeydown}
1559
+ >
997
1560
  ${months.map(
998
1561
  (m) => html`
999
- <div
1562
+ <button
1000
1563
  class="dropdown-item ${m.value === this.viewMonth ? "selected" : ""}"
1564
+ type="button"
1565
+ tabindex="-1"
1001
1566
  @click=${(e: Event) => this.selectMonth(m.value, e)}
1002
1567
  role="option"
1003
1568
  aria-selected=${m.value === this.viewMonth}
1004
1569
  >
1005
1570
  ${m.label}
1006
- </div>
1571
+ </button>
1007
1572
  `
1008
1573
  )}
1009
1574
  </div>
@@ -1014,22 +1579,31 @@ export class RelDatePicker extends LitElement {
1014
1579
  class="dropdown-trigger"
1015
1580
  type="button"
1016
1581
  @click=${this.toggleYearDropdown}
1582
+ @keydown=${(e: KeyboardEvent) => this.handleDropdownTriggerKeydown(e, "year")}
1017
1583
  aria-label="Select year"
1584
+ aria-haspopup="listbox"
1018
1585
  aria-expanded=${this.yearDropdownOpen}
1019
1586
  >
1020
1587
  ${this.viewYear}
1021
1588
  </button>
1022
- <div class="dropdown-menu ${this.yearDropdownOpen ? "open" : ""}">
1589
+ <div
1590
+ class="dropdown-menu ${this.yearDropdownOpen ? "open" : ""}"
1591
+ role="listbox"
1592
+ aria-label="Year"
1593
+ @keydown=${this.handleDropdownKeydown}
1594
+ >
1023
1595
  ${years.map(
1024
1596
  (y) => html`
1025
- <div
1597
+ <button
1026
1598
  class="dropdown-item ${y === this.viewYear ? "selected" : ""}"
1599
+ type="button"
1600
+ tabindex="-1"
1027
1601
  @click=${(e: Event) => this.selectYear(y, e)}
1028
1602
  role="option"
1029
1603
  aria-selected=${y === this.viewYear}
1030
1604
  >
1031
1605
  ${y}
1032
- </div>
1606
+ </button>
1033
1607
  `
1034
1608
  )}
1035
1609
  </div>
@@ -1060,34 +1634,42 @@ export class RelDatePicker extends LitElement {
1060
1634
  )}
1061
1635
  </div>
1062
1636
 
1063
- <div class="calendar-grid" role="grid">
1064
- ${days.map(
1065
- (day, index) => html`
1066
- <button
1067
- class="day-button ${day.isSelected ? "selected" : ""} ${day.isToday
1068
- ? "today"
1069
- : ""} ${!day.isCurrentMonth ? "other-month" : ""}"
1070
- role="gridcell"
1071
- type="button"
1072
- aria-label="${this.formatISODate(day.date)}"
1073
- aria-selected="${day.isSelected}"
1074
- ?disabled=${day.isDisabled}
1075
- @click=${() => this.selectDay(day)}
1076
- @keydown=${(e: KeyboardEvent) => this.handleDayKeydown(e, day, index)}
1077
- tabindex="${day.isSelected || (index === 0 && !this.value) ? 0 : -1}"
1078
- >
1079
- ${day.day}
1080
- </button>
1637
+ <div class="calendar-grid" role="grid" aria-label="${this.getMonthName()} ${this.viewYear}">
1638
+ ${weeks.map(
1639
+ (week, weekIndex) => html`
1640
+ <div class="calendar-week" role="row">
1641
+ ${week.map((day, dayIndex) => {
1642
+ const index = weekIndex * 7 + dayIndex;
1643
+ return html`
1644
+ <button
1645
+ class="day-button ${day.isSelected ? "selected" : ""} ${day.isToday
1646
+ ? "today"
1647
+ : ""} ${!day.isCurrentMonth ? "other-month" : ""}"
1648
+ role="gridcell"
1649
+ type="button"
1650
+ aria-label="${this.formatDayLabel(day.date)}"
1651
+ aria-selected="${day.isSelected}"
1652
+ aria-current=${day.isToday ? "date" : nothing}
1653
+ ?disabled=${day.isDisabled}
1654
+ @click=${() => this.selectDay(day)}
1655
+ @keydown=${(e: KeyboardEvent) => this.handleDayKeydown(e, day, index)}
1656
+ tabindex="${index === rovingIndex ? 0 : -1}"
1657
+ >
1658
+ ${day.day}
1659
+ </button>
1660
+ `;
1661
+ })}
1662
+ </div>
1081
1663
  `
1082
1664
  )}
1083
1665
  </div>
1084
1666
  </div>
1085
1667
 
1086
- ${belowText
1087
- ? html`<div class="date-picker-support ${isError ? "error" : ""}" id=${describedById}>
1088
- ${belowText}
1089
- </div>`
1090
- : nothing}
1668
+ <div
1669
+ class="date-picker-support ${isError ? "error" : ""} ${belowText ? "" : "empty"}"
1670
+ id="date-picker-desc"
1671
+ aria-live="polite"
1672
+ >${belowText ?? nothing}</div>
1091
1673
  </div>
1092
1674
  `;
1093
1675
  }