@adia-ai/web-components 0.6.33 → 0.6.34
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.
- package/CHANGELOG.md +22 -0
- package/components/accordion/accordion.css +2 -2
- package/components/action-list/action-list.css +2 -2
- package/components/agent-artifact/agent-artifact.css +31 -31
- package/components/agent-feedback-bar/agent-feedback-bar.css +10 -10
- package/components/agent-questions/agent-questions.css +57 -57
- package/components/agent-reasoning/agent-reasoning.css +62 -62
- package/components/agent-suggestions/agent-suggestions.css +4 -4
- package/components/agent-trace/agent-trace.css +53 -53
- package/components/alert/alert.css +41 -41
- package/components/avatar/avatar.css +27 -27
- package/components/badge/badge.css +27 -27
- package/components/block/block.css +16 -16
- package/components/breadcrumb/breadcrumb.css +23 -23
- package/components/button/button.css +101 -91
- package/components/calendar-grid/calendar-grid.a2ui.json +136 -0
- package/components/calendar-grid/calendar-grid.css +226 -0
- package/components/calendar-grid/calendar-grid.d.ts +37 -0
- package/components/calendar-grid/calendar-grid.js +17 -0
- package/components/calendar-grid/calendar-grid.yaml +116 -0
- package/components/calendar-grid/class.js +300 -0
- package/components/calendar-picker/calendar-picker.css +139 -139
- package/components/canvas/canvas.css +12 -12
- package/components/card/card.css +83 -83
- package/components/chart/chart.css +224 -224
- package/components/chart-legend/chart-legend.css +26 -26
- package/components/check/check.css +40 -40
- package/components/code/code.css +125 -125
- package/components/col/col.css +15 -15
- package/components/color-picker/color-picker.css +55 -55
- package/components/combobox/class.js +861 -0
- package/components/combobox/combobox.a2ui.json +363 -0
- package/components/combobox/combobox.css +244 -0
- package/components/combobox/combobox.d.ts +113 -0
- package/components/combobox/combobox.examples.md +59 -0
- package/components/combobox/combobox.js +17 -0
- package/components/combobox/combobox.test.js +181 -0
- package/components/combobox/combobox.yaml +369 -0
- package/components/command/command.css +90 -90
- package/components/date-range-picker/class.js +775 -0
- package/components/date-range-picker/date-range-picker.a2ui.json +300 -0
- package/components/date-range-picker/date-range-picker.css +178 -0
- package/components/date-range-picker/date-range-picker.d.ts +82 -0
- package/components/date-range-picker/date-range-picker.examples.md +37 -0
- package/components/date-range-picker/date-range-picker.js +17 -0
- package/components/date-range-picker/date-range-picker.test.js +387 -0
- package/components/date-range-picker/date-range-picker.yaml +285 -0
- package/components/datetime-picker/class.js +706 -0
- package/components/datetime-picker/datetime-picker.a2ui.json +334 -0
- package/components/datetime-picker/datetime-picker.css +150 -0
- package/components/datetime-picker/datetime-picker.d.ts +86 -0
- package/components/datetime-picker/datetime-picker.examples.md +46 -0
- package/components/datetime-picker/datetime-picker.js +17 -0
- package/components/datetime-picker/datetime-picker.test.js +454 -0
- package/components/datetime-picker/datetime-picker.yaml +332 -0
- package/components/demo-toggle/demo-toggle.css +27 -27
- package/components/description-list/description-list.css +18 -18
- package/components/divider/divider.css +24 -24
- package/components/embed/embed.css +6 -6
- package/components/empty-state/empty-state.css +27 -27
- package/components/feed/feed.css +12 -12
- package/components/field/field.css +28 -28
- package/components/fields/fields.css +5 -5
- package/components/grid/grid.css +5 -5
- package/components/heatmap/heatmap.css +63 -63
- package/components/icon/icon.css +12 -12
- package/components/image/image.css +14 -14
- package/components/index.js +8 -0
- package/components/input/input.css +66 -66
- package/components/inspector/inspector.css +6 -6
- package/components/integration-card/class.js +410 -0
- package/components/integration-card/integration-card.a2ui.json +268 -0
- package/components/integration-card/integration-card.css +169 -0
- package/components/integration-card/integration-card.d.ts +63 -0
- package/components/integration-card/integration-card.examples.md +41 -0
- package/components/integration-card/integration-card.js +17 -0
- package/components/integration-card/integration-card.test.js +306 -0
- package/components/integration-card/integration-card.yaml +280 -0
- package/components/kbd/kbd.css +32 -32
- package/components/link/link.css +12 -12
- package/components/list/list.css +8 -8
- package/components/list-window/class.js +688 -0
- package/components/list-window/list-window.a2ui.json +277 -0
- package/components/list-window/list-window.css +124 -0
- package/components/list-window/list-window.d.ts +84 -0
- package/components/list-window/list-window.examples.md +73 -0
- package/components/list-window/list-window.js +17 -0
- package/components/list-window/list-window.test.js +303 -0
- package/components/list-window/list-window.yaml +270 -0
- package/components/menu/menu.css +8 -8
- package/components/modal/modal.css +43 -43
- package/components/nav/nav.css +40 -40
- package/components/nav-group/nav-group.css +52 -52
- package/components/nav-item/nav-item.css +44 -44
- package/components/noodles/noodles.css +31 -31
- package/components/option-card/option-card.css +69 -69
- package/components/otp-input/otp-input.css +30 -30
- package/components/page/page.css +18 -18
- package/components/pagination/pagination.css +61 -61
- package/components/pane/pane.css +57 -57
- package/components/pipeline-status/pipeline-status.css +65 -65
- package/components/popover/popover.css +17 -17
- package/components/progress/progress.css +23 -23
- package/components/progress-row/progress-row.css +17 -17
- package/components/radio/radio.css +39 -39
- package/components/range/range.css +55 -55
- package/components/rating/rating.css +28 -28
- package/components/richtext/richtext.css +133 -133
- package/components/row/row.css +19 -19
- package/components/search/search.css +5 -5
- package/components/segment/segment.css +24 -24
- package/components/segmented/segmented.css +25 -25
- package/components/select/select.css +84 -84
- package/components/skeleton/skeleton.css +14 -14
- package/components/slider/slider.css +46 -46
- package/components/spinner/class.js +69 -0
- package/components/spinner/spinner.a2ui.json +197 -0
- package/components/spinner/spinner.css +165 -0
- package/components/spinner/spinner.d.ts +26 -0
- package/components/spinner/spinner.examples.md +26 -0
- package/components/spinner/spinner.js +17 -0
- package/components/spinner/spinner.test.js +234 -0
- package/components/spinner/spinner.yaml +230 -0
- package/components/stack/stack.css +11 -11
- package/components/stat/stat.css +25 -25
- package/components/step-progress/step-progress.css +20 -20
- package/components/stepper/stepper.css +29 -29
- package/components/stream/stream.css +12 -12
- package/components/swatch/swatch.css +68 -68
- package/components/swiper/swiper.css +57 -57
- package/components/switch/switch.css +52 -52
- package/components/table/table.css +162 -162
- package/components/table-toolbar/table-toolbar.css +32 -32
- package/components/tabs/tabs.css +51 -51
- package/components/tag/tag.css +48 -48
- package/components/text/text.css +44 -44
- package/components/textarea/textarea.css +46 -46
- package/components/time-picker/class.js +693 -0
- package/components/time-picker/time-picker.a2ui.json +267 -0
- package/components/time-picker/time-picker.css +122 -0
- package/components/time-picker/time-picker.d.ts +75 -0
- package/components/time-picker/time-picker.examples.md +35 -0
- package/components/time-picker/time-picker.js +17 -0
- package/components/time-picker/time-picker.test.js +287 -0
- package/components/time-picker/time-picker.yaml +256 -0
- package/components/timeline/timeline.css +50 -50
- package/components/toast/toast.css +58 -58
- package/components/toggle-group/toggle-group.css +6 -6
- package/components/toggle-scheme/toggle-scheme.css +2 -2
- package/components/toolbar/toolbar.css +17 -17
- package/components/tooltip/tooltip.css +2 -2
- package/components/tree/tree.css +37 -37
- package/components/upload/upload.css +49 -49
- package/dist/web-components.min.css +1 -1
- package/dist/web-components.min.js +121 -83
- package/package.json +1 -1
- package/styles/components.css +8 -0
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Non-side-effect class export for `<time-picker-ui>`.
|
|
3
|
+
*
|
|
4
|
+
* Importing this file gives you the class(es) without auto-registering the tag.
|
|
5
|
+
* Useful for test isolation, subclassing with tag-name override, or selective
|
|
6
|
+
* composition.
|
|
7
|
+
*
|
|
8
|
+
* The auto-register path stays at `@adia-ai/web-components/components/time-picker`
|
|
9
|
+
* (which imports this file + calls `defineIfFree()`).
|
|
10
|
+
*
|
|
11
|
+
* @see ../../USAGE.md#registration--auto-vs-explicit
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* <time-picker-ui> — Standalone time-of-day picker.
|
|
16
|
+
*
|
|
17
|
+
* Per-segment Spinbutton model (WAI-APG): hour : minute : (second?) :
|
|
18
|
+
* (meridiem?). Each segment is a contenteditable span with role=spinbutton,
|
|
19
|
+
* aria-valuemin / -valuemax / -valuenow / -valuetext, and Arrow Up/Down
|
|
20
|
+
* to step. Form-associated via UIFormElement + ElementInternals; the form
|
|
21
|
+
* value is ISO 8601 time string ("HH:mm" or "HH:mm:ss") under [name].
|
|
22
|
+
*
|
|
23
|
+
* <time-picker-ui name="start-time" value="09:30"
|
|
24
|
+
* min="08:00" max="18:00" step="900"></time-picker-ui>
|
|
25
|
+
*
|
|
26
|
+
* <time-picker-ui name="cursor" precision="second" step="1"
|
|
27
|
+
* value="14:30:45"></time-picker-ui>
|
|
28
|
+
*
|
|
29
|
+
* <time-picker-ui name="alarm" hour-cycle="h12" value="07:00"></time-picker-ui>
|
|
30
|
+
*
|
|
31
|
+
* SPEC-043. Per ADR-0025 no native form controls — never wraps
|
|
32
|
+
* <input type="time">.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { UIFormElement } from '../../core/form.js';
|
|
36
|
+
|
|
37
|
+
const HOUR_MAX_24 = 23;
|
|
38
|
+
const HOUR_MAX_12 = 12;
|
|
39
|
+
const HOUR_MIN_12 = 1;
|
|
40
|
+
const MINUTE_MAX = 59;
|
|
41
|
+
const SECOND_MAX = 59;
|
|
42
|
+
|
|
43
|
+
const SEG_ORDER_BASE = ['hour', 'minute'];
|
|
44
|
+
|
|
45
|
+
/** Parse "HH:mm" or "HH:mm:ss" → {h, m, s} (or null if unparseable). */
|
|
46
|
+
function parseISOTime(str) {
|
|
47
|
+
if (!str) return null;
|
|
48
|
+
const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(String(str).trim());
|
|
49
|
+
if (!m) return null;
|
|
50
|
+
const h = Number(m[1]);
|
|
51
|
+
const min = Number(m[2]);
|
|
52
|
+
const s = m[3] != null ? Number(m[3]) : 0;
|
|
53
|
+
if (!Number.isFinite(h) || h < 0 || h > 23) return null;
|
|
54
|
+
if (!Number.isFinite(min) || min < 0 || min > 59) return null;
|
|
55
|
+
if (!Number.isFinite(s) || s < 0 || s > 59) return null;
|
|
56
|
+
return { h, m: min, s };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Format {h, m, s} as ISO 8601. `precision="second"` includes seconds. */
|
|
60
|
+
function formatISOTime({ h, m, s }, precision) {
|
|
61
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
62
|
+
return precision === 'second'
|
|
63
|
+
? `${pad(h)}:${pad(m)}:${pad(s)}`
|
|
64
|
+
: `${pad(h)}:${pad(m)}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Total seconds since 00:00 for comparison against min/max. */
|
|
68
|
+
function toSeconds({ h, m, s }) { return h * 3600 + m * 60 + s; }
|
|
69
|
+
|
|
70
|
+
/** Derive hour-cycle from BCP-47 locale. Returns 'h12' or 'h23'. */
|
|
71
|
+
function deriveHourCycle(localeTag) {
|
|
72
|
+
try {
|
|
73
|
+
const tag = localeTag || document?.documentElement?.lang || undefined;
|
|
74
|
+
const f = new Intl.DateTimeFormat(tag, { hour: 'numeric' });
|
|
75
|
+
const opts = f.resolvedOptions();
|
|
76
|
+
// resolvedOptions().hourCycle is one of h11/h12/h23/h24; collapse to
|
|
77
|
+
// h12 vs h23 (we ignore the midnight-as-12 vs 0 distinction inside
|
|
78
|
+
// each family).
|
|
79
|
+
const c = opts.hourCycle || (opts.hour12 ? 'h12' : 'h23');
|
|
80
|
+
return c === 'h11' || c === 'h12' ? 'h12' : 'h23';
|
|
81
|
+
} catch { return 'h23'; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export class UITimePicker extends UIFormElement {
|
|
85
|
+
// Per-segment role announces independently; no inert above-the-field
|
|
86
|
+
// label rendering — group `aria-label` instead. Field-ui wrap is the
|
|
87
|
+
// canonical labelling path.
|
|
88
|
+
static labelDeprecated = false;
|
|
89
|
+
|
|
90
|
+
static properties = {
|
|
91
|
+
...UIFormElement.properties,
|
|
92
|
+
min: { type: String, default: '', reflect: true },
|
|
93
|
+
max: { type: String, default: '', reflect: true },
|
|
94
|
+
step: { type: Number, default: 60, reflect: true },
|
|
95
|
+
precision: { type: String, default: 'minute', reflect: true },
|
|
96
|
+
hourcycle: { type: String, default: '', reflect: true, attribute: 'hour-cycle' },
|
|
97
|
+
placeholder:{ type: String, default: '--', reflect: false },
|
|
98
|
+
locale: { type: String, default: '', reflect: false },
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
static template = () => null;
|
|
102
|
+
|
|
103
|
+
// Stamped segment refs (re-bound after each (re)stamp)
|
|
104
|
+
#segs = null;
|
|
105
|
+
// The keys of #segs in DOM-order (e.g. ['hour','minute','second','meridiem'])
|
|
106
|
+
#segOrder = [];
|
|
107
|
+
// Last stamped shape signature so we know whether to re-stamp on attr change.
|
|
108
|
+
#lastShape = '';
|
|
109
|
+
// Currently focused segment key (or null).
|
|
110
|
+
#focusedKey = null;
|
|
111
|
+
|
|
112
|
+
// Effective hour cycle ('h12' or 'h23') resolved from props/locale.
|
|
113
|
+
get #cycle() {
|
|
114
|
+
if (this.hourcycle === 'h12' || this.hourcycle === 'h23') return this.hourcycle;
|
|
115
|
+
return deriveHourCycle(this.locale);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
get #showsMeridiem() { return this.#cycle === 'h12'; }
|
|
119
|
+
get #showsSeconds() { return this.precision === 'second'; }
|
|
120
|
+
|
|
121
|
+
// Shape signature drives re-stamp decisions. Change → re-stamp segments.
|
|
122
|
+
get #shape() {
|
|
123
|
+
return `${this.#cycle}|${this.precision}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Lifecycle ──
|
|
127
|
+
|
|
128
|
+
connected() {
|
|
129
|
+
super.connected();
|
|
130
|
+
// Host is the group; first segment is the keyboard entry point.
|
|
131
|
+
this.setAttribute('role', 'group');
|
|
132
|
+
if (!this.hasAttribute('aria-label') && !this.hasAttribute('aria-labelledby')) {
|
|
133
|
+
// Provide a sensible default label so SRs don't read just "group".
|
|
134
|
+
this.setAttribute('aria-label', 'Time');
|
|
135
|
+
}
|
|
136
|
+
if (this.required) this.setAttribute('aria-required', 'true');
|
|
137
|
+
|
|
138
|
+
this.#stampSegments();
|
|
139
|
+
|
|
140
|
+
this.addEventListener('keydown', this.#onKeydown);
|
|
141
|
+
this.addEventListener('focusin', this.#onFocusIn);
|
|
142
|
+
this.addEventListener('focusout', this.#onFocusOut);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
render() {
|
|
146
|
+
// Re-stamp if precision / hour-cycle changed (segment set differs).
|
|
147
|
+
if (this.#lastShape && this.#lastShape !== this.#shape) {
|
|
148
|
+
this.#stampSegments();
|
|
149
|
+
}
|
|
150
|
+
// Sync segment text + ARIA from current value.
|
|
151
|
+
this.#syncSegmentsFromValue();
|
|
152
|
+
// Disabled / readonly state propagation onto segments.
|
|
153
|
+
this.#applyEditableState();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
disconnected() {
|
|
157
|
+
super.disconnected();
|
|
158
|
+
this.removeEventListener('keydown', this.#onKeydown);
|
|
159
|
+
this.removeEventListener('focusin', this.#onFocusIn);
|
|
160
|
+
this.removeEventListener('focusout', this.#onFocusOut);
|
|
161
|
+
this.#segs = null;
|
|
162
|
+
this.#segOrder = [];
|
|
163
|
+
this.#focusedKey = null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Public methods ──
|
|
167
|
+
|
|
168
|
+
clear() {
|
|
169
|
+
this.value = '';
|
|
170
|
+
this.syncValue('');
|
|
171
|
+
this.#syncSegmentsFromValue();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Increment a segment programmatically.
|
|
176
|
+
* @param {'hour'|'minute'|'second'|'meridiem'} segment
|
|
177
|
+
* @param {number} [n] step count (default 1)
|
|
178
|
+
*/
|
|
179
|
+
stepUp(segment, n = 1) {
|
|
180
|
+
this.#stepSegment(segment, +1 * (n | 0 || 1));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
stepDown(segment, n = 1) {
|
|
184
|
+
this.#stepSegment(segment, -1 * (n | 0 || 1));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ── Stamping ──
|
|
188
|
+
|
|
189
|
+
#stampSegments() {
|
|
190
|
+
const cycle = this.#cycle;
|
|
191
|
+
const showSeconds = this.#showsSeconds;
|
|
192
|
+
const showMeridiem = this.#showsMeridiem;
|
|
193
|
+
|
|
194
|
+
// Build the segment order for this shape.
|
|
195
|
+
const order = [...SEG_ORDER_BASE];
|
|
196
|
+
if (showSeconds) order.push('second');
|
|
197
|
+
if (showMeridiem) order.push('meridiem');
|
|
198
|
+
this.#segOrder = order;
|
|
199
|
+
|
|
200
|
+
// Compose markup. We rebuild the inner shell on a shape change; prefix
|
|
201
|
+
// / suffix slotted content survives because consumers place it OUTSIDE
|
|
202
|
+
// any [slot="field"] container — at the host level — and we move it
|
|
203
|
+
// back on re-stamp. (Simpler than diffing.)
|
|
204
|
+
const prefix = this.querySelector(':scope > [slot="prefix"]');
|
|
205
|
+
const suffix = this.querySelector(':scope > [slot="suffix"]');
|
|
206
|
+
|
|
207
|
+
const segHTML = (key, label, max) =>
|
|
208
|
+
`<span data-segment="${key}" role="spinbutton" tabindex="0"
|
|
209
|
+
aria-label="${label}" aria-valuemin="0" aria-valuemax="${max}"
|
|
210
|
+
aria-valuenow=""></span>`;
|
|
211
|
+
|
|
212
|
+
const sep = `<span data-separator aria-hidden="true">:</span>`;
|
|
213
|
+
|
|
214
|
+
let inner = '';
|
|
215
|
+
inner += segHTML('hour', 'Hours', showMeridiem ? HOUR_MAX_12 : HOUR_MAX_24);
|
|
216
|
+
inner += sep;
|
|
217
|
+
inner += segHTML('minute', 'Minutes', MINUTE_MAX);
|
|
218
|
+
if (showSeconds) {
|
|
219
|
+
inner += sep;
|
|
220
|
+
inner += segHTML('second', 'Seconds', SECOND_MAX);
|
|
221
|
+
}
|
|
222
|
+
if (showMeridiem) {
|
|
223
|
+
// Meridiem is a toggle (AM/PM); reflect via aria-valuetext only.
|
|
224
|
+
inner += `<span data-segment="meridiem" role="spinbutton" tabindex="0"
|
|
225
|
+
aria-label="AM/PM" aria-valuemin="0" aria-valuemax="1"
|
|
226
|
+
aria-valuenow="" data-meridiem></span>`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Replace host content; re-attach prefix/suffix.
|
|
230
|
+
this.innerHTML = inner;
|
|
231
|
+
if (prefix) this.insertBefore(prefix, this.firstChild);
|
|
232
|
+
if (suffix) this.appendChild(suffix);
|
|
233
|
+
|
|
234
|
+
// Re-bind refs.
|
|
235
|
+
this.#segs = {};
|
|
236
|
+
for (const key of order) {
|
|
237
|
+
const el = this.querySelector(`[data-segment="${key}"]`);
|
|
238
|
+
if (el) this.#segs[key] = el;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Bind per-segment input listeners (digit typing path).
|
|
242
|
+
for (const key of order) {
|
|
243
|
+
const seg = this.#segs[key];
|
|
244
|
+
if (!seg) continue;
|
|
245
|
+
seg.contentEditable = 'plaintext-only';
|
|
246
|
+
seg.addEventListener('beforeinput', this.#onSegBeforeInput);
|
|
247
|
+
seg.addEventListener('input', this.#onSegInput);
|
|
248
|
+
seg.addEventListener('blur', this.#onSegBlur);
|
|
249
|
+
seg.addEventListener('focus', this.#onSegFocus);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
this.#lastShape = this.#shape;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
#applyEditableState() {
|
|
256
|
+
if (!this.#segs) return;
|
|
257
|
+
const editable = !(this.disabled || this.readonly);
|
|
258
|
+
for (const key of this.#segOrder) {
|
|
259
|
+
const seg = this.#segs[key];
|
|
260
|
+
if (!seg) continue;
|
|
261
|
+
seg.contentEditable = editable ? 'plaintext-only' : 'false';
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Value ↔ segment sync ──
|
|
266
|
+
|
|
267
|
+
#parts() {
|
|
268
|
+
const parsed = parseISOTime(this.value);
|
|
269
|
+
if (parsed) return parsed;
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
#syncSegmentsFromValue() {
|
|
274
|
+
if (!this.#segs) return;
|
|
275
|
+
const parts = this.#parts();
|
|
276
|
+
const cycle = this.#cycle;
|
|
277
|
+
|
|
278
|
+
if (!parts) {
|
|
279
|
+
// Empty value → empty segments + placeholder via :empty CSS.
|
|
280
|
+
for (const key of this.#segOrder) {
|
|
281
|
+
const seg = this.#segs[key];
|
|
282
|
+
if (!seg) continue;
|
|
283
|
+
// Skip the segment the user is mid-typing on.
|
|
284
|
+
if (document.activeElement === seg && seg.textContent !== '') continue;
|
|
285
|
+
seg.textContent = '';
|
|
286
|
+
seg.setAttribute('aria-valuenow', '');
|
|
287
|
+
seg.setAttribute('aria-valuetext', this.placeholder);
|
|
288
|
+
}
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Compute display hour for the chosen cycle.
|
|
293
|
+
const dispHour = this.#showsMeridiem
|
|
294
|
+
? ((parts.h % 12) || 12)
|
|
295
|
+
: parts.h;
|
|
296
|
+
|
|
297
|
+
const display = {
|
|
298
|
+
hour: pad2(dispHour),
|
|
299
|
+
minute: pad2(parts.m),
|
|
300
|
+
second: pad2(parts.s),
|
|
301
|
+
meridiem: cycle === 'h12' ? (parts.h >= 12 ? 'PM' : 'AM') : '',
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const ariaNow = {
|
|
305
|
+
hour: String(dispHour),
|
|
306
|
+
minute: String(parts.m),
|
|
307
|
+
second: String(parts.s),
|
|
308
|
+
meridiem: cycle === 'h12' ? (parts.h >= 12 ? '1' : '0') : '',
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const ariaText = {
|
|
312
|
+
hour: `${dispHour} hour${dispHour === 1 ? '' : 's'}`,
|
|
313
|
+
minute: `${parts.m} minute${parts.m === 1 ? '' : 's'}`,
|
|
314
|
+
second: `${parts.s} second${parts.s === 1 ? '' : 's'}`,
|
|
315
|
+
meridiem: cycle === 'h12' ? (parts.h >= 12 ? 'PM' : 'AM') : '',
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
for (const key of this.#segOrder) {
|
|
319
|
+
const seg = this.#segs[key];
|
|
320
|
+
if (!seg) continue;
|
|
321
|
+
// Don't clobber a segment the user is currently typing in.
|
|
322
|
+
if (document.activeElement === seg) continue;
|
|
323
|
+
seg.textContent = display[key];
|
|
324
|
+
seg.setAttribute('aria-valuenow', ariaNow[key]);
|
|
325
|
+
seg.setAttribute('aria-valuetext', ariaText[key]);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Read all segments → re-emit as ISO 8601 value, validate, commit. */
|
|
330
|
+
#commitFromSegments(triggerSegment) {
|
|
331
|
+
if (!this.#segs) return;
|
|
332
|
+
const cycle = this.#cycle;
|
|
333
|
+
|
|
334
|
+
// Read raw text from each segment; missing → bail with empty.
|
|
335
|
+
const raw = {};
|
|
336
|
+
for (const key of this.#segOrder) {
|
|
337
|
+
raw[key] = (this.#segs[key]?.textContent || '').trim();
|
|
338
|
+
}
|
|
339
|
+
// Hours / minutes / seconds need numeric content; meridiem is AM/PM.
|
|
340
|
+
if (!raw.hour && !raw.minute) {
|
|
341
|
+
// All empty — store empty value.
|
|
342
|
+
this.value = '';
|
|
343
|
+
this.syncValue('');
|
|
344
|
+
this.dispatchEvent(new CustomEvent('change', {
|
|
345
|
+
bubbles: true,
|
|
346
|
+
detail: { value: '', segment: triggerSegment || null },
|
|
347
|
+
}));
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
let h = clampInt(raw.hour, 0, cycle === 'h12' ? HOUR_MAX_12 : HOUR_MAX_24);
|
|
352
|
+
let m = clampInt(raw.minute, 0, MINUTE_MAX);
|
|
353
|
+
let s = this.#showsSeconds ? clampInt(raw.second, 0, SECOND_MAX) : 0;
|
|
354
|
+
|
|
355
|
+
if (h == null || m == null || s == null) {
|
|
356
|
+
this.dispatchEvent(new CustomEvent('invalid', {
|
|
357
|
+
bubbles: true,
|
|
358
|
+
detail: { value: this.value, reason: 'parse' },
|
|
359
|
+
}));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
if (cycle === 'h12') {
|
|
364
|
+
// Map 12h display → 24h storage.
|
|
365
|
+
const ampm = String(raw.meridiem || '').trim().toUpperCase();
|
|
366
|
+
const isPM = ampm === 'PM';
|
|
367
|
+
if (h === 12) h = isPM ? 12 : 0;
|
|
368
|
+
else if (isPM) h += 12;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const parts = { h, m, s };
|
|
372
|
+
const isoSeconds = toSeconds(parts);
|
|
373
|
+
const minP = parseISOTime(this.min);
|
|
374
|
+
const maxP = parseISOTime(this.max);
|
|
375
|
+
if (minP && isoSeconds < toSeconds(minP)) {
|
|
376
|
+
this.dispatchEvent(new CustomEvent('invalid', {
|
|
377
|
+
bubbles: true,
|
|
378
|
+
detail: { value: this.value, reason: 'min' },
|
|
379
|
+
}));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (maxP && isoSeconds > toSeconds(maxP)) {
|
|
383
|
+
this.dispatchEvent(new CustomEvent('invalid', {
|
|
384
|
+
bubbles: true,
|
|
385
|
+
detail: { value: this.value, reason: 'max' },
|
|
386
|
+
}));
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const next = formatISOTime(parts, this.precision);
|
|
391
|
+
if (next === this.value) {
|
|
392
|
+
// Still sync display in case raw was unpadded (e.g. "9" → "09").
|
|
393
|
+
this.#syncSegmentsFromValue();
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
this.value = next;
|
|
397
|
+
this.syncValue(next);
|
|
398
|
+
this.#syncSegmentsFromValue();
|
|
399
|
+
this.dispatchEvent(new CustomEvent('change', {
|
|
400
|
+
bubbles: true,
|
|
401
|
+
detail: { value: next, segment: triggerSegment || null },
|
|
402
|
+
}));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ── Per-segment Arrow / Page / Home / End ──
|
|
406
|
+
|
|
407
|
+
#stepSegment(key, direction) {
|
|
408
|
+
if (this.disabled || this.readonly) return;
|
|
409
|
+
if (!this.#segs?.[key]) return;
|
|
410
|
+
const cycle = this.#cycle;
|
|
411
|
+
const stepSecs = Math.max(1, this.step | 0);
|
|
412
|
+
|
|
413
|
+
// Default to "now" when value is empty so steps start from a sane base.
|
|
414
|
+
let parts = this.#parts();
|
|
415
|
+
if (!parts) {
|
|
416
|
+
parts = { h: 0, m: 0, s: 0 };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (key === 'hour') {
|
|
420
|
+
parts.h = wrap(parts.h + direction, 0, 24);
|
|
421
|
+
} else if (key === 'minute') {
|
|
422
|
+
const delta = Math.max(1, Math.floor(stepSecs / 60)) * direction;
|
|
423
|
+
parts.m = parts.m + delta;
|
|
424
|
+
while (parts.m < 0) { parts.m += 60; parts.h = wrap(parts.h - 1, 0, 24); }
|
|
425
|
+
while (parts.m > 59) { parts.m -= 60; parts.h = wrap(parts.h + 1, 0, 24); }
|
|
426
|
+
} else if (key === 'second') {
|
|
427
|
+
const delta = Math.max(1, stepSecs) * direction;
|
|
428
|
+
parts.s = parts.s + delta;
|
|
429
|
+
while (parts.s < 0) { parts.s += 60; parts.m -= 1; }
|
|
430
|
+
while (parts.s > 59) { parts.s -= 60; parts.m += 1; }
|
|
431
|
+
while (parts.m < 0) { parts.m += 60; parts.h = wrap(parts.h - 1, 0, 24); }
|
|
432
|
+
while (parts.m > 59) { parts.m -= 60; parts.h = wrap(parts.h + 1, 0, 24); }
|
|
433
|
+
} else if (key === 'meridiem') {
|
|
434
|
+
parts.h = wrap(parts.h + 12, 0, 24);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Clamp by min/max — refuse the step if it would cross a bound.
|
|
438
|
+
const isoSeconds = toSeconds(parts);
|
|
439
|
+
const minP = parseISOTime(this.min);
|
|
440
|
+
const maxP = parseISOTime(this.max);
|
|
441
|
+
if (minP && isoSeconds < toSeconds(minP)) {
|
|
442
|
+
this.dispatchEvent(new CustomEvent('invalid', {
|
|
443
|
+
bubbles: true,
|
|
444
|
+
detail: { value: this.value, reason: 'min' },
|
|
445
|
+
}));
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
if (maxP && isoSeconds > toSeconds(maxP)) {
|
|
449
|
+
this.dispatchEvent(new CustomEvent('invalid', {
|
|
450
|
+
bubbles: true,
|
|
451
|
+
detail: { value: this.value, reason: 'max' },
|
|
452
|
+
}));
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const next = formatISOTime(parts, this.precision);
|
|
457
|
+
this.value = next;
|
|
458
|
+
this.syncValue(next);
|
|
459
|
+
this.#syncSegmentsFromValue();
|
|
460
|
+
this.dispatchEvent(new CustomEvent('input', {
|
|
461
|
+
bubbles: true,
|
|
462
|
+
detail: { value: next },
|
|
463
|
+
}));
|
|
464
|
+
this.dispatchEvent(new CustomEvent('change', {
|
|
465
|
+
bubbles: true,
|
|
466
|
+
detail: { value: next, segment: key },
|
|
467
|
+
}));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
#jumpSegment(key, where) {
|
|
471
|
+
if (this.disabled || this.readonly) return;
|
|
472
|
+
const cycle = this.#cycle;
|
|
473
|
+
let parts = this.#parts() || { h: 0, m: 0, s: 0 };
|
|
474
|
+
if (key === 'hour') {
|
|
475
|
+
parts.h = where === 'min' ? 0 : 23;
|
|
476
|
+
} else if (key === 'minute') {
|
|
477
|
+
parts.m = where === 'min' ? 0 : 59;
|
|
478
|
+
} else if (key === 'second') {
|
|
479
|
+
parts.s = where === 'min' ? 0 : 59;
|
|
480
|
+
} else if (key === 'meridiem') {
|
|
481
|
+
parts.h = where === 'min' ? (parts.h % 12) : ((parts.h % 12) + 12);
|
|
482
|
+
}
|
|
483
|
+
const next = formatISOTime(parts, this.precision);
|
|
484
|
+
this.value = next;
|
|
485
|
+
this.syncValue(next);
|
|
486
|
+
this.#syncSegmentsFromValue();
|
|
487
|
+
this.dispatchEvent(new CustomEvent('change', {
|
|
488
|
+
bubbles: true,
|
|
489
|
+
detail: { value: next, segment: key },
|
|
490
|
+
}));
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
#moveFocus(currentKey, delta) {
|
|
494
|
+
const idx = this.#segOrder.indexOf(currentKey);
|
|
495
|
+
if (idx < 0) return;
|
|
496
|
+
const next = idx + delta;
|
|
497
|
+
if (next < 0 || next >= this.#segOrder.length) return;
|
|
498
|
+
const target = this.#segs?.[this.#segOrder[next]];
|
|
499
|
+
target?.focus();
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ── Event handlers ──
|
|
503
|
+
|
|
504
|
+
#segmentOf(target) {
|
|
505
|
+
if (!(target instanceof Element)) return null;
|
|
506
|
+
const el = target.closest('[data-segment]');
|
|
507
|
+
if (!el || !this.contains(el)) return null;
|
|
508
|
+
return el.getAttribute('data-segment');
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
#onKeydown = (e) => {
|
|
512
|
+
if (this.disabled) return;
|
|
513
|
+
const key = this.#segmentOf(e.target);
|
|
514
|
+
if (!key) return;
|
|
515
|
+
|
|
516
|
+
switch (e.key) {
|
|
517
|
+
case 'ArrowUp':
|
|
518
|
+
e.preventDefault();
|
|
519
|
+
this.#stepSegment(key, +1);
|
|
520
|
+
return;
|
|
521
|
+
case 'ArrowDown':
|
|
522
|
+
e.preventDefault();
|
|
523
|
+
this.#stepSegment(key, -1);
|
|
524
|
+
return;
|
|
525
|
+
case 'PageUp':
|
|
526
|
+
e.preventDefault();
|
|
527
|
+
this.#stepSegment(key, +10);
|
|
528
|
+
return;
|
|
529
|
+
case 'PageDown':
|
|
530
|
+
e.preventDefault();
|
|
531
|
+
this.#stepSegment(key, -10);
|
|
532
|
+
return;
|
|
533
|
+
case 'Home':
|
|
534
|
+
e.preventDefault();
|
|
535
|
+
this.#jumpSegment(key, 'min');
|
|
536
|
+
return;
|
|
537
|
+
case 'End':
|
|
538
|
+
e.preventDefault();
|
|
539
|
+
this.#jumpSegment(key, 'max');
|
|
540
|
+
return;
|
|
541
|
+
case 'ArrowLeft':
|
|
542
|
+
e.preventDefault();
|
|
543
|
+
this.#moveFocus(key, -1);
|
|
544
|
+
return;
|
|
545
|
+
case 'ArrowRight':
|
|
546
|
+
e.preventDefault();
|
|
547
|
+
this.#moveFocus(key, +1);
|
|
548
|
+
return;
|
|
549
|
+
case 'Enter':
|
|
550
|
+
e.preventDefault();
|
|
551
|
+
this.#commitFromSegments(key);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
#onFocusIn = (e) => {
|
|
557
|
+
const key = this.#segmentOf(e.target);
|
|
558
|
+
if (!key) return;
|
|
559
|
+
this.#focusedKey = key;
|
|
560
|
+
this.setAttribute('editing', '');
|
|
561
|
+
this.setAttribute('segment', key);
|
|
562
|
+
};
|
|
563
|
+
|
|
564
|
+
#onFocusOut = (e) => {
|
|
565
|
+
// Defer to next tick — focusout fires before the new focusin lands; if
|
|
566
|
+
// it's another segment we want to stay in editing state. We rely on
|
|
567
|
+
// focusin to overwrite [segment="…"].
|
|
568
|
+
queueMicrotask(() => {
|
|
569
|
+
const active = document.activeElement;
|
|
570
|
+
if (!active || !this.contains(active)) {
|
|
571
|
+
this.removeAttribute('editing');
|
|
572
|
+
this.removeAttribute('segment');
|
|
573
|
+
this.#focusedKey = null;
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
};
|
|
577
|
+
|
|
578
|
+
// ── Per-segment digit entry ──
|
|
579
|
+
|
|
580
|
+
#onSegBeforeInput = (e) => {
|
|
581
|
+
// Allow deletions, formatting, composition — gate insertions.
|
|
582
|
+
const t = e.inputType;
|
|
583
|
+
if (!t || !t.startsWith('insert')) return;
|
|
584
|
+
if (t === 'insertCompositionText') return;
|
|
585
|
+
const data = e.data ?? '';
|
|
586
|
+
if (!data) return;
|
|
587
|
+
const key = this.#segmentOf(e.target);
|
|
588
|
+
if (key === 'meridiem') {
|
|
589
|
+
// Allow only A/P/M letters; cap meridiem segment at 2 chars (AM/PM).
|
|
590
|
+
if (!/^[apmAPM]$/.test(data)) { e.preventDefault(); return; }
|
|
591
|
+
const cur = e.target.textContent || '';
|
|
592
|
+
if (cur.length >= 2) e.preventDefault();
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (!/^\d$/.test(data)) { e.preventDefault(); return; }
|
|
596
|
+
const cur = e.target.textContent || '';
|
|
597
|
+
if (cur.length >= 2) e.preventDefault();
|
|
598
|
+
};
|
|
599
|
+
|
|
600
|
+
#onSegInput = (e) => {
|
|
601
|
+
const key = this.#segmentOf(e.target);
|
|
602
|
+
if (!key) return;
|
|
603
|
+
const raw = (e.target.textContent || '').trim();
|
|
604
|
+
|
|
605
|
+
// Emit input as we go (without committing — change fires on blur/Enter).
|
|
606
|
+
this.dispatchEvent(new CustomEvent('input', {
|
|
607
|
+
bubbles: true,
|
|
608
|
+
detail: { value: this.value },
|
|
609
|
+
}));
|
|
610
|
+
|
|
611
|
+
// Auto-advance: when the segment is "full" (e.g. typing "14" on a 23-max
|
|
612
|
+
// hour segment when the value can't take any more digits), jump to the
|
|
613
|
+
// next segment.
|
|
614
|
+
if (key === 'meridiem') {
|
|
615
|
+
const upper = raw.toUpperCase();
|
|
616
|
+
if (upper === 'AM' || upper === 'PM') {
|
|
617
|
+
e.target.textContent = upper;
|
|
618
|
+
// After meridiem set, commit.
|
|
619
|
+
this.#commitFromSegments(key);
|
|
620
|
+
}
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (raw.length < 2) {
|
|
625
|
+
// First digit — auto-advance if a second digit can't possibly fit.
|
|
626
|
+
const max = parseInt(e.target.getAttribute('aria-valuemax') || '0', 10);
|
|
627
|
+
const n = parseInt(raw, 10);
|
|
628
|
+
if (!Number.isNaN(n)) {
|
|
629
|
+
// If adding a second digit would exceed max no matter what, advance.
|
|
630
|
+
// For hour h23: max 23 — first digit 3..9 forces single-digit.
|
|
631
|
+
// For minute: max 59 — first digit 6..9 forces single-digit.
|
|
632
|
+
if (n * 10 > max) {
|
|
633
|
+
// Single-digit value can't be extended; commit now + advance.
|
|
634
|
+
this.#commitFromSegments(key);
|
|
635
|
+
this.#moveFocus(key, +1);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
} else {
|
|
639
|
+
// Two digits — segment full; commit + advance.
|
|
640
|
+
this.#commitFromSegments(key);
|
|
641
|
+
this.#moveFocus(key, +1);
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
#onSegBlur = (e) => {
|
|
646
|
+
const key = this.#segmentOf(e.target);
|
|
647
|
+
if (!key) return;
|
|
648
|
+
// Commit on blur so manually-typed values land in `value`.
|
|
649
|
+
this.#commitFromSegments(key);
|
|
650
|
+
};
|
|
651
|
+
|
|
652
|
+
#onSegFocus = (e) => {
|
|
653
|
+
// Select-all so the next digit overwrites cleanly.
|
|
654
|
+
const target = e.target;
|
|
655
|
+
if (!(target instanceof HTMLElement)) return;
|
|
656
|
+
requestAnimationFrame(() => {
|
|
657
|
+
if (document.activeElement !== target) return;
|
|
658
|
+
const sel = window.getSelection?.();
|
|
659
|
+
if (!sel) return;
|
|
660
|
+
const range = document.createRange();
|
|
661
|
+
range.selectNodeContents(target);
|
|
662
|
+
sel.removeAllRanges();
|
|
663
|
+
sel.addRange(range);
|
|
664
|
+
});
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
// ── Form lifecycle ──
|
|
668
|
+
|
|
669
|
+
onFormReset() {
|
|
670
|
+
this.value = '';
|
|
671
|
+
this.#syncSegmentsFromValue();
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ── Helpers ──
|
|
676
|
+
|
|
677
|
+
function pad2(n) { return String(n).padStart(2, '0'); }
|
|
678
|
+
|
|
679
|
+
function clampInt(str, min, max) {
|
|
680
|
+
if (str === '' || str == null) return null;
|
|
681
|
+
const n = Number(str);
|
|
682
|
+
if (!Number.isFinite(n) || !Number.isInteger(n)) return null;
|
|
683
|
+
if (n < min || n > max) return null;
|
|
684
|
+
return n;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function wrap(n, min, modulus) {
|
|
688
|
+
// wraparound for hours (0..23). modulus = 24.
|
|
689
|
+
let r = n - min;
|
|
690
|
+
const span = modulus - min;
|
|
691
|
+
r = ((r % span) + span) % span;
|
|
692
|
+
return r + min;
|
|
693
|
+
}
|