@reliability-design/web 1.4.4 → 1.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/dist/components/rel-accordion/rel-accordion.ts +13 -1
  2. package/dist/components/rel-alert/rel-alert.ts +1 -1
  3. package/dist/components/rel-autocomplete/rel-autocomplete.ts +33 -2
  4. package/dist/components/rel-badge/rel-badge.ts +6 -1
  5. package/dist/components/rel-button/rel-button.ts +18 -2
  6. package/dist/components/rel-calendar/rel-calendar.ts +86 -18
  7. package/dist/components/rel-carousel/rel-carousel.ts +45 -2
  8. package/dist/components/rel-checkbox/rel-checkbox.ts +4 -1
  9. package/dist/components/rel-chip/rel-chip.ts +59 -19
  10. package/dist/components/rel-date-picker/date-parse.test.ts +116 -0
  11. package/dist/components/rel-date-picker/date-parse.ts +178 -0
  12. package/dist/components/rel-date-picker/rel-date-picker.ts +675 -93
  13. package/dist/components/rel-drawer/rel-drawer.ts +28 -61
  14. package/dist/components/rel-menu/rel-menu.ts +130 -15
  15. package/dist/components/rel-menubar/rel-menubar.ts +221 -63
  16. package/dist/components/rel-nav-menu/rel-nav-menu.ts +39 -3
  17. package/dist/components/rel-otp/rel-otp.ts +14 -4
  18. package/dist/components/rel-pagination/rel-pagination.ts +13 -0
  19. package/dist/components/rel-popover/rel-popover.ts +64 -5
  20. package/dist/components/rel-progress/rel-progress.ts +13 -4
  21. package/dist/components/rel-radio/README.md +6 -3
  22. package/dist/components/rel-radio/rel-radio.ts +236 -60
  23. package/dist/components/rel-range-picker/rel-range-picker.ts +581 -91
  24. package/dist/components/rel-resizable/rel-resizable-group.ts +20 -0
  25. package/dist/components/rel-segmented-button/rel-segmented-button.ts +55 -13
  26. package/dist/components/rel-select/rel-select.ts +203 -24
  27. package/dist/components/rel-sheet/rel-sheet.ts +184 -18
  28. package/dist/components/rel-sidebar/rel-sidebar.ts +50 -40
  29. package/dist/components/rel-skeleton/rel-skeleton.ts +9 -0
  30. package/dist/components/rel-slider/rel-slider.ts +6 -3
  31. package/dist/components/rel-snackbar/rel-snackbar.ts +20 -16
  32. package/dist/components/rel-stepper/rel-stepper.ts +47 -2
  33. package/dist/components/rel-switch/rel-switch.ts +3 -1
  34. package/dist/components/rel-tab/rel-tab.ts +32 -7
  35. package/dist/components/rel-textfield/rel-textfield.ts +263 -75
  36. package/dist/components/rel-time-picker/rel-time-picker.ts +749 -261
  37. package/dist/components/rel-time-picker/time-parse.test.ts +67 -0
  38. package/dist/components/rel-time-picker/time-parse.ts +85 -0
  39. package/dist/components/rel-toast/rel-toast.ts +4 -2
  40. package/dist/components/rel-tooltip/README.md +13 -5
  41. package/dist/components/rel-tooltip/rel-tooltip.ts +33 -0
  42. package/dist/components/rel-topbar/rel-topbar-actions.ts +1 -1
  43. package/dist/components/rel-topbar/rel-topbar-brand.ts +1 -1
  44. package/dist/components/rel-topbar/rel-topbar-dropdown-item.ts +1 -1
  45. package/dist/components/rel-topbar/rel-topbar-dropdown.ts +1 -1
  46. package/dist/components/rel-topbar/rel-topbar-item.ts +318 -319
  47. package/dist/components/rel-topbar/rel-topbar-mobile-panel.ts +1 -1
  48. package/dist/components/rel-topbar/rel-topbar-profile.ts +224 -224
  49. package/dist/components/rel-topbar/rel-topbar-utilities.ts +1 -1
  50. package/dist/components/rel-topbar/rel-topbar.ts +440 -440
  51. package/dist/components/rel-upload/rel-upload.ts +25 -0
  52. package/dist/index.d.mts +461 -23
  53. package/dist/index.d.ts +461 -23
  54. package/dist/index.js +3906 -807
  55. package/dist/index.js.map +1 -1
  56. package/dist/index.mjs +4143 -1055
  57. package/dist/index.mjs.map +1 -1
  58. package/package.json +2 -2
@@ -1,9 +1,12 @@
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
- type TimeColumn = "hour" | "minute" | "period";
9
+ type TimeColumn = "hour" | "minute" | "second" | "period";
7
10
 
