@antadesign/anta 0.3.16 → 0.3.17

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.
@@ -1,15 +1,16 @@
1
1
  import { HTMLElementBase } from "../anta_helpers";
2
2
  import "./a-input-time.css";
3
- const FORWARDED = ["name", "aria-label", "required"];
4
3
  const CLEAR_TRIGGER = "clearrequest";
5
4
  const CLEAR_INPUT_EVENT = "clearinput";
6
5
  const pad2 = (n) => String(n).padStart(2, "0");
6
+ const inputColumns = (value) => Array.from(value).reduce((columns, char) => columns + (/[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︐-﹯!-⦆¢-₩]/u.test(char) ? 2 : 1), 0);
7
7
  const to24 = (h12, period) => period === "pm" ? h12 % 12 + 12 : h12 % 12;
8
+ const parseTime = (value) => {
9
+ const match = /^(\d{1,2}):(\d{2})$/.exec((value ?? "").trim());
10
+ return match ? { h: Math.min(23, Number(match[1])), min: Math.min(59, Number(match[2])) } : null;
11
+ };
8
12
  const SHADOW_STYLE = `
9
13
  :host {
10
- --_fs: 15px;
11
- --_lh: 20px;
12
-
13
14
  display: grid;
14
15
  grid-template-columns: minmax(0, 1fr);
15
16
  row-gap: 4px;
@@ -20,11 +21,10 @@ const SHADOW_STYLE = `
20
21
  display: none;
21
22
  color: var(--input-time-label);
22
23
  font-family: var(--sans-serif);
23
- font-size: var(--_fs);
24
- line-height: var(--_lh);
24
+ font-size: var(--input-time-font-size);
25
+ line-height: var(--input-time-line-height);
25
26
  font-weight: 500;
26
27
  }
27
- .label.has-label { display: block; }
28
28
 
29
29
  .field {
30
30
  --_bc: var(--input-time-border);
@@ -33,24 +33,23 @@ const SHADOW_STYLE = `
33
33
  display: flex;
34
34
  align-items: center;
35
35
  box-sizing: border-box;
36
- min-height: 28px;
36
+ min-height: var(--input-time-field-height);
37
37
  background: var(--input-time-bg);
38
- border-radius: 4px;
38
+ border-radius: var(--input-time-field-radius);
39
39
  box-shadow: inset 0 0 0 var(--_bw) var(--_bc);
40
40
  transition: box-shadow 120ms ease;
41
41
  }
42
42
  :host([status]) .field { --_bw: 1px; }
43
- :host([size="small"]) { --_fs: 13px; --_lh: 16px; }
44
- :host([size="large"]) { --_fs: 17px; --_lh: 22px; }
45
- :host([size="small"]) .field { min-height: 24px; }
46
- :host([size="large"]) .field { min-height: 32px; }
47
- :host([round]) .field { border-radius: var(--input-time-round, 999px); }
48
43
 
49
44
  @media (hover: hover) and (pointer: fine) {
50
- :host(:not(:disabled)) .field:hover { --_bc: var(--input-time-border-hover); }
45
+ :host(:not(:disabled)) .field:hover {
46
+ --_bc: var(--input-time-border-hover);
47
+ --_bw: 1px;
48
+ }
51
49
  }
52
- .field:has(.seg:focus) {
50
+ .field:focus-within {
53
51
  --_bc: var(--input-time-border-hover);
52
+ --_bw: 1px;
54
53
  outline: 1px solid var(--focus-ring);
55
54
  outline-offset: 1px;
56
55
  }
@@ -59,16 +58,9 @@ const SHADOW_STYLE = `
59
58
  slot[name="leading"] {
60
59
  display: none;
61
60
  color: var(--input-time-adornment);
62
- font-size: var(--_fs);
63
- line-height: var(--_lh);
61
+ font-size: var(--input-time-font-size);
62
+ line-height: var(--input-time-line-height);
64
63
  }
65
- .field.has-leading slot[name="leading"] {
66
- display: flex;
67
- align-items: center;
68
- flex-shrink: 0;
69
- margin-inline-start: 7px;
70
- }
71
- .field.has-leading .segments { padding-inline-start: 5px; }
72
64
  :host([dim-actions]) slot[name="leading"] { opacity: 0.6; transition: opacity 120ms ease; }
73
65
  :host([dim-actions]:not(:disabled)) .field:hover slot[name="leading"],
74
66
  :host([dim-actions]:not(:disabled)) .field:focus-within slot[name="leading"] { opacity: 1; }
@@ -84,53 +76,50 @@ const SHADOW_STYLE = `
84
76
  font-family: var(--sans-serif);
85
77
  font-feature-settings: 'ss02', 'ss05', 'tnum';
86
78
  font-variation-settings: 'wdth' 100, 'slnt' 0, 'ital' 0;
87
- font-size: var(--_fs);
88
- line-height: var(--_lh);
79
+ font-size: var(--input-time-font-size);
80
+ line-height: var(--input-time-line-height);
89
81
  font-weight: 400;
90
82
  }
91
83
 
92
84
  .seg {
85
+ appearance: none;
93
86
  display: inline-block;
94
87
  box-sizing: border-box;
88
+ min-width: 0;
95
89
  padding-inline: 1px;
90
+ border: 0;
96
91
  border-radius: 2px;
92
+ background: transparent;
93
+ color: inherit;
94
+ font: inherit;
95
+ font-feature-settings: 'ss02', 'ss05', 'tnum';
96
+ font-variation-settings: 'wdth' 100, 'slnt' 0, 'ital' 0;
97
+ line-height: inherit;
97
98
  text-align: center;
98
99
  white-space: nowrap;
99
100
  outline: none;
100
- cursor: default;
101
+ cursor: text;
101
102
  font-variant-numeric: tabular-nums;
102
- caret-color: transparent;
103
103
  }
104
- .seg[inputmode="numeric"] {
105
- /* Fixed to two tabular digits so the box never reflows: neither the narrower
106
- placeholder dashes nor a mid-entry single digit changes the field width. */
107
- width: calc(2ch + 4px);
104
+ .seg--period {
105
+ field-sizing: content;
106
+ width: auto;
107
+ color: var(--input-time-period-color);
108
108
  }
109
- .seg:focus {
110
- background: var(--input-time-seg-focus-bg);
111
- color: var(--input-time-seg-focus-text);
109
+ .seg::placeholder {
110
+ color: var(--input-time-placeholder);
111
+ opacity: 1;
112
+ }
113
+ .lit {
114
+ margin-inline: -1px;
115
+ white-space: pre;
116
+ color: var(--input-time-text);
112
117
  }
113
- .seg[data-placeholder] { color: var(--input-time-placeholder); }
114
- .lit { white-space: pre; color: var(--input-time-text); }
115
-
116
- /* No text-selection highlight inside the field: the segments are spinbuttons,
117
- and a selection painted over the focused segment's tint compounds into an
118
- unreadable block. The focus tint alone marks the active segment. Scoped to
119
- the shadow, so page selection is unaffected. */
120
- ::selection { background: transparent; }
121
118
 
122
119
  :host(:disabled) .segments { cursor: not-allowed; }
123
120
  :host(:disabled) .seg { cursor: not-allowed; }
124
121
 
125
122
  slot[name="trailing"] { display: none; }
126
- .field.has-trailing slot[name="trailing"] {
127
- display: flex;
128
- align-items: center;
129
- gap: 2px;
130
- flex-shrink: 0;
131
- color: var(--input-time-adornment);
132
- font-size: var(--_fs);
133
- }
134
123
  :host([dim-actions]) slot[name="trailing"],
135
124
  :host([dim-actions]) slot[name="clear"] { opacity: 0.6; transition: opacity 120ms ease; }
136
125
  :host([dim-actions]:not(:disabled)) .field:hover slot[name="trailing"],
@@ -144,7 +133,7 @@ const SHADOW_STYLE = `
144
133
  flex-shrink: 0;
145
134
  visibility: hidden;
146
135
  }
