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