8
11
  /**
9
12
  * `<rel-time-picker>` — time selection field.
@@ -13,12 +16,18 @@ type TimeColumn = "hour" | "minute" | "period";
13
16
  * column selections are held in draft state until Apply is clicked; Cancel,
14
17
  * Escape, or an outside click discards the draft.
15
18
  *
16
- * The committed `value` is ALWAYS 24-hour "HH:mm" regardless of the display
17
- * `format`, so consumers get a consistent machine-readable string.
19
+ * The committed `value` is ALWAYS 24-hour "HH:mm" (or "HH:mm:ss" when
20
+ * `seconds` is enabled) regardless of the display `format`, so consumers get
21
+ * a consistent machine-readable string.
22
+ *
23
+ * Panel visuals follow the Figma "Time Picker" component (Reliability DS
24
+ * V1.0, node 8301:3771): fixed panel widths 220/250/280 by size, full-bleed
25
+ * columns with 1px dividers, neutral selected state, and a Now / Cancel / OK
26
+ * footer whose buttons are always size S.
18
27
  */
19
28
  @customElement("rel-time-picker")
20
29
  export class RelTimePicker extends LitElement {
21
- /** Committed time, 24-hour "HH:mm" (e.g. "09:30", "17:05"). */
30
+ /** Committed time, 24-hour "HH:mm" or "HH:mm:ss" (e.g. "09:30", "17:05:30"). */
22
31
  @property({ type: String, reflect: true }) value: string | null = null;
23
32
  @property({ type: String }) placeholder = "Select time";
24
33
  @property({ type: String }) label: string | null = null;
@@ -26,10 +35,12 @@ export class RelTimePicker extends LitElement {
26
35
  @property({ type: Boolean }) required = false;
27
36
  @property({ type: Boolean, reflect: true }) readonly = false;
28
37
 
29
- /** Display format of the trigger text and hour column. Value stays "HH:mm". */
38
+ /** Display format of the trigger text and hour column. Value stays 24-hour. */
30
39
  @property({ type: String }) format: "12h" | "24h" = "12h";
40
+ /** Shows the seconds column; committed value becomes "HH:mm:ss". */
41
+ @property({ type: Boolean, reflect: true }) seconds = false;
31
42
  /** Minute granularity of the minutes column (1, 5, 10, 15, 30, …). */
32
- @property({ type: Number, attribute: "minute-step" }) minuteStep = 1;
43
+ @property({ type: Number, attribute: "minute-step" }) minuteStep = 5;
33
44
 
34
45
  /** Earliest selectable time, "HH:mm" (inclusive). */
35
46
  @property({ type: String, attribute: "min-time" }) minTime: string | null = null;
@@ -50,11 +61,25 @@ export class RelTimePicker extends LitElement {
50
61
  @property({ type: String, reflect: true }) variant: "outlined" = "outlined";
51
62
  @property({ type: Boolean, reflect: true }) fullWidth = false;
52
63
 
53
- // Draft selection (hour is 0–23 internally; committed on Apply only)
64
+ // Draft selection (hour is 0–23 internally; committed on OK only)
54
65
  @state() private draftHour: number | null = null;
55
66
  @state() private draftMinute: number | null = null;
67
+ @state() private draftSecond: number | null = null;
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;
72
+
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;
56
79
 
57
- @query(".trigger-button") private triggerButton?: HTMLButtonElement;
80
+ @query(".trigger-input") private triggerInput?: HTMLInputElement;
81
+ @query(".trigger-button") private triggerButton?: HTMLElement;
82
+ @query(".popover") private popoverElement?: HTMLElement;
58
83
  @query(".columns") private columnsElement?: HTMLElement;
59
84
 
60
85
  static styles = css`
@@ -118,6 +143,11 @@ export class RelTimePicker extends LitElement {
118
143
  color: var(--rel-text-danger);
119
144
  }
120
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
+
121
151
  .trigger-button {
122
152
  width: 100%;
123
153
  min-width: 160px;
@@ -140,28 +170,48 @@ export class RelTimePicker extends LitElement {
140
170
  letter-spacing: var(--rel-body-md-regular-letter-spacing);
141
171
  }
142
172
 
143
- .trigger-button:hover:not(:disabled) {
173
+ :host(:not([disabled])) .trigger-button:hover {
144
174
  border-color: var(--rel-border-strong);
145
175
  }
146
176
 
147
- .trigger-button:focus-visible {
177
+ .trigger-button:focus-within {
148
178
  outline: 2px solid var(--rel-border-focus);
149
179
  outline-offset: 2px;
150
180
  border-color: var(--rel-border-focus);
151
181
  }
152
182
 
153
- .trigger-button:disabled {
183
+ :host([disabled]) .trigger-button {
154
184
  cursor: not-allowed;
155
185
  background: var(--rel-bg-surface-elevated);
156
186
  }
157
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
+
158
208
  /* Error state */
159
209
  .trigger-button.error,
160
- .trigger-button.error:hover:not(:disabled) {
210
+ :host(:not([disabled])) .trigger-button.error:hover {
161
211
  border-color: var(--rel-border-danger);
162
212
  }
163
213
 
164
- .trigger-button.error:focus-visible {
214
+ .trigger-button.error:focus-within {
165
215
  outline-color: var(--rel-border-danger);
166
216
  border-color: var(--rel-border-danger);
167
217
  }
@@ -185,15 +235,6 @@ export class RelTimePicker extends LitElement {
185
235
  letter-spacing: var(--rel-body-lg-regular-letter-spacing);
186
236
  }
187
237
 
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
238
  .trigger-icons {
198
239
  display: flex;
199
240
  align-items: center;
@@ -224,16 +265,36 @@ export class RelTimePicker extends LitElement {
224
265
  height: 16px;
225
266
  }
226
267
 
268
+ /* Panel (Figma "Time Picker"): fixed width per size, full-bleed content. */
227
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;
228
281
  position: absolute;
229
282
  top: calc(100% + 4px);
230
283
  left: 0;
231
- z-index: 1000;
284
+ z-index: 2147483647;
232
285
  background: var(--rel-bg-surface);
233
- border: 1px solid var(--rel-border-default);
286
+ border: 1px solid var(--rel-border-subtle);
234
287
  border-radius: var(--rel-border-radius-sm);
235
288
  box-shadow: var(--rel-shadows-shadow-300);
236
- padding: var(--rel-spacing-md);
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));
295
+ width: 250px;
296
+ max-width: calc(100vw - 16px);
297
+ max-height: calc(100vh - 16px);
237
298
  display: none;
238
299
  }
239
300
 
@@ -241,94 +302,122 @@ export class RelTimePicker extends LitElement {
241
302
  display: block;
242
303
  }
243
304
 
244
- /* Draft readout (mirrors the range-picker FROM/TO block) */
245
- .time-readout {
305
+ /* Typed-shortcut hints ("N → Now"); each chip applies its value. */
306
+ .shortcut-row {
246
307
  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);
308
+ flex-wrap: wrap;
309
+ gap: var(--rel-spacing-2xs);
310
+ padding: var(--rel-spacing-2xs) var(--rel-spacing-xs);
251
311
  border-bottom: 1px solid var(--rel-border-subtle);
252
312
  }
253
313
 
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);
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);
262
330
  }
263
331
 
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);
332
+ .shortcut-chip:hover {
333
+ background: var(--rel-bg-neutral-elevated-1);
271
334
  }
272
335
 
273
- .time-readout-value.empty {
274
- color: var(--rel-text-placeholder);
336
+ .shortcut-chip:focus-visible {
337
+ outline: 2px solid var(--rel-border-focus);
338
+ outline-offset: -2px;
275
339
  }
276
340
 
277
- .columns {
278
- display: flex;
279
- gap: var(--rel-spacing-xs);
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);
280
352
  }
281
353
 
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 {
354
+ /* Full-bleed column row with 1px dividers (Figma "Columns"). */
355
+ .columns {
285
356
  display: flex;
286
- flex-direction: column;
287
- flex: 1;
288
- min-width: 64px;
357
+ align-items: stretch;
358
+ height: 260px;
289
359
  }
290
360
 
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);
361
+ .column-divider {
362
+ flex-shrink: 0;
363
+ width: 1px;
364
+ background: var(--rel-border-subtle);
301
365
  }
302
366
 
367
+ /* Each column scrolls independently. The 4px rail is always reserved
368
+ (scrollbar-gutter) so columns stay aligned whether or not they
369
+ overflow; the thumb only appears on overflow. */
303
370
  .column-list {
371
+ flex: 1;
372
+ min-width: 0;
304
373
  display: flex;
305
374
  flex-direction: column;
306
- gap: var(--rel-spacing-3xs);
307
- max-height: 224px;
375
+ /* Positioned so option offsetTop is list-relative — the in-column
376
+ scroll math depends on it (we never scrollIntoView: it scrolls every
377
+ ancestor, page included). */
378
+ position: relative;
308
379
  overflow-y: auto;
309
- padding: var(--rel-spacing-3xs);
380
+ padding: var(--rel-spacing-2xs);
310
381
  scrollbar-width: thin;
382
+ scrollbar-color: var(--rel-border-default) transparent;
383
+ scrollbar-gutter: stable;
384
+ }
385
+
386
+ .column-list::-webkit-scrollbar {
387
+ width: 4px;
388
+ }
389
+
390
+ .column-list::-webkit-scrollbar-track {
391
+ background: transparent;
392
+ }
393
+
394
+ .column-list::-webkit-scrollbar-thumb {
395
+ background: var(--rel-border-default);
396
+ border-radius: var(--rel-border-radius-full-rounded);
311
397
  }
312
398
 
399
+ /* Option cell (Figma ".Time Option"): selected uses the neutral
400
+ elevated-2 fill + medium weight, NOT the brand color. */
313
401
  .option-button {
314
402
  border: none;
315
403
  background: transparent;
316
- border-radius: var(--rel-border-radius-sm);
404
+ border-radius: var(--rel-border-radius-xs);
317
405
  color: var(--rel-text-default);
318
406
  cursor: pointer;
319
- padding: var(--rel-spacing-xs) var(--rel-spacing-sm);
320
- text-align: center;
321
407
  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);
408
+ height: 36px;
409
+ padding: 0 var(--rel-spacing-xs);
410
+ text-align: center;
411
+ transition: background 0.15s ease;
412
+ font-family: var(--rel-body-md-regular-font-family);
413
+ font-weight: var(--rel-body-md-regular-font-weight);
414
+ font-size: var(--rel-body-md-regular-font-size);
415
+ line-height: var(--rel-body-md-regular-line-height);
416
+ letter-spacing: var(--rel-body-md-regular-letter-spacing);
328
417
  }
329
418
 
330
419
  .option-button:hover:not(:disabled):not(.selected) {
331
- background: var(--rel-bg-surface-elevated);
420
+ background: var(--rel-bg-neutral-elevated-1);
332
421
  }
333
422
 
334
423
  .option-button:focus-visible {
@@ -338,9 +427,9 @@ export class RelTimePicker extends LitElement {
338
427
  }
339
428
 
340
429
  .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);
430
+ background: var(--rel-bg-neutral-elevated-2);
431
+ color: var(--rel-text-default);
432
+ font-weight: var(--rel-body-md-medium-font-weight);
344
433
  }
345
434
 
346
435
  .option-button:disabled {
@@ -353,14 +442,15 @@ export class RelTimePicker extends LitElement {
353
442
  background: transparent;
354
443
  }
355
444
 
356
- /* Footer (mirrors the range-picker calendar-footer) */
445
+ /* Footer (Figma "Footer"): 8px padding + 8px extra left so the "Now"
446
+ link aligns with the buttons' inner padding. Buttons are always S. */
357
447
  .time-footer {
358
448
  display: flex;
359
449
  align-items: center;
360
450
  justify-content: space-between;
361
- gap: var(--rel-spacing-sm);
362
- padding-top: var(--rel-spacing-sm);
363
- margin-top: var(--rel-spacing-sm);
451
+ gap: var(--rel-spacing-xs);
452
+ padding: var(--rel-spacing-xs);
453
+ padding-left: calc(var(--rel-spacing-xs) * 2);
364
454
  border-top: 1px solid var(--rel-border-subtle);
365
455
  }
366
456
 
@@ -370,6 +460,56 @@ export class RelTimePicker extends LitElement {
370
460
  gap: var(--rel-spacing-xs);
371
461
  }
372
462
 
463
+ /* The design narrows OK below the DS small min-width (60 vs 80) so the
464
+ compact footer fits the 220px panel. */
465
+ .footer-right rel-button[variant="primary"] {
466
+ --rel-button-min-width: 60px;
467
+ }
468
+
469
+ /* Panel size variants (Figma S/M/L): width 220/250/280, option height
470
+ 32/36/40, body-sm/md/lg type. Medium is the base above. */
471
+ .time-picker-wrapper.small .popover {
472
+ width: 220px;
473
+ }
474
+
475
+ .time-picker-wrapper.small .columns {
476
+ height: 232px;
477
+ }
478
+
479
+ .time-picker-wrapper.small .option-button {
480
+ height: 32px;
481
+ font-family: var(--rel-body-sm-regular-font-family);
482
+ font-weight: var(--rel-body-sm-regular-font-weight);
483
+ font-size: var(--rel-body-sm-regular-font-size);
484
+ line-height: var(--rel-body-sm-regular-line-height);
485
+ letter-spacing: var(--rel-body-sm-regular-letter-spacing);
486
+ }
487
+
488
+ .time-picker-wrapper.small .option-button.selected {
489
+ font-weight: var(--rel-body-sm-medium-font-weight);
490
+ }
491
+
492
+ .time-picker-wrapper.large .popover {
493
+ width: 280px;
494
+ }
495
+
496
+ .time-picker-wrapper.large .columns {
497
+ height: 288px;
498
+ }
499
+
500
+ .time-picker-wrapper.large .option-button {
501
+ height: 40px;
502
+ font-family: var(--rel-body-lg-regular-font-family);
503
+ font-weight: var(--rel-body-lg-regular-font-weight);
504
+ font-size: var(--rel-body-lg-regular-font-size);
505
+ line-height: var(--rel-body-lg-regular-line-height);
506
+ letter-spacing: var(--rel-body-lg-regular-letter-spacing);
507
+ }
508
+
509
+ .time-picker-wrapper.large .option-button.selected {
510
+ font-weight: var(--rel-body-lg-medium-font-weight);
511
+ }
512
+
373
513
  @media (prefers-reduced-motion: reduce) {
374
514
  .trigger-button,
375
515
  .icon-button,
@@ -389,13 +529,17 @@ export class RelTimePicker extends LitElement {
389
529
  super.disconnectedCallback();
390
530
  document.removeEventListener("click", this.handleClickOutside);
391
531
  document.removeEventListener("keydown", this.handleEscapeKey);
532
+ window.removeEventListener("resize", this.onReposition);
533
+ window.removeEventListener("scroll", this.onReposition, true);
534
+ this.hideTopLayer();
392
535
  }
393
536
 
394
537
  private handleClickOutside = (e: MouseEvent) => {
395
538
  if (!this.open) return;
396
539
  const path = e.composedPath();
397
540
  if (!path.includes(this)) {
398
- this.cancelPopover();
541
+ // Don't refocus the field — the user clicked somewhere else on purpose.
542
+ this.cancelPopover(false);
399
543
  }
400
544
  };
401
545
 
@@ -405,49 +549,163 @@ export class RelTimePicker extends LitElement {
405
549
  }
406
550
  };
407
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.
408
555
  updated(changedProperties: Map<string, any>) {
409
- if (changedProperties.has("open") && this.open) {
556
+ if (changedProperties.has("open")) {
557
+ if (!this.open) {
558
+ window.removeEventListener("resize", this.onReposition);
559
+ window.removeEventListener("scroll", this.onReposition, true);
560
+ this.hideTopLayer();
561
+ this.focusPanelOnOpen = true;
562
+ return;
563
+ }
410
564
  this.resetDraftFromValue();
411
- // Focus the selected (or first enabled) hour and center each column's
412
- // selection once the popover has rendered.
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;
413
573
  setTimeout(() => {
414
574
  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();
575
+ if (focusPanel) this.focusPanelSelection();
423
576
  }, 0);
424
577
  }
425
578
  }
426
579
 
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 });
677
+ }
678
+
427
679
  // ── Parsing / formatting ──
428
680
 
429
- private parseTime(time: string | null): { hour: number; minute: number } | null {
681
+ private parseTime(
682
+ time: string | null
683
+ ): { hour: number; minute: number; second: number } | null {
430
684
  if (!time) return null;
431
- const match = time.match(/^(\d{1,2}):(\d{2})$/);
685
+ const match = time.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
432
686
  if (!match) return null;
433
687
  const hour = parseInt(match[1], 10);
434
688
  const minute = parseInt(match[2], 10);
435
- if (hour > 23 || minute > 59) return null;
436
- return { hour, minute };
689
+ const second = match[3] ? parseInt(match[3], 10) : 0;
690
+ if (hour > 23 || minute > 59 || second > 59) return null;
691
+ return { hour, minute, second };
437
692
  }
438
693
 
439
- private formatValue(hour: number, minute: number): string {
440
- return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
694
+ private formatValue(hour: number, minute: number, second = 0): string {
695
+ const hh = String(hour).padStart(2, "0");
696
+ const mm = String(minute).padStart(2, "0");
697
+ return this.seconds ? `${hh}:${mm}:${String(second).padStart(2, "0")}` : `${hh}:${mm}`;
441
698
  }
442
699
 
443
- private formatDisplay(hour: number, minute: number): string {
700
+ private formatDisplay(hour: number, minute: number, second = 0): string {
444
701
  const mm = String(minute).padStart(2, "0");
702
+ const ss = this.seconds ? `:${String(second).padStart(2, "0")}` : "";
445
703
  if (this.format === "24h") {
446
- return `${String(hour).padStart(2, "0")}:${mm}`;
704
+ return `${String(hour).padStart(2, "0")}:${mm}${ss}`;
447
705
  }
448
706
  const period = hour < 12 ? "AM" : "PM";
449
707
  const hour12 = hour % 12 === 0 ? 12 : hour % 12;
450
- return `${hour12}:${mm} ${period}`;
708
+ return `${hour12}:${mm}${ss} ${period}`;
451
709
  }
452
710
 
453
711
  private toMinutes(hour: number, minute: number): number {
@@ -504,13 +762,22 @@ export class RelTimePicker extends LitElement {
504
762
  hour24: h,
505
763
  }));
506
764
  }
507
- // 12-hour clock, chronological within the current period: 12, 01 … 11
765
+ // 12-hour clock, 01 … 12 as in the Figma "Hour 12" column ("12" maps to
766
+ // 00:xx AM / 12:xx PM).
508
767
  const period = this.draftPeriod ?? "AM";
509
768
  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
- }));
769
+ return Array.from({ length: 12 }, (_, i) => {
770
+ const display = i + 1;
771
+ return {
772
+ label: String(display).padStart(2, "0"),
773
+ hour24: base + (display % 12),
774
+ };
775
+ });
776
+ }
777
+
778
+ /** Seconds shown in the seconds column (always step 1). */
779
+ private getSecondOptions(): number[] {
780
+ return Array.from({ length: 60 }, (_, s) => s);
514
781
  }
515
782
 
516
783
  private get draftPeriod(): "AM" | "PM" | null {
@@ -522,6 +789,7 @@ export class RelTimePicker extends LitElement {
522
789
  const parsed = this.parseTime(this.value);
523
790
  this.draftHour = parsed ? parsed.hour : null;
524
791
  this.draftMinute = parsed ? parsed.minute : null;
792
+ this.draftSecond = parsed ? parsed.second : null;
525
793
  }
526
794
 
527
795
  /** Snap the draft minute to the nearest valid step minute for the hour. */
@@ -555,9 +823,9 @@ export class RelTimePicker extends LitElement {
555
823
  }
556
824
 
557
825
  /** Close discarding the draft (Cancel / Escape / outside click). */
558
- private cancelPopover() {
826
+ private cancelPopover(refocus = true) {
559
827
  this.open = false;
560
- this.triggerButton?.focus();
828
+ if (refocus) this.triggerInput?.focus();
561
829
  this.dispatchEvent(
562
830
  new CustomEvent("rel-open-change", {
563
831
  detail: { open: false },
@@ -569,8 +837,20 @@ export class RelTimePicker extends LitElement {
569
837
 
570
838
  private applySelection() {
571
839
  if (this.draftHour === null) return;
572
- const newValue = this.formatValue(this.draftHour, this.draftMinute ?? 0);
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);
573
851
  this.value = newValue;
852
+ this.inputText = null;
853
+ this.inputError = null;
574
854
  this.dispatchEvent(
575
855
  new CustomEvent("rel-change", {
576
856
  detail: { value: newValue },
@@ -578,7 +858,6 @@ export class RelTimePicker extends LitElement {
578
858
  composed: true,
579
859
  })
580
860
  );
581
- this.cancelPopover();
582
861
  }
583
862
 
584
863
  /** Set the draft to the current time, snapped to the minute step and range. */
@@ -586,6 +865,7 @@ export class RelTimePicker extends LitElement {
586
865
  const now = new Date();
587
866
  const step = this.minuteStepSafe;
588
867
  this.draftHour = now.getHours();
868
+ this.draftSecond = this.seconds ? now.getSeconds() : null;
589
869
  this.draftMinute = Math.round(now.getMinutes() / step) * step;
590
870
  if (this.draftMinute >= 60) {
591
871
  this.draftMinute = 0;
@@ -610,6 +890,9 @@ export class RelTimePicker extends LitElement {
610
890
  this.value = null;
611
891
  this.draftHour = null;
612
892
  this.draftMinute = null;
893
+ this.draftSecond = null;
894
+ this.inputText = null;
895
+ this.inputError = null;
613
896
  this.dispatchEvent(
614
897
  new CustomEvent("rel-change", {
615
898
  detail: { value: null },
@@ -619,6 +902,73 @@ export class RelTimePicker extends LitElement {
619
902
  );
620
903
  }
621
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
+
622
972
  // ── Column selection ──
623
973
 
624
974
  private selectHour(hour24: number) {
@@ -643,6 +993,22 @@ export class RelTimePicker extends LitElement {
643
993
  this.draftMinute = minute;
644
994
  }
645
995
 
996
+ private selectSecond(second: number) {
997
+ if (this.draftHour === null) {
998
+ // Second picked first — default the hour to the first enabled one.
999
+ for (let h = 0; h < 24; h++) {
1000
+ if (this.isHourEnabled(h)) {
1001
+ this.draftHour = h;
1002
+ break;
1003
+ }
1004
+ }
1005
+ if (this.draftHour === null) return;
1006
+ }
1007
+ if (this.draftMinute === null) this.draftMinute = 0;
1008
+ this.draftSecond = second;
1009
+ this.clampDraftToRange();
1010
+ }
1011
+
646
1012
  private selectPeriod(period: "AM" | "PM") {
647
1013
  if (!this.isPeriodEnabled(period)) return;
648
1014
  const base = period === "AM" ? 0 : 12;
@@ -663,25 +1029,111 @@ export class RelTimePicker extends LitElement {
663
1029
  this.scrollColumnsToSelection();
664
1030
  }
665
1031
 
1032
+ /**
1033
+ * Scrolls the column's own scroll container only. scrollIntoView is off
1034
+ * limits here: it walks ALL scrollable ancestors, so it yanked the page
1035
+ * up/down whenever a column recentred (user-reported jump bug).
1036
+ */
1037
+ private scrollOptionInColumn(btn: HTMLElement, block: "center" | "nearest") {
1038
+ const list = btn.parentElement;
1039
+ if (!list) return;
1040
+ const top = btn.offsetTop;
1041
+ const height = btn.offsetHeight;
1042
+ if (block === "center") {
1043
+ list.scrollTop = top - (list.clientHeight - height) / 2;
1044
+ } else if (top < list.scrollTop) {
1045
+ list.scrollTop = top;
1046
+ } else if (top + height > list.scrollTop + list.clientHeight) {
1047
+ list.scrollTop = top + height - list.clientHeight;
1048
+ }
1049
+ }
1050
+
666
1051
  private scrollColumnsToSelection() {
667
1052
  this.updateComplete.then(() => {
668
1053
  this.columnsElement
669
1054
  ?.querySelectorAll<HTMLButtonElement>(".option-button.selected")
670
- .forEach((btn) => btn.scrollIntoView({ block: "center" }));
1055
+ .forEach((btn) => this.scrollOptionInColumn(btn, "center"));
671
1056
  });
672
1057
  }
673
1058
 
674
1059
  // ── Keyboard ──
675
1060
 
676
- private handleTriggerKeydown(e: KeyboardEvent) {
677
- 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") {
678
1082
  e.preventDefault();
679
1083
  if (!this.open) {
680
1084
  this.toggleTimePopover();
1085
+ } else {
1086
+ this.focusPanelSelection();
681
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;
682
1094
  }
683
1095
  }
684
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
+
685
1137
  /**
686
1138
  * Column navigation: Up/Down move within the column (and select), Left/Right
687
1139
  * jump between columns, Home/End go to the first/last option, Enter applies.
@@ -729,14 +1181,15 @@ export class RelTimePicker extends LitElement {
729
1181
 
730
1182
  if (target && target !== buttons[index]) {
731
1183
  target.click();
732
- target.focus();
733
- target.scrollIntoView({ block: "nearest" });
1184
+ target.focus({ preventScroll: true });
1185
+ this.scrollOptionInColumn(target, "nearest");
734
1186
  }
735
1187
  }
736
1188
 
737
1189
  private focusAdjacentColumn(from: TimeColumn, direction: 1 | -1) {
738
- const order: TimeColumn[] =
739
- this.format === "12h" ? ["hour", "minute", "period"] : ["hour", "minute"];
1190
+ const order: TimeColumn[] = ["hour", "minute"];
1191
+ if (this.seconds) order.push("second");
1192
+ if (this.format === "12h") order.push("period");
740
1193
  const nextIndex = order.indexOf(from) + direction;
741
1194
  if (nextIndex < 0 || nextIndex >= order.length) return;
742
1195
  const lists = this.columnsElement?.querySelectorAll(".column-list");
@@ -744,26 +1197,27 @@ export class RelTimePicker extends LitElement {
744
1197
  const target =
745
1198
  list?.querySelector<HTMLButtonElement>(".option-button.selected:not(:disabled)") ??
746
1199
  list?.querySelector<HTMLButtonElement>(".option-button:not(:disabled)");
747
- target?.focus();
748
- target?.scrollIntoView({ block: "nearest" });
1200
+ if (target) {
1201
+ target.focus({ preventScroll: true });
1202
+ this.scrollOptionInColumn(target, "nearest");
1203
+ }
749
1204
  }
750
1205
 
751
1206
  // ── Render ──
752
1207
 
753
1208
  render() {
754
- const isError = this.error || this.invalid;
755
- 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;
756
1213
  const describedById = belowText ? "time-picker-desc" : undefined;
757
1214
  const parsedValue = this.parseTime(this.value);
758
1215
  const displayText = parsedValue
759
- ? this.formatDisplay(parsedValue.hour, parsedValue.minute)
1216
+ ? this.formatDisplay(parsedValue.hour, parsedValue.minute, parsedValue.second)
760
1217
  : "";
761
- const draftDisplay =
762
- this.draftHour !== null
763
- ? this.formatDisplay(this.draftHour, this.draftMinute ?? 0)
764
- : "";
765
1218
  const hourOptions = this.getHourOptions();
766
1219
  const minuteOptions = this.getMinuteOptions();
1220
+ const secondOptions = this.seconds ? this.getSecondOptions() : [];
767
1221
  const draftPeriod = this.draftPeriod;
768
1222
  const wrapperClasses = [
769
1223
  "time-picker-wrapper",
@@ -788,33 +1242,39 @@ export class RelTimePicker extends LitElement {
788
1242
  ? html`<input type="hidden" name=${this.name} .value=${this.value ?? ""} />`
789
1243
  : nothing}
790
1244
 
791
- <button
1245
+ <div
792
1246
  part="trigger"
793
1247
  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}
1248
+ @click=${this.handleFieldClick}
814
1249
  >
815
- <span part="value" class="trigger-content ${displayText ? "" : "placeholder"}">
816
- ${displayText || this.placeholder}
817
- </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
+ />
818
1278
  <span class="trigger-icons">
819
1279
  ${this.clearable && this.value
820
1280
  ? html`
@@ -835,126 +1295,158 @@ export class RelTimePicker extends LitElement {
835
1295
  </button>
836
1296
  `
837
1297
  : nothing}
838
- <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>
839
1308
  </span>
840
- </button>
1309
+ </div>
841
1310
 
842
1311
  <div
843
1312
  part="panel"
1313
+ id="time-picker-panel"
844
1314
  class="popover ${this.open ? "open" : ""}"
1315
+ popover="manual"
845
1316
  role="dialog"
846
1317
  aria-label="Choose time"
1318
+ @keydown=${this.handlePanelKeydown}
847
1319
  >
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>
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>
853
1329
  </div>
854
-
855
1330
  <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>
1331
+ <div class="column-list" role="listbox" aria-label="Hours">
1332
+ ${hourOptions.map(
1333
+ (opt, i) => html`
1334
+ <button
1335
+ part="option"
1336
+ class="option-button ${this.draftHour === opt.hour24
1337
+ ? "selected"
1338
+ : ""}"
1339
+ type="button"
1340
+ role="option"
1341
+ aria-selected=${this.draftHour === opt.hour24}
1342
+ ?disabled=${!this.isHourEnabled(opt.hour24)}
1343
+ @click=${() => this.selectHour(opt.hour24)}
1344
+ @keydown=${(e: KeyboardEvent) => this.handleOptionKeydown(e, "hour")}
1345
+ tabindex=${this.draftHour === opt.hour24 ||
1346
+ (this.draftHour === null && i === 0)
1347
+ ? 0
1348
+ : -1}
1349
+ >
1350
+ ${opt.label}
1351
+ </button>
1352
+ `
1353
+ )}
882
1354
  </div>
883
1355
 
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>
1356
+ <div class="column-divider" aria-hidden="true"></div>
1357
+
1358
+ <div class="column-list" role="listbox" aria-label="Minutes">
1359
+ ${minuteOptions.map(
1360
+ (m, i) => html`
1361
+ <button
1362
+ part="option"
1363
+ class="option-button ${this.draftMinute === m ? "selected" : ""}"
1364
+ type="button"
1365
+ role="option"
1366
+ aria-selected=${this.draftMinute === m}
1367
+ ?disabled=${this.draftHour !== null &&
1368
+ !this.isTimeInRange(this.draftHour, m)}
1369
+ @click=${() => this.selectMinute(m)}
1370
+ @keydown=${(e: KeyboardEvent) =>
1371
+ this.handleOptionKeydown(e, "minute")}
1372
+ tabindex=${this.draftMinute === m ||
1373
+ (this.draftMinute === null && i === 0)
1374
+ ? 0
1375
+ : -1}
1376
+ >
1377
+ ${String(m).padStart(2, "0")}
1378
+ </button>
1379
+ `
1380
+ )}
910
1381
  </div>
911
1382
 
1383
+ ${this.seconds
1384
+ ? html`
1385
+ <div class="column-divider" aria-hidden="true"></div>
1386
+ <div class="column-list" role="listbox" aria-label="Seconds">
1387
+ ${secondOptions.map(
1388
+ (s, i) => html`
1389
+ <button
1390
+ part="option"
1391
+ class="option-button ${this.draftSecond === s
1392
+ ? "selected"
1393
+ : ""}"
1394
+ type="button"
1395
+ role="option"
1396
+ aria-selected=${this.draftSecond === s}
1397
+ @click=${() => this.selectSecond(s)}
1398
+ @keydown=${(e: KeyboardEvent) =>
1399
+ this.handleOptionKeydown(e, "second")}
1400
+ tabindex=${this.draftSecond === s ||
1401
+ (this.draftSecond === null && i === 0)
1402
+ ? 0
1403
+ : -1}
1404
+ >
1405
+ ${String(s).padStart(2, "0")}
1406
+ </button>
1407
+ `
1408
+ )}
1409
+ </div>
1410
+ `
1411
+ : nothing}
912
1412
  ${this.format === "12h"
913
1413
  ? 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>
1414
+ <div class="column-divider" aria-hidden="true"></div>
1415
+ <div class="column-list" role="listbox" aria-label="AM or PM">
1416
+ ${(["AM", "PM"] as const).map(
1417
+ (p) => html`
1418
+ <button
1419
+ part="option"
1420
+ class="option-button ${draftPeriod === p ? "selected" : ""}"
1421
+ type="button"
1422
+ role="option"
1423
+ aria-selected=${draftPeriod === p}
1424
+ ?disabled=${!this.isPeriodEnabled(p)}
1425
+ @click=${() => this.selectPeriod(p)}
1426
+ @keydown=${(e: KeyboardEvent) =>
1427
+ this.handleOptionKeydown(e, "period")}
1428
+ tabindex=${draftPeriod === p ||
1429
+ (draftPeriod === null && p === "AM")
1430
+ ? 0
1431
+ : -1}
1432
+ >
1433
+ ${p}
1434
+ </button>
1435
+ `
1436
+ )}
939
1437
  </div>
940
1438
  `
941
1439
  : nothing}
942
1440
  </div>
943
1441
 
944
1442
  <div part="footer" class="time-footer">
945
- <rel-button
946
- variant="secondary"
947
- size="small"
948
- border="corner-radius"
949
- @click=${() => this.selectNow()}
950
- >
1443
+ <rel-button variant="link" size="small" @click=${() => this.selectNow()}>
951
1444
  Now
952
1445
  </rel-button>
953
1446
  <div class="footer-right">
954
1447
  <rel-button
955
- variant="secondary"
1448
+ variant="ghost"
956
1449
  size="small"
957
- border="corner-radius"
958
1450
  @click=${() => this.cancelPopover()}
959
1451
  >
960
1452
  Cancel
@@ -963,25 +1455,21 @@ export class RelTimePicker extends LitElement {
963
1455
  variant="primary"
964
1456
  tone="default"
965
1457
  size="small"
966
- border="corner-radius"
967
1458
  ?disabled=${this.draftHour === null}
968
1459
  @click=${() => this.applySelection()}
969
1460
  >
970
- Apply
1461
+ OK
971
1462
  </rel-button>
972
1463
  </div>
973
1464
  </div>
974
1465
  </div>
975
1466
 
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}
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>
985
1473
  </div>
986
1474
  `;
987
1475
  }