147
- .field.is-filled slot[name="clear"] { visibility: visible; }
136
+ :host(:state(filled)) slot[name="clear"] { visibility: visible; }
148
137
  :host(:disabled) slot[name="clear"] { display: none; }
149
138
 
150
139
  .hint {
@@ -154,33 +143,59 @@ const SHADOW_STYLE = `
154
143
  padding-inline-start: 1px;
155
144
  color: var(--input-time-hint);
156
145
  font-family: var(--sans-serif);
157
- font-size: calc(var(--_fs) - 1px);
158
- line-height: calc(var(--_lh) - 2px);
146
+ font-size: calc(var(--input-time-font-size) - 1px);
147
+ line-height: calc(var(--input-time-line-height) - 2px);
159
148
  }
160
- .hint.has-hint { display: flex; }
161
149
  `;
150
+ const INPUT_TIME_TEMPLATE = typeof document === "undefined" ? void 0 : (() => {
151
+ const template = document.createElement("template");
152
+ const style = document.createElement("style");
153
+ style.textContent = SHADOW_STYLE;
154
+ const label = document.createElement("slot");
155
+ label.className = "label";
156
+ label.part.add("label");
157
+ label.name = "label";
158
+ const field = document.createElement("div");
159
+ field.className = "field";
160
+ field.part.add("field");
161
+ const leadingSlot = document.createElement("slot");
162
+ leadingSlot.name = "leading";
163
+ leadingSlot.part.add("leading");
164
+ const segments = document.createElement("div");
165
+ segments.className = "segments";
166
+ segments.part.add("segments");
167
+ segments.setAttribute("role", "group");
168
+ const clearSlot = document.createElement("slot");
169
+ clearSlot.name = "clear";
170
+ clearSlot.part.add("clear");
171
+ const trailingSlot = document.createElement("slot");
172
+ trailingSlot.name = "trailing";
173
+ trailingSlot.part.add("trailing");
174
+ field.append(leadingSlot, segments, clearSlot, trailingSlot);
175
+ const hint = document.createElement("slot");
176
+ hint.className = "hint";
177
+ hint.part.add("hint");
178
+ hint.name = "hint";
179
+ template.content.append(style, label, field, document.createElement("slot"), hint);
180
+ return template;
181
+ })();
162
182
  class AInputTimeElement extends HTMLElementBase {
163
183
  static formAssociated = true;
164
184
  static observedAttributes = [
165
- ...FORWARDED,
166
185
  "value",
167
186
  "defaultvalue",
168
187
  "locale",
169
188
  "hour12",
170
189
  "status",
171
190
  "disabled",
172
- "size",
173
191
  "min",
174
- "max"
192
+ "max",
193
+ "aria-label",
194
+ "required"
175
195
  ];
176
196
  #internals;
177
197
  #field;
178
- #labelBox;
179
198
  #labelSlot;
180
- #hintBox;
181
- #hintSlot;
182
- #leadingSlot;
183
- #trailingSlot;
184
199
  #segRow;
185
200
  #ready = false;
186
201
  #formDisabled = false;
@@ -197,9 +212,10 @@ class AInputTimeElement extends HTMLElementBase {
197
212
  #minute = null;
198
213
  #period = null;
199
214
  #segs = [];
200
- // In-progress typed digits for the focused numeric segment (reset on blur /
201
- // segment change / commit), so `1` then `2` builds `12`.
202
- #buf = "";
215
+ #literals = [];
216
+ // Pointer-focused inputs keep the browser's caret placement. Keyboard focus
217
+ // selects the segment so a new value can be typed immediately.
218
+ #pointerSegment;
203
219
  constructor() {
204
220
  super();
205
221
  try {
@@ -208,51 +224,20 @@ class AInputTimeElement extends HTMLElementBase {
208
224
  console.warn("a-input-time: ElementInternals unavailable \u2014 form association disabled.", err);
209
225
  }
210
226
  const shadow = this.attachShadow({ mode: "open", delegatesFocus: true });
211
- const style = document.createElement("style");
212
- style.textContent = SHADOW_STYLE;
213
- this.#labelBox = document.createElement("div");
214
- this.#labelBox.className = "label";
215
- this.#labelBox.setAttribute("part", "label");
216
- this.#labelSlot = document.createElement("slot");
217
- this.#labelSlot.name = "label";
218
- this.#labelBox.append(this.#labelSlot);
227
+ if (!INPUT_TIME_TEMPLATE) throw new Error("a-input-time requires a DOM document");
228
+ shadow.append(INPUT_TIME_TEMPLATE.content.cloneNode(true));
229
+ this.#labelSlot = shadow.querySelector(".label");
219
230
  this.#labelSlot.addEventListener("click", () => this.#segs[0]?.el.focus());
220
231
  this.#labelSlot.addEventListener("slotchange", this.#onLabelSlotChange);
221
- this.#field = document.createElement("div");
222
- this.#field.className = "field";
223
- this.#field.setAttribute("part", "field");
224
- this.#segRow = document.createElement("div");
225
- this.#segRow.className = "segments";
226
- this.#segRow.setAttribute("part", "segments");
227
- this.#segRow.setAttribute("role", "group");
228
- this.#field.addEventListener("mousedown", (e) => {
229
- if (e.target === this.#field || e.target === this.#segRow) {
230
- e.preventDefault();
231
- this.#segs[0]?.el.focus();
232
- }
233
- });
234
- this.#leadingSlot = document.createElement("slot");
235
- this.#leadingSlot.name = "leading";
236
- this.#leadingSlot.setAttribute("part", "leading");
237
- this.#leadingSlot.addEventListener("slotchange", () => this.#field.classList.toggle("has-leading", this.#leadingSlot.assignedNodes().length > 0));
238
- const clearSlot = document.createElement("slot");
239
- clearSlot.name = "clear";
240
- clearSlot.setAttribute("part", "clear");
241
- this.#trailingSlot = document.createElement("slot");
242
- this.#trailingSlot.name = "trailing";
243
- this.#trailingSlot.setAttribute("part", "trailing");
244
- this.#trailingSlot.addEventListener("slotchange", () => this.#field.classList.toggle("has-trailing", this.#trailingSlot.assignedNodes().length > 0));
245
- this.#field.append(this.#leadingSlot, this.#segRow, clearSlot, this.#trailingSlot);
232
+ this.#field = shadow.querySelector(".field");
233
+ this.#segRow = shadow.querySelector(".segments");
234
+ this.#field.addEventListener("mousedown", this.#onFieldMouseDown);
235
+ this.#segRow.addEventListener("input", this.#onInput);
236
+ this.#segRow.addEventListener("keydown", this.#onKeyDown);
237
+ this.#segRow.addEventListener("focusin", this.#onFocusIn);
238
+ this.#segRow.addEventListener("focusout", this.#onFocusOut);
239
+ this.#segRow.addEventListener("paste", this.#onPaste);
246
240
  this.addEventListener(CLEAR_TRIGGER, () => this.clear());
247
- this.#hintBox = document.createElement("div");
248
- this.#hintBox.className = "hint";
249
- this.#hintBox.setAttribute("part", "hint");
250
- this.#hintSlot = document.createElement("slot");
251
- this.#hintSlot.name = "hint";
252
- this.#hintSlot.addEventListener("slotchange", this.#onHintSlotChange);
253
- this.#hintBox.append(this.#hintSlot);
254
- const extrasSlot = document.createElement("slot");
255
- shadow.append(style, this.#labelBox, this.#field, extrasSlot, this.#hintBox);
256
241
  }
257
242
  getAnchorRect() {
258
243
  return this.#field.getBoundingClientRect();
@@ -292,13 +277,7 @@ class AInputTimeElement extends HTMLElementBase {
292
277
  this.#syncDisabled();
293
278
  return;
294
279
  }
295
- if (name === "size") return;
296
280
  if (name === "min" || name === "max") {
297
- this.#clampIfComplete();
298
- this.#iso = this.value;
299
- this.#render();
300
- this.#internals?.setFormValue(this.value);
301
- this.#updateFilled();
302
281
  this.#updateValidity();
303
282
  return;
304
283
  }
@@ -350,161 +329,211 @@ class AInputTimeElement extends HTMLElementBase {
350
329
  { type: "minute", value: "05" }
351
330
  ];
352
331
  }
332
+ this.#amText = amText;
333
+ this.#pmText = pmText;
353
334
  this.#segRow.replaceChildren();
354
335
  this.#segs = [];
336
+ this.#literals = [];
355
337
  for (const part of parts) {
356
338
  if (part.type === "hour" || part.type === "minute" || part.type === "dayPeriod") {
357
- const el = document.createElement("span");
358
- el.className = "seg";
359
- el.tabIndex = 0;
360
- el.setAttribute("role", "spinbutton");
361
- el.contentEditable = "true";
362
- el.spellcheck = false;
363
- el.setAttribute("autocorrect", "off");
364
- el.setAttribute("autocapitalize", "off");
339
+ const el = document.createElement("input");
365
340
  const kind = part.type === "dayPeriod" ? "period" : part.type;
341
+ el.className = `seg seg--${kind}`;
342
+ el.type = "text";
343
+ el.spellcheck = false;
344
+ el.part.add("segment", kind);
345
+ el.autocomplete = "off";
346
+ el.autocorrect = false;
347
+ el.autocapitalize = "off";
366
348
  el.setAttribute("aria-label", kind === "hour" ? "Hour" : kind === "minute" ? "Minute" : "AM/PM");
367
- el.setAttribute("inputmode", kind === "period" ? "text" : "numeric");
349
+ el.inputMode = kind === "period" ? "text" : "numeric";
350
+ if (kind === "period") {
351
+ const length = Math.max(2, inputColumns(this.#amText), inputColumns(this.#pmText));
352
+ el.maxLength = length;
353
+ el.size = length;
354
+ } else {
355
+ el.maxLength = 2;
356
+ el.size = 2;
357
+ }
368
358
  const seg = kind === "hour" ? { kind, el, min: hour12 ? 1 : 0, max: hour12 ? 12 : 23 } : kind === "minute" ? { kind, el, min: 0, max: 59 } : { kind, el, min: 0, max: 1 };
369
- el.addEventListener("keydown", (e) => this.#onSegKeyDown(e, seg));
370
- el.addEventListener("beforeinput", (e) => this.#onSegBeforeInput(e, seg));
371
- el.addEventListener("focus", () => {
372
- this.#buf = "";
373
- });
374
- el.addEventListener("blur", () => this.#onSegBlur());
375
359
  this.#segRow.append(el);
376
360
  this.#segs.push(seg);
377
361
  } else {
362
+ if (!part.value.trim()) continue;
378
363
  const lit = document.createElement("span");
379
364
  lit.className = "lit";
365
+ lit.part.add("literal");
380
366
  lit.setAttribute("aria-hidden", "true");
381
367
  lit.textContent = part.value;
382
368
  this.#segRow.append(lit);
369
+ this.#literals.push(part.value);
383
370
  }
384
371
  }
385
- this.#amText = amText;
386
- this.#pmText = pmText;
387
372
  this.#applyValue(keep);
388
373
  this.#applyGroupLabel();
389
374
  this.#syncStatus();
390
375
  this.#syncDisabled();
391
- this.#render();
392
- this.#internals?.setFormValue(this.value);
393
- this.#updateFilled();
394
- this.#updateValidity();
376
+ this.#commitEdit({ dispatch: false });
395
377
  }
396
378
  #amText = "AM";
397
379
  #pmText = "PM";
398
- // --- Keyboard ---
399
- // Navigation + stepping only. Character entry and deletion go through
400
- // `#onSegBeforeInput` (so mobile virtual keyboards, which don't fire keydown
401
- // reliably, work) — these keys don't produce `beforeinput`, so there's no
402
- // double handling.
403
- #onSegKeyDown(e, seg) {
404
- if (this.hasAttribute("disabled") || this.#formDisabled) return;
405
- const k = e.key;
406
- if (k === "ArrowUp" || k === "ArrowDown") {
380
+ // --- Native segment editing + navigation ---
381
+ #segFromTarget(target) {
382
+ if (!(target instanceof HTMLInputElement)) return void 0;
383
+ return this.#segs.find((seg) => seg.el === target);
384
+ }
385
+ // Clicking the field's padding (rather than a native input) moves focus into
386
+ // the editor. A real pointer interaction leaves caret placement to the browser.
387
+ #onFieldMouseDown = (e) => {
388
+ const seg = this.#segFromTarget(e.target);
389
+ if (seg) {
390
+ this.#pointerSegment = seg.el;
391
+ return;
392
+ }
393
+ if (e.target === this.#field || e.target === this.#segRow) {
407
394
  e.preventDefault();
408
- this.#step(seg, k === "ArrowUp" ? 1 : -1);
409
- } else if (k === "PageUp" || k === "PageDown") {
395
+ this.#focusSegment(this.#segs[0]);
396
+ }
397
+ };
398
+ #onFocusIn = (e) => {
399
+ const seg = this.#segFromTarget(e.target);
400
+ if (!seg) return;
401
+ const pointerFocused = this.#pointerSegment === seg.el;
402
+ this.#pointerSegment = void 0;
403
+ if (!pointerFocused) {
404
+ queueMicrotask(() => {
405
+ if (this.shadowRoot?.activeElement === seg.el) seg.el.select();
406
+ });
407
+ }
408
+ };
409
+ #onFocusOut = (_e) => {
410
+ queueMicrotask(() => {
411
+ const active = this.shadowRoot?.activeElement;
412
+ if (this.#segs.some((seg) => seg.el === active)) return;
413
+ if (this.#clampIfComplete()) this.#commitEdit();
414
+ this.#dispatch("change");
415
+ });
416
+ };
417
+ #onKeyDown = (e) => {
418
+ const seg = this.#segFromTarget(e.target);
419
+ if (!seg) return;
420
+ const { el } = seg;
421
+ const key = e.key;
422
+ if (key === "ArrowUp" || key === "ArrowDown") {
410
423
  e.preventDefault();
411
- this.#step(seg, (k === "PageUp" ? 1 : -1) * (seg.kind === "minute" ? 5 : 1));
412
- } else if (k === "Home") {
424
+ this.#step(seg, key === "ArrowUp" ? 1 : -1);
425
+ return;
426
+ }
427
+ if (key === "PageUp" || key === "PageDown") {
413
428
  e.preventDefault();
414
- this.#set(seg, seg.min);
415
- } else if (k === "End") {
429
+ this.#step(seg, (key === "PageUp" ? 1 : -1) * (seg.kind === "minute" ? 5 : 1));
430
+ return;
431
+ }
432
+ if (key === "ArrowLeft" || key === "ArrowRight") {
433
+ const start = el.selectionStart ?? 0;
434
+ const end = el.selectionEnd ?? start;
435
+ const boundary = key === "ArrowLeft" ? start === 0 && end === 0 : start === el.value.length && end === el.value.length;
436
+ if (boundary) {
437
+ e.preventDefault();
438
+ this.#render();
439
+ this.#moveFocus(seg, key === "ArrowRight" ? 1 : -1);
440
+ }
441
+ return;
442
+ }
443
+ if (key === "Backspace" && el.value === "") {
416
444
  e.preventDefault();
417
- this.#set(seg, seg.max);
418
- } else if (k === "ArrowLeft" || k === "ArrowRight") {
445
+ this.#moveFocus(seg, -1);
446
+ return;
447
+ }
448
+ if (seg.kind !== "period" && this.#literals.some((literal) => literal.includes(key))) {
419
449
  e.preventDefault();
420
- this.#moveFocus(seg, k === "ArrowRight" ? 1 : -1);
450
+ this.#render();
451
+ this.#moveFocus(seg, 1, { numeric: true });
421
452
  }
422
- }
423
- // Character entry (digits, a/p) and deletion, from a physical or virtual
424
- // keyboard. We never let the contentEditable mutate — preventDefault every
425
- // `beforeinput` and route it, keeping the segment text under our control.
426
- #onSegBeforeInput(e, seg) {
453
+ };
454
+ #onPaste = (e) => {
455
+ const text = e.clipboardData?.getData("text/plain").trim() ?? "";
456
+ if (!/^(?:[01]?\d|2[0-3]):[0-5]\d$/.test(text)) return;
427
457
  e.preventDefault();
428
- if (this.hasAttribute("disabled") || this.#formDisabled) return;
429
- const type = e.inputType;
430
- if (type === "deleteContentBackward" || type === "deleteContentForward") {
431
- this.#buf = "";
432
- this.#clearSeg(seg);
433
- if (type === "deleteContentBackward") this.#moveFocus(seg, -1);
458
+ this.#applyValue(text);
459
+ this.#commitEdit();
460
+ this.#dispatch("change");
461
+ };
462
+ #onInput = (e) => {
463
+ const seg = this.#segFromTarget(e.target);
464
+ if (!seg || e.isComposing) return;
465
+ if (seg.kind === "period") this.#onPeriodInput(e, seg);
466
+ else this.#onNumericInput(seg);
467
+ };
468
+ #onNumericInput(seg) {
469
+ const { el } = seg;
470
+ const typed = el.value.replace(/\D/g, "");
471
+ if (typed !== el.value) el.value = typed;
472
+ const hour12Hour = seg.kind === "hour" && this.#hour12;
473
+ const typeMax = hour12Hour ? 23 : seg.max;
474
+ if (typed === "") {
475
+ if (seg.kind === "hour") this.#hour = null;
476
+ else this.#minute = null;
477
+ this.#commitEdit({ dispatch: false, preserve: el });
434
478
  return;
435
479
  }
436
- if (type === "insertFromPaste") {
437
- const text = e.dataTransfer?.getData("text/plain") || e.data || "";
438
- const parsed = this.#parsePaste(text);
439
- if (parsed) {
440
- this.#buf = "";
441
- this.#applyValue(`${pad2(parsed.h)}:${pad2(parsed.min)}`);
442
- this.#clampIfComplete();
443
- this.#commitEdit();
444
- this.#dispatch("change");
445
- } else {
446
- for (const ch of text) this.#typeChar(seg, ch);
447
- }
448
- return;
480
+ let raw = typed.slice(0, 2);
481
+ let value = Number(raw);
482
+ let normalized = false;
483
+ if (value > typeMax) {
484
+ raw = raw.at(-1);
485
+ value = Number(raw);
486
+ normalized = true;
449
487
  }
450
- if (type.startsWith("insert")) {
451
- for (const ch of e.data ?? "") this.#typeChar(seg, ch);
452
- }
453
- }
454
- /** Parse a pasted time to 24-hour `{ h, min }`, or null. Accepts `14:30`,
455
- * `9:5`, `2:30 pm`, `12am`, and run-together `230` / `1430` — mirrors
456
- * `calendar-core`'s `parseTimeInput` but Temporal-free, so the element's
457
- * granular import stays lean. */
458
- #parsePaste(text) {
459
- let s = (text ?? "").trim().toLowerCase();
460
- if (!s) return null;
461
- let mer = null;
462
- const m = s.match(/([ap])\.?m?\.?$/);
463
- if (m) {
464
- mer = m[1] === "p" ? "pm" : "am";
465
- s = s.slice(0, m.index).trim();
466
- }
467
- let h;
468
- let min;
469
- if (/[:.]/.test(s)) {
470
- const [hp, mp] = s.split(/[:.]/);
471
- h = Number(hp);
472
- min = Number(mp ?? "0");
473
- } else {
474
- const d = s.replace(/\D/g, "");
475
- if (!d) return null;
476
- if (d.length <= 2) {
477
- h = Number(d);
478
- min = 0;
488
+ const advance = raw.length === 2 || Number(`${value}0`) > typeMax;
489
+ if (hour12Hour) {
490
+ if (value === 0 && raw.length === 1) this.#hour = null;
491
+ else if (value === 0) {
492
+ this.#hour = 12;
493
+ this.#period = "am";
494
+ } else if (value > 12) {
495
+ this.#hour = value - 12;
496
+ this.#period = "pm";
479
497
  } else {
480
- const cut = d.length - 2;
481
- h = Number(d.slice(0, cut));
482
- min = Number(d.slice(cut));
498
+ this.#hour = value;
499
+ if (this.#period == null) this.#period = "am";
483
500
  }
501
+ } else {
502
+ this.#assign(seg, Math.max(seg.min, value));
484
503
  }
485
- if (!Number.isInteger(h) || !Number.isInteger(min)) return null;
486
- if (mer === "pm" && h < 12) h += 12;
487
- if (mer === "am" && h === 12) h = 0;
488
- if (h < 0 || h > 23 || min < 0 || min > 59) return null;
489
- return { h, min };
504
+ this.#commitEdit({ dispatch: false, preserve: normalized || advance ? void 0 : el });
505
+ if (advance) this.#moveFocus(seg, 1);
490
506
  }
491
- #typeChar(seg, ch) {
492
- if (seg.kind === "period") {
493
- const c = ch.toLowerCase();
494
- if (c === "a" || c === "p") {
495
- this.#period = c === "p" ? "pm" : "am";
496
- this.#commitEdit();
497
- this.#moveFocus(seg, 1);
498
- }
507
+ #onPeriodInput(e, seg) {
508
+ const { el } = seg;
509
+ const raw = el.value.trim();
510
+ if (!raw) {
511
+ this.#period = null;
512
+ this.#commitEdit({ dispatch: false, preserve: el });
513
+ return;
514
+ }
515
+ const lower = raw.toLowerCase();
516
+ const am = this.#amText.toLowerCase();
517
+ const pm = this.#pmText.toLowerCase();
518
+ const period = lower === "a" || lower === "am" || lower === am ? "am" : lower === "p" || lower === "pm" || lower === pm ? "pm" : null;
519
+ if (period) {
520
+ this.#period = period;
521
+ this.#commitEdit({ dispatch: false });
522
+ this.#moveFocus(seg, 1);
523
+ return;
524
+ }
525
+ if (am.startsWith(lower) || pm.startsWith(lower)) {
526
+ this.#period = null;
527
+ this.#commitEdit({ dispatch: false, preserve: el });
499
528
  return;
500
529
  }
501
- if (/[0-9]/.test(ch)) this.#typeDigit(seg, ch);
530
+ e.stopPropagation();
531
+ el.value = this.#period === "pm" ? this.#pmText : this.#period === "am" ? this.#amText : "";
502
532
  }
503
- /** Arrow / page / home / end increment: wraps within a segment's [min, max];
533
+ /** Arrow / page increments wrap within a segment's [min, max];
504
534
  * from empty, ↑ lands on min and ↓ on max. Clamps the resulting complete value
505
535
  * into the field's `min`/`max` range (so ↑ can't step past `max`). */
506
536
  #step(seg, delta) {
507
- this.#buf = "";
508
537
  if (seg.kind === "period") {
509
538
  this.#period = this.#period == null ? "am" : this.#period === "am" ? "pm" : "am";
510
539
  this.#clampIfComplete();
@@ -520,22 +549,16 @@ class AInputTimeElement extends HTMLElementBase {
520
549
  this.#clampIfComplete();
521
550
  this.#commitEdit();
522
551
  }
523
- /** Mutate a segment's state without committing (so callers can clamp first). */
552
+ /** Mutate a numeric segment without committing (so callers can clamp first). */
524
553
  #assign(seg, val) {
525
- if (seg.kind === "period") return;
526
554
  if (seg.kind === "hour") this.#hour = val;
527
555
  else this.#minute = val;
528
556
  if (seg.kind === "hour" && this.#hour12 && this.#period == null) this.#period = "am";
529
557
  }
530
- #set(seg, val) {
531
- this.#assign(seg, val);
532
- this.#commitEdit();
533
- }
534
558
  // --- min / max range (total minutes since 00:00, or null when unbounded) ---
535
559
  #parseTotal(v) {
536
- const m = /^(\d{1,2}):(\d{2})$/.exec((v ?? "").trim());
537
- if (!m) return null;
538
- return Math.min(23, Number(m[1])) * 60 + Math.min(59, Number(m[2]));
560
+ const time = parseTime(v);
561
+ return time ? time.h * 60 + time.min : null;
539
562
  }
540
563
  get #minTotal() {
541
564
  return this.#parseTotal(this.getAttribute("min"));
@@ -562,75 +585,20 @@ class AInputTimeElement extends HTMLElementBase {
562
585
  this.#applyValue(`${pad2(Math.floor(clamped / 60))}:${pad2(clamped % 60)}`);
563
586
  return true;
564
587
  }
565
- #clearSeg(seg) {
566
- if (seg.kind === "hour") this.#hour = null;
567
- else if (seg.kind === "minute") this.#minute = null;
568
- else this.#period = null;
569
- this.#commitEdit();
570
- }
571
- /** Type a digit into a numeric segment, accumulating and auto-advancing once the
572
- * segment can't take another digit (the `Number(v + '0') > max` heuristic). */
573
- #typeDigit(seg, d) {
574
- const hour12Hour = seg.kind === "hour" && this.#hour12;
575
- const typeMax = hour12Hour ? 23 : seg.max;
576
- const candidate = Number(this.#buf + d);
577
- let raw;
578
- let advance = false;
579
- if (candidate > typeMax) {
580
- raw = Number(d);
581
- this.#buf = d;
582
- } else {
583
- raw = candidate;
584
- this.#buf += d;
585
- }
586
- if (Number(`${raw}0`) > typeMax || this.#buf.length >= String(typeMax).length) {
587
- advance = true;
588
- this.#buf = "";
589
- }
590
- if (hour12Hour) {
591
- if (raw === 0 && !advance) {
592
- seg.el.textContent = "0";
593
- seg.el.setAttribute("data-placeholder", "");
594
- seg.el.removeAttribute("aria-valuenow");
595
- seg.el.removeAttribute("aria-valuetext");
588
+ #focusSegment(seg) {
589
+ if (!seg) return;
590
+ seg.el.focus();
591
+ seg.el.select();
592
+ }
593
+ #moveFocus(from, dir, options = {}) {
594
+ const index = this.#segs.indexOf(from);
595
+ for (let i = index + dir; i >= 0 && i < this.#segs.length; i += dir) {
596
+ const next = this.#segs[i];
597
+ if (!options.numeric || next.kind !== "period") {
598
+ this.#focusSegment(next);
596
599
  return;
597
600
  }
598
- if (raw === 0) {
599
- this.#hour = 12;
600
- this.#period = "am";
601
- } else if (raw > 12) {
602
- this.#hour = raw - 12;
603
- this.#period = "pm";
604
- } else {
605
- this.#hour = raw;
606
- if (this.#period == null) this.#period = "am";
607
- }
608
- this.#commitEdit();
609
- if (advance) this.#moveFocus(seg, 1);
610
- return;
611
601
  }
612
- this.#assign(seg, Math.max(seg.min, raw));
613
- this.#commitEdit();
614
- if (advance) this.#moveFocus(seg, 1);
615
- }
616
- #moveFocus(from, dir) {
617
- const i = this.#segs.indexOf(from);
618
- const next = this.#segs[i + dir];
619
- if (next) next.el.focus();
620
- }
621
- /** On losing a segment, reset the digit buffer; when focus has left the whole
622
- * group (not just hopped to a sibling segment), clamp into `min`/`max` and
623
- * fire `change`. `shadowRoot.activeElement` reliably reports the focused
624
- * segment even across the shadow boundary (blur `relatedTarget` is retargeted
625
- * to null), so defer one microtask to read where focus landed. */
626
- #onSegBlur() {
627
- this.#buf = "";
628
- queueMicrotask(() => {
629
- const active = this.shadowRoot?.activeElement;
630
- if (this.#segs.some((s) => s.el === active)) return;
631
- if (this.#clampIfComplete()) this.#commitEdit();
632
- this.#dispatch("change");
633
- });
634
602
  }
635
603
  // --- Value + rendering ---
636
604
  /** Canonical 0–23 hour from the display state, or null when incomplete. */
@@ -640,70 +608,48 @@ class AInputTimeElement extends HTMLElementBase {
640
608
  if (this.#period == null) return null;
641
609
  return to24(this.#hour, this.#period);
642
610
  }
643
- #commitEdit() {
644
- this.#render();
611
+ #commitEdit({ dispatch = true, preserve } = {}) {
612
+ this.#render(preserve);
645
613
  this.#iso = this.value;
646
614
  this.#internals?.setFormValue(this.value);
647
615
  this.#updateFilled();
648
616
  this.#updateValidity();
649
- this.#dispatch("input");
617
+ if (dispatch) this.#dispatch("input");
650
618
  }
651
- #render() {
619
+ #render(preserve) {
652
620
  for (const seg of this.#segs) {
653
- if (seg.kind === "period") this.#renderSeg(seg, null);
654
- else this.#renderSeg(seg, seg.kind === "hour" ? this.#hour : this.#minute);
621
+ this.#renderSeg(seg, preserve === seg.el);
655
622
  }
656
623
  }
657
- /** Paint one segment: its value (or placeholder) as text, plus the spinbutton
658
- * ARIA values. `override` lets `#typeDigit` show a partial value mid-entry. */
659
- #renderSeg(seg, override) {
624
+ /** Paint a segment's committed value without disturbing a focused native input
625
+ * that already owns valid in-progress text and selection. */
626
+ #renderSeg(seg, preserve) {
660
627
  const el = seg.el;
661
628
  if (seg.kind === "period") {
662
- const set2 = this.#period != null;
663
- el.textContent = set2 ? this.#period === "pm" ? this.#pmText : this.#amText : this.#amText;
664
- el.toggleAttribute("data-placeholder", !set2);
665
- el.setAttribute("aria-valuemin", "0");
666
- el.setAttribute("aria-valuemax", "1");
667
- if (set2) {
668
- el.setAttribute("aria-valuenow", this.#period === "pm" ? "1" : "0");
669
- el.setAttribute("aria-valuetext", this.#period === "pm" ? this.#pmText : this.#amText);
670
- } else {
671
- el.removeAttribute("aria-valuenow");
672
- el.removeAttribute("aria-valuetext");
673
- }
629
+ const value2 = this.#period === "pm" ? this.#pmText : this.#period === "am" ? this.#amText : "";
630
+ if (!preserve) el.value = value2;
631
+ el.placeholder = this.#amText;
674
632
  return;
675
633
  }
676
- const val = override;
677
- const set = val != null;
678
- el.textContent = set ? pad2(val) : "\u2013\u2013";
679
- el.toggleAttribute("data-placeholder", !set);
680
- el.setAttribute("aria-valuemin", String(seg.min));
681
- el.setAttribute("aria-valuemax", String(seg.max));
682
- if (set) {
683
- el.setAttribute("aria-valuenow", String(val));
684
- el.setAttribute("aria-valuetext", pad2(val));
685
- } else {
686
- el.removeAttribute("aria-valuenow");
687
- el.removeAttribute("aria-valuetext");
688
- }
634
+ const value = seg.kind === "hour" ? this.#hour : this.#minute;
635
+ if (!preserve) el.value = value == null ? "" : pad2(value);
636
+ el.placeholder = "\u2013\u2013";
689
637
  }
690
638
  /** Parse a `"HH:mm"` value into the display segments (24h → display units). */
691
639
  #applyValue(v) {
692
- const m = /^(\d{1,2}):(\d{2})$/.exec((v ?? "").trim());
693
- if (!m) {
640
+ const time = parseTime(v);
641
+ if (!time) {
694
642
  this.#hour = null;
695
643
  this.#minute = null;
696
644
  this.#period = null;
697
645
  return;
698
646
  }
699
- const h = Math.min(23, Number(m[1]));
700
- const min = Math.min(59, Number(m[2]));
701
- this.#minute = min;
647
+ this.#minute = time.min;
702
648
  if (this.#hour12) {
703
- this.#hour = (h + 11) % 12 + 1;
704
- this.#period = h < 12 ? "am" : "pm";
649
+ this.#hour = (time.h + 11) % 12 + 1;
650
+ this.#period = time.h < 12 ? "am" : "pm";
705
651
  } else {
706
- this.#hour = h;
652
+ this.#hour = time.h;
707
653
  this.#period = null;
708
654
  }
709
655
  }
@@ -716,20 +662,11 @@ class AInputTimeElement extends HTMLElementBase {
716
662
  else this.#segRow.removeAttribute("aria-label");
717
663
  }
718
664
  #onLabelSlotChange = () => {
719
- this.#labelBox.classList.toggle("has-label", this.#labelSlot.assignedNodes().length > 0);
720
665
  this.#applyGroupLabel();
721
666
  };
722
- #onHintSlotChange = () => {
723
- this.#hintBox.classList.toggle("has-hint", this.#hintSlot.assignedNodes().length > 0);
724
- };
725
667
  #syncDisabled() {
726
668
  const off = this.hasAttribute("disabled") || this.#formDisabled;
727
- for (const seg of this.#segs) {
728
- if (off) seg.el.setAttribute("aria-disabled", "true");
729
- else seg.el.removeAttribute("aria-disabled");
730
- seg.el.tabIndex = off ? -1 : 0;
731
- seg.el.contentEditable = off ? "false" : "true";
732
- }
669
+ for (const seg of this.#segs) seg.el.disabled = off;
733
670
  }
734
671
  #syncStatus() {
735
672
  const critical = this.getAttribute("status") === "critical";
@@ -740,13 +677,8 @@ class AInputTimeElement extends HTMLElementBase {
740
677
  }
741
678
  }
742
679
  #updateFilled() {
743
- const filled = !!this.value;
744
- this.#field.classList.toggle("is-filled", filled);
745
- try {
746
- if (filled) this.#internals?.states.add("filled");
747
- else this.#internals?.states.delete("filled");
748
- } catch {
749
- }
680
+ if (this.value) this.#internals?.states.add("filled");
681
+ else this.#internals?.states.delete("filled");
750
682
  }
751
683
  #updateValidity() {
752
684
  if (!this.#internals) return;
@@ -779,12 +711,10 @@ class AInputTimeElement extends HTMLElementBase {
779
711
  const next = v ?? "";
780
712
  if (next === this.value) return;
781
713
  this.#applyValue(next);
782
- this.#iso = this.value;
783
714
  if (this.#ready) {
784
- this.#render();
785
- this.#internals?.setFormValue(this.value);
786
- this.#updateFilled();
787
- this.#updateValidity();
715
+ this.#commitEdit({ dispatch: false });
716
+ } else {
717
+ this.#iso = this.value;
788
718
  }
789
719
  }
790
720
  /** Empty the field, refocus the first segment, and fire input + change. */
@@ -792,12 +722,7 @@ class AInputTimeElement extends HTMLElementBase {
792
722
  this.#hour = null;
793
723
  this.#minute = null;
794
724
  this.#period = null;
795
- this.#iso = "";
796
- this.#render();
797
- this.#internals?.setFormValue("");
798
- this.#updateFilled();
799
- this.#updateValidity();
800
- this.#dispatch("input");
725
+ this.#commitEdit();
801
726
  this.#dispatch("change");
802
727
  this.dispatchEvent(new CustomEvent(CLEAR_INPUT_EVENT, { bubbles: true }));
803
728
  this.#segs[0]?.el.focus();
@@ -829,12 +754,7 @@ class AInputTimeElement extends HTMLElementBase {
829
754
  }
830
755
  formResetCallback() {
831
756
  this.#applyValue(this.getAttribute("defaultvalue") ?? "");
832
- this.#iso = this.value;
833
- this.#render();
834
- this.#internals?.setFormValue(this.value);
835
- this.#updateFilled();
836
- this.#updateValidity();
837
- this.#dispatch("input");
757
+ this.#commitEdit();
838
758
  this.#dispatch("change");
839
759
  }
840
760
  formDisabledCallback(disabled) {