@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,7 +1,11 @@
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 "@reliability-design/icons/rel-icon";
4
5
  import "../rel-button/rel-button";
6
+ import { announce } from "../../utils/announce";
7
+ import { isEditableTarget } from "../../utils/shortcuts";
8
+ import { parseDateInput } from "../rel-date-picker/date-parse";
5
9
 
6
10
  interface RangeDay {
7
11
  date: Date;
@@ -42,6 +46,7 @@ export class RelRangePicker extends LitElement {
42
46
  @property({ type: String, attribute: "max-height" }) maxHeight: string = "";
43
47
  @property({ type: String }) placeholder: string = "Select date range";
44
48
  @property({ type: String, attribute: "trigger-variant" }) triggerVariant: "default" | "icon" = "default";
49
+ @property({ type: Boolean }) clearable = false;
45
50
 
46
51
  @state() private draftFrom: string = "";
47
52
  @state() private draftTo: string = "";
@@ -50,8 +55,14 @@ export class RelRangePicker extends LitElement {
50
55
  @state() private viewMonth: number = new Date().getMonth();
51
56
  @state() private isSelectingRange: boolean = false;
52
57
  @state() private hoverDate: string | null = null;
53
-
54
- @query(".trigger-button") private triggerButton?: HTMLButtonElement;
58
+ /** ISO date of the cell that owns the grid's single Tab stop (roving tabindex). */
59
+ @state() private focusedDate: string | null = null;
60
+ /** Uncommitted typed text; null → the input shows the committed range. */
61
+ @state() private inputText: string | null = null;
62
+ /** Parse/range error message for the typed text (cleared on next input). */
63
+ @state() private inputError: string | null = null;
64
+
65
+ @query(".trigger-input") private triggerInput?: HTMLInputElement;
55
66
  @query(".popover") private popoverEl?: HTMLDivElement;
56
67
  @query(".backdrop") private backdropEl?: HTMLDivElement;
57
68
 
@@ -95,22 +106,81 @@ export class RelRangePicker extends LitElement {
95
106
  letter-spacing: var(--rel-body-md-regular-letter-spacing);
96
107
  }
97
108
 
98
- .trigger-button:hover:not(:disabled) {
109
+ :host(:not([disabled])) .trigger-button:hover {
99
110
  border-color: var(--rel-border-accent);
100
111
  }
101
112
 
102
- .trigger-button:focus-visible {
113
+ .trigger-button:focus-within {
103
114
  outline: 2px solid var(--rel-border-focus);
104
115
  outline-offset: 2px;
105
116
  }
106
117
 
107
- .trigger-content {
118
+ .trigger-button.error {
119
+ border-color: var(--rel-border-danger);
120
+ }
121
+
122
+ .trigger-button.error:focus-within {
123
+ outline-color: var(--rel-border-danger);
124
+ }
125
+
126
+ /* The editable field itself — the wrapper carries the box styles. */
127
+ .trigger-input {
108
128
  flex: 1;
129
+ min-width: 0;
130
+ border: none;
131
+ background: transparent;
132
+ padding: 0;
133
+ margin: 0;
134
+ outline: none;
109
135
  color: var(--rel-text-default);
136
+ font: inherit;
137
+ letter-spacing: inherit;
138
+ cursor: inherit;
110
139
  }
111
140
 
112
- .trigger-content.placeholder {
141
+ .trigger-input::placeholder {
113
142
  color: var(--rel-text-placeholder);
143
+ opacity: 1;
144
+ }
145
+
146
+ /* Typed-entry error line (the component has no helper-text API). */
147
+ .range-picker-support.error {
148
+ margin-top: var(--rel-spacing-2xs);
149
+ color: var(--rel-text-danger);
150
+ font-family: var(--rel-body-xs-regular-font-family);
151
+ font-weight: var(--rel-body-xs-regular-font-weight);
152
+ font-size: var(--rel-body-xs-regular-font-size);
153
+ line-height: var(--rel-body-xs-regular-line-height);
154
+ letter-spacing: var(--rel-body-xs-regular-letter-spacing);
155
+ }
156
+
157
+ .trigger-icons {
158
+ display: flex;
159
+ align-items: center;
160
+ gap: var(--rel-spacing-2xs);
161
+ }
162
+
163
+ .icon-button {
164
+ background: none;
165
+ border: none;
166
+ padding: var(--rel-spacing-3xs);
167
+ cursor: pointer;
168
+ color: var(--rel-text-placeholder);
169
+ display: flex;
170
+ align-items: center;
171
+ justify-content: center;
172
+ border-radius: var(--rel-border-radius-xs);
173
+ transition: all 0.15s ease;
174
+ }
175
+
176
+ .icon-button:hover {
177
+ background: var(--rel-bg-surface-elevated);
178
+ color: var(--rel-text-default);
179
+ }
180
+
181
+ .icon-button svg {
182
+ width: 16px;
183
+ height: 16px;
114
184
  }
115
185
 
116
186
  /* ── Popover shell ── */
@@ -134,7 +204,13 @@ export class RelRangePicker extends LitElement {
134
204
  border-radius: var(--rel-border-radius-sm, 8px);
135
205
  background: var(--rel-bg-neutral);
136
206
  box-shadow: var(--rel-shadows-shadow-200);
137
- overflow: visible;
207
+ /* Never wider or taller than the viewport (small screens) — the
208
+ two-calendar layout scrolls inside the shell instead of bleeding
209
+ off-screen. border-box so the caps include the 1px borders. */
210
+ box-sizing: border-box;
211
+ max-width: calc(100vw - 16px);
212
+ max-height: calc(100vh - 16px);
213
+ overflow: auto;
138
214
  display: none;
139
215
  }
140
216
 
@@ -225,6 +301,28 @@ export class RelRangePicker extends LitElement {
225
301
  grid-column: 1 / -1;
226
302
  }
227
303
 
304
+ /* Single-key hint chip on each preset (T, M, W, …). */
305
+ .preset-key {
306
+ flex-shrink: 0;
307
+ margin-left: var(--rel-spacing-2xs);
308
+ border: 1px solid var(--rel-border-default);
309
+ border-bottom-width: 2px;
310
+ border-radius: var(--rel-border-radius-xs);
311
+ background: var(--rel-bg-surface);
312
+ padding: 0 var(--rel-spacing-3xs);
313
+ min-width: 18px;
314
+ text-align: center;
315
+ font-family: var(--rel-body-xs-regular-font-family);
316
+ font-weight: var(--rel-body-xs-regular-font-weight);
317
+ font-size: var(--rel-body-xs-regular-font-size);
318
+ line-height: var(--rel-body-xs-regular-line-height);
319
+ color: var(--rel-text-label);
320
+ }
321
+
322
+ .preset-item.active .preset-key {
323
+ display: none;
324
+ }
325
+
228
326
  /* ── FROM/TO inputs ── */
229
327
  .date-inputs {
230
328
  display: flex;
@@ -338,11 +436,23 @@ export class RelRangePicker extends LitElement {
338
436
  }
339
437
 
340
438
  /* ── Calendar grid ── */
439
+ .calendar-table {
440
+ display: flex;
441
+ flex-direction: column;
442
+ gap: 6px;
443
+ }
444
+
341
445
  .calendar-grid {
342
446
  display: grid;
343
447
  grid-template-columns: repeat(7, 1fr);
344
448
  }
345
449
 
450
+ .calendar-week {
451
+ grid-column: 1 / -1;
452
+ display: grid;
453
+ grid-template-columns: repeat(7, 1fr);
454
+ }
455
+
346
456
  .day-cell {
347
457
  height: 36px;
348
458
  position: relative;
@@ -352,6 +462,12 @@ export class RelRangePicker extends LitElement {
352
462
  cursor: pointer;
353
463
  }
354
464
 
465
+ .day-cell:focus-visible {
466
+ outline: 2px solid var(--rel-border-focus);
467
+ outline-offset: -2px;
468
+ z-index: 2;
469
+ }
470
+
355
471
  .day-cell.other-month {
356
472
  opacity: 0.35;
357
473
  pointer-events: none;
@@ -450,7 +566,7 @@ export class RelRangePicker extends LitElement {
450
566
  }
451
567
 
452
568
  @media (prefers-reduced-motion: reduce) {
453
- .preset-item, .nav-btn, .day-inner {
569
+ .preset-item, .nav-btn, .day-inner, .icon-button {
454
570
  transition: none;
455
571
  }
456
572
  }
@@ -557,8 +673,9 @@ export class RelRangePicker extends LitElement {
557
673
  }
558
674
  let left = t.left;
559
675
  if (left + c.width > window.innerWidth - 8) {
560
- left = Math.max(8, window.innerWidth - c.width - 8);
676
+ left = window.innerWidth - c.width - 8;
561
677
  }
678
+ left = Math.max(8, left);
562
679
 
563
680
  // Without top-layer support a transformed ancestor may be the containing
564
681
  // block for position:fixed, shifting the shell away from the viewport
@@ -588,11 +705,15 @@ export class RelRangePicker extends LitElement {
588
705
 
589
706
  if (e.key === "Escape") {
590
707
  this.cancelPopover();
591
- this.triggerButton?.focus();
708
+ this.triggerInput?.focus();
592
709
  return;
593
710
  }
594
711
 
595
- if ((e.target as HTMLElement)?.tagName === "INPUT") return;
712
+ // composedPath-aware: a document-level e.target retargets to the host,
713
+ // so a plain tagName check misses the typed-entry input in the shadow
714
+ // root — single-key preset shortcuts must not fire while typing.
715
+ if (isEditableTarget(e)) return;
716
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
596
717
 
597
718
  const key = e.key.toUpperCase();
598
719
  const keyMap: Record<string, string> = {
@@ -627,6 +748,7 @@ export class RelRangePicker extends LitElement {
627
748
  this.draftActivePreset = null;
628
749
  this.isSelectingRange = false;
629
750
  this.hoverDate = null;
751
+ this.focusedDate = null;
630
752
 
631
753
  if (this.draftFrom) {
632
754
  const date = this.parseISODate(this.draftFrom);
@@ -668,27 +790,30 @@ export class RelRangePicker extends LitElement {
668
790
  }
669
791
 
670
792
  private applySelection() {
671
- if (this.draftFrom || this.draftTo) {
672
- this.from = this.draftFrom;
673
- this.to = this.draftTo;
674
- this.dispatchEvent(
675
- new CustomEvent("rel-change", {
676
- detail: { from: this.from, to: this.to, label: this.displayLabel },
677
- bubbles: true,
678
- composed: true,
679
- })
680
- );
681
- this.open = false;
682
- this.isSelectingRange = false;
683
- this.hoverDate = null;
684
- this.dispatchEvent(
685
- new CustomEvent("rel-open-change", {
686
- detail: { open: false },
687
- bubbles: true,
688
- composed: true,
689
- })
690
- );
691
- }
793
+ // An empty draft is committable when a committed range exists — this is
794
+ // how Reset propagates to the trigger (Reset → Apply clears the input).
795
+ if (!this.draftFrom && !this.draftTo && !this.from && !this.to) return;
796
+ this.from = this.draftFrom;
797
+ this.to = this.draftTo;
798
+ this.inputText = null;
799
+ this.inputError = null;
800
+ this.dispatchEvent(
801
+ new CustomEvent("rel-change", {
802
+ detail: { from: this.from, to: this.to, label: this.displayLabel },
803
+ bubbles: true,
804
+ composed: true,
805
+ })
806
+ );
807
+ this.open = false;
808
+ this.isSelectingRange = false;
809
+ this.hoverDate = null;
810
+ this.dispatchEvent(
811
+ new CustomEvent("rel-open-change", {
812
+ detail: { open: false },
813
+ bubbles: true,
814
+ composed: true,
815
+ })
816
+ );
692
817
  }
693
818
 
694
819
  private resetDraft() {
@@ -701,6 +826,162 @@ export class RelRangePicker extends LitElement {
701
826
  }
702
827
  }
703
828
 
829
+ private clearRange(e: Event) {
830
+ e.stopPropagation();
831
+ this.from = "";
832
+ this.to = "";
833
+ this.draftFrom = "";
834
+ this.draftTo = "";
835
+ this.draftActivePreset = null;
836
+ this.isSelectingRange = false;
837
+ this.hoverDate = null;
838
+ this.inputText = null;
839
+ this.inputError = null;
840
+ this.dispatchEvent(
841
+ new CustomEvent("rel-change", {
842
+ detail: { from: "", to: "", label: "" },
843
+ bubbles: true,
844
+ composed: true,
845
+ })
846
+ );
847
+ }
848
+
849
+ // ── Typed keyboard entry ──
850
+
851
+ /**
852
+ * Parse "date → date" text into an ISO from/to pair, or null. Each side
853
+ * goes through rel-date-picker's parseDateInput, so ISO / numeric /
854
+ * month-name dates and the t / t±N shortcuts all work per side.
855
+ * Separators: "→", "..", spaced "-", or the word "to" ("t - t+7",
856
+ * "21/7 to 28/7"). A single date yields a one-day range. Reversed input
857
+ * is normalized (from ≤ to). dayFirst is hard-coded false because the
858
+ * displayed format is always MM/DD/YYYY.
859
+ */
860
+ private parseRangeText(text: string): { from: string; to: string } | null {
861
+ const parts = text
862
+ .split(/\s+to\s+|\s*(?:→|\.\.)\s*|\s+-\s+/i)
863
+ .map((p) => p.trim())
864
+ .filter(Boolean);
865
+ if (parts.length < 1 || parts.length > 2) return null;
866
+
867
+ const opts = { referenceDate: new Date(), dayFirst: false, locale: this.locale };
868
+ const fromDate = parseDateInput(parts[0], opts);
869
+ if (!fromDate) return null;
870
+ const toDate = parts.length === 2 ? parseDateInput(parts[1], opts) : fromDate;
871
+ if (!toDate) return null;
872
+
873
+ const [a, b] =
874
+ fromDate.getTime() <= toDate.getTime() ? [fromDate, toDate] : [toDate, fromDate];
875
+ return { from: this.formatISODate(a), to: this.formatISODate(b) };
876
+ }
877
+
878
+ /**
879
+ * Parse and commit the typed text (Enter / blur). Invalid or out-of-range
880
+ * text keeps the field in an error state with the text preserved for
881
+ * correction; the committed range is untouched (Escape reverts the text).
882
+ */
883
+ private commitTypedValue() {
884
+ if (this.inputText === null) return; // nothing typed since last commit
885
+ const text = this.inputText.trim();
886
+
887
+ if (!text) {
888
+ // Field cleared by keyboard — behaves like the clear button.
889
+ this.inputText = null;
890
+ this.inputError = null;
891
+ if (this.from || this.to) {
892
+ this.from = "";
893
+ this.to = "";
894
+ this.draftFrom = "";
895
+ this.draftTo = "";
896
+ this.draftActivePreset = null;
897
+ this.dispatchEvent(
898
+ new CustomEvent("rel-change", {
899
+ detail: { from: "", to: "", label: "" },
900
+ bubbles: true,
901
+ composed: true,
902
+ })
903
+ );
904
+ }
905
+ if (this.open) this.cancelPopover();
906
+ return;
907
+ }
908
+
909
+ const range = this.parseRangeText(text);
910
+ if (!range) {
911
+ this.inputError = "Invalid date range";
912
+ return;
913
+ }
914
+ const fromDate = this.parseISODate(range.from)!;
915
+ const toDate = this.parseISODate(range.to)!;
916
+ if (this.isDateDisabled(fromDate) || this.isDateDisabled(toDate)) {
917
+ this.inputError = "Date not available";
918
+ return;
919
+ }
920
+
921
+ this.from = range.from;
922
+ this.to = range.to;
923
+ this.inputText = null;
924
+ this.inputError = null;
925
+ this.dispatchEvent(
926
+ new CustomEvent("rel-change", {
927
+ detail: { from: this.from, to: this.to, label: this.displayLabel },
928
+ bubbles: true,
929
+ composed: true,
930
+ })
931
+ );
932
+ if (this.open) this.cancelPopover();
933
+ }
934
+
935
+ private handleInputInput(e: Event) {
936
+ this.inputText = (e.target as HTMLInputElement).value;
937
+ if (this.inputError) this.inputError = null;
938
+ }
939
+
940
+ private handleInputKeydown(e: KeyboardEvent) {
941
+ if (e.key === "Enter") {
942
+ e.preventDefault();
943
+ if (this.inputText === null) {
944
+ // Nothing typed — Enter opens the panel (previous trigger behavior).
945
+ if (!this.open) this.openPopover();
946
+ } else {
947
+ this.commitTypedValue();
948
+ }
949
+ return;
950
+ }
951
+ if (e.key === "ArrowDown") {
952
+ e.preventDefault();
953
+ if (!this.open) this.openPopover();
954
+ return;
955
+ }
956
+ if (e.key === "Escape" && this.inputText !== null) {
957
+ // Revert the typed text; the document listener closes the panel.
958
+ this.inputText = null;
959
+ this.inputError = null;
960
+ }
961
+ }
962
+
963
+ /** Clicking anywhere on the field opens the panel (never closes). */
964
+ private handleFieldClick() {
965
+ if (this.open || this.disabled) return;
966
+ this.openPopover();
967
+ }
968
+
969
+ /** The calendar icon toggles the panel (previous trigger behavior). */
970
+ private handleIconClick(e: Event) {
971
+ e.stopPropagation();
972
+ this.toggleOpen();
973
+ }
974
+
975
+ /** Commit typed text when focus leaves the component entirely. */
976
+ private handleInputBlur = (e: FocusEvent) => {
977
+ this.dispatchEvent(new CustomEvent("rel-blur", { bubbles: true, composed: true }));
978
+ const related = e.relatedTarget as Node | null;
979
+ if (related && (this.renderRoot.contains(related) || this.contains(related))) {
980
+ return; // focus moved into the panel/icons — their own handlers commit
981
+ }
982
+ this.commitTypedValue();
983
+ };
984
+
704
985
  private getTriggerLabel(): string {
705
986
  const from = this.from ? this.formatDisplayDate(this.from) : null;
706
987
  const to = this.to ? this.formatDisplayDate(this.to) : null;
@@ -823,6 +1104,7 @@ export class RelRangePicker extends LitElement {
823
1104
  } else {
824
1105
  this.viewMonth--;
825
1106
  }
1107
+ announce(`${this.getMonthName()} ${this.viewYear}`);
826
1108
  }
827
1109
 
828
1110
  private nextMonth() {
@@ -832,6 +1114,7 @@ export class RelRangePicker extends LitElement {
832
1114
  } else {
833
1115
  this.viewMonth++;
834
1116
  }
1117
+ announce(`${this.getMonthName()} ${this.viewYear}`);
835
1118
  }
836
1119
 
837
1120
  private getMonthName(): string {
@@ -840,6 +1123,23 @@ export class RelRangePicker extends LitElement {
840
1123
  );
841
1124
  }
842
1125
 
1126
+ private dayLabelFormatter: Intl.DateTimeFormat | null = null;
1127
+
1128
+ private formatDayLabel(day: RangeDay): string {
1129
+ if (!this.dayLabelFormatter) {
1130
+ this.dayLabelFormatter = new Intl.DateTimeFormat("en-US", {
1131
+ weekday: "long",
1132
+ month: "long",
1133
+ day: "numeric",
1134
+ year: "numeric",
1135
+ });
1136
+ }
1137
+ let label = this.dayLabelFormatter.format(day.date);
1138
+ if (day.isDraftFrom) label += ", range start";
1139
+ if (day.isDraftTo) label += ", range end";
1140
+ return label;
1141
+ }
1142
+
843
1143
  private getWeekdayLabels(): string[] {
844
1144
  const formatter = new Intl.DateTimeFormat("en-US", { weekday: "short" });
845
1145
  const base = new Date(2024, 0, 7); // Sunday Jan 7 2024
@@ -956,19 +1256,23 @@ export class RelRangePicker extends LitElement {
956
1256
  this.draftActivePreset = null;
957
1257
  this.isSelectingRange = true;
958
1258
  this.hoverDate = null;
1259
+ announce(`From date ${this.formatDisplayDate(iso)}`);
959
1260
  } else {
960
1261
  const fromDate = this.parseISODate(this.draftFrom);
961
1262
  if (!fromDate) {
962
1263
  this.draftFrom = iso;
963
1264
  this.draftTo = "";
1265
+ announce(`From date ${this.formatDisplayDate(iso)}`);
964
1266
  return;
965
1267
  }
966
1268
 
967
1269
  if (day.date.getTime() >= fromDate.getTime()) {
968
1270
  this.draftTo = iso;
1271
+ announce(`To date ${this.formatDisplayDate(iso)}`);
969
1272
  } else {
970
1273
  this.draftTo = this.draftFrom;
971
1274
  this.draftFrom = iso;
1275
+ announce(`From date ${this.formatDisplayDate(iso)}`);
972
1276
  }
973
1277
 
974
1278
  this.isSelectingRange = false;
@@ -982,12 +1286,135 @@ export class RelRangePicker extends LitElement {
982
1286
  this.hoverDate = this.formatISODate(day.date);
983
1287
  }
984
1288
 
1289
+ // ── Grid keyboard navigation (APG grid pattern) ──
1290
+
1291
+ /**
1292
+ * The cell that carries tabindex="0". Prefers the last roved date, then the
1293
+ * draft start, then today, then the first selectable day — always exactly
1294
+ * one Tab stop, even after arrowing away with nothing selected.
1295
+ */
1296
+ private getRovingIso(days: RangeDay[]): string {
1297
+ const inView = days.filter((d) => d.isCurrentMonth);
1298
+ if (inView.length === 0) return "";
1299
+
1300
+ const candidates = [
1301
+ this.focusedDate,
1302
+ this.draftFrom,
1303
+ this.formatISODate(new Date()),
1304
+ ];
1305
+ for (const iso of candidates) {
1306
+ if (iso && inView.some((d) => this.formatISODate(d.date) === iso)) {
1307
+ return iso;
1308
+ }
1309
+ }
1310
+ const firstEnabled = inView.find((d) => !d.isDisabled) ?? inView[0];
1311
+ return this.formatISODate(firstEnabled.date);
1312
+ }
1313
+
1314
+ private handleGridKeydown = (e: KeyboardEvent) => {
1315
+ const cell = (e.target as HTMLElement).closest?.(".day-cell") as
1316
+ | HTMLElement
1317
+ | null;
1318
+ const iso = cell?.dataset.iso;
1319
+ if (!iso) return;
1320
+ const date = this.parseISODate(iso);
1321
+ if (!date) return;
1322
+
1323
+ if (e.key === "Enter" || e.key === " ") {
1324
+ e.preventDefault();
1325
+ const isDisabled = this.isDateDisabled(date);
1326
+ if (!isDisabled) {
1327
+ this.handleDayClick({
1328
+ date,
1329
+ day: date.getDate(),
1330
+ isCurrentMonth: true,
1331
+ isToday: false,
1332
+ isDraftFrom: false,
1333
+ isDraftTo: false,
1334
+ isInRange: false,
1335
+ isDisabled,
1336
+ isWeekend: false,
1337
+ });
1338
+ }
1339
+ return;
1340
+ }
1341
+
1342
+ const next = new Date(date);
1343
+ const dow = next.getDay();
1344
+ switch (e.key) {
1345
+ case "ArrowLeft":
1346
+ next.setDate(next.getDate() - 1);
1347
+ break;
1348
+ case "ArrowRight":
1349
+ next.setDate(next.getDate() + 1);
1350
+ break;
1351
+ case "ArrowUp":
1352
+ next.setDate(next.getDate() - 7);
1353
+ break;
1354
+ case "ArrowDown":
1355
+ next.setDate(next.getDate() + 7);
1356
+ break;
1357
+ case "Home":
1358
+ next.setDate(
1359
+ next.getDate() -
1360
+ (this.weekStart === "mon" ? (dow === 0 ? 6 : dow - 1) : dow)
1361
+ );
1362
+ break;
1363
+ case "End":
1364
+ next.setDate(
1365
+ next.getDate() +
1366
+ (this.weekStart === "mon" ? (dow === 0 ? 0 : 7 - dow) : 6 - dow)
1367
+ );
1368
+ break;
1369
+ case "PageUp":
1370
+ if (e.shiftKey) next.setFullYear(next.getFullYear() - 1);
1371
+ else next.setMonth(next.getMonth() - 1);
1372
+ break;
1373
+ case "PageDown":
1374
+ if (e.shiftKey) next.setFullYear(next.getFullYear() + 1);
1375
+ else next.setMonth(next.getMonth() + 1);
1376
+ break;
1377
+ default:
1378
+ return;
1379
+ }
1380
+
1381
+ e.preventDefault();
1382
+ this.moveGridFocusTo(next);
1383
+ };
1384
+
1385
+ private async moveGridFocusTo(date: Date) {
1386
+ this.focusedDate = this.formatISODate(date);
1387
+
1388
+ if (
1389
+ date.getMonth() !== this.viewMonth ||
1390
+ date.getFullYear() !== this.viewYear
1391
+ ) {
1392
+ this.viewMonth = date.getMonth();
1393
+ this.viewYear = date.getFullYear();
1394
+ }
1395
+
1396
+ // Keyboard twin of handleDayHover: keep the range preview following focus.
1397
+ if (this.isSelectingRange && !this.isDateDisabled(date)) {
1398
+ this.hoverDate = this.focusedDate;
1399
+ }
1400
+
1401
+ await this.updateComplete;
1402
+ this.renderRoot
1403
+ .querySelector<HTMLElement>(`.day-cell[data-iso="${this.focusedDate}"]`)
1404
+ ?.focus();
1405
+ }
1406
+
985
1407
  // ── Render ──
986
1408
 
987
1409
  render() {
988
1410
  const quickSelects = this.getQuickSelects();
989
1411
  const days = this.getCalendarDays();
1412
+ const rovingIso = this.getRovingIso(days);
990
1413
  const weekdayLabels = this.getWeekdayLabels();
1414
+ const weeks: RangeDay[][] = [];
1415
+ for (let i = 0; i < days.length; i += 7) {
1416
+ weeks.push(days.slice(i, i + 7));
1417
+ }
991
1418
 
992
1419
  const draftFromDisplay = this.draftFrom ? this.formatDisplayDate(this.draftFrom) : "";
993
1420
  const draftToDisplay = this.draftTo ? this.formatDisplayDate(this.draftTo) : "";
@@ -1011,22 +1438,71 @@ export class RelRangePicker extends LitElement {
1011
1438
  </rel-button>
1012
1439
  `
1013
1440
  : html`
1014
- <button
1015
- part="trigger" class="trigger-button"
1016
- type="button"
1017
- ?disabled=${this.disabled}
1018
- aria-haspopup="dialog"
1019
- aria-expanded=${this.open}
1020
- aria-label=${triggerLabel || this.placeholder}
1021
- @click=${this.toggleOpen}
1441
+ <div
1442
+ part="trigger"
1443
+ class="trigger-button ${this.inputError ? "error" : ""}"
1444
+ @click=${this.handleFieldClick}
1022
1445
  >
1023
- <span class="trigger-content ${triggerLabel ? "" : "placeholder"}">
1024
- ${triggerLabel || this.placeholder}
1446
+ <input
1447
+ part="value"
1448
+ class="trigger-input"
1449
+ type="text"
1450
+ .value=${live(this.inputText ?? triggerLabel)}
1451
+ placeholder=${this.placeholder}
1452
+ ?disabled=${this.disabled}
1453
+ autocomplete="off"
1454
+ spellcheck="false"
1455
+ enterkeyhint="done"
1456
+ @input=${this.handleInputInput}
1457
+ @keydown=${this.handleInputKeydown}
1458
+ @blur=${this.handleInputBlur}
1459
+ role="combobox"
1460
+ aria-label=${this.placeholder}
1461
+ aria-haspopup="dialog"
1462
+ aria-expanded=${this.open}
1463
+ aria-controls="range-picker-panel"
1464
+ aria-invalid=${this.inputError !== null}
1465
+ aria-describedby=${this.inputError ? "range-picker-desc" : nothing}
1466
+ />
1467
+ <span class="trigger-icons">
1468
+ ${this.clearable && (this.from || this.to)
1469
+ ? html`
1470
+ <button
1471
+ class="icon-button"
1472
+ type="button"
1473
+ aria-label="Clear date range"
1474
+ @click=${this.clearRange}
1475
+ >
1476
+ <svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
1477
+ <path
1478
+ d="M4 4l8 8M12 4l-8 8"
1479
+ stroke="currentColor"
1480
+ stroke-width="2"
1481
+ stroke-linecap="round"
1482
+ />
1483
+ </svg>
1484
+ </button>
1485
+ `
1486
+ : nothing}
1487
+ <button
1488
+ class="icon-button"
1489
+ type="button"
1490
+ aria-label="Open date range panel"
1491
+ aria-expanded=${this.open}
1492
+ ?disabled=${this.disabled}
1493
+ @click=${this.handleIconClick}
1494
+ >
1495
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
1496
+ <path 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"/>
1497
+ </svg>
1498
+ </button>
1025
1499
  </span>
1026
- <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
1027
- <path 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"/>
1028
- </svg>
1029
- </button>
1500
+ </div>
1501
+ ${this.inputError
1502
+ ? html`<div class="range-picker-support error" id="range-picker-desc">
1503
+ ${this.inputError}
1504
+ </div>`
1505
+ : nothing}
1030
1506
  `}
1031
1507
 
1032
1508
  ${isPopup && this.open
@@ -1035,6 +1511,7 @@ export class RelRangePicker extends LitElement {
1035
1511
 
1036
1512
  <div
1037
1513
  part="panel" class="popover ${this.open ? "open" : ""} ${isPopup ? "popup" : ""}"
1514
+ id="range-picker-panel"
1038
1515
  popover="manual"
1039
1516
  role="dialog"
1040
1517
  aria-label="Select date range"
@@ -1043,18 +1520,20 @@ export class RelRangePicker extends LitElement {
1043
1520
 
1044
1521
  <!-- ── Left: Quick select ── -->
1045
1522
  <div part="list" class="presets">
1046
- <div class="presets-grid" role="list" aria-label="Quick date selections">
1523
+ <div class="presets-grid" role="group" aria-label="Quick date selections">
1047
1524
  ${quickSelects.map(
1048
1525
  (qs) => html`
1049
1526
  <button
1050
1527
  part="option" class="preset-item ${qs.fullWidth ? "full-width" : ""} ${this.draftActivePreset === qs.label ? "active" : ""}"
1051
1528
  type="button"
1052
- role="listitem"
1053
1529
  ?disabled=${this.disabled}
1054
1530
  aria-pressed=${this.draftActivePreset === qs.label}
1055
1531
  @click=${() => this.selectQuickSelect(qs)}
1056
1532
  >
1057
1533
  <span>${qs.label}</span>
1534
+ ${this.draftActivePreset !== qs.label
1535
+ ? html`<kbd class="preset-key" aria-hidden="true" title="Press ${qs.key} while the panel is open">${qs.key}</kbd>`
1536
+ : nothing}
1058
1537
  ${this.draftActivePreset === qs.label
1059
1538
  ? html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
1060
1539
  <circle cx="12" cy="12" r="12" fill="var(--rel-bg-brand)"/>
@@ -1106,49 +1585,60 @@ export class RelRangePicker extends LitElement {
1106
1585
  </button>
1107
1586
  </div>
1108
1587
 
1109
- <div class="week-header" role="row">
1110
- ${weekdayLabels.map(
1111
- (label) => html`
1112
- <div class="week-day" role="columnheader" aria-label="${label}">
1113
- ${label}
1114
- </div>
1115
- `
1116
- )}
1117
- </div>
1588
+ <div class="calendar-table" role="grid" aria-label="Select date range">
1589
+ <div class="week-header" role="row">
1590
+ ${weekdayLabels.map(
1591
+ (label) => html`
1592
+ <div class="week-day" role="columnheader" aria-label="${label}">
1593
+ ${label}
1594
+ </div>
1595
+ `
1596
+ )}
1597
+ </div>
1118
1598
 
1119
- <div
1120
- part="list" class="calendar-grid"
1121
- role="grid"
1122
- aria-label="Select date range"
1123
- @mouseleave=${() => { this.hoverDate = null; }}
1124
- >
1125
- ${days.map((day) => {
1126
- const cellClasses = [
1127
- "day-cell",
1128
- day.isInRange ? "in-range" : "",
1129
- day.isDraftFrom ? "range-start" : "",
1130
- day.isDraftTo ? "range-end" : "",
1131
- day.isToday ? "today" : "",
1132
- day.isDisabled ? "disabled" : "",
1133
- !day.isCurrentMonth ? "other-month" : "",
1134
- day.isWeekend ? "weekend" : "",
1135
- ]
1136
- .filter(Boolean)
1137
- .join(" ");
1138
-
1139
- return html`
1140
- <div
1141
- class="${cellClasses}"
1142
- role="gridcell"
1143
- aria-label="${this.formatISODate(day.date)}"
1144
- aria-selected="${day.isDraftFrom || day.isDraftTo || day.isInRange}"
1145
- @click=${() => this.handleDayClick(day)}
1146
- @mouseenter=${() => this.handleDayHover(day)}
1147
- >
1148
- <span class="day-inner">${day.day}</span>
1149
- </div>
1150
- `;
1151
- })}
1599
+ <div
1600
+ part="list" class="calendar-grid"
1601
+ @mouseleave=${() => { this.hoverDate = null; }}
1602
+ @keydown=${this.handleGridKeydown}
1603
+ >
1604
+ ${weeks.map(
1605
+ (week) => html`
1606
+ <div class="calendar-week" role="row">
1607
+ ${week.map((day) => {
1608
+ const iso = this.formatISODate(day.date);
1609
+ const cellClasses = [
1610
+ "day-cell",
1611
+ day.isInRange ? "in-range" : "",
1612
+ day.isDraftFrom ? "range-start" : "",
1613
+ day.isDraftTo ? "range-end" : "",
1614
+ day.isToday ? "today" : "",
1615
+ day.isDisabled ? "disabled" : "",
1616
+ !day.isCurrentMonth ? "other-month" : "",
1617
+ day.isWeekend ? "weekend" : "",
1618
+ ]
1619
+ .filter(Boolean)
1620
+ .join(" ");
1621
+
1622
+ return html`
1623
+ <div
1624
+ class="${cellClasses}"
1625
+ role="gridcell"
1626
+ tabindex="${day.isCurrentMonth && iso === rovingIso ? 0 : -1}"
1627
+ data-iso="${iso}"
1628
+ aria-label="${this.formatDayLabel(day)}"
1629
+ aria-selected="${day.isDraftFrom || day.isDraftTo || day.isInRange}"
1630
+ aria-disabled="${day.isDisabled ? "true" : nothing}"
1631
+ @click=${() => this.handleDayClick(day)}
1632
+ @mouseenter=${() => this.handleDayHover(day)}
1633
+ >
1634
+ <span class="day-inner">${day.day}</span>
1635
+ </div>
1636
+ `;
1637
+ })}
1638
+ </div>
1639
+ `
1640
+ )}
1641
+ </div>
1152
1642
  </div>
1153
1643
 
1154
1644
  <div part="footer" class="calendar-footer">
@@ -1175,7 +1665,7 @@ export class RelRangePicker extends LitElement {
1175
1665
  tone="default"
1176
1666
  size="small"
1177
1667
  border="corner-radius"
1178
- ?disabled=${isDraftEmpty}
1668
+ ?disabled=${isDraftEmpty && !this.from && !this.to}
1179
1669
  @click=${() => this.applySelection()}
1180
1670
  >
1181
1671
  Apply