@reliability-design/web 1.4.2 → 1.4.3

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.
@@ -0,0 +1,994 @@
1
+ import { LitElement, html, css, nothing } from "lit";
2
+ import { customElement, property, state, query } from "lit/decorators.js";
3
+ import "@reliability-design/icons/rel-icon";
4
+ import "../rel-button/rel-button";
5
+
6
+ type TimeColumn = "hour" | "minute" | "period";
7
+
8
+ /**
9
+ * `<rel-time-picker>` — time selection field.
10
+ *
11
+ * Follows the rel-date-picker trigger/field conventions (label, helper/error
12
+ * text, sizes, fullWidth, clearable) and the rel-range-picker draft model:
13
+ * column selections are held in draft state until Apply is clicked; Cancel,
14
+ * Escape, or an outside click discards the draft.
15
+ *
16
+ * The committed `value` is ALWAYS 24-hour "HH:mm" regardless of the display
17
+ * `format`, so consumers get a consistent machine-readable string.
18
+ */
19
+ @customElement("rel-time-picker")
20
+ export class RelTimePicker extends LitElement {
21
+ /** Committed time, 24-hour "HH:mm" (e.g. "09:30", "17:05"). */
22
+ @property({ type: String, reflect: true }) value: string | null = null;
23
+ @property({ type: String }) placeholder = "Select time";
24
+ @property({ type: String }) label: string | null = null;
25
+ @property({ type: Boolean, reflect: true }) disabled = false;
26
+ @property({ type: Boolean }) required = false;
27
+ @property({ type: Boolean, reflect: true }) readonly = false;
28
+
29
+ /** Display format of the trigger text and hour column. Value stays "HH:mm". */
30
+ @property({ type: String }) format: "12h" | "24h" = "12h";
31
+ /** Minute granularity of the minutes column (1, 5, 10, 15, 30, …). */
32
+ @property({ type: Number, attribute: "minute-step" }) minuteStep = 1;
33
+
34
+ /** Earliest selectable time, "HH:mm" (inclusive). */
35
+ @property({ type: String, attribute: "min-time" }) minTime: string | null = null;
36
+ /** Latest selectable time, "HH:mm" (inclusive). */
37
+ @property({ type: String, attribute: "max-time" }) maxTime: string | null = null;
38
+
39
+ @property({ type: Boolean, reflect: true }) open = false;
40
+ @property({ type: Boolean }) clearable = false;
41
+
42
+ // ── Standardized input API (mirrors rel-textfield / rel-date-picker) ──
43
+ @property({ type: Boolean }) error = false;
44
+ /** Alias for `error` (requested-API compat); either flag applies error styling. */
45
+ @property({ type: Boolean }) invalid = false;
46
+ @property({ type: String, attribute: "error-message" }) errorMessage?: string;
47
+ @property({ type: String, attribute: "helper-text" }) helperText?: string;
48
+ @property({ type: String }) name?: string;
49
+ @property({ type: String }) size: "small" | "medium" | "large" = "medium";
50
+ @property({ type: String, reflect: true }) variant: "outlined" = "outlined";
51
+ @property({ type: Boolean, reflect: true }) fullWidth = false;
52
+
53
+ // Draft selection (hour is 0–23 internally; committed on Apply only)
54
+ @state() private draftHour: number | null = null;
55
+ @state() private draftMinute: number | null = null;
56
+
57
+ @query(".trigger-button") private triggerButton?: HTMLButtonElement;
58
+ @query(".columns") private columnsElement?: HTMLElement;
59
+
60
+ static styles = css`
61
+ :host {
62
+ color: var(--rel-text-default);
63
+ display: block;
64
+ font-family: var(--rel-fonts-font-family);
65
+ position: relative;
66
+ }
67
+
68
+ /* Intrinsic width by default; set fullWidth to fill the parent. */
69
+ :host(:not([fullwidth])) {
70
+ display: inline-block;
71
+ width: auto;
72
+ }
73
+
74
+ :host([disabled]) {
75
+ opacity: 0.6;
76
+ cursor: not-allowed;
77
+ }
78
+
79
+ :host([disabled]) * {
80
+ pointer-events: none;
81
+ }
82
+
83
+ :host([readonly]) .trigger-button {
84
+ cursor: default;
85
+ }
86
+
87
+ .time-picker-wrapper {
88
+ display: flex;
89
+ flex-direction: column;
90
+ gap: var(--rel-spacing-xs);
91
+ }
92
+
93
+ .label {
94
+ color: var(--rel-text-label);
95
+ font-family: var(--rel-body-sm-medium-font-family);
96
+ font-weight: var(--rel-body-sm-medium-font-weight);
97
+ font-size: var(--rel-body-sm-medium-font-size);
98
+ line-height: var(--rel-body-sm-medium-line-height);
99
+ letter-spacing: var(--rel-body-sm-medium-letter-spacing);
100
+ }
101
+
102
+ .required-mark {
103
+ color: var(--rel-text-danger);
104
+ margin-left: 2px;
105
+ }
106
+
107
+ /* Support text (helperText / errorMessage) */
108
+ .time-picker-support {
109
+ color: var(--rel-text-placeholder);
110
+ font-family: var(--rel-body-xs-regular-font-family);
111
+ font-weight: var(--rel-body-xs-regular-font-weight);
112
+ font-size: var(--rel-body-xs-regular-font-size);
113
+ line-height: var(--rel-body-xs-regular-line-height);
114
+ letter-spacing: var(--rel-body-xs-regular-letter-spacing);
115
+ }
116
+
117
+ .time-picker-support.error {
118
+ color: var(--rel-text-danger);
119
+ }
120
+
121
+ .trigger-button {
122
+ width: 100%;
123
+ min-width: 160px;
124
+ display: flex;
125
+ align-items: center;
126
+ justify-content: space-between;
127
+ gap: var(--rel-spacing-xs);
128
+ padding: var(--rel-spacing-sm) var(--rel-spacing-md);
129
+ background: var(--rel-bg-surface);
130
+ border: 1px solid var(--rel-border-default);
131
+ border-radius: var(--rel-border-radius-sm);
132
+ color: var(--rel-text-default);
133
+ cursor: pointer;
134
+ transition: all 0.15s ease;
135
+ text-align: left;
136
+ font-family: var(--rel-body-md-regular-font-family);
137
+ font-weight: var(--rel-body-md-regular-font-weight);
138
+ font-size: var(--rel-body-md-regular-font-size);
139
+ line-height: var(--rel-body-md-regular-line-height);
140
+ letter-spacing: var(--rel-body-md-regular-letter-spacing);
141
+ }
142
+
143
+ .trigger-button:hover:not(:disabled) {
144
+ border-color: var(--rel-border-strong);
145
+ }
146
+
147
+ .trigger-button:focus-visible {
148
+ outline: 2px solid var(--rel-border-focus);
149
+ outline-offset: 2px;
150
+ border-color: var(--rel-border-focus);
151
+ }
152
+
153
+ .trigger-button:disabled {
154
+ cursor: not-allowed;
155
+ background: var(--rel-bg-surface-elevated);
156
+ }
157
+
158
+ /* Error state */
159
+ .trigger-button.error,
160
+ .trigger-button.error:hover:not(:disabled) {
161
+ border-color: var(--rel-border-danger);
162
+ }
163
+
164
+ .trigger-button.error:focus-visible {
165
+ outline-color: var(--rel-border-danger);
166
+ border-color: var(--rel-border-danger);
167
+ }
168
+
169
+ /* Size variants (medium is the base .trigger-button style) */
170
+ .time-picker-wrapper.small .trigger-button {
171
+ padding: var(--rel-spacing-xs) var(--rel-spacing-sm);
172
+ font-family: var(--rel-body-sm-regular-font-family);
173
+ font-weight: var(--rel-body-sm-regular-font-weight);
174
+ font-size: var(--rel-body-sm-regular-font-size);
175
+ line-height: var(--rel-body-sm-regular-line-height);
176
+ letter-spacing: var(--rel-body-sm-regular-letter-spacing);
177
+ }
178
+
179
+ .time-picker-wrapper.large .trigger-button {
180
+ padding: var(--rel-spacing-md) var(--rel-spacing-md);
181
+ font-family: var(--rel-body-lg-regular-font-family);
182
+ font-weight: var(--rel-body-lg-regular-font-weight);
183
+ font-size: var(--rel-body-lg-regular-font-size);
184
+ line-height: var(--rel-body-lg-regular-line-height);
185
+ letter-spacing: var(--rel-body-lg-regular-letter-spacing);
186
+ }
187
+
188
+ .trigger-content {
189
+ flex: 1;
190
+ color: var(--rel-text-default);
191
+ }
192
+
193
+ .trigger-content.placeholder {
194
+ color: var(--rel-text-placeholder);
195
+ }
196
+
197
+ .trigger-icons {
198
+ display: flex;
199
+ align-items: center;
200
+ gap: var(--rel-spacing-2xs);
201
+ color: var(--rel-text-placeholder);
202
+ }
203
+
204
+ .icon-button {
205
+ background: none;
206
+ border: none;
207
+ padding: var(--rel-spacing-3xs);
208
+ cursor: pointer;
209
+ color: var(--rel-text-placeholder);
210
+ display: flex;
211
+ align-items: center;
212
+ justify-content: center;
213
+ border-radius: var(--rel-border-radius-xs);
214
+ transition: all 0.15s ease;
215
+ }
216
+
217
+ .icon-button:hover {
218
+ background: var(--rel-bg-surface-elevated);
219
+ color: var(--rel-text-default);
220
+ }
221
+
222
+ .icon-button svg {
223
+ width: 16px;
224
+ height: 16px;
225
+ }
226
+
227
+ .popover {
228
+ position: absolute;
229
+ top: calc(100% + 4px);
230
+ left: 0;
231
+ z-index: 1000;
232
+ background: var(--rel-bg-surface);
233
+ border: 1px solid var(--rel-border-default);
234
+ border-radius: var(--rel-border-radius-sm);
235
+ box-shadow: var(--rel-shadows-shadow-300);
236
+ padding: var(--rel-spacing-md);
237
+ display: none;
238
+ }
239
+
240
+ .popover.open {
241
+ display: block;
242
+ }
243
+
244
+ /* Draft readout (mirrors the range-picker FROM/TO block) */
245
+ .time-readout {
246
+ display: flex;
247
+ align-items: baseline;
248
+ gap: var(--rel-spacing-sm);
249
+ padding-bottom: var(--rel-spacing-sm);
250
+ margin-bottom: var(--rel-spacing-sm);
251
+ border-bottom: 1px solid var(--rel-border-subtle);
252
+ }
253
+
254
+ .time-readout-label {
255
+ color: var(--rel-text-placeholder);
256
+ text-transform: uppercase;
257
+ font-family: var(--rel-body-xs-semibold-font-family);
258
+ font-weight: var(--rel-body-xs-semibold-font-weight);
259
+ font-size: var(--rel-body-xs-semibold-font-size);
260
+ line-height: var(--rel-body-xs-semibold-line-height);
261
+ letter-spacing: var(--rel-body-xs-semibold-letter-spacing);
262
+ }
263
+
264
+ .time-readout-value {
265
+ color: var(--rel-text-default);
266
+ font-family: var(--rel-body-md-semibold-font-family);
267
+ font-weight: var(--rel-body-md-semibold-font-weight);
268
+ font-size: var(--rel-body-md-semibold-font-size);
269
+ line-height: var(--rel-body-md-semibold-line-height);
270
+ letter-spacing: var(--rel-body-md-semibold-letter-spacing);
271
+ }
272
+
273
+ .time-readout-value.empty {
274
+ color: var(--rel-text-placeholder);
275
+ }
276
+
277
+ .columns {
278
+ display: flex;
279
+ gap: var(--rel-spacing-xs);
280
+ }
281
+
282
+ /* Columns share the panel width equally, so the two-column 24h layout
283
+ fills the same footprint the footer dictates instead of bunching left. */
284
+ .column {
285
+ display: flex;
286
+ flex-direction: column;
287
+ flex: 1;
288
+ min-width: 64px;
289
+ }
290
+
291
+ .column-header {
292
+ text-align: center;
293
+ color: var(--rel-text-placeholder);
294
+ padding: var(--rel-spacing-xs);
295
+ text-transform: uppercase;
296
+ font-family: var(--rel-body-xs-semibold-font-family);
297
+ font-weight: var(--rel-body-xs-semibold-font-weight);
298
+ font-size: var(--rel-body-xs-semibold-font-size);
299
+ line-height: var(--rel-body-xs-semibold-line-height);
300
+ letter-spacing: var(--rel-body-xs-semibold-letter-spacing);
301
+ }
302
+
303
+ .column-list {
304
+ display: flex;
305
+ flex-direction: column;
306
+ gap: var(--rel-spacing-3xs);
307
+ max-height: 224px;
308
+ overflow-y: auto;
309
+ padding: var(--rel-spacing-3xs);
310
+ scrollbar-width: thin;
311
+ }
312
+
313
+ .option-button {
314
+ border: none;
315
+ background: transparent;
316
+ border-radius: var(--rel-border-radius-sm);
317
+ color: var(--rel-text-default);
318
+ cursor: pointer;
319
+ padding: var(--rel-spacing-xs) var(--rel-spacing-sm);
320
+ text-align: center;
321
+ flex-shrink: 0;
322
+ transition: all 0.15s ease;
323
+ font-family: var(--rel-body-sm-medium-font-family);
324
+ font-weight: var(--rel-body-sm-medium-font-weight);
325
+ font-size: var(--rel-body-sm-medium-font-size);
326
+ line-height: var(--rel-body-sm-medium-line-height);
327
+ letter-spacing: var(--rel-body-sm-medium-letter-spacing);
328
+ }
329
+
330
+ .option-button:hover:not(:disabled):not(.selected) {
331
+ background: var(--rel-bg-surface-elevated);
332
+ }
333
+
334
+ .option-button:focus-visible {
335
+ outline: 2px solid var(--rel-border-focus);
336
+ outline-offset: -2px;
337
+ z-index: 1;
338
+ }
339
+
340
+ .option-button.selected {
341
+ background: var(--rel-action-brand-fill);
342
+ color: var(--rel-action-brand-label-icons-primary);
343
+ font-weight: var(--rel-body-md-semibold-font-weight);
344
+ }
345
+
346
+ .option-button:disabled {
347
+ color: var(--rel-text-disabled);
348
+ cursor: not-allowed;
349
+ opacity: 0.5;
350
+ }
351
+
352
+ .option-button:disabled:hover {
353
+ background: transparent;
354
+ }
355
+
356
+ /* Footer (mirrors the range-picker calendar-footer) */
357
+ .time-footer {
358
+ display: flex;
359
+ align-items: center;
360
+ justify-content: space-between;
361
+ gap: var(--rel-spacing-sm);
362
+ padding-top: var(--rel-spacing-sm);
363
+ margin-top: var(--rel-spacing-sm);
364
+ border-top: 1px solid var(--rel-border-subtle);
365
+ }
366
+
367
+ .footer-right {
368
+ display: flex;
369
+ align-items: center;
370
+ gap: var(--rel-spacing-xs);
371
+ }
372
+
373
+ @media (prefers-reduced-motion: reduce) {
374
+ .trigger-button,
375
+ .icon-button,
376
+ .option-button {
377
+ transition: none;
378
+ }
379
+ }
380
+ `;
381
+
382
+ connectedCallback() {
383
+ super.connectedCallback();
384
+ document.addEventListener("click", this.handleClickOutside);
385
+ document.addEventListener("keydown", this.handleEscapeKey);
386
+ }
387
+
388
+ disconnectedCallback() {
389
+ super.disconnectedCallback();
390
+ document.removeEventListener("click", this.handleClickOutside);
391
+ document.removeEventListener("keydown", this.handleEscapeKey);
392
+ }
393
+
394
+ private handleClickOutside = (e: MouseEvent) => {
395
+ if (!this.open) return;
396
+ const path = e.composedPath();
397
+ if (!path.includes(this)) {
398
+ this.cancelPopover();
399
+ }
400
+ };
401
+
402
+ private handleEscapeKey = (e: KeyboardEvent) => {
403
+ if (e.key === "Escape" && this.open) {
404
+ this.cancelPopover();
405
+ }
406
+ };
407
+
408
+ updated(changedProperties: Map<string, any>) {
409
+ if (changedProperties.has("open") && this.open) {
410
+ this.resetDraftFromValue();
411
+ // Focus the selected (or first enabled) hour and center each column's
412
+ // selection once the popover has rendered.
413
+ setTimeout(() => {
414
+ this.scrollColumnsToSelection();
415
+ const target =
416
+ this.columnsElement?.querySelector<HTMLButtonElement>(
417
+ ".option-button.selected:not(:disabled)"
418
+ ) ??
419
+ this.columnsElement?.querySelector<HTMLButtonElement>(
420
+ ".option-button:not(:disabled)"
421
+ );
422
+ target?.focus();
423
+ }, 0);
424
+ }
425
+ }
426
+
427
+ // ── Parsing / formatting ──
428
+
429
+ private parseTime(time: string | null): { hour: number; minute: number } | null {
430
+ if (!time) return null;
431
+ const match = time.match(/^(\d{1,2}):(\d{2})$/);
432
+ if (!match) return null;
433
+ const hour = parseInt(match[1], 10);
434
+ const minute = parseInt(match[2], 10);
435
+ if (hour > 23 || minute > 59) return null;
436
+ return { hour, minute };
437
+ }
438
+
439
+ private formatValue(hour: number, minute: number): string {
440
+ return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
441
+ }
442
+
443
+ private formatDisplay(hour: number, minute: number): string {
444
+ const mm = String(minute).padStart(2, "0");
445
+ if (this.format === "24h") {
446
+ return `${String(hour).padStart(2, "0")}:${mm}`;
447
+ }
448
+ const period = hour < 12 ? "AM" : "PM";
449
+ const hour12 = hour % 12 === 0 ? 12 : hour % 12;
450
+ return `${hour12}:${mm} ${period}`;
451
+ }
452
+
453
+ private toMinutes(hour: number, minute: number): number {
454
+ return hour * 60 + minute;
455
+ }
456
+
457
+ private get minuteStepSafe(): number {
458
+ const step = Math.floor(this.minuteStep);
459
+ return step >= 1 && step <= 60 ? step : 1;
460
+ }
461
+
462
+ private get minBoundMinutes(): number {
463
+ const parsed = this.parseTime(this.minTime);
464
+ return parsed ? this.toMinutes(parsed.hour, parsed.minute) : 0;
465
+ }
466
+
467
+ private get maxBoundMinutes(): number {
468
+ const parsed = this.parseTime(this.maxTime);
469
+ return parsed ? this.toMinutes(parsed.hour, parsed.minute) : 23 * 60 + 59;
470
+ }
471
+
472
+ private isTimeInRange(hour: number, minute: number): boolean {
473
+ const total = this.toMinutes(hour, minute);
474
+ return total >= this.minBoundMinutes && total <= this.maxBoundMinutes;
475
+ }
476
+
477
+ /** Minutes shown in the minutes column (respecting minuteStep). */
478
+ private getMinuteOptions(): number[] {
479
+ const minutes: number[] = [];
480
+ for (let m = 0; m < 60; m += this.minuteStepSafe) {
481
+ minutes.push(m);
482
+ }
483
+ return minutes;
484
+ }
485
+
486
+ /** An hour is enabled if at least one of its step minutes is in range. */
487
+ private isHourEnabled(hour: number): boolean {
488
+ return this.getMinuteOptions().some((m) => this.isTimeInRange(hour, m));
489
+ }
490
+
491
+ private isPeriodEnabled(period: "AM" | "PM"): boolean {
492
+ const start = period === "AM" ? 0 : 12;
493
+ for (let h = start; h < start + 12; h++) {
494
+ if (this.isHourEnabled(h)) return true;
495
+ }
496
+ return false;
497
+ }
498
+
499
+ /** Hour column entries: display label + the 0–23 hour it maps to. */
500
+ private getHourOptions(): { label: string; hour24: number }[] {
501
+ if (this.format === "24h") {
502
+ return Array.from({ length: 24 }, (_, h) => ({
503
+ label: String(h).padStart(2, "0"),
504
+ hour24: h,
505
+ }));
506
+ }
507
+ // 12-hour clock, chronological within the current period: 12, 01 … 11
508
+ const period = this.draftPeriod ?? "AM";
509
+ const base = period === "AM" ? 0 : 12;
510
+ return Array.from({ length: 12 }, (_, i) => ({
511
+ label: i === 0 ? "12" : String(i).padStart(2, "0"),
512
+ hour24: base + i,
513
+ }));
514
+ }
515
+
516
+ private get draftPeriod(): "AM" | "PM" | null {
517
+ if (this.draftHour === null) return null;
518
+ return this.draftHour < 12 ? "AM" : "PM";
519
+ }
520
+
521
+ private resetDraftFromValue() {
522
+ const parsed = this.parseTime(this.value);
523
+ this.draftHour = parsed ? parsed.hour : null;
524
+ this.draftMinute = parsed ? parsed.minute : null;
525
+ }
526
+
527
+ /** Snap the draft minute to the nearest valid step minute for the hour. */
528
+ private clampDraftToRange() {
529
+ if (this.draftHour === null || this.draftMinute === null) return;
530
+ if (this.isTimeInRange(this.draftHour, this.draftMinute)) return;
531
+ const hour = this.draftHour;
532
+ const valid = this.getMinuteOptions().filter((m) => this.isTimeInRange(hour, m));
533
+ if (valid.length === 0) {
534
+ this.draftMinute = null;
535
+ return;
536
+ }
537
+ const current = this.draftMinute;
538
+ this.draftMinute = valid.reduce((best, m) =>
539
+ Math.abs(m - current) < Math.abs(best - current) ? m : best
540
+ );
541
+ }
542
+
543
+ // ── Open / close / commit ──
544
+
545
+ private toggleTimePopover() {
546
+ if (this.disabled || this.readonly) return;
547
+ this.open = !this.open;
548
+ this.dispatchEvent(
549
+ new CustomEvent("rel-open-change", {
550
+ detail: { open: this.open },
551
+ bubbles: true,
552
+ composed: true,
553
+ })
554
+ );
555
+ }
556
+
557
+ /** Close discarding the draft (Cancel / Escape / outside click). */
558
+ private cancelPopover() {
559
+ this.open = false;
560
+ this.triggerButton?.focus();
561
+ this.dispatchEvent(
562
+ new CustomEvent("rel-open-change", {
563
+ detail: { open: false },
564
+ bubbles: true,
565
+ composed: true,
566
+ })
567
+ );
568
+ }
569
+
570
+ private applySelection() {
571
+ if (this.draftHour === null) return;
572
+ const newValue = this.formatValue(this.draftHour, this.draftMinute ?? 0);
573
+ this.value = newValue;
574
+ this.dispatchEvent(
575
+ new CustomEvent("rel-change", {
576
+ detail: { value: newValue },
577
+ bubbles: true,
578
+ composed: true,
579
+ })
580
+ );
581
+ this.cancelPopover();
582
+ }
583
+
584
+ /** Set the draft to the current time, snapped to the minute step and range. */
585
+ private selectNow() {
586
+ const now = new Date();
587
+ const step = this.minuteStepSafe;
588
+ this.draftHour = now.getHours();
589
+ this.draftMinute = Math.round(now.getMinutes() / step) * step;
590
+ if (this.draftMinute >= 60) {
591
+ this.draftMinute = 0;
592
+ this.draftHour = (this.draftHour + 1) % 24;
593
+ }
594
+ if (!this.isHourEnabled(this.draftHour)) {
595
+ // Current hour is outside min/max — snap to the nearest enabled hour.
596
+ const enabled: number[] = [];
597
+ for (let h = 0; h < 24; h++) if (this.isHourEnabled(h)) enabled.push(h);
598
+ if (enabled.length === 0) return;
599
+ const currentHour = this.draftHour;
600
+ this.draftHour = enabled.reduce((best, h) =>
601
+ Math.abs(h - currentHour) < Math.abs(best - currentHour) ? h : best
602
+ );
603
+ }
604
+ this.clampDraftToRange();
605
+ this.scrollColumnsToSelection();
606
+ }
607
+
608
+ private clearValue(e: Event) {
609
+ e.stopPropagation();
610
+ this.value = null;
611
+ this.draftHour = null;
612
+ this.draftMinute = null;
613
+ this.dispatchEvent(
614
+ new CustomEvent("rel-change", {
615
+ detail: { value: null },
616
+ bubbles: true,
617
+ composed: true,
618
+ })
619
+ );
620
+ }
621
+
622
+ // ── Column selection ──
623
+
624
+ private selectHour(hour24: number) {
625
+ if (!this.isHourEnabled(hour24)) return;
626
+ this.draftHour = hour24;
627
+ if (this.draftMinute === null) this.draftMinute = 0;
628
+ this.clampDraftToRange();
629
+ }
630
+
631
+ private selectMinute(minute: number) {
632
+ if (this.draftHour === null) {
633
+ // Minute picked first — default the hour to the first enabled one.
634
+ for (let h = 0; h < 24; h++) {
635
+ if (this.isHourEnabled(h)) {
636
+ this.draftHour = h;
637
+ break;
638
+ }
639
+ }
640
+ if (this.draftHour === null) return;
641
+ }
642
+ if (!this.isTimeInRange(this.draftHour, minute)) return;
643
+ this.draftMinute = minute;
644
+ }
645
+
646
+ private selectPeriod(period: "AM" | "PM") {
647
+ if (!this.isPeriodEnabled(period)) return;
648
+ const base = period === "AM" ? 0 : 12;
649
+ const hour12 = this.draftHour === null ? 0 : this.draftHour % 12;
650
+ this.draftHour = base + hour12;
651
+ if (this.draftMinute === null) this.draftMinute = 0;
652
+ if (!this.isHourEnabled(this.draftHour)) {
653
+ // Same clock position is out of range in the new period — snap to the
654
+ // nearest enabled hour within that period.
655
+ const enabled: number[] = [];
656
+ for (let h = base; h < base + 12; h++) if (this.isHourEnabled(h)) enabled.push(h);
657
+ const currentHour = this.draftHour;
658
+ this.draftHour = enabled.reduce((best, h) =>
659
+ Math.abs(h - currentHour) < Math.abs(best - currentHour) ? h : best
660
+ );
661
+ }
662
+ this.clampDraftToRange();
663
+ this.scrollColumnsToSelection();
664
+ }
665
+
666
+ private scrollColumnsToSelection() {
667
+ this.updateComplete.then(() => {
668
+ this.columnsElement
669
+ ?.querySelectorAll<HTMLButtonElement>(".option-button.selected")
670
+ .forEach((btn) => btn.scrollIntoView({ block: "center" }));
671
+ });
672
+ }
673
+
674
+ // ── Keyboard ──
675
+
676
+ private handleTriggerKeydown(e: KeyboardEvent) {
677
+ if (e.key === "Enter" || e.key === " " || e.key === "ArrowDown") {
678
+ e.preventDefault();
679
+ if (!this.open) {
680
+ this.toggleTimePopover();
681
+ }
682
+ }
683
+ }
684
+
685
+ /**
686
+ * Column navigation: Up/Down move within the column (and select), Left/Right
687
+ * jump between columns, Home/End go to the first/last option, Enter applies.
688
+ */
689
+ private handleOptionKeydown(e: KeyboardEvent, column: TimeColumn) {
690
+ const list = (e.currentTarget as HTMLElement).closest(".column-list");
691
+ if (!list) return;
692
+ const buttons = Array.from(
693
+ list.querySelectorAll<HTMLButtonElement>(".option-button:not(:disabled)")
694
+ );
695
+ const index = buttons.indexOf(e.currentTarget as HTMLButtonElement);
696
+ let target: HTMLButtonElement | undefined;
697
+
698
+ switch (e.key) {
699
+ case "ArrowUp":
700
+ e.preventDefault();
701
+ target = buttons[Math.max(0, index - 1)];
702
+ break;
703
+ case "ArrowDown":
704
+ e.preventDefault();
705
+ target = buttons[Math.min(buttons.length - 1, index + 1)];
706
+ break;
707
+ case "Home":
708
+ e.preventDefault();
709
+ target = buttons[0];
710
+ break;
711
+ case "End":
712
+ e.preventDefault();
713
+ target = buttons[buttons.length - 1];
714
+ break;
715
+ case "ArrowLeft":
716
+ case "ArrowRight": {
717
+ e.preventDefault();
718
+ this.focusAdjacentColumn(column, e.key === "ArrowRight" ? 1 : -1);
719
+ return;
720
+ }
721
+ case "Enter":
722
+ e.preventDefault();
723
+ (e.currentTarget as HTMLButtonElement).click();
724
+ this.applySelection();
725
+ return;
726
+ default:
727
+ return;
728
+ }
729
+
730
+ if (target && target !== buttons[index]) {
731
+ target.click();
732
+ target.focus();
733
+ target.scrollIntoView({ block: "nearest" });
734
+ }
735
+ }
736
+
737
+ private focusAdjacentColumn(from: TimeColumn, direction: 1 | -1) {
738
+ const order: TimeColumn[] =
739
+ this.format === "12h" ? ["hour", "minute", "period"] : ["hour", "minute"];
740
+ const nextIndex = order.indexOf(from) + direction;
741
+ if (nextIndex < 0 || nextIndex >= order.length) return;
742
+ const lists = this.columnsElement?.querySelectorAll(".column-list");
743
+ const list = lists?.[nextIndex];
744
+ const target =
745
+ list?.querySelector<HTMLButtonElement>(".option-button.selected:not(:disabled)") ??
746
+ list?.querySelector<HTMLButtonElement>(".option-button:not(:disabled)");
747
+ target?.focus();
748
+ target?.scrollIntoView({ block: "nearest" });
749
+ }
750
+
751
+ // ── Render ──
752
+
753
+ render() {
754
+ const isError = this.error || this.invalid;
755
+ const belowText = isError && this.errorMessage ? this.errorMessage : this.helperText;
756
+ const describedById = belowText ? "time-picker-desc" : undefined;
757
+ const parsedValue = this.parseTime(this.value);
758
+ const displayText = parsedValue
759
+ ? this.formatDisplay(parsedValue.hour, parsedValue.minute)
760
+ : "";
761
+ const draftDisplay =
762
+ this.draftHour !== null
763
+ ? this.formatDisplay(this.draftHour, this.draftMinute ?? 0)
764
+ : "";
765
+ const hourOptions = this.getHourOptions();
766
+ const minuteOptions = this.getMinuteOptions();
767
+ const draftPeriod = this.draftPeriod;
768
+ const wrapperClasses = [
769
+ "time-picker-wrapper",
770
+ this.size,
771
+ isError ? "error" : "",
772
+ this.readonly ? "readonly" : "",
773
+ ]
774
+ .filter(Boolean)
775
+ .join(" ");
776
+
777
+ return html`
778
+ <div part="base" class="${wrapperClasses}">
779
+ ${this.label
780
+ ? html`<label part="label" class="label" id="time-picker-label"
781
+ >${this.label}${this.required
782
+ ? html`<span class="required-mark" aria-hidden="true">*</span>`
783
+ : nothing}</label
784
+ >`
785
+ : nothing}
786
+
787
+ ${this.name
788
+ ? html`<input type="hidden" name=${this.name} .value=${this.value ?? ""} />`
789
+ : nothing}
790
+
791
+ <button
792
+ part="trigger"
793
+ class="trigger-button ${isError ? "error" : ""}"
794
+ type="button"
795
+ ?disabled=${this.disabled}
796
+ @click=${this.toggleTimePopover}
797
+ @keydown=${this.handleTriggerKeydown}
798
+ @focus=${() =>
799
+ this.dispatchEvent(
800
+ new CustomEvent("rel-focus", { bubbles: true, composed: true })
801
+ )}
802
+ @blur=${() =>
803
+ this.dispatchEvent(
804
+ new CustomEvent("rel-blur", { bubbles: true, composed: true })
805
+ )}
806
+ aria-label=${this.label || "Select time"}
807
+ aria-haspopup="dialog"
808
+ aria-expanded=${this.open}
809
+ aria-required=${this.required}
810
+ aria-invalid=${isError}
811
+ aria-readonly=${this.readonly}
812
+ aria-describedby=${describedById ?? nothing}
813
+ aria-labelledby=${this.label ? "time-picker-label" : nothing}
814
+ >
815
+ <span part="value" class="trigger-content ${displayText ? "" : "placeholder"}">
816
+ ${displayText || this.placeholder}
817
+ </span>
818
+ <span class="trigger-icons">
819
+ ${this.clearable && this.value
820
+ ? html`
821
+ <button
822
+ class="icon-button"
823
+ type="button"
824
+ aria-label="Clear time"
825
+ @click=${this.clearValue}
826
+ >
827
+ <svg viewBox="0 0 16 16" fill="currentColor">
828
+ <path
829
+ d="M4 4l8 8M12 4l-8 8"
830
+ stroke="currentColor"
831
+ stroke-width="2"
832
+ stroke-linecap="round"
833
+ />
834
+ </svg>
835
+ </button>
836
+ `
837
+ : nothing}
838
+ <rel-icon part="icon" name="time-filled" size="sm"></rel-icon>
839
+ </span>
840
+ </button>
841
+
842
+ <div
843
+ part="panel"
844
+ class="popover ${this.open ? "open" : ""}"
845
+ role="dialog"
846
+ aria-label="Choose time"
847
+ >
848
+ <div class="time-readout">
849
+ <span class="time-readout-label">Time</span>
850
+ <span class="time-readout-value ${draftDisplay ? "" : "empty"}">
851
+ ${draftDisplay || (this.format === "24h" ? "--:--" : "--:-- --")}
852
+ </span>
853
+ </div>
854
+
855
+ <div class="columns">
856
+ <div class="column">
857
+ <div class="column-header" aria-hidden="true">Hour</div>
858
+ <div class="column-list" role="listbox" aria-label="Hours">
859
+ ${hourOptions.map(
860
+ (opt, i) => html`
861
+ <button
862
+ part="option"
863
+ class="option-button ${this.draftHour === opt.hour24
864
+ ? "selected"
865
+ : ""}"
866
+ type="button"
867
+ role="option"
868
+ aria-selected=${this.draftHour === opt.hour24}
869
+ ?disabled=${!this.isHourEnabled(opt.hour24)}
870
+ @click=${() => this.selectHour(opt.hour24)}
871
+ @keydown=${(e: KeyboardEvent) => this.handleOptionKeydown(e, "hour")}
872
+ tabindex=${this.draftHour === opt.hour24 ||
873
+ (this.draftHour === null && i === 0)
874
+ ? 0
875
+ : -1}
876
+ >
877
+ ${opt.label}
878
+ </button>
879
+ `
880
+ )}
881
+ </div>
882
+ </div>
883
+
884
+ <div class="column">
885
+ <div class="column-header" aria-hidden="true">Min</div>
886
+ <div class="column-list" role="listbox" aria-label="Minutes">
887
+ ${minuteOptions.map(
888
+ (m, i) => html`
889
+ <button
890
+ part="option"
891
+ class="option-button ${this.draftMinute === m ? "selected" : ""}"
892
+ type="button"
893
+ role="option"
894
+ aria-selected=${this.draftMinute === m}
895
+ ?disabled=${this.draftHour !== null &&
896
+ !this.isTimeInRange(this.draftHour, m)}
897
+ @click=${() => this.selectMinute(m)}
898
+ @keydown=${(e: KeyboardEvent) =>
899
+ this.handleOptionKeydown(e, "minute")}
900
+ tabindex=${this.draftMinute === m ||
901
+ (this.draftMinute === null && i === 0)
902
+ ? 0
903
+ : -1}
904
+ >
905
+ ${String(m).padStart(2, "0")}
906
+ </button>
907
+ `
908
+ )}
909
+ </div>
910
+ </div>
911
+
912
+ ${this.format === "12h"
913
+ ? html`
914
+ <div class="column">
915
+ <div class="column-header" aria-hidden="true">AM/PM</div>
916
+ <div class="column-list" role="listbox" aria-label="AM or PM">
917
+ ${(["AM", "PM"] as const).map(
918
+ (p) => html`
919
+ <button
920
+ part="option"
921
+ class="option-button ${draftPeriod === p ? "selected" : ""}"
922
+ type="button"
923
+ role="option"
924
+ aria-selected=${draftPeriod === p}
925
+ ?disabled=${!this.isPeriodEnabled(p)}
926
+ @click=${() => this.selectPeriod(p)}
927
+ @keydown=${(e: KeyboardEvent) =>
928
+ this.handleOptionKeydown(e, "period")}
929
+ tabindex=${draftPeriod === p ||
930
+ (draftPeriod === null && p === "AM")
931
+ ? 0
932
+ : -1}
933
+ >
934
+ ${p}
935
+ </button>
936
+ `
937
+ )}
938
+ </div>
939
+ </div>
940
+ `
941
+ : nothing}
942
+ </div>
943
+
944
+ <div part="footer" class="time-footer">
945
+ <rel-button
946
+ variant="secondary"
947
+ size="small"
948
+ border="corner-radius"
949
+ @click=${() => this.selectNow()}
950
+ >
951
+ Now
952
+ </rel-button>
953
+ <div class="footer-right">
954
+ <rel-button
955
+ variant="secondary"
956
+ size="small"
957
+ border="corner-radius"
958
+ @click=${() => this.cancelPopover()}
959
+ >
960
+ Cancel
961
+ </rel-button>
962
+ <rel-button
963
+ variant="primary"
964
+ tone="default"
965
+ size="small"
966
+ border="corner-radius"
967
+ ?disabled=${this.draftHour === null}
968
+ @click=${() => this.applySelection()}
969
+ >
970
+ Apply
971
+ </rel-button>
972
+ </div>
973
+ </div>
974
+ </div>
975
+
976
+ ${belowText
977
+ ? html`<div
978
+ part="helper"
979
+ class="time-picker-support ${isError ? "error" : ""}"
980
+ id=${describedById}
981
+ >
982
+ ${belowText}
983
+ </div>`
984
+ : nothing}
985
+ </div>
986
+ `;
987
+ }
988
+ }
989
+
990
+ declare global {
991
+ interface HTMLElementTagNameMap {
992
+ "rel-time-picker": RelTimePicker;
993
+ }
994
+ }