@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,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 "@reliability-design/icons/rel-icon";
4
5
  import "../rel-button/rel-button";
6
+ import { parseTimeInput } from "./time-parse";
7
+ import { announce } from "../../utils/announce";
5
8
 
6
9
  type TimeColumn = "hour" | "minute" | "second" | "period";
7
10
 
@@ -62,10 +65,21 @@ export class RelTimePicker extends LitElement {
62
65
  @state() private draftHour: number | null = null;
63
66
  @state() private draftMinute: number | null = null;
64
67
  @state() private draftSecond: number | null = null;
65
- /** Panel opens above the field when there is no viewport space below. */
66
- @state() private openUp = false;
68
+ /** Uncommitted typed text; null → the input shows the committed value. */
69
+ @state() private inputText: string | null = null;
70
+ /** Parse/range error message for the typed text (cleared on next input). */
71
+ @state() private inputError: string | null = null;
67
72
 
68
- @query(".trigger-button") private triggerButton?: HTMLButtonElement;
73
+ /**
74
+ * Whether the next popover open should move focus into the columns.
75
+ * Icon-click / ArrowDown / external `open` do (previous behavior); opening
76
+ * by clicking into the text input must NOT, or typing would be impossible.
77
+ */
78
+ private focusPanelOnOpen = true;
79
+
80
+ @query(".trigger-input") private triggerInput?: HTMLInputElement;
81
+ @query(".trigger-button") private triggerButton?: HTMLElement;
82
+ @query(".popover") private popoverElement?: HTMLElement;
69
83
  @query(".columns") private columnsElement?: HTMLElement;
70
84
 
71
85
  static styles = css`
@@ -129,6 +143,11 @@ export class RelTimePicker extends LitElement {
129
143
  color: var(--rel-text-danger);
130
144
  }
131
145
 
146
+ /* Persistent live region — out of flow while empty so it adds no gap. */
147
+ .time-picker-support.empty {
148
+ position: absolute;
149
+ }
150
+
132
151
  .trigger-button {
133
152
  width: 100%;
134
153
  min-width: 160px;
@@ -151,28 +170,48 @@ export class RelTimePicker extends LitElement {
151
170
  letter-spacing: var(--rel-body-md-regular-letter-spacing);
152
171
  }
153
172
 
154
- .trigger-button:hover:not(:disabled) {
173
+ :host(:not([disabled])) .trigger-button:hover {
155
174
  border-color: var(--rel-border-strong);
156
175
  }
157
176
 
158
- .trigger-button:focus-visible {
177
+ .trigger-button:focus-within {
159
178
  outline: 2px solid var(--rel-border-focus);
160
179
  outline-offset: 2px;
161
180
  border-color: var(--rel-border-focus);
162
181
  }
163
182
 
164
- .trigger-button:disabled {
183
+ :host([disabled]) .trigger-button {
165
184
  cursor: not-allowed;
166
185
  background: var(--rel-bg-surface-elevated);
167
186
  }
168
187
 
188
+ /* The editable field itself — the wrapper carries the box styles. */
189
+ .trigger-input {
190
+ flex: 1;
191
+ min-width: 0;
192
+ border: none;
193
+ background: transparent;
194
+ padding: 0;
195
+ margin: 0;
196
+ outline: none;
197
+ color: var(--rel-text-default);
198
+ font: inherit;
199
+ letter-spacing: inherit;
200
+ cursor: inherit;
201
+ }
202
+
203
+ .trigger-input::placeholder {
204
+ color: var(--rel-text-placeholder);
205
+ opacity: 1;
206
+ }
207
+
169
208
  /* Error state */
170
209
  .trigger-button.error,
171
- .trigger-button.error:hover:not(:disabled) {
210
+ :host(:not([disabled])) .trigger-button.error:hover {
172
211
  border-color: var(--rel-border-danger);
173
212
  }
174
213
 
175
- .trigger-button.error:focus-visible {
214
+ .trigger-button.error:focus-within {
176
215
  outline-color: var(--rel-border-danger);
177
216
  border-color: var(--rel-border-danger);
178
217
  }
@@ -196,15 +235,6 @@ export class RelTimePicker extends LitElement {
196
235
  letter-spacing: var(--rel-body-lg-regular-letter-spacing);
197
236
  }
198
237
 
199
- .trigger-content {
200
- flex: 1;
201
- color: var(--rel-text-default);
202
- }
203
-
204
- .trigger-content.placeholder {
205
- color: var(--rel-text-placeholder);
206
- }
207
-
208
238
  .trigger-icons {
209
239
  display: flex;
210
240
  align-items: center;
@@ -237,17 +267,34 @@ export class RelTimePicker extends LitElement {
237
267
 
238
268
  /* Panel (Figma "Time Picker"): fixed width per size, full-bleed content. */
239
269
  .popover {
270
+ /* Reset native [popover] UA styles first (the panel is promoted to
271
+ the top layer so a drawer/sheet/modal ancestor — transformed,
272
+ overflow:hidden, or an equal-z-index sibling panel — can't clip it
273
+ or paint over it). While in the top layer, JS overrides the
274
+ anchored position below with inline fixed coordinates; the CSS
275
+ anchoring is the no-Popover-API fallback. */
276
+ margin: 0;
277
+ padding: 0;
278
+ height: auto;
279
+ inset: auto;
280
+ color: inherit;
240
281
  position: absolute;
241
282
  top: calc(100% + 4px);
242
283
  left: 0;
243
- z-index: 1000;
284
+ z-index: 2147483647;
244
285
  background: var(--rel-bg-surface);
245
286
  border: 1px solid var(--rel-border-subtle);
246
287
  border-radius: var(--rel-border-radius-sm);
247
288
  box-shadow: var(--rel-shadows-shadow-300);
248
- overflow: hidden;
249
- min-width: 220px;
289
+ /* Never wider or taller than the viewport (small screens). border-box
290
+ so the Figma widths and viewport caps include the 1px borders. */
291
+ box-sizing: border-box;
292
+ overflow-x: hidden;
293
+ overflow-y: auto;
294
+ min-width: min(220px, calc(100vw - 16px));
250
295
  width: 250px;
296
+ max-width: calc(100vw - 16px);
297
+ max-height: calc(100vh - 16px);
251
298
  display: none;
252
299
  }
253
300
 
@@ -255,10 +302,53 @@ export class RelTimePicker extends LitElement {
255
302
  display: block;
256
303
  }
257
304
 
258
- /* Flip above the field when the viewport has no room below. */
259
- .popover.open-up {
260
- top: auto;
261
- bottom: calc(100% + 4px);
305
+ /* Typed-shortcut hints ("N → Now"); each chip applies its value. */
306
+ .shortcut-row {
307
+ display: flex;
308
+ flex-wrap: wrap;
309
+ gap: var(--rel-spacing-2xs);
310
+ padding: var(--rel-spacing-2xs) var(--rel-spacing-xs);
311
+ border-bottom: 1px solid var(--rel-border-subtle);
312
+ }
313
+
314
+ .shortcut-chip {
315
+ display: inline-flex;
316
+ align-items: center;
317
+ gap: var(--rel-spacing-2xs);
318
+ border: none;
319
+ background: transparent;
320
+ border-radius: var(--rel-border-radius-xs);
321
+ padding: var(--rel-spacing-3xs) var(--rel-spacing-2xs);
322
+ cursor: pointer;
323
+ color: var(--rel-text-default);
324
+ transition: background 0.15s ease;
325
+ font-family: var(--rel-body-xs-regular-font-family);
326
+ font-weight: var(--rel-body-xs-regular-font-weight);
327
+ font-size: var(--rel-body-xs-regular-font-size);
328
+ line-height: var(--rel-body-xs-regular-line-height);
329
+ letter-spacing: var(--rel-body-xs-regular-letter-spacing);
330
+ }
331
+
332
+ .shortcut-chip:hover {
333
+ background: var(--rel-bg-neutral-elevated-1);
334
+ }
335
+
336
+ .shortcut-chip:focus-visible {
337
+ outline: 2px solid var(--rel-border-focus);
338
+ outline-offset: -2px;
339
+ }
340
+
341
+ .shortcut-chip kbd {
342
+ border: 1px solid var(--rel-border-default);
343
+ border-bottom-width: 2px;
344
+ border-radius: var(--rel-border-radius-xs);
345
+ background: var(--rel-bg-surface-elevated);
346
+ padding: 0 var(--rel-spacing-3xs);
347
+ min-width: 18px;
348
+ text-align: center;
349
+ font-family: inherit;
350
+ font-size: inherit;
351
+ color: var(--rel-text-label);
262
352
  }
263
353
 
264
354
  /* Full-bleed column row with 1px dividers (Figma "Columns"). */
@@ -439,13 +529,17 @@ export class RelTimePicker extends LitElement {
439
529
  super.disconnectedCallback();
440
530
  document.removeEventListener("click", this.handleClickOutside);
441
531
  document.removeEventListener("keydown", this.handleEscapeKey);
532
+ window.removeEventListener("resize", this.onReposition);
533
+ window.removeEventListener("scroll", this.onReposition, true);
534
+ this.hideTopLayer();
442
535
  }
443
536
 
444
537
  private handleClickOutside = (e: MouseEvent) => {
445
538
  if (!this.open) return;
446
539
  const path = e.composedPath();
447
540
  if (!path.includes(this)) {
448
- this.cancelPopover();
541
+ // Don't refocus the field — the user clicked somewhere else on purpose.
542
+ this.cancelPopover(false);
449
543
  }
450
544
  };
451
545
 
@@ -455,39 +549,131 @@ export class RelTimePicker extends LitElement {
455
549
  }
456
550
  };
457
551
 
552
+ // `open` is a public reflected property (consumers may set it directly,
553
+ // not only via the trigger), so top-layer promotion syncs here rather
554
+ // than inside toggle/cancel.
458
555
  updated(changedProperties: Map<string, any>) {
459
556
  if (changedProperties.has("open")) {
460
557
  if (!this.open) {
461
- this.openUp = false;
558
+ window.removeEventListener("resize", this.onReposition);
559
+ window.removeEventListener("scroll", this.onReposition, true);
560
+ this.hideTopLayer();
561
+ this.focusPanelOnOpen = true;
462
562
  return;
463
563
  }
464
564
  this.resetDraftFromValue();
465
- // Once the popover has rendered: flip it above the field if the
466
- // viewport lacks room below, center each column's selection, and focus
467
- // the selected (or first enabled) hour without scrolling the page.
565
+ this.showTopLayer();
566
+ window.addEventListener("resize", this.onReposition);
567
+ window.addEventListener("scroll", this.onReposition, true);
568
+ // Once the popover has rendered: center each column's selection, and —
569
+ // unless the open came from clicking into the text input (typing must
570
+ // keep working) — focus the selected hour without scrolling the page.
571
+ const focusPanel = this.focusPanelOnOpen;
572
+ this.focusPanelOnOpen = true;
468
573
  setTimeout(() => {
469
- this.updatePanelPlacement();
470
574
  this.scrollColumnsToSelection();
471
- const target =
472
- this.columnsElement?.querySelector<HTMLButtonElement>(
473
- ".option-button.selected:not(:disabled)"
474
- ) ??
475
- this.columnsElement?.querySelector<HTMLButtonElement>(
476
- ".option-button:not(:disabled)"
477
- );
478
- target?.focus({ preventScroll: true });
575
+ if (focusPanel) this.focusPanelSelection();
479
576
  }, 0);
480
577
  }
481
578
  }
482
579
 
483
- /** Open upward when the panel doesn't fit below but does fit above. */
484
- private updatePanelPlacement() {
485
- const panel = this.renderRoot.querySelector<HTMLElement>(".popover");
486
- if (!panel) return;
487
- const rect = this.getBoundingClientRect();
488
- const panelHeight = panel.offsetHeight;
489
- const spaceBelow = window.innerHeight - rect.bottom;
490
- this.openUp = spaceBelow < panelHeight + 8 && rect.top > panelHeight + 8;
580
+ /* A drawer/sheet/modal ancestor breaks the anchored panel two ways: a
581
+ * transform makes it the containing block for position:fixed descendants
582
+ * (and overflow:hidden clips absolute ones), and an equal-z-index panel
583
+ * later in the DOM paints over the columns. Promoting the panel to the
584
+ * browser top layer sidesteps both — same fix as rel-popover /
585
+ * rel-autocomplete / rel-select / rel-range-picker / rel-date-picker.
586
+ * `manual` keeps outside-click and Escape handling under this component's
587
+ * own control. While in the top layer the CSS anchoring is meaningless, so
588
+ * the panel gets inline fixed coordinates from updatePopoverPosition()
589
+ * (which also replaces the old .open-up CSS flip). */
590
+
591
+ private showTopLayer() {
592
+ const el = this.popoverElement;
593
+ if (el && typeof el.showPopover === "function") {
594
+ try {
595
+ if (!el.matches(":popover-open")) el.showPopover();
596
+ } catch {
597
+ /* older engines / detached element — CSS fallback applies */
598
+ }
599
+ }
600
+ requestAnimationFrame(() => this.updatePopoverPosition());
601
+ }
602
+
603
+ private hideTopLayer() {
604
+ const el = this.popoverElement;
605
+ if (!el) return;
606
+ if (typeof el.hidePopover === "function") {
607
+ try {
608
+ if (el.matches(":popover-open")) el.hidePopover();
609
+ } catch {
610
+ /* already hidden or unsupported — nothing to do */
611
+ }
612
+ }
613
+ el.style.position = "";
614
+ el.style.top = "";
615
+ el.style.left = "";
616
+ el.style.maxHeight = "";
617
+ }
618
+
619
+ private onReposition = () => {
620
+ if (this.open) this.updatePopoverPosition();
621
+ };
622
+
623
+ private updatePopoverPosition = () => {
624
+ const panel = this.popoverElement;
625
+ const anchor = this.triggerButton;
626
+ if (!panel || !anchor || !this.open) return;
627
+
628
+ const t = anchor.getBoundingClientRect();
629
+ // Measure the natural height, clearing any cap from a previous pass.
630
+ panel.style.maxHeight = "";
631
+ const c = panel.getBoundingClientRect();
632
+
633
+ // Below the trigger; flip above when there's no room below. Either side
634
+ // keeps the same 4px gap to the field (above, the panel may cover the
635
+ // label) and the panel shrinks to the available space — columns scroll —
636
+ // rather than sliding over the trigger. 8px viewport margin on every edge.
637
+ const spaceBelow = Math.max(0, window.innerHeight - 8 - (t.bottom + 4));
638
+ const spaceAbove = Math.max(0, t.top - 4 - 8);
639
+ let top: number;
640
+ if (c.height <= spaceBelow || spaceBelow >= spaceAbove) {
641
+ panel.style.maxHeight = `${Math.min(c.height, spaceBelow)}px`;
642
+ top = t.bottom + 4;
643
+ } else {
644
+ const height = Math.min(c.height, spaceAbove);
645
+ panel.style.maxHeight = `${height}px`;
646
+ top = t.top - 4 - height;
647
+ }
648
+ let left = t.left;
649
+ if (left + c.width > window.innerWidth - 8) {
650
+ left = window.innerWidth - c.width - 8;
651
+ }
652
+ left = Math.max(8, left);
653
+
654
+ // Without top-layer support a transformed ancestor may be the containing
655
+ // block for position:fixed, shifting the panel away from the viewport
656
+ // coordinates computed above. Apply, re-measure, and cancel the offset.
657
+ panel.style.position = "fixed";
658
+ panel.style.top = `${top}px`;
659
+ panel.style.left = `${left}px`;
660
+ const applied = panel.getBoundingClientRect();
661
+ top -= applied.top - top;
662
+ left -= applied.left - left;
663
+ panel.style.top = `${top}px`;
664
+ panel.style.left = `${left}px`;
665
+ };
666
+
667
+ /** Focus the selected (or first enabled) option in the columns. */
668
+ private focusPanelSelection() {
669
+ const target =
670
+ this.columnsElement?.querySelector<HTMLButtonElement>(
671
+ ".option-button.selected:not(:disabled)"
672
+ ) ??
673
+ this.columnsElement?.querySelector<HTMLButtonElement>(
674
+ ".option-button:not(:disabled)"
675
+ );
676
+ target?.focus({ preventScroll: true });
491
677
  }
492
678
 
493
679
  // ── Parsing / formatting ──
@@ -637,9 +823,9 @@ export class RelTimePicker extends LitElement {
637
823
  }
638
824
 
639
825
  /** Close discarding the draft (Cancel / Escape / outside click). */
640
- private cancelPopover() {
826
+ private cancelPopover(refocus = true) {
641
827
  this.open = false;
642
- this.triggerButton?.focus();
828
+ if (refocus) this.triggerInput?.focus();
643
829
  this.dispatchEvent(
644
830
  new CustomEvent("rel-open-change", {
645
831
  detail: { open: false },
@@ -651,12 +837,20 @@ export class RelTimePicker extends LitElement {
651
837
 
652
838
  private applySelection() {
653
839
  if (this.draftHour === null) return;
654
- const newValue = this.formatValue(
655
- this.draftHour,
656
- this.draftMinute ?? 0,
657
- this.draftSecond ?? 0
658
- );
840
+ const hour = this.draftHour;
841
+ const minute = this.draftMinute ?? 0;
842
+ const second = this.draftSecond ?? 0;
843
+ this.commitTime(hour, minute, second);
844
+ announce(`${this.formatDisplay(hour, minute, second)} selected`);
845
+ this.cancelPopover();
846
+ }
847
+
848
+ /** Commit a time: set value, reset typed text/error, fire rel-change. */
849
+ private commitTime(hour: number, minute: number, second: number) {
850
+ const newValue = this.formatValue(hour, minute, second);
659
851
  this.value = newValue;
852
+ this.inputText = null;
853
+ this.inputError = null;
660
854
  this.dispatchEvent(
661
855
  new CustomEvent("rel-change", {
662
856
  detail: { value: newValue },
@@ -664,7 +858,6 @@ export class RelTimePicker extends LitElement {
664
858
  composed: true,
665
859
  })
666
860
  );
667
- this.cancelPopover();
668
861
  }
669
862
 
670
863
  /** Set the draft to the current time, snapped to the minute step and range. */
@@ -698,6 +891,8 @@ export class RelTimePicker extends LitElement {
698
891
  this.draftHour = null;
699
892
  this.draftMinute = null;
700
893
  this.draftSecond = null;
894
+ this.inputText = null;
895
+ this.inputError = null;
701
896
  this.dispatchEvent(
702
897
  new CustomEvent("rel-change", {
703
898
  detail: { value: null },
@@ -707,6 +902,73 @@ export class RelTimePicker extends LitElement {
707
902
  );
708
903
  }
709
904
 
905
+ // ── Typed keyboard entry ──
906
+
907
+ /**
908
+ * Parse and commit the typed text (Enter / blur). Invalid or out-of-range
909
+ * text keeps the field in an error state with the text preserved for
910
+ * correction; the committed value is untouched (Escape reverts the text).
911
+ */
912
+ private commitTypedValue() {
913
+ if (this.inputText === null) return; // nothing typed since last commit
914
+ const text = this.inputText.trim();
915
+
916
+ if (!text) {
917
+ // Field cleared by keyboard — behaves like the clear button.
918
+ this.inputText = null;
919
+ this.inputError = null;
920
+ if (this.value !== null) {
921
+ this.value = null;
922
+ this.dispatchEvent(
923
+ new CustomEvent("rel-change", {
924
+ detail: { value: null },
925
+ bubbles: true,
926
+ composed: true,
927
+ })
928
+ );
929
+ }
930
+ if (this.open) this.cancelPopover();
931
+ return;
932
+ }
933
+
934
+ const parsed = parseTimeInput(text);
935
+ if (!parsed) {
936
+ this.inputError = "Invalid time";
937
+ return;
938
+ }
939
+
940
+ if (parsed.kind === "now") {
941
+ this.applyNowShortcut();
942
+ return;
943
+ }
944
+
945
+ if (!this.isTimeInRange(parsed.hour, parsed.minute)) {
946
+ this.inputError = "Time out of range";
947
+ return;
948
+ }
949
+
950
+ // Typed entry accepts exact minutes/seconds (not limited to minuteStep).
951
+ this.commitTime(parsed.hour, parsed.minute, this.seconds ? parsed.second : 0);
952
+ if (this.open) this.cancelPopover();
953
+ }
954
+
955
+ /** "Now", committed directly (N shortcut / popover chip), clamped to range. */
956
+ private applyNowShortcut() {
957
+ const now = new Date();
958
+ let hour = now.getHours();
959
+ let minute = now.getMinutes();
960
+ const total = this.toMinutes(hour, minute);
961
+ if (total < this.minBoundMinutes) {
962
+ hour = Math.floor(this.minBoundMinutes / 60);
963
+ minute = this.minBoundMinutes % 60;
964
+ } else if (total > this.maxBoundMinutes) {
965
+ hour = Math.floor(this.maxBoundMinutes / 60);
966
+ minute = this.maxBoundMinutes % 60;
967
+ }
968
+ this.commitTime(hour, minute, this.seconds ? now.getSeconds() : 0);
969
+ if (this.open) this.cancelPopover();
970
+ }
971
+
710
972
  // ── Column selection ──
711
973
 
712
974
  private selectHour(hour24: number) {
@@ -796,15 +1058,82 @@ export class RelTimePicker extends LitElement {
796
1058
 
797
1059
  // ── Keyboard ──
798
1060
 
799
- private handleTriggerKeydown(e: KeyboardEvent) {
800
- if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
1061
+ private handleInputInput(e: Event) {
1062
+ this.inputText = (e.target as HTMLInputElement).value;
1063
+ if (this.inputError) this.inputError = null;
1064
+ }
1065
+
1066
+ private handleInputKeydown(e: KeyboardEvent) {
1067
+ if (e.key === "Enter") {
1068
+ e.preventDefault();
1069
+ if (this.inputText === null) {
1070
+ // Nothing typed — Enter toggles the popover (previous trigger behavior).
1071
+ if (!this.open) {
1072
+ this.toggleTimePopover();
1073
+ } else {
1074
+ this.applySelection();
1075
+ }
1076
+ } else {
1077
+ this.commitTypedValue();
1078
+ }
1079
+ return;
1080
+ }
1081
+ if (e.key === "ArrowDown") {
801
1082
  e.preventDefault();
802
1083
  if (!this.open) {
803
1084
  this.toggleTimePopover();
1085
+ } else {
1086
+ this.focusPanelSelection();
804
1087
  }
1088
+ return;
1089
+ }
1090
+ if (e.key === "Escape" && this.inputText !== null) {
1091
+ // Revert the typed text; the document listener closes the popover.
1092
+ this.inputText = null;
1093
+ this.inputError = null;
805
1094
  }
806
1095
  }
807
1096
 
1097
+ /** Clicking anywhere on the field opens the popover, keeping focus typed-in. */
1098
+ private handleFieldClick() {
1099
+ if (this.open || this.disabled || this.readonly) return;
1100
+ this.focusPanelOnOpen = false;
1101
+ this.toggleTimePopover();
1102
+ }
1103
+
1104
+ /** The clock icon toggles the popover with the previous focus behavior. */
1105
+ private handleIconClick(e: Event) {
1106
+ e.stopPropagation();
1107
+ if (this.open) {
1108
+ this.cancelPopover();
1109
+ } else {
1110
+ this.toggleTimePopover();
1111
+ }
1112
+ }
1113
+
1114
+ /** Commit typed text when focus leaves the component entirely. */
1115
+ private handleInputBlur = (e: FocusEvent) => {
1116
+ this.dispatchEvent(new CustomEvent("rel-blur", { bubbles: true, composed: true }));
1117
+ const related = e.relatedTarget as Node | null;
1118
+ if (related && (this.renderRoot.contains(related) || this.contains(related))) {
1119
+ return; // focus moved into the panel/icons — their own handlers commit
1120
+ }
1121
+ this.commitTypedValue();
1122
+ };
1123
+
1124
+ /** N inside the panel (columns/footer focus) applies Now directly. */
1125
+ private handlePanelKeydown = (e: KeyboardEvent) => {
1126
+ if (
1127
+ (e.key === "n" || e.key === "N") &&
1128
+ !e.ctrlKey &&
1129
+ !e.metaKey &&
1130
+ !e.altKey
1131
+ ) {
1132
+ e.preventDefault();
1133
+ this.applyNowShortcut();
1134
+ }
1135
+ };
1136
+
808
1137
  /**
809
1138
  * Column navigation: Up/Down move within the column (and select), Left/Right
810
1139
  * jump between columns, Home/End go to the first/last option, Enter applies.
@@ -877,8 +1206,10 @@ export class RelTimePicker extends LitElement {
877
1206
  // ── Render ──
878
1207
 
879
1208
  render() {
880
- const isError = this.error || this.invalid;
881
- const belowText = isError && this.errorMessage ? this.errorMessage : this.helperText;
1209
+ const isError = this.error || this.invalid || this.inputError !== null;
1210
+ const belowText = isError
1211
+ ? this.errorMessage ?? this.inputError ?? this.helperText
1212
+ : this.helperText;
882
1213
  const describedById = belowText ? "time-picker-desc" : undefined;
883
1214
  const parsedValue = this.parseTime(this.value);
884
1215
  const displayText = parsedValue
@@ -911,33 +1242,39 @@ export class RelTimePicker extends LitElement {
911
1242
  ? html`<input type="hidden" name=${this.name} .value=${this.value ?? ""} />`
912
1243
  : nothing}
913
1244
 
914
- <button
1245
+ <div
915
1246
  part="trigger"
916
1247
  class="trigger-button ${isError ? "error" : ""}"
917
- type="button"
918
- ?disabled=${this.disabled}
919
- @click=${this.toggleTimePopover}
920
- @keydown=${this.handleTriggerKeydown}
921
- @focus=${() =>
922
- this.dispatchEvent(
923
- new CustomEvent("rel-focus", { bubbles: true, composed: true })
924
- )}
925
- @blur=${() =>
926
- this.dispatchEvent(
927
- new CustomEvent("rel-blur", { bubbles: true, composed: true })
928
- )}
929
- aria-label=${this.label || "Select time"}
930
- aria-haspopup="dialog"
931
- aria-expanded=${this.open}
932
- aria-required=${this.required}
933
- aria-invalid=${isError}
934
- aria-readonly=${this.readonly}
935
- aria-describedby=${describedById ?? nothing}
936
- aria-labelledby=${this.label ? "time-picker-label" : nothing}
1248
+ @click=${this.handleFieldClick}
937
1249
  >
938
- <span part="value" class="trigger-content ${displayText ? "" : "placeholder"}">
939
- ${displayText || this.placeholder}
940
- </span>
1250
+ <input
1251
+ part="value"
1252
+ class="trigger-input"
1253
+ type="text"
1254
+ .value=${live(this.inputText ?? displayText)}
1255
+ placeholder=${this.placeholder}
1256
+ ?disabled=${this.disabled}
1257
+ ?readonly=${this.readonly}
1258
+ autocomplete="off"
1259
+ spellcheck="false"
1260
+ enterkeyhint="done"
1261
+ @input=${this.handleInputInput}
1262
+ @keydown=${this.handleInputKeydown}
1263
+ @focus=${() =>
1264
+ this.dispatchEvent(
1265
+ new CustomEvent("rel-focus", { bubbles: true, composed: true })
1266
+ )}
1267
+ @blur=${this.handleInputBlur}
1268
+ role="combobox"
1269
+ aria-label=${this.label || "Select time"}
1270
+ aria-haspopup="dialog"
1271
+ aria-expanded=${this.open}
1272
+ aria-controls="time-picker-panel"
1273
+ aria-required=${this.required}
1274
+ aria-invalid=${isError}
1275
+ aria-describedby=${describedById ?? nothing}
1276
+ aria-labelledby=${this.label ? "time-picker-label" : nothing}
1277
+ />
941
1278
  <span class="trigger-icons">
942
1279
  ${this.clearable && this.value
943
1280
  ? html`
@@ -958,16 +1295,38 @@ export class RelTimePicker extends LitElement {
958
1295
  </button>
959
1296
  `
960
1297
  : nothing}
961
- <rel-icon part="icon" name="time-filled" size="sm"></rel-icon>
1298
+ <button
1299
+ class="icon-button"
1300
+ type="button"
1301
+ aria-label="Open time panel"
1302
+ aria-expanded=${this.open}
1303
+ ?disabled=${this.disabled}
1304
+ @click=${this.handleIconClick}
1305
+ >
1306
+ <rel-icon part="icon" name="time-filled" size="sm"></rel-icon>
1307
+ </button>
962
1308
  </span>
963
- </button>
1309
+ </div>
964
1310
 
965
1311
  <div
966
1312
  part="panel"
967
- class="popover ${this.open ? "open" : ""} ${this.openUp ? "open-up" : ""}"
1313
+ id="time-picker-panel"
1314
+ class="popover ${this.open ? "open" : ""}"
1315
+ popover="manual"
968
1316
  role="dialog"
969
1317
  aria-label="Choose time"
1318
+ @keydown=${this.handlePanelKeydown}
970
1319
  >
1320
+ <div part="shortcuts" class="shortcut-row">
1321
+ <button
1322
+ class="shortcut-chip"
1323
+ type="button"
1324
+ title="Type N in the field for the current time"
1325
+ @click=${() => this.applyNowShortcut()}
1326
+ >
1327
+ <kbd aria-hidden="true">N</kbd><span>Now</span>
1328
+ </button>
1329
+ </div>
971
1330
  <div class="columns">
972
1331
  <div class="column-list" role="listbox" aria-label="Hours">
973
1332
  ${hourOptions.map(
@@ -1105,15 +1464,12 @@ export class RelTimePicker extends LitElement {
1105
1464
  </div>
1106
1465
  </div>
1107
1466
 
1108
- ${belowText
1109
- ? html`<div
1110
- part="helper"
1111
- class="time-picker-support ${isError ? "error" : ""}"
1112
- id=${describedById}
1113
- >
1114
- ${belowText}
1115
- </div>`
1116
- : nothing}
1467
+ <div
1468
+ part="helper"
1469
+ class="time-picker-support ${isError ? "error" : ""} ${belowText ? "" : "empty"}"
1470
+ id="time-picker-desc"
1471
+ aria-live="polite"
1472
+ >${belowText ?? nothing}</div>
1117
1473
  </div>
1118
1474
  `;
1119
1475
  }