@alacris/ui 0.0.0 → 0.1.0

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 (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -1
  3. package/package.json +75 -3
  4. package/src/components/base.js +46 -0
  5. package/src/components/ui-accordion-item.js +157 -0
  6. package/src/components/ui-accordion.js +58 -0
  7. package/src/components/ui-alert.js +168 -0
  8. package/src/components/ui-app-bar.js +119 -0
  9. package/src/components/ui-autocomplete.js +371 -0
  10. package/src/components/ui-avatar.js +104 -0
  11. package/src/components/ui-backdrop.js +66 -0
  12. package/src/components/ui-badge.js +95 -0
  13. package/src/components/ui-bottom-app-bar.js +78 -0
  14. package/src/components/ui-bottom-nav.js +83 -0
  15. package/src/components/ui-breadcrumbs.js +100 -0
  16. package/src/components/ui-button-group.js +49 -0
  17. package/src/components/ui-button.js +143 -0
  18. package/src/components/ui-card.js +86 -0
  19. package/src/components/ui-carousel-item.js +53 -0
  20. package/src/components/ui-carousel.js +301 -0
  21. package/src/components/ui-checkbox.js +165 -0
  22. package/src/components/ui-chip-set.js +90 -0
  23. package/src/components/ui-chip.js +226 -0
  24. package/src/components/ui-container.js +59 -0
  25. package/src/components/ui-date-picker.js +774 -0
  26. package/src/components/ui-dialog.js +179 -0
  27. package/src/components/ui-divider.js +58 -0
  28. package/src/components/ui-drawer.js +169 -0
  29. package/src/components/ui-fab-menu.js +122 -0
  30. package/src/components/ui-fab.js +117 -0
  31. package/src/components/ui-icon-button.js +112 -0
  32. package/src/components/ui-icon.js +64 -0
  33. package/src/components/ui-list-item.js +166 -0
  34. package/src/components/ui-list.js +38 -0
  35. package/src/components/ui-loading-indicator.js +74 -0
  36. package/src/components/ui-menu-item.js +117 -0
  37. package/src/components/ui-menu.js +192 -0
  38. package/src/components/ui-nav-item.js +138 -0
  39. package/src/components/ui-nav-rail.js +111 -0
  40. package/src/components/ui-option.js +85 -0
  41. package/src/components/ui-pagination.js +173 -0
  42. package/src/components/ui-progress.js +100 -0
  43. package/src/components/ui-radio-group.js +104 -0
  44. package/src/components/ui-radio.js +143 -0
  45. package/src/components/ui-rating.js +126 -0
  46. package/src/components/ui-search.js +316 -0
  47. package/src/components/ui-select.js +418 -0
  48. package/src/components/ui-sheet.js +205 -0
  49. package/src/components/ui-side-sheet.js +221 -0
  50. package/src/components/ui-skeleton.js +89 -0
  51. package/src/components/ui-slider.js +266 -0
  52. package/src/components/ui-snackbar.js +233 -0
  53. package/src/components/ui-spinner.js +101 -0
  54. package/src/components/ui-split-button.js +158 -0
  55. package/src/components/ui-stack.js +37 -0
  56. package/src/components/ui-step.js +91 -0
  57. package/src/components/ui-stepper.js +86 -0
  58. package/src/components/ui-surface.js +51 -0
  59. package/src/components/ui-switch.js +153 -0
  60. package/src/components/ui-tab-panel.js +54 -0
  61. package/src/components/ui-tab.js +116 -0
  62. package/src/components/ui-table-footer.js +141 -0
  63. package/src/components/ui-table-toolbar.js +219 -0
  64. package/src/components/ui-table.js +735 -0
  65. package/src/components/ui-tabs.js +178 -0
  66. package/src/components/ui-text-field.js +319 -0
  67. package/src/components/ui-text.js +36 -0
  68. package/src/components/ui-time-picker.js +753 -0
  69. package/src/components/ui-toggle-button.js +170 -0
  70. package/src/components/ui-toggle-group.js +111 -0
  71. package/src/components/ui-toolbar.js +64 -0
  72. package/src/components/ui-tooltip.js +148 -0
  73. package/src/index.js +121 -0
  74. package/src/motion/animate.js +145 -0
  75. package/src/motion/flip.js +46 -0
  76. package/src/motion/index.js +4 -0
  77. package/src/motion/presence.js +104 -0
  78. package/src/motion/ripple.js +108 -0
  79. package/src/theme/apply-theme.js +142 -0
  80. package/src/theme/create-theme.js +70 -0
  81. package/src/theme/index.js +5 -0
  82. package/src/tokens/color.js +237 -0
  83. package/src/tokens/index.js +14 -0
  84. package/src/tokens/sys.js +76 -0
  85. package/src/tokens/system.js +114 -0
  86. package/src/tokens/typography.js +189 -0
  87. package/src/util/focus.js +89 -0
  88. package/src/util/form.js +69 -0
  89. package/src/util/icons.js +161 -0
  90. package/src/util/keys.js +110 -0
  91. package/src/util/position.js +102 -0
  92. package/src/util/table.js +230 -0
  93. package/types/elements.d.ts +78 -0
  94. package/types/index.d.ts +168 -0
@@ -0,0 +1,753 @@
1
+ // <ui-time-picker> — Material time picker: a text-field-style control that
2
+ // opens an hour/minute chooser. Value is a 24-hour `HH:mm` string.
3
+ //
4
+ // <ui-time-picker label="Alarm" value=${time}
5
+ // @change=${(e) => time(e.detail.value)}></ui-time-picker>
6
+ //
7
+ // The keyboard icon in the panel toggles between the analog dial and the
8
+ // digital hour/minute grids (MD3 input-method toggle). Hour and minute
9
+ // faces crossfade; the clock hand rotates with the motion tokens.
10
+ //
11
+ // @prop {string} label=''
12
+ // @prop {string} value='' — 24-hour HH:mm, or '' for none
13
+ // @prop {string} variant='filled' — filled | outlined
14
+ // @prop {string} view='clock' — clock | input (dial vs digital grid)
15
+ // @prop {string} hourCycle='12' — 12 | 24
16
+ // @prop {number} minuteStep=5 — minute choices (1, 5, or 15 typical)
17
+ // @prop {string} locale='' — BCP 47 tag; empty uses the runtime locale
18
+ // @prop {boolean} disabled=false
19
+ // @prop {boolean} required=false
20
+ // @prop {string} name='' — form participation
21
+ // @prop {string} placeholder=''
22
+ // @event change — committed; detail: { value }
23
+ // @event input — field keystroke; detail: { value } (the raw text)
24
+ // @event open — panel visible (after the enter animation)
25
+ // @event close — panel removed (after the exit animation)
26
+ // @part field, input, label, panel, dial
27
+ // @vars see `t` below (`themeVars.names`)
28
+
29
+ import { define, html, css, vars, computed, signal, effect, onCleanup, each } from '@alacris/core';
30
+ import { sys } from '../tokens/sys.js';
31
+ import { base } from './base.js';
32
+ import { formBind } from '../util/form.js';
33
+ import { presence } from '../motion/presence.js';
34
+ import { fx } from '../motion/animate.js';
35
+ import { autoUpdate } from '../util/position.js';
36
+ import { rovingTabindex } from '../util/keys.js';
37
+ import './ui-icon-button.js';
38
+
39
+ const TIME = /^(\d{1,2}):(\d{2})$/;
40
+ const pad = (n) => String(n).padStart(2, '0');
41
+ const toValue = (h, m) => `${pad(((h % 24) + 24) % 24)}:${pad(((m % 60) + 60) % 60)}`;
42
+ const parseTime = (s) => {
43
+ const m = TIME.exec((s || '').trim());
44
+ if (!m) return null;
45
+ const h = +m[1];
46
+ const min = +m[2];
47
+ if (h > 23 || min > 59) return null;
48
+ return { h, m: min };
49
+ };
50
+ const loc = (locale) => locale || undefined;
51
+ const formatTime = (value, locale, hourCycle) => {
52
+ const t = parseTime(value);
53
+ if (!t) return '';
54
+ const d = new Date(2026, 0, 1, t.h, t.m);
55
+ return new Intl.DateTimeFormat(loc(locale), {
56
+ hour: 'numeric',
57
+ minute: '2-digit',
58
+ hourCycle: hourCycle === '24' ? 'h23' : 'h12',
59
+ }).format(d);
60
+ };
61
+ const nowValue = () => {
62
+ const n = new Date();
63
+ return toValue(n.getHours(), n.getMinutes());
64
+ };
65
+
66
+ const t = vars('ui-time-picker', {
67
+ bg: sys.color.surfaceContainerHighest,
68
+ fg: sys.color.onSurface,
69
+ labelFg: sys.color.onSurfaceVariant,
70
+ accent: sys.color.primary,
71
+ onAccent: sys.color.onPrimary,
72
+ outlineColor: sys.color.outline,
73
+ radius: sys.radius.xs,
74
+ font: sys.type.bodyLg,
75
+ height: '56px',
76
+ panelBg: sys.color.surfaceContainerHigh,
77
+ panelRadius: sys.radius.xl,
78
+ digitBg: sys.color.surfaceContainerHighest,
79
+ selectedBg: sys.color.primaryContainer,
80
+ selectedFg: sys.color.onPrimaryContainer,
81
+ });
82
+
83
+ const styles = css`
84
+ :host { display: block; inline-size: 240px; }
85
+ .root { display: block; position: relative; }
86
+ .field {
87
+ position: relative;
88
+ display: flex;
89
+ align-items: center;
90
+ gap: ${sys.space(1)};
91
+ min-block-size: calc(${t.height} + var(--ui-density, 0) * 4px);
92
+ padding-inline: ${sys.space(4)} ${sys.space(1)};
93
+ border-radius: ${t.radius};
94
+ font: ${t.font};
95
+ letter-spacing: ${sys.tracking.bodyLg};
96
+ color: ${t.fg};
97
+ cursor: text;
98
+ --ui-icon-size: 1.5rem;
99
+ }
100
+ .filled .field {
101
+ background: ${t.bg};
102
+ border-start-start-radius: ${t.radius};
103
+ border-start-end-radius: ${t.radius};
104
+ border-end-start-radius: 0;
105
+ border-end-end-radius: 0;
106
+ }
107
+ .filled .field::after {
108
+ content: '';
109
+ position: absolute;
110
+ inset-inline: 0;
111
+ inset-block-end: 0;
112
+ block-size: 1px;
113
+ background: ${sys.color.onSurfaceVariant};
114
+ transition: block-size ${sys.duration.short2} ${sys.easing.standard},
115
+ background-color ${sys.duration.short2} ${sys.easing.standard};
116
+ }
117
+ .filled:focus-within .field::after,
118
+ .filled.open .field::after { block-size: 2px; background: ${t.accent}; }
119
+ .filled .field::before {
120
+ content: '';
121
+ position: absolute; inset: 0; pointer-events: none;
122
+ background: ${sys.color.onSurface};
123
+ opacity: 0;
124
+ border-radius: inherit;
125
+ transition: opacity ${sys.duration.short2} ${sys.easing.standard};
126
+ }
127
+ .filled:hover:not(:focus-within):not(.open):not(.disabled) .field::before {
128
+ opacity: ${sys.state.hover};
129
+ }
130
+ .filled:hover:not(:focus-within):not(.open):not(.disabled) .field::after {
131
+ background: ${sys.color.onSurface};
132
+ }
133
+
134
+ fieldset {
135
+ position: absolute;
136
+ inset: -6px 0 0;
137
+ margin: 0;
138
+ padding: 0 calc(${sys.space(3)} - 2px);
139
+ min-inline-size: 0;
140
+ box-sizing: border-box;
141
+ appearance: none;
142
+ border: 1px solid ${t.outlineColor};
143
+ border-radius: ${t.radius};
144
+ pointer-events: none;
145
+ transition: border-color ${sys.duration.short2} ${sys.easing.standard},
146
+ border-width ${sys.duration.short2} ${sys.easing.standard};
147
+ }
148
+ legend {
149
+ float: unset;
150
+ display: block;
151
+ width: max-content;
152
+ padding: 0;
153
+ margin: 0;
154
+ margin-inline-start: 0;
155
+ white-space: nowrap;
156
+ overflow: hidden;
157
+ font: ${t.font};
158
+ font-size: 0.75em;
159
+ letter-spacing: calc(${sys.tracking.bodyLg} * 0.75);
160
+ visibility: hidden;
161
+ max-inline-size: 0.01px;
162
+ height: 12px;
163
+ line-height: 12px;
164
+ transition: max-inline-size ${sys.duration.short2} ${sys.easing.standard};
165
+ }
166
+ legend span {
167
+ padding-inline: ${sys.space(1)} calc(${sys.space(1)} - 0.5px);
168
+ display: inline-block;
169
+ opacity: 0;
170
+ visibility: visible;
171
+ }
172
+ .outlined.floating legend {
173
+ max-inline-size: 100%;
174
+ transition: max-inline-size ${sys.duration.short3} ${sys.easing.standard};
175
+ }
176
+ .outlined:focus-within fieldset,
177
+ .outlined.open fieldset { border-width: 2px; border-color: ${t.accent}; }
178
+ .root:hover:not(:focus-within):not(.open) fieldset { border-color: ${sys.color.onSurface}; }
179
+
180
+ .label {
181
+ position: absolute;
182
+ inset-inline-start: ${sys.space(4)};
183
+ inset-block-start: 50%;
184
+ translate: 0 -50%;
185
+ color: ${t.labelFg};
186
+ pointer-events: none;
187
+ transform-origin: 0 50%;
188
+ transition: translate ${sys.duration.short3} ${sys.easing.standard},
189
+ scale ${sys.duration.short3} ${sys.easing.standard},
190
+ color ${sys.duration.short2} ${sys.easing.standard};
191
+ }
192
+ .filled.floating .label { translate: 0 calc(-50% - 16px); scale: 0.75; }
193
+ .outlined.floating .label {
194
+ translate: 0 calc(-50% - (${t.height} + var(--ui-density, 0) * 4px) / 2);
195
+ scale: 0.75;
196
+ }
197
+ :focus-within .label, .open .label { color: ${t.accent}; }
198
+
199
+ input {
200
+ flex: 1;
201
+ min-inline-size: 0;
202
+ margin: 0;
203
+ border: none;
204
+ outline: none;
205
+ appearance: none;
206
+ background: transparent;
207
+ font: inherit;
208
+ letter-spacing: inherit;
209
+ color: inherit;
210
+ padding: 0;
211
+ }
212
+ .filled.has-label input { padding-block-start: 18px; }
213
+ input::placeholder { color: ${t.labelFg}; opacity: 0; }
214
+ .floating input::placeholder { opacity: 1; }
215
+
216
+ .panel {
217
+ position: fixed;
218
+ z-index: ${sys.z.modal};
219
+ display: flex;
220
+ flex-direction: column;
221
+ gap: ${sys.space(3)};
222
+ padding: ${sys.space(4)};
223
+ min-inline-size: 320px;
224
+ background: ${t.panelBg};
225
+ border-radius: ${t.panelRadius};
226
+ box-shadow: ${sys.elevation[3]};
227
+ color: ${t.fg};
228
+ overflow: auto;
229
+ }
230
+ .face {
231
+ display: flex;
232
+ align-items: center;
233
+ justify-content: center;
234
+ gap: ${sys.space(1)};
235
+ }
236
+ .digit {
237
+ min-inline-size: 72px;
238
+ block-size: 64px;
239
+ margin: 0;
240
+ padding: 0;
241
+ border: none;
242
+ outline: none;
243
+ appearance: none;
244
+ border-radius: ${sys.radius.sm};
245
+ background: ${t.digitBg};
246
+ color: ${t.fg};
247
+ font: ${sys.type.displaySm};
248
+ letter-spacing: ${sys.tracking.displaySm};
249
+ cursor: pointer;
250
+ transition: background-color ${sys.duration.short4} ${sys.easing.standard},
251
+ color ${sys.duration.short4} ${sys.easing.standard};
252
+ }
253
+ .digit.active { background: ${t.selectedBg}; color: ${t.selectedFg}; }
254
+ .digit:focus-visible { outline: ${sys.focus.ring}; outline-offset: 2px; }
255
+ .colon {
256
+ font: ${sys.type.displaySm};
257
+ color: ${t.fg};
258
+ padding-inline: calc(${sys.space(1)} / 2);
259
+ }
260
+ .period {
261
+ display: flex;
262
+ flex-direction: column;
263
+ margin-inline-start: ${sys.space(2)};
264
+ border: 1px solid ${t.outlineColor};
265
+ border-radius: ${sys.radius.xs};
266
+ overflow: hidden;
267
+ }
268
+ .period button {
269
+ min-inline-size: 48px;
270
+ block-size: 32px;
271
+ margin: 0;
272
+ padding: 0;
273
+ border: none;
274
+ outline: none;
275
+ appearance: none;
276
+ background: transparent;
277
+ color: ${t.labelFg};
278
+ font: ${sys.type.labelMd};
279
+ letter-spacing: ${sys.tracking.labelMd};
280
+ cursor: pointer;
281
+ transition: background-color ${sys.duration.short4} ${sys.easing.standard},
282
+ color ${sys.duration.short4} ${sys.easing.standard};
283
+ }
284
+ .period button.active { background: ${t.selectedBg}; color: ${t.selectedFg}; }
285
+ .period button:focus-visible { outline: ${sys.focus.ring}; outline-offset: -2px; }
286
+
287
+ .grid {
288
+ display: grid;
289
+ grid-template-columns: repeat(4, 1fr);
290
+ gap: ${sys.space(1)};
291
+ transition: opacity ${sys.duration.short4} ${sys.easing.standard};
292
+ }
293
+ .grid.off {
294
+ opacity: 0;
295
+ pointer-events: none;
296
+ }
297
+ .choice {
298
+ position: relative;
299
+ isolation: isolate;
300
+ block-size: 40px;
301
+ margin: 0;
302
+ padding: 0;
303
+ border: none;
304
+ outline: none;
305
+ appearance: none;
306
+ background: transparent;
307
+ border-radius: ${sys.radius.full};
308
+ font: ${sys.type.bodyMd};
309
+ letter-spacing: ${sys.tracking.bodyMd};
310
+ color: ${t.fg};
311
+ cursor: pointer;
312
+ transition: background-color ${sys.duration.short4} ${sys.easing.standard},
313
+ color ${sys.duration.short4} ${sys.easing.standard};
314
+ }
315
+ .choice .layer {
316
+ position: absolute; inset: 0; z-index: -1;
317
+ border-radius: inherit; background: currentColor; opacity: 0;
318
+ }
319
+ .choice:hover .layer { opacity: ${sys.state.hover}; }
320
+ .choice:active .layer { opacity: ${sys.state.pressed}; }
321
+ .choice.active { background: ${t.accent}; color: ${t.onAccent}; }
322
+ .choice:focus-visible { outline: ${sys.focus.ring}; outline-offset: -2px; }
323
+
324
+ .dial {
325
+ position: relative;
326
+ inline-size: 256px;
327
+ block-size: 256px;
328
+ margin-inline: auto;
329
+ border-radius: 50%;
330
+ background: ${t.digitBg};
331
+ }
332
+ .hand {
333
+ position: absolute;
334
+ inset-inline-start: 50%;
335
+ inset-block-start: 50%;
336
+ inline-size: 2px;
337
+ block-size: 108px;
338
+ margin-inline-start: -1px;
339
+ margin-block-start: -108px;
340
+ background: ${t.accent};
341
+ transform-origin: center bottom;
342
+ pointer-events: none;
343
+ z-index: 0;
344
+ transition: transform ${sys.duration.medium4} ${sys.easing.emphasized};
345
+ }
346
+ .hand::after {
347
+ content: '';
348
+ position: absolute;
349
+ inset-inline-start: 50%;
350
+ inset-block-start: 0;
351
+ inline-size: 40px;
352
+ block-size: 40px;
353
+ margin-inline-start: -20px;
354
+ margin-block-start: -20px;
355
+ border-radius: 50%;
356
+ background: ${t.accent};
357
+ }
358
+ .hub {
359
+ position: absolute;
360
+ inset: 0;
361
+ margin: auto;
362
+ inline-size: 8px;
363
+ block-size: 8px;
364
+ border-radius: 50%;
365
+ background: ${t.accent};
366
+ pointer-events: none;
367
+ z-index: 2;
368
+ }
369
+ .tick {
370
+ position: absolute;
371
+ inset: 0;
372
+ z-index: 1;
373
+ margin: auto;
374
+ inline-size: 40px;
375
+ block-size: 40px;
376
+ border: none;
377
+ outline: none;
378
+ appearance: none;
379
+ border-radius: 50%;
380
+ background: transparent;
381
+ color: ${t.fg};
382
+ font: ${sys.type.bodyMd};
383
+ letter-spacing: ${sys.tracking.bodyMd};
384
+ cursor: pointer;
385
+ transform: rotate(var(--a)) translateY(-108px) rotate(calc(-1 * var(--a)));
386
+ transition: color ${sys.duration.short4} ${sys.easing.standard};
387
+ }
388
+ .tick.inner {
389
+ transform: rotate(var(--a)) translateY(-68px) rotate(calc(-1 * var(--a)));
390
+ font: ${sys.type.bodySm};
391
+ letter-spacing: ${sys.tracking.bodySm};
392
+ }
393
+ .tick.active { color: ${t.onAccent}; }
394
+ .tick:focus-visible { outline: ${sys.focus.ring}; outline-offset: 2px; }
395
+ .ticks {
396
+ position: absolute;
397
+ inset: 0;
398
+ opacity: 0;
399
+ pointer-events: none;
400
+ scale: 0.85;
401
+ transition: opacity ${sys.duration.short4} ${sys.easing.standard},
402
+ scale ${sys.duration.medium2} ${sys.easing.emphasizedDecelerate};
403
+ }
404
+ .ticks.on {
405
+ opacity: 1;
406
+ pointer-events: auto;
407
+ scale: 1;
408
+ }
409
+ .pane { display: grid; }
410
+ .grids { display: grid; min-inline-size: 100%; }
411
+ .grids > .grid { grid-area: 1 / 1; }
412
+ .pane > * { grid-area: 1 / 1; }
413
+ .pane .dial, .pane .grids {
414
+ transition: opacity ${sys.duration.medium2} ${sys.easing.emphasizedDecelerate},
415
+ scale ${sys.duration.medium2} ${sys.easing.emphasizedDecelerate};
416
+ }
417
+ .pane .dial.off, .pane .grids.off {
418
+ opacity: 0;
419
+ scale: 0.96;
420
+ pointer-events: none;
421
+ }
422
+ .switch {
423
+ display: flex;
424
+ justify-content: flex-end;
425
+ padding-block-start: ${sys.space(1)};
426
+ }
427
+
428
+ .disabled { opacity: ${sys.state.disabledContent}; pointer-events: none; }
429
+ `;
430
+
431
+ define('ui-time-picker', {
432
+ formAssociated: true,
433
+ props: {
434
+ label: '', value: '', variant: 'filled', view: 'clock', hourCycle: '12', minuteStep: 5,
435
+ locale: '', disabled: false, required: false, name: '', placeholder: '',
436
+ },
437
+ styles: [base, styles],
438
+ setup(p, host) {
439
+ const { label, value, variant, view, hourCycle, minuteStep, locale, disabled, required, name, placeholder } = p;
440
+ formBind(host, { name, value, disabled });
441
+
442
+ const open = signal(false);
443
+ const focused = signal(false);
444
+ const text = signal('');
445
+ const selecting = signal('hour'); // 'hour' | 'minute'
446
+ const draft = signal(value() || nowValue());
447
+ const face = signal(view() === 'input' ? 'input' : 'clock');
448
+ let fieldEl = null;
449
+ let stopAuto = null;
450
+
451
+ effect(() => {
452
+ const v = value();
453
+ text.set(formatTime(v, locale(), hourCycle()) || v);
454
+ });
455
+
456
+ const floating = computed(() => focused() || text() !== '' || placeholder() !== '' || open());
457
+ const cls = computed(() =>
458
+ ['root', variant(), floating() && 'floating', open() && 'open',
459
+ disabled() && 'disabled', label() && 'has-label'].filter(Boolean).join(' '));
460
+
461
+ const parsed = computed(() => parseTime(draft()) || { h: 0, m: 0 });
462
+ const is12 = computed(() => String(hourCycle()) !== '24');
463
+ const hour12 = computed(() => {
464
+ const h = parsed().h % 12;
465
+ return h === 0 ? 12 : h;
466
+ });
467
+ const period = computed(() => (parsed().h < 12 ? 'AM' : 'PM'));
468
+ const hourChoices = computed(() =>
469
+ is12() ? [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] : Array.from({ length: 24 }, (_, i) => i));
470
+ const minuteChoices = computed(() => {
471
+ const step = Math.max(1, minuteStep() || 5);
472
+ const list = [];
473
+ for (let m = 0; m < 60; m += step) list.push(m);
474
+ const cur = parsed().m;
475
+ if (!list.includes(cur)) list.push(cur);
476
+ return list.sort((a, b) => a - b);
477
+ });
478
+ const clockHours = [12, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
479
+ const innerHours = computed(() => (is12() ? [] : [0, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]));
480
+ const clockMinutes = [0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55];
481
+ const isClock = computed(() => face() !== 'input');
482
+ const handDeg = computed(() => {
483
+ if (selecting() === 'minute') return parsed().m * 6;
484
+ return ((is12() ? hour12() : parsed().h) % 12) * 30;
485
+ });
486
+ const hourActive = (h) => (is12() ? h === hour12() : h === parsed().h);
487
+
488
+ const commit = (next, { close = true } = {}) => {
489
+ if (next !== value()) {
490
+ value.set(next);
491
+ host.emit('change', { value: next });
492
+ }
493
+ text.set(formatTime(next, locale(), hourCycle()) || next);
494
+ if (close) closePanel();
495
+ };
496
+
497
+ const setDraft = (h, m) => draft.set(toValue(h, m));
498
+
499
+ const pickHour = (display) => {
500
+ const { m } = parsed();
501
+ let h;
502
+ if (is12()) {
503
+ const pm = period() === 'PM';
504
+ h = display === 12 ? (pm ? 12 : 0) : display + (pm ? 12 : 0);
505
+ } else {
506
+ h = display;
507
+ }
508
+ setDraft(h, m);
509
+ selecting.set('minute');
510
+ };
511
+ const pickMinute = (m) => commit(toValue(parsed().h, m));
512
+ const setPeriod = (next) => {
513
+ let { h, m } = parsed();
514
+ if (next === 'AM' && h >= 12) h -= 12;
515
+ if (next === 'PM' && h < 12) h += 12;
516
+ setDraft(h, m);
517
+ };
518
+
519
+ const openPanel = () => {
520
+ if (disabled() || open()) return;
521
+ draft.set(parseTime(value()) ? value() : nowValue());
522
+ selecting.set('hour');
523
+ face.set(view() === 'input' ? 'input' : 'clock');
524
+ open.set(true);
525
+ };
526
+ const closePanel = () => open.set(false);
527
+ const toggle = (e) => {
528
+ e?.stopPropagation();
529
+ open() ? closePanel() : openPanel();
530
+ };
531
+
532
+ const onInput = (e) => {
533
+ text.set(e.target.value);
534
+ host.emit('input', { value: e.target.value });
535
+ };
536
+ const onBlur = () => {
537
+ focused.set(false);
538
+ let parsedVal = parseTime(text());
539
+ if (!parsedVal) {
540
+ const d = new Date(`1970-01-01T${text()}`);
541
+ if (!Number.isNaN(d.getTime())) parsedVal = { h: d.getHours(), m: d.getMinutes() };
542
+ }
543
+ if (!parsedVal && text().trim() === '') {
544
+ if (value()) commit('', { close: false });
545
+ return;
546
+ }
547
+ if (parsedVal) commit(toValue(parsedVal.h, parsedVal.m), { close: false });
548
+ else text.set(formatTime(value(), locale(), hourCycle()) || value());
549
+ };
550
+ const onKeydown = (e) => {
551
+ if (e.key === 'ArrowDown' && e.altKey) { e.preventDefault(); openPanel(); }
552
+ else if (e.key === 'F4') { e.preventDefault(); toggle(); }
553
+ else if (e.key === 'Enter') {
554
+ e.preventDefault();
555
+ const p0 = parseTime(text());
556
+ if (p0) commit(toValue(p0.h, p0.m));
557
+ } else if (e.key === 'Escape' && open()) {
558
+ e.preventDefault();
559
+ closePanel();
560
+ }
561
+ };
562
+
563
+ effect(() => {
564
+ if (!open()) return;
565
+ const onDoc = (e) => {
566
+ if (e.composedPath().includes(host)) return;
567
+ commit(draft());
568
+ };
569
+ const onEsc = (e) => { if (e.key === 'Escape') closePanel(); };
570
+ document.addEventListener('pointerdown', onDoc);
571
+ document.addEventListener('keydown', onEsc, true);
572
+ return () => {
573
+ document.removeEventListener('pointerdown', onDoc);
574
+ document.removeEventListener('keydown', onEsc, true);
575
+ };
576
+ });
577
+ // One tab stop per clock face / digital grid; arrows rove. Do not steal
578
+ // focus from the field — only retarget the tab stop onto the current value.
579
+ effect(() => {
580
+ if (!open()) return;
581
+ isClock();
582
+ selecting();
583
+ let roving = null;
584
+ let cancelled = false;
585
+ queueMicrotask(() => {
586
+ if (cancelled || !open.peek()) return;
587
+ const root = host.shadowRoot;
588
+ if (!root) return;
589
+ if (isClock.peek()) {
590
+ const ticks = root.querySelector('.ticks.on');
591
+ if (!ticks) return;
592
+ const sel = selecting.peek() === 'hour' ? '[data-hour]' : '[data-minute]';
593
+ roving = rovingTabindex(ticks, { selector: sel, orientation: 'both' });
594
+ const current = ticks.querySelector('.tick.active') || ticks.querySelector(sel);
595
+ if (current) roving.activate(current);
596
+ } else {
597
+ const grid = root.querySelector('.grid:not(.off)');
598
+ if (!grid) return;
599
+ roving = rovingTabindex(grid, { selector: '[role=option]', orientation: 'both' });
600
+ const current = grid.querySelector('[aria-selected="true"]') || grid.querySelector('[role=option]');
601
+ if (current) roving.activate(current);
602
+ }
603
+ });
604
+ return () => {
605
+ cancelled = true;
606
+ roving?.destroy();
607
+ };
608
+ });
609
+ effect(() => {
610
+ if (!open() && stopAuto) { stopAuto(); stopAuto = null; }
611
+ });
612
+ onCleanup(() => stopAuto?.());
613
+
614
+ const panelRef = (el) => {
615
+ stopAuto?.();
616
+ stopAuto = autoUpdate(el, fieldEl, { placement: 'bottom-start', offset: 4 });
617
+ };
618
+
619
+ const panelView = () => html`
620
+ <div class="panel" part="panel" role="dialog" aria-label=${() => label() || 'Choose time'}
621
+ ref=${panelRef}>
622
+ <div class="face">
623
+ <button type="button" class=${() => ({ digit: true, active: selecting() === 'hour' })}
624
+ aria-label="Hours" @click=${() => selecting.set('hour')}>
625
+ ${() => pad(is12() ? hour12() : parsed().h)}
626
+ </button>
627
+ <span class="colon" aria-hidden="true">:</span>
628
+ <button type="button" class=${() => ({ digit: true, active: selecting() === 'minute' })}
629
+ aria-label="Minutes" @click=${() => selecting.set('minute')}>
630
+ ${() => pad(parsed().m)}
631
+ </button>
632
+ ${() => (is12()
633
+ ? html`<div class="period">
634
+ <button type="button" class=${() => ({ active: period() === 'AM' })}
635
+ @click=${() => setPeriod('AM')}>AM</button>
636
+ <button type="button" class=${() => ({ active: period() === 'PM' })}
637
+ @click=${() => setPeriod('PM')}>PM</button>
638
+ </div>`
639
+ : null)}
640
+ </div>
641
+ <div class="pane">
642
+ <div class=${() => `dial${isClock() ? '' : ' off'}`} part="dial">
643
+ <div class="hand" aria-hidden="true"
644
+ style=${() => ({ transform: `rotate(${handDeg()}deg)` })}></div>
645
+ <div class="hub" aria-hidden="true"></div>
646
+ <div class=${() => `ticks${selecting() === 'hour' ? ' on' : ''}`}>
647
+ ${clockHours.map((h) => html`
648
+ <button type="button" class=${() => ({ tick: true, active: hourActive(h) })}
649
+ style=${{ '--a': ((h % 12) * 30) + 'deg' }}
650
+ data-hour=${h}
651
+ aria-label=${h + ' hours'}
652
+ tabindex="-1"
653
+ @click=${() => pickHour(h)}>${h}</button>`)}
654
+ ${() => innerHours().map((h) => html`
655
+ <button type="button" class=${() => ({ tick: true, inner: true, active: hourActive(h) })}
656
+ style=${{ '--a': ((h % 12) * 30) + 'deg' }}
657
+ data-hour=${h}
658
+ aria-label=${h + ' hours'}
659
+ tabindex="-1"
660
+ @click=${() => pickHour(h)}>${pad(h)}</button>`)}
661
+ </div>
662
+ <div class=${() => `ticks${selecting() === 'minute' ? ' on' : ''}`}>
663
+ ${clockMinutes.map((m) => html`
664
+ <button type="button" class=${() => ({ tick: true, active: m === parsed().m })}
665
+ style=${{ '--a': (m * 6) + 'deg' }}
666
+ data-minute=${m}
667
+ aria-label=${m + ' minutes'}
668
+ tabindex="-1"
669
+ @click=${() => pickMinute(m)}>${pad(m)}</button>`)}
670
+ </div>
671
+ </div>
672
+ <div class=${() => `grids${isClock() ? ' off' : ''}`}>
673
+ <div class=${() => `grid${selecting() === 'hour' ? '' : ' off'}`}
674
+ role="listbox" aria-label="Hours">
675
+ ${each(
676
+ () => hourChoices(),
677
+ (h) => html`
678
+ <button type="button" role="option" class=${() => ({
679
+ choice: true,
680
+ active: is12() ? h() === hour12() : h() === parsed().h,
681
+ })}
682
+ data-hour=${() => h()}
683
+ tabindex="-1"
684
+ aria-selected=${() => String(is12() ? h() === hour12() : h() === parsed().h)}
685
+ @click=${() => pickHour(h())}>
686
+ <span class="layer" aria-hidden="true"></span>
687
+ ${() => pad(h())}
688
+ </button>`,
689
+ (h) => 'h' + h,
690
+ )}
691
+ </div>
692
+ <div class=${() => `grid${selecting() === 'minute' ? '' : ' off'}`}
693
+ role="listbox" aria-label="Minutes">
694
+ ${each(
695
+ () => minuteChoices(),
696
+ (m) => html`
697
+ <button type="button" role="option" class=${() => ({
698
+ choice: true,
699
+ active: m() === parsed().m,
700
+ })}
701
+ data-minute=${() => m()}
702
+ tabindex="-1"
703
+ aria-selected=${() => String(m() === parsed().m)}
704
+ @click=${() => pickMinute(m())}>
705
+ <span class="layer" aria-hidden="true"></span>
706
+ ${() => pad(m())}
707
+ </button>`,
708
+ (m) => 'm' + m,
709
+ )}
710
+ </div>
711
+ </div>
712
+ </div>
713
+ <div class="switch">
714
+ <ui-icon-button
715
+ icon=${() => (isClock() ? 'keyboard' : 'clock')}
716
+ label=${() => (isClock() ? 'Switch to text input' : 'Switch to clock')}
717
+ @click=${(e) => { e.stopPropagation(); face.set(isClock() ? 'input' : 'clock'); }}></ui-icon-button>
718
+ </div>
719
+ </div>`;
720
+
721
+ return html`
722
+ <div class=${cls}>
723
+ <div class="field" part="field" ref=${(el) => (fieldEl = el)}>
724
+ ${() => (variant() === 'outlined'
725
+ ? html`<fieldset aria-hidden="true"><legend><span>${label}${() => (required() ? ' *' : '')}</span></legend></fieldset>`
726
+ : null)}
727
+ ${() => (label() ? html`<span class="label" part="label" id="field-label">${label}${() => (required() ? ' *' : '')}</span>` : null)}
728
+ <input part="input" .value=${text}
729
+ placeholder=${() => placeholder() || null}
730
+ ?disabled=${disabled} ?required=${required}
731
+ aria-labelledby=${() => (label() ? 'field-label' : null)}
732
+ aria-label=${() => (label() ? null : (placeholder() || 'Time'))}
733
+ aria-haspopup="dialog" aria-expanded=${() => String(open())}
734
+ autocomplete="off"
735
+ @input=${onInput} @focus=${() => focused.set(true)} @blur=${onBlur}
736
+ @keydown=${onKeydown}>
737
+ <ui-icon-button icon="clock" label=${() => (open() ? 'Close time picker' : 'Open time picker')}
738
+ @click=${toggle}></ui-icon-button>
739
+ </div>
740
+ ${presence(open, panelView, {
741
+ enter: fx.scaleIn,
742
+ exit: fx.scaleOut,
743
+ enterDuration: 'short4',
744
+ exitDuration: 'short4',
745
+ onEntered: () => host.emit('open'),
746
+ onExited: () => host.emit('close'),
747
+ })}
748
+ </div>`;
749
+ },
750
+ });
751
+
752
+ export const tag = 'ui-time-picker';
753
+ export const themeVars = t;