@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.
- package/dist/components/rel-alert/rel-alert.ts +1 -1
- package/dist/components/rel-autocomplete/rel-autocomplete.ts +33 -2
- package/dist/components/rel-badge/rel-badge.ts +6 -1
- package/dist/components/rel-calendar/rel-calendar.ts +64 -18
- package/dist/components/rel-carousel/rel-carousel.ts +25 -2
- package/dist/components/rel-chip/rel-chip.ts +31 -20
- package/dist/components/rel-date-picker/date-parse.test.ts +116 -0
- package/dist/components/rel-date-picker/date-parse.ts +178 -0
- package/dist/components/rel-date-picker/rel-date-picker.ts +529 -90
- package/dist/components/rel-drawer/rel-drawer.ts +6 -3
- package/dist/components/rel-menu/rel-menu.ts +6 -0
- package/dist/components/rel-otp/rel-otp.ts +12 -2
- package/dist/components/rel-pagination/rel-pagination.ts +13 -0
- package/dist/components/rel-popover/rel-popover.ts +33 -2
- package/dist/components/rel-progress/rel-progress.ts +13 -4
- package/dist/components/rel-radio/README.md +6 -3
- package/dist/components/rel-radio/rel-radio.ts +236 -60
- package/dist/components/rel-range-picker/rel-range-picker.ts +452 -95
- package/dist/components/rel-segmented-button/rel-segmented-button.ts +5 -1
- package/dist/components/rel-select/rel-select.ts +44 -13
- package/dist/components/rel-sheet/rel-sheet.ts +163 -18
- package/dist/components/rel-sidebar/rel-sidebar.ts +7 -1
- package/dist/components/rel-skeleton/rel-skeleton.ts +9 -0
- package/dist/components/rel-slider/rel-slider.ts +6 -3
- package/dist/components/rel-snackbar/rel-snackbar.ts +20 -16
- package/dist/components/rel-stepper/rel-stepper.ts +3 -1
- package/dist/components/rel-switch/rel-switch.ts +3 -1
- package/dist/components/rel-textfield/rel-textfield.ts +263 -75
- package/dist/components/rel-time-picker/rel-time-picker.ts +450 -94
- package/dist/components/rel-time-picker/time-parse.test.ts +67 -0
- package/dist/components/rel-time-picker/time-parse.ts +85 -0
- package/dist/components/rel-toast/rel-toast.ts +4 -2
- package/dist/components/rel-tooltip/README.md +13 -5
- package/dist/components/rel-tooltip/rel-tooltip.ts +3 -0
- package/dist/components/rel-upload/rel-upload.ts +25 -0
- package/dist/index.d.mts +241 -11
- package/dist/index.d.ts +241 -11
- package/dist/index.js +2573 -598
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2614 -640
- package/dist/index.mjs.map +1 -1
- 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
|
-
/**
|
|
66
|
-
@state() private
|
|
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
|
-
|
|
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
|
-
|
|
173
|
+
:host(:not([disabled])) .trigger-button:hover {
|
|
155
174
|
border-color: var(--rel-border-strong);
|
|
156
175
|
}
|
|
157
176
|
|
|
158
|
-
.trigger-button:focus-
|
|
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
|
|
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
|
|
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-
|
|
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:
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
/*
|
|
259
|
-
.
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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.
|
|
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
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
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
|
|
800
|
-
|
|
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
|
|
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
|
-
<
|
|
1245
|
+
<div
|
|
915
1246
|
part="trigger"
|
|
916
1247
|
class="trigger-button ${isError ? "error" : ""}"
|
|
917
|
-
|
|
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
|
-
<
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
1309
|
+
</div>
|
|
964
1310
|
|
|
965
1311
|
<div
|
|
966
1312
|
part="panel"
|
|
967
|
-
|
|
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
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
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
|
}
|