@reliability-design/web 1.4.5 → 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 (42) hide show
  1. package/dist/components/rel-alert/rel-alert.ts +1 -1
  2. package/dist/components/rel-autocomplete/rel-autocomplete.ts +33 -2
  3. package/dist/components/rel-badge/rel-badge.ts +6 -1
  4. package/dist/components/rel-calendar/rel-calendar.ts +64 -18
  5. package/dist/components/rel-carousel/rel-carousel.ts +25 -2
  6. package/dist/components/rel-chip/rel-chip.ts +31 -20
  7. package/dist/components/rel-date-picker/date-parse.test.ts +116 -0
  8. package/dist/components/rel-date-picker/date-parse.ts +178 -0
  9. package/dist/components/rel-date-picker/rel-date-picker.ts +529 -90
  10. package/dist/components/rel-drawer/rel-drawer.ts +6 -3
  11. package/dist/components/rel-menu/rel-menu.ts +6 -0
  12. package/dist/components/rel-otp/rel-otp.ts +12 -2
  13. package/dist/components/rel-pagination/rel-pagination.ts +13 -0
  14. package/dist/components/rel-popover/rel-popover.ts +33 -2
  15. package/dist/components/rel-progress/rel-progress.ts +13 -4
  16. package/dist/components/rel-radio/README.md +6 -3
  17. package/dist/components/rel-radio/rel-radio.ts +236 -60
  18. package/dist/components/rel-range-picker/rel-range-picker.ts +452 -95
  19. package/dist/components/rel-segmented-button/rel-segmented-button.ts +5 -1
  20. package/dist/components/rel-select/rel-select.ts +44 -13
  21. package/dist/components/rel-sheet/rel-sheet.ts +163 -18
  22. package/dist/components/rel-sidebar/rel-sidebar.ts +7 -1
  23. package/dist/components/rel-skeleton/rel-skeleton.ts +9 -0
  24. package/dist/components/rel-slider/rel-slider.ts +6 -3
  25. package/dist/components/rel-snackbar/rel-snackbar.ts +20 -16
  26. package/dist/components/rel-stepper/rel-stepper.ts +3 -1
  27. package/dist/components/rel-switch/rel-switch.ts +3 -1
  28. package/dist/components/rel-textfield/rel-textfield.ts +263 -75
  29. package/dist/components/rel-time-picker/rel-time-picker.ts +450 -94
  30. package/dist/components/rel-time-picker/time-parse.test.ts +67 -0
  31. package/dist/components/rel-time-picker/time-parse.ts +85 -0
  32. package/dist/components/rel-toast/rel-toast.ts +4 -2
  33. package/dist/components/rel-tooltip/README.md +13 -5
  34. package/dist/components/rel-tooltip/rel-tooltip.ts +3 -0
  35. package/dist/components/rel-upload/rel-upload.ts +25 -0
  36. package/dist/index.d.mts +241 -11
  37. package/dist/index.d.ts +241 -11
  38. package/dist/index.js +2573 -598
  39. package/dist/index.js.map +1 -1
  40. package/dist/index.mjs +2614 -640
  41. package/dist/index.mjs.map +1 -1
  42. 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 = "";
@@ -52,8 +57,12 @@ export class RelRangePicker extends LitElement {
52
57
  @state() private hoverDate: string | null = null;
53
58
  /** ISO date of the cell that owns the grid's single Tab stop (roving tabindex). */
54
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;
55
64
 
56
- @query(".trigger-button") private triggerButton?: HTMLButtonElement;
65
+ @query(".trigger-input") private triggerInput?: HTMLInputElement;
57
66
  @query(".popover") private popoverEl?: HTMLDivElement;
58
67
  @query(".backdrop") private backdropEl?: HTMLDivElement;
59
68
 
@@ -97,22 +106,81 @@ export class RelRangePicker extends LitElement {
97
106
  letter-spacing: var(--rel-body-md-regular-letter-spacing);
98
107
  }
99
108
 
100
- .trigger-button:hover:not(:disabled) {
109
+ :host(:not([disabled])) .trigger-button:hover {
101
110
  border-color: var(--rel-border-accent);
102
111
  }
103
112
 
104
- .trigger-button:focus-visible {
113
+ .trigger-button:focus-within {
105
114
  outline: 2px solid var(--rel-border-focus);
106
115
  outline-offset: 2px;
107
116
  }
108
117
 
109
- .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 {
110
128
  flex: 1;
129
+ min-width: 0;
130
+ border: none;
131
+ background: transparent;
132
+ padding: 0;
133
+ margin: 0;
134
+ outline: none;
111
135
  color: var(--rel-text-default);
136
+ font: inherit;
137
+ letter-spacing: inherit;
138
+ cursor: inherit;
112
139
  }
113
140
 
114
- .trigger-content.placeholder {
141
+ .trigger-input::placeholder {
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;
115
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;
116
184
  }
117
185
 
118
186
  /* ── Popover shell ── */
@@ -136,7 +204,13 @@ export class RelRangePicker extends LitElement {
136
204
  border-radius: var(--rel-border-radius-sm, 8px);
137
205
  background: var(--rel-bg-neutral);
138
206
  box-shadow: var(--rel-shadows-shadow-200);
139
- 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;
140
214
  display: none;
141
215
  }
142
216
 
@@ -227,6 +301,28 @@ export class RelRangePicker extends LitElement {
227
301
  grid-column: 1 / -1;
228
302
  }
229
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
+
230
326
  /* ── FROM/TO inputs ── */
231
327
  .date-inputs {
232
328
  display: flex;
@@ -340,11 +436,23 @@ export class RelRangePicker extends LitElement {
340
436
  }
341
437
 
342
438
  /* ── Calendar grid ── */
439
+ .calendar-table {
440
+ display: flex;
441
+ flex-direction: column;
442
+ gap: 6px;
443
+ }
444
+
343
445
  .calendar-grid {
344
446
  display: grid;
345
447
  grid-template-columns: repeat(7, 1fr);
346
448
  }
347
449
 
450
+ .calendar-week {
451
+ grid-column: 1 / -1;
452
+ display: grid;
453
+ grid-template-columns: repeat(7, 1fr);
454
+ }
455
+
348
456
  .day-cell {
349
457
  height: 36px;
350
458
  position: relative;
@@ -458,7 +566,7 @@ export class RelRangePicker extends LitElement {
458
566
  }
459
567
 
460
568
  @media (prefers-reduced-motion: reduce) {
461
- .preset-item, .nav-btn, .day-inner {
569
+ .preset-item, .nav-btn, .day-inner, .icon-button {
462
570
  transition: none;
463
571
  }
464
572
  }
@@ -565,8 +673,9 @@ export class RelRangePicker extends LitElement {
565
673
  }
566
674
  let left = t.left;
567
675
  if (left + c.width > window.innerWidth - 8) {
568
- left = Math.max(8, window.innerWidth - c.width - 8);
676
+ left = window.innerWidth - c.width - 8;
569
677
  }
678
+ left = Math.max(8, left);
570
679
 
571
680
  // Without top-layer support a transformed ancestor may be the containing
572
681
  // block for position:fixed, shifting the shell away from the viewport
@@ -596,11 +705,15 @@ export class RelRangePicker extends LitElement {
596
705
 
597
706
  if (e.key === "Escape") {
598
707
  this.cancelPopover();
599
- this.triggerButton?.focus();
708
+ this.triggerInput?.focus();
600
709
  return;
601
710
  }
602
711
 
603
- 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;
604
717
 
605
718
  const key = e.key.toUpperCase();
606
719
  const keyMap: Record<string, string> = {
@@ -677,27 +790,30 @@ export class RelRangePicker extends LitElement {
677
790
  }
678
791
 
679
792
  private applySelection() {
680
- if (this.draftFrom || this.draftTo) {
681
- this.from = this.draftFrom;
682
- this.to = this.draftTo;
683
- this.dispatchEvent(
684
- new CustomEvent("rel-change", {
685
- detail: { from: this.from, to: this.to, label: this.displayLabel },
686
- bubbles: true,
687
- composed: true,
688
- })
689
- );
690
- this.open = false;
691
- this.isSelectingRange = false;
692
- this.hoverDate = null;
693
- this.dispatchEvent(
694
- new CustomEvent("rel-open-change", {
695
- detail: { open: false },
696
- bubbles: true,
697
- composed: true,
698
- })
699
- );
700
- }
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
+ );
701
817
  }
702
818
 
703
819
  private resetDraft() {
@@ -710,6 +826,162 @@ export class RelRangePicker extends LitElement {
710
826
  }
711
827
  }
712
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
+
713
985
  private getTriggerLabel(): string {
714
986
  const from = this.from ? this.formatDisplayDate(this.from) : null;
715
987
  const to = this.to ? this.formatDisplayDate(this.to) : null;
@@ -832,6 +1104,7 @@ export class RelRangePicker extends LitElement {
832
1104
  } else {
833
1105
  this.viewMonth--;
834
1106
  }
1107
+ announce(`${this.getMonthName()} ${this.viewYear}`);
835
1108
  }
836
1109
 
837
1110
  private nextMonth() {
@@ -841,6 +1114,7 @@ export class RelRangePicker extends LitElement {
841
1114
  } else {
842
1115
  this.viewMonth++;
843
1116
  }
1117
+ announce(`${this.getMonthName()} ${this.viewYear}`);
844
1118
  }
845
1119
 
846
1120
  private getMonthName(): string {
@@ -849,6 +1123,23 @@ export class RelRangePicker extends LitElement {
849
1123
  );
850
1124
  }
851
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
+
852
1143
  private getWeekdayLabels(): string[] {
853
1144
  const formatter = new Intl.DateTimeFormat("en-US", { weekday: "short" });
854
1145
  const base = new Date(2024, 0, 7); // Sunday Jan 7 2024
@@ -965,19 +1256,23 @@ export class RelRangePicker extends LitElement {
965
1256
  this.draftActivePreset = null;
966
1257
  this.isSelectingRange = true;
967
1258
  this.hoverDate = null;
1259
+ announce(`From date ${this.formatDisplayDate(iso)}`);
968
1260
  } else {
969
1261
  const fromDate = this.parseISODate(this.draftFrom);
970
1262
  if (!fromDate) {
971
1263
  this.draftFrom = iso;
972
1264
  this.draftTo = "";
1265
+ announce(`From date ${this.formatDisplayDate(iso)}`);
973
1266
  return;
974
1267
  }
975
1268
 
976
1269
  if (day.date.getTime() >= fromDate.getTime()) {
977
1270
  this.draftTo = iso;
1271
+ announce(`To date ${this.formatDisplayDate(iso)}`);
978
1272
  } else {
979
1273
  this.draftTo = this.draftFrom;
980
1274
  this.draftFrom = iso;
1275
+ announce(`From date ${this.formatDisplayDate(iso)}`);
981
1276
  }
982
1277
 
983
1278
  this.isSelectingRange = false;
@@ -1116,6 +1411,10 @@ export class RelRangePicker extends LitElement {
1116
1411
  const days = this.getCalendarDays();
1117
1412
  const rovingIso = this.getRovingIso(days);
1118
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
+ }
1119
1418
 
1120
1419
  const draftFromDisplay = this.draftFrom ? this.formatDisplayDate(this.draftFrom) : "";
1121
1420
  const draftToDisplay = this.draftTo ? this.formatDisplayDate(this.draftTo) : "";
@@ -1139,22 +1438,71 @@ export class RelRangePicker extends LitElement {
1139
1438
  </rel-button>
1140
1439
  `
1141
1440
  : html`
1142
- <button
1143
- part="trigger" class="trigger-button"
1144
- type="button"
1145
- ?disabled=${this.disabled}
1146
- aria-haspopup="dialog"
1147
- aria-expanded=${this.open}
1148
- aria-label=${triggerLabel || this.placeholder}
1149
- @click=${this.toggleOpen}
1441
+ <div
1442
+ part="trigger"
1443
+ class="trigger-button ${this.inputError ? "error" : ""}"
1444
+ @click=${this.handleFieldClick}
1150
1445
  >
1151
- <span class="trigger-content ${triggerLabel ? "" : "placeholder"}">
1152
- ${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>
1153
1499
  </span>
1154
- <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
1155
- <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"/>
1156
- </svg>
1157
- </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}
1158
1506
  `}
1159
1507
 
1160
1508
  ${isPopup && this.open
@@ -1163,6 +1511,7 @@ export class RelRangePicker extends LitElement {
1163
1511
 
1164
1512
  <div
1165
1513
  part="panel" class="popover ${this.open ? "open" : ""} ${isPopup ? "popup" : ""}"
1514
+ id="range-picker-panel"
1166
1515
  popover="manual"
1167
1516
  role="dialog"
1168
1517
  aria-label="Select date range"
@@ -1171,18 +1520,20 @@ export class RelRangePicker extends LitElement {
1171
1520
 
1172
1521
  <!-- ── Left: Quick select ── -->
1173
1522
  <div part="list" class="presets">
1174
- <div class="presets-grid" role="list" aria-label="Quick date selections">
1523
+ <div class="presets-grid" role="group" aria-label="Quick date selections">
1175
1524
  ${quickSelects.map(
1176
1525
  (qs) => html`
1177
1526
  <button
1178
1527
  part="option" class="preset-item ${qs.fullWidth ? "full-width" : ""} ${this.draftActivePreset === qs.label ? "active" : ""}"
1179
1528
  type="button"
1180
- role="listitem"
1181
1529
  ?disabled=${this.disabled}
1182
1530
  aria-pressed=${this.draftActivePreset === qs.label}
1183
1531
  @click=${() => this.selectQuickSelect(qs)}
1184
1532
  >
1185
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}
1186
1537
  ${this.draftActivePreset === qs.label
1187
1538
  ? html`<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
1188
1539
  <circle cx="12" cy="12" r="12" fill="var(--rel-bg-brand)"/>
@@ -1234,54 +1585,60 @@ export class RelRangePicker extends LitElement {
1234
1585
  </button>
1235
1586
  </div>
1236
1587
 
1237
- <div class="week-header" role="row">
1238
- ${weekdayLabels.map(
1239
- (label) => html`
1240
- <div class="week-day" role="columnheader" aria-label="${label}">
1241
- ${label}
1242
- </div>
1243
- `
1244
- )}
1245
- </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>
1246
1598
 
1247
- <div
1248
- part="list" class="calendar-grid"
1249
- role="grid"
1250
- aria-label="Select date range"
1251
- @mouseleave=${() => { this.hoverDate = null; }}
1252
- @keydown=${this.handleGridKeydown}
1253
- >
1254
- ${days.map((day) => {
1255
- const iso = this.formatISODate(day.date);
1256
- const cellClasses = [
1257
- "day-cell",
1258
- day.isInRange ? "in-range" : "",
1259
- day.isDraftFrom ? "range-start" : "",
1260
- day.isDraftTo ? "range-end" : "",
1261
- day.isToday ? "today" : "",
1262
- day.isDisabled ? "disabled" : "",
1263
- !day.isCurrentMonth ? "other-month" : "",
1264
- day.isWeekend ? "weekend" : "",
1265
- ]
1266
- .filter(Boolean)
1267
- .join(" ");
1268
-
1269
- return html`
1270
- <div
1271
- class="${cellClasses}"
1272
- role="gridcell"
1273
- tabindex="${day.isCurrentMonth && iso === rovingIso ? 0 : -1}"
1274
- data-iso="${iso}"
1275
- aria-label="${iso}"
1276
- aria-selected="${day.isDraftFrom || day.isDraftTo || day.isInRange}"
1277
- aria-disabled="${day.isDisabled ? "true" : nothing}"
1278
- @click=${() => this.handleDayClick(day)}
1279
- @mouseenter=${() => this.handleDayHover(day)}
1280
- >
1281
- <span class="day-inner">${day.day}</span>
1282
- </div>
1283
- `;
1284
- })}
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>
1285
1642
  </div>
1286
1643
 
1287
1644
  <div part="footer" class="calendar-footer">
@@ -1308,7 +1665,7 @@ export class RelRangePicker extends LitElement {
1308
1665
  tone="default"
1309
1666
  size="small"
1310
1667
  border="corner-radius"
1311
- ?disabled=${isDraftEmpty}
1668
+ ?disabled=${isDraftEmpty && !this.from && !this.to}
1312
1669
  @click=${() => this.applySelection()}
1313
1670
  >
1314
1671
  Apply