segmented-input 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,990 @@
1
+ /*! <segmented-input> MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
2
+
3
+ /**
4
+ * Compute the start/end character positions of each segment within the formatted string.
5
+ *
6
+ * Segments are located by searching for each segment's value in the formatted string
7
+ * in order, advancing the search position after each find. This correctly handles
8
+ * any format function that may transform or pad individual values (e.g. padStart),
9
+ * as long as segments appear in their natural left-to-right order in the output.
10
+ *
11
+ * @param {string} value - the full formatted string currently in the input
12
+ * @param {function(string): string[]} parse - splits the formatted string into segment values
13
+ * @param {function(string[]): string} format - joins segment values back into a formatted string
14
+ * @returns {Array<{start: number, end: number, value: string}>}
15
+ */
16
+ function getSegmentRanges (value, parse, format) {
17
+ const segmentValues = parse(value)
18
+ // Use the normalised/formatted string so that positions are consistent
19
+ // with whatever value the SegmentedInput class writes back to input.value.
20
+ const formatted = format(segmentValues)
21
+ const ranges = []
22
+ let searchFrom = 0
23
+
24
+ for (let i = 0; i < segmentValues.length; i++) {
25
+ const segVal = String(segmentValues[i])
26
+ const start = formatted.indexOf(segVal, searchFrom)
27
+ if (start === -1) {
28
+ // Fallback: append a zero-width range at the current search position
29
+ ranges.push({ start: searchFrom, end: searchFrom, value: segVal })
30
+ } else {
31
+ const end = start + segVal.length
32
+ ranges.push({ start, end, value: segVal })
33
+ searchFrom = end
34
+ }
35
+ }
36
+
37
+ return ranges
38
+ }
39
+
40
+ /**
41
+ * Determine which segment index a cursor position falls within.
42
+ * When the cursor is between segments (e.g. on a separator) the nearest segment
43
+ * is returned. Clamped to [0, segmentRanges.length - 1].
44
+ *
45
+ * @param {number} cursorPos
46
+ * @param {Array<{start: number, end: number}>} segmentRanges
47
+ * @returns {number} segment index
48
+ */
49
+ function getCursorSegment (cursorPos, segmentRanges) {
50
+ if (!segmentRanges.length) return 0
51
+
52
+ // Exact hit – cursor is inside the segment
53
+ for (let i = 0; i < segmentRanges.length; i++) {
54
+ if (cursorPos >= segmentRanges[i].start && cursorPos <= segmentRanges[i].end) {
55
+ return i
56
+ }
57
+ }
58
+
59
+ // Cursor is before the first segment
60
+ if (cursorPos < segmentRanges[0].start) return 0
61
+
62
+ // Cursor is after the last segment
63
+ if (cursorPos > segmentRanges[segmentRanges.length - 1].end) {
64
+ return segmentRanges.length - 1
65
+ }
66
+
67
+ // Cursor is on a separator between two segments – pick the nearest one
68
+ for (let i = 0; i < segmentRanges.length - 1; i++) {
69
+ if (cursorPos > segmentRanges[i].end && cursorPos < segmentRanges[i + 1].start) {
70
+ const distLeft = cursorPos - segmentRanges[i].end
71
+ const distRight = segmentRanges[i + 1].start - cursorPos
72
+ return distLeft <= distRight ? i : i + 1
73
+ }
74
+ }
75
+
76
+ return 0
77
+ }
78
+
79
+ /**
80
+ * Highlight (select) a segment inside an input element.
81
+ *
82
+ * @param {HTMLInputElement} input
83
+ * @param {number} segmentIndex
84
+ * @param {Array<{start: number, end: number}>} segmentRanges
85
+ */
86
+ function highlightSegment (input, segmentIndex, segmentRanges) {
87
+ const seg = segmentRanges[segmentIndex]
88
+ if (seg) {
89
+ input.setSelectionRange(seg.start, seg.end)
90
+ }
91
+ }
92
+
93
+ /**
94
+ * A `SegmentedInput` instance attaches to an `<input>` element and turns it into
95
+ * a segmented picker that works like `<input type="date">`.
96
+ *
97
+ * @example
98
+ * // RGBA colour picker
99
+ * const picker = new SegmentedInput(document.querySelector('#color'), {
100
+ * segments: [
101
+ * { value: '125', min: 0, max: 255, step: 1 },
102
+ * { value: '125', min: 0, max: 255, step: 1 },
103
+ * { value: '125', min: 0, max: 255, step: 1 },
104
+ * { value: '0.5', min: 0, max: 1, step: 0.1 },
105
+ * ],
106
+ * format: (v) => `rgba(${v[0]}, ${v[1]}, ${v[2]}, ${v[3]})`,
107
+ * parse: (s) => {
108
+ * const m = s.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/)
109
+ * return m ? [m[1], m[2], m[3], m[4] ?? '1'] : ['0', '0', '0', '1']
110
+ * },
111
+ * })
112
+ */
113
+ class SegmentedInput extends EventTarget {
114
+ // ---------------------------------------------------------------------------
115
+ // Private fields
116
+ // ---------------------------------------------------------------------------
117
+
118
+ #format
119
+ #parse
120
+ #activeSegment
121
+ #invalidMessage
122
+ #segmentBuffer
123
+ #placeholderJustSet
124
+ #placeholderValues
125
+ #formattedPlaceholder
126
+ /** CSS class added to `input` when the active segment is a selectable action segment.
127
+ * Lets developers style `input.si-action-active::selection` differently. */
128
+ #actionClass
129
+ /** clientX captured at mousedown – used to recover intended click position after
130
+ * the value changes in #onFocusIn for an initially-empty input. */
131
+ #pendingClickX = null
132
+
133
+ // Bound event-handler references kept for clean removeEventListener in destroy().
134
+ #onClick
135
+ #onFocus
136
+ #onBlur
137
+ #onKeyDown
138
+ #onMouseDown
139
+
140
+ /**
141
+ * @param {HTMLInputElement} input - the input element to enhance
142
+ * @param {Object} options
143
+ * @param {Array<{value?: string, placeholder?: string, type?: string, options?: string[], onClick?: Function, min?: number, max?: number, step?: number, radix?: number, pattern?: RegExp, maxLength?: number}>} options.segments
144
+ * Segment metadata. `value` is the default numeric value used when incrementing from a blank state;
145
+ * `placeholder` is the display string shown in the segment when it has no real value (e.g. 'hh', 'mm', 'ss') –
146
+ * defaults to `value` when not set;
147
+ * `options` is an array of allowed string values; ↑/↓ cycle through them and typing matches the first option
148
+ * whose text starts with the pressed key (skips `min`/`max`/`pattern` processing);
149
+ * `onClick` makes the segment an **action segment** – it cannot be focused, typed into, or incremented;
150
+ * when the user clicks on it, `onClick(instance, currentOption)` is called — `currentOption` is the
151
+ * currently selected option value when the segment also has `options: []`, otherwise `undefined`
152
+ * (useful for "set to today" calendar buttons, lock/unlock toggles, encrypt/decrypt, etc.);
153
+ * action segments are excluded from constraint-validity checks so they never block form submission;
154
+ * add `selectable: true` to an action segment to make it reachable via Tab/Arrow keys; once focused,
155
+ * pressing Enter triggers its `onClick(instance, currentOption)` — useful for making icon buttons keyboard accessible;
156
+ * when a selectable action segment also has `options: []`, ↑/↓ cycles through the options (changing
157
+ * the displayed icon/label) and typing a key selects the first matching option;
158
+ * `min`/`max` clamp up/down arrow changes;
159
+ * `step` controls how much each arrow press changes the value (default 1);
160
+ * `radix` sets the numeric base for increment/decrement (default 10, use 16 for hex segments);
161
+ * `pattern` is an optional RegExp tested against each typed character – non-matching keys are blocked;
162
+ * `maxLength` sets the maximum number of typed characters before auto-advancing (inferred from `max` when not set).
163
+ * @param {function(string[]): string} options.format
164
+ * Converts an array of segment value strings into the full display string.
165
+ * @param {function(string): string[]} options.parse
166
+ * Splits the full display string back into an array of segment value strings.
167
+ * Must always return the same number of elements as `options.segments`.
168
+ * @param {string} [options.invalidMessage]
169
+ * The message passed to `setCustomValidity()` when one or more segments still show
170
+ * placeholder text (i.e. the value is incomplete). Defaults to `'Please fill in all fields.'`.
171
+ * @param {string} [options.actionActiveClass]
172
+ * CSS class added to the `<input>` when the active segment is a selectable action segment.
173
+ * Defaults to `'si-action-active'`. Use for `input.si-action-active::selection { ... }` styling.
174
+ */
175
+ constructor (input, options) {
176
+ if (!input || input.tagName !== 'INPUT') {
177
+ throw new TypeError('SegmentedInput: first argument must be an <input> element')
178
+ }
179
+ if (typeof options.format !== 'function' || typeof options.parse !== 'function') {
180
+ throw new TypeError('SegmentedInput: options.format and options.parse must be functions')
181
+ }
182
+
183
+ super() // EventTarget constructor
184
+
185
+ this.input = input
186
+ this.segments = options.segments || []
187
+ this.#format = options.format
188
+ this.#parse = options.parse
189
+ this.#activeSegment = this.#findEditable(0, +1) ?? 0
190
+ this.#invalidMessage = options.invalidMessage ?? 'Please fill in all fields.'
191
+ this.#actionClass = options.actionActiveClass ?? 'si-action-active'
192
+ // Buffer accumulates typed characters for the active segment between focus changes.
193
+ this.#segmentBuffer = ''
194
+ // Flag set by #onFocusIn when it fills in the placeholder from an empty value;
195
+ // used by #onClickOrFocus together with #pendingClickX for single-click action detection.
196
+ this.#placeholderJustSet = false
197
+
198
+ // Compute the placeholder values once (used for Backspace reset and the HTML placeholder).
199
+ // Resolution order: explicit `placeholder` string > `value` default > `min` > '0'.
200
+ // For non-numeric segments (e.g. UUID hex groups) always set an explicit `placeholder`.
201
+ this.#placeholderValues = this.segments.map(s => s.placeholder ?? String(s.value ?? s.min ?? 0))
202
+ // Guarded version (ZWS around action segments) is used for input.value.
203
+ // Clean version (no ZWS) is used for the HTML placeholder attribute.
204
+ this.#formattedPlaceholder = this.#formatGuarded(this.#placeholderValues)
205
+
206
+ // Set input.placeholder to the formatted segment placeholders when one is not already set.
207
+ if (!input.placeholder) {
208
+ input.placeholder = this.#format(this.#placeholderValues)
209
+ }
210
+
211
+ // Leave input.value as-is when it already has a real value from markup.
212
+ // When empty, we keep it empty so the browser shows the HTML placeholder attribute
213
+ // and the field correctly fails constraint validation (e.g. required).
214
+
215
+ // Set initial validity so a pre-filled value with partial placeholders is flagged.
216
+ this.#updateValidity()
217
+
218
+ this.#onClick = this.#onClickOrFocus.bind(this)
219
+ this.#onFocus = this.#onFocusIn.bind(this)
220
+ this.#onBlur = this.#onBlurOut.bind(this)
221
+ this.#onKeyDown = this.#onKeydown.bind(this)
222
+ this.#onMouseDown = this.#captureMouseX.bind(this)
223
+
224
+ input.addEventListener('mousedown', this.#onMouseDown)
225
+ input.addEventListener('click', this.#onClick)
226
+ input.addEventListener('focus', this.#onFocus)
227
+ input.addEventListener('blur', this.#onBlur)
228
+ input.addEventListener('keydown', this.#onKeyDown)
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Public API
233
+ // ---------------------------------------------------------------------------
234
+
235
+ /**
236
+ * The "clean" value — `input.value` with every action segment's text (and any
237
+ * immediately-preceding zero-width space separators) removed.
238
+ *
239
+ * Returns `""` when the field is empty or still showing its full placeholder.
240
+ *
241
+ * @example
242
+ * // dateWithPicker: input.value is "2024-01-15​📅"
243
+ * // instance.value is "2024-01-15"
244
+ * console.log(instance.value)
245
+ */
246
+ get value () {
247
+ if (!this.input.value || this.#isPlaceholderState()) return ''
248
+ const ranges = this.getSegmentRanges()
249
+ let result = this.input.value
250
+ // Iterate in reverse so earlier offsets remain valid after each splice.
251
+ for (let i = ranges.length - 1; i >= 0; i--) {
252
+ if (!this.#isActionSegment(this.segments[i])) continue
253
+ const { start, end } = ranges[i]
254
+ // Strip ZWS guards the library adds on both sides of each action segment value.
255
+ let from = start
256
+ while (from > 0 && result.codePointAt(from - 1) === 0x200B) from--
257
+ let to = end
258
+ while (to < result.length && result.codePointAt(to) === 0x200B) to++
259
+ result = result.slice(0, from) + result.slice(to)
260
+ }
261
+ // Trim trailing whitespace that may remain from the separator before the icon.
262
+ return result.trimEnd()
263
+ }
264
+
265
+ /**
266
+ * Compute and return the character ranges for every segment based on the
267
+ * current input value.
268
+ * @returns {Array<{start: number, end: number, value: string}>}
269
+ */
270
+ getSegmentRanges () {
271
+ return getSegmentRanges(
272
+ this.input.value,
273
+ v => this.#parse(this.#stripZWS(v)),
274
+ v => this.#formatGuarded(v),
275
+ )
276
+ }
277
+
278
+ /**
279
+ * Move focus (text selection) to a specific segment.
280
+ * The index is clamped to the valid range so callers don't have to guard it.
281
+ * @param {number} index
282
+ */
283
+ focusSegment (index) {
284
+ let clamped = Math.max(0, Math.min(index, this.segments.length - 1))
285
+ // If the target is a non-selectable action segment, find the nearest editable one
286
+ const seg = this.segments[clamped]
287
+ if (this.#isActionSegment(seg) && !seg.selectable) {
288
+ const fwd = this.#findEditable(clamped + 1, +1)
289
+ const bwd = this.#findEditable(clamped - 1, -1)
290
+ if (fwd !== null) clamped = fwd
291
+ else if (bwd !== null) clamped = bwd
292
+ else return // all segments are action segments (edge case)
293
+ }
294
+ // Emit blur for the segment we're leaving (only when actually changing)
295
+ if (clamped !== this.#activeSegment) {
296
+ const prevIndex = this.#activeSegment
297
+ const prevSeg = this.segments[prevIndex]
298
+ this.#emit('segmentblur', { index: prevIndex, segment: prevSeg })
299
+ }
300
+ this.#activeSegment = clamped
301
+ this.#segmentBuffer = '' // clear typed-character buffer on every segment change
302
+ // Add/remove the action CSS class so developers can style ::selection differently.
303
+ const targetSeg = this.segments[clamped]
304
+ if (this.#isActionSegment(targetSeg) && targetSeg.selectable) {
305
+ this.input.classList.add(this.#actionClass)
306
+ } else {
307
+ this.input.classList.remove(this.#actionClass)
308
+ }
309
+ highlightSegment(this.input, clamped, this.getSegmentRanges())
310
+ this.#emit('segmentfocus', { index: clamped, segment: this.segments[clamped] })
311
+ }
312
+
313
+ /**
314
+ * Return the current string value of a specific segment.
315
+ * @param {number} index
316
+ * @returns {string}
317
+ */
318
+ getSegmentValue (index) {
319
+ return this.#currentValues()[index]
320
+ }
321
+
322
+ /**
323
+ * Overwrite a single segment's value, reformat the input, and rehighlight.
324
+ * Fires synthetic `input` and `change` events so that framework bindings work.
325
+ * @param {number} index
326
+ * @param {string|number} newValue
327
+ */
328
+ setSegmentValue (index, newValue) {
329
+ const values = this.#currentValues()
330
+ values[index] = String(newValue)
331
+ this.input.value = this.#formatGuarded(values)
332
+ this.focusSegment(index)
333
+ this.#dispatch('input')
334
+ this.#dispatch('change')
335
+ this.#updateValidity()
336
+ this.#emit('segmentchange', { index, value: String(newValue) })
337
+ }
338
+
339
+ /**
340
+ * Increment the active segment by its configured `step` (default 1),
341
+ * clamped to `max` if defined.
342
+ */
343
+ increment () {
344
+ this.#adjustSegment(this.#activeSegment, +1)
345
+ }
346
+
347
+ /**
348
+ * Decrement the active segment by its configured `step` (default 1),
349
+ * clamped to `min` if defined.
350
+ */
351
+ decrement () {
352
+ this.#adjustSegment(this.#activeSegment, -1)
353
+ }
354
+
355
+ /**
356
+ * Remove all event listeners and detach the instance from the input element.
357
+ */
358
+ destroy () {
359
+ this.input.removeEventListener('mousedown', this.#onMouseDown)
360
+ this.input.removeEventListener('click', this.#onClick)
361
+ this.input.removeEventListener('focus', this.#onFocus)
362
+ this.input.removeEventListener('blur', this.#onBlur)
363
+ this.input.removeEventListener('keydown', this.#onKeyDown)
364
+ }
365
+
366
+ // ---------------------------------------------------------------------------
367
+ // Private helpers
368
+ // ---------------------------------------------------------------------------
369
+
370
+ #adjustSegment (index, direction) {
371
+ const seg = this.segments[index]
372
+ if (!seg) return
373
+
374
+ // Action segments cannot be adjusted — unless they are selectable and have options
375
+ // (in which case ↑/↓ cycles through the options list, changing the displayed icon/text).
376
+ if (this.#isActionSegment(seg) && !(seg.selectable && seg.options)) return
377
+
378
+ // Text segments (type: 'text') have no numeric meaning; up/down is a no-op.
379
+ if (seg.type === 'text') return
380
+
381
+ // Enum segments (options: [...]) cycle through the list with ↑/↓.
382
+ if (seg.options) {
383
+ if (!this.input.value) this.input.value = this.#formattedPlaceholder
384
+ const values = this.#currentValues()
385
+ const idx = seg.options.indexOf(values[index])
386
+ const newIdx = ((idx === -1 ? 0 : idx) + direction + seg.options.length) % seg.options.length
387
+ values[index] = seg.options[newIdx]
388
+ this.input.value = this.#formatGuarded(values)
389
+ this.focusSegment(index)
390
+ this.#dispatch('input')
391
+ this.#dispatch('change')
392
+ this.#updateValidity()
393
+ this.#emit('segmentchange', { index, value: values[index] })
394
+ return
395
+ }
396
+
397
+ // Ensure the placeholder is shown before we start reading/writing the value.
398
+ if (!this.input.value) this.input.value = this.#formattedPlaceholder
399
+
400
+ const radix = seg.radix ?? 10
401
+ const values = this.#currentValues()
402
+ const step = seg.step ?? 1
403
+
404
+ // Use parseFloat for base-10 (supports decimals), parseInt for other radixes
405
+ let current = radix === 10
406
+ ? parseFloat(values[index])
407
+ : parseInt(values[index], radix)
408
+ if (isNaN(current)) {
409
+ current = radix === 10
410
+ ? parseFloat(String(seg.value ?? 0)) || 0
411
+ : parseInt(String(seg.value ?? 0), radix) || 0
412
+ }
413
+
414
+ let next = current + direction * step
415
+
416
+ if (seg.max !== undefined && next > seg.max) next = seg.max
417
+ if (seg.min !== undefined && next < seg.min) next = seg.min
418
+
419
+ if (radix !== 10) {
420
+ values[index] = Math.round(next).toString(radix).toUpperCase()
421
+ } else {
422
+ // Preserve the number of decimal places implied by `step`
423
+ const decimals = (String(step).split('.')[1] || '').length
424
+ values[index] = decimals > 0 ? next.toFixed(decimals) : String(Math.round(next))
425
+ }
426
+
427
+ this.input.value = this.#formatGuarded(values)
428
+ this.focusSegment(index)
429
+ this.#dispatch('input')
430
+ this.#dispatch('change')
431
+ this.#updateValidity()
432
+ this.#emit('segmentchange', { index, value: values[index] })
433
+ }
434
+
435
+ #dispatch (type) {
436
+ this.input.dispatchEvent(new Event(type, { bubbles: true, cancelable: true }))
437
+ }
438
+
439
+ /**
440
+ * Dispatch a CustomEvent on this SegmentedInput instance (which extends EventTarget).
441
+ * Listeners can be added via `instance.addEventListener(type, handler)`.
442
+ * @param {string} type
443
+ * @param {object} detail
444
+ */
445
+ #emit (type, detail) {
446
+ this.dispatchEvent(new CustomEvent(type, { detail }))
447
+ }
448
+
449
+ // ---------------------------------------------------------------------------
450
+ // Event handlers
451
+ // ---------------------------------------------------------------------------
452
+
453
+ /** Store the mouse X position at mousedown time, before #onFocusIn can change
454
+ * input.value (which resets selectionStart to 0). Used in #onClickOrFocus to
455
+ * recover the intended click target via canvas character-width estimation. */
456
+ #captureMouseX (event) {
457
+ this.#pendingClickX = event.clientX
458
+ }
459
+
460
+ /**
461
+ * Estimate which character index in the input corresponds to a given clientX.
462
+ * Uses canvas measureText so it works with any font and handles emoji / multi-
463
+ * code-unit characters correctly for typical input fonts.
464
+ * Falls back to selectionStart on any error (e.g. no 2D canvas support).
465
+ * @param {number} clientX
466
+ * @returns {number}
467
+ */
468
+ #charPosFromX (clientX) {
469
+ try {
470
+ const canvas = document.createElement('canvas')
471
+ const ctx = canvas.getContext('2d')
472
+ const style = getComputedStyle(this.input)
473
+ ctx.font = [style.fontStyle, style.fontWeight, style.fontSize, style.fontFamily]
474
+ .filter(Boolean).join(' ')
475
+ const text = this.input.value || this.#formattedPlaceholder
476
+ const rect = this.input.getBoundingClientRect()
477
+ const padding = parseFloat(style.paddingLeft) || 0
478
+ const x = clientX - rect.left - padding
479
+
480
+ // Binary search: smallest i where measureText(text[0..i]) >= x
481
+ let lo = 0, hi = text.length
482
+ while (lo < hi) {
483
+ const mid = (lo + hi) >> 1
484
+ if (ctx.measureText(text.slice(0, mid)).width < x) lo = mid + 1
485
+ else hi = mid
486
+ }
487
+ return Math.min(lo, text.length)
488
+ } catch (_) {
489
+ return this.input.selectionStart ?? 0
490
+ }
491
+ }
492
+
493
+ #onFocusIn () {
494
+ // When the input has no value, fill in the formatted placeholder so the segments
495
+ // are visible while the field is focused. We do this synchronously (before the
496
+ // setTimeout) so that the click handler that fires right after can read the value.
497
+ if (!this.input.value) {
498
+ this.input.value = this.#formattedPlaceholder
499
+ // Set this flag so #onClickOrFocus knows the value was just set from empty;
500
+ // after the programmatic value change selectionStart is reset to 0, so we
501
+ // use #pendingClickX (captured at mousedown) to recover the intended target.
502
+ this.#placeholderJustSet = true
503
+ // Immediately sync validity: the value is now non-empty so 'required' would
504
+ // pass, but the placeholder state is still incomplete — set the custom validity
505
+ // message now so the browser's constraint popup shows our message instead of
506
+ // silently treating the field as valid.
507
+ this.#updateValidity()
508
+ } else {
509
+ // Normalize to the canonical ZWS-guarded format in case the value was set
510
+ // externally (e.g. directly via input.value = "2024-01-15 📅") without ZWS guards.
511
+ const normalized = this.#formatGuarded(this.#parse(this.#stripZWS(this.input.value)))
512
+ if (this.input.value !== normalized) this.input.value = normalized
513
+ }
514
+ // Defer the actual selection so the browser has finished placing its own cursor.
515
+ setTimeout(() => {
516
+ this.#placeholderJustSet = false
517
+ this.focusSegment(this.#activeSegment)
518
+ }, 0)
519
+ }
520
+
521
+ #onBlurOut () {
522
+ // Remove the action class and notify listeners that the current segment is losing focus.
523
+ this.input.classList.remove(this.#actionClass)
524
+ if (this.segments.length > 0) {
525
+ this.#emit('segmentblur', { index: this.#activeSegment, segment: this.segments[this.#activeSegment] })
526
+ }
527
+ // If the user left the field without entering any real data (all segments still
528
+ // show their placeholder text), clear the value so the HTML placeholder attribute
529
+ // is shown again and constraint validation (e.g. required) fails correctly.
530
+ if (this.#isPlaceholderState()) {
531
+ this.input.value = ''
532
+ this.#activeSegment = 0
533
+ this.#segmentBuffer = ''
534
+ }
535
+ // Keep custom validity in sync regardless (covers the partial-placeholder case
536
+ // like "hh:30:10" where the empty-value case is already handled by required).
537
+ this.#updateValidity()
538
+ }
539
+
540
+ #onClickOrFocus (event) {
541
+ if (this.#placeholderJustSet) {
542
+ this.#placeholderJustSet = false
543
+
544
+ // #onFocusIn just set input.value from empty, resetting selectionStart to 0.
545
+ // Use the clientX captured at mousedown to estimate the intended click target.
546
+ // This enables single-click action-segment firing even on an unfocused input.
547
+ let charPos = null
548
+ let targetIndex = this.#activeSegment
549
+ if (this.#pendingClickX !== null) {
550
+ charPos = this.#charPosFromX(this.#pendingClickX)
551
+ targetIndex = getCursorSegment(charPos, this.getSegmentRanges())
552
+ }
553
+ this.#pendingClickX = null
554
+
555
+ const clickedSeg = this.segments[targetIndex]
556
+ if (this.#isActionSegment(clickedSeg)) {
557
+ // Only fire onClick when charPos is strictly inside the action segment (exclusive end).
558
+ // A click on the trailing ZWS or the right margin of the input routes to the nearest
559
+ // editable segment instead.
560
+ const r = this.getSegmentRanges()[targetIndex]
561
+ if (r && charPos !== null && charPos >= r.start && charPos < r.end) {
562
+ if (typeof clickedSeg.onClick === 'function') {
563
+ clickedSeg.onClick(this, this.#getActionOption(clickedSeg, targetIndex))
564
+ }
565
+ } else {
566
+ const prev = this.#findEditable(targetIndex - 1, -1)
567
+ if (prev !== null) this.#activeSegment = prev
568
+ }
569
+ // The setTimeout queued by #onFocusIn will call focusSegment(#activeSegment).
570
+ return
571
+ }
572
+
573
+ // Non-action segment: update #activeSegment so the setTimeout from #onFocusIn
574
+ // focuses the segment the user actually clicked on (not always the first one).
575
+ this.#activeSegment = targetIndex
576
+ this.#segmentBuffer = ''
577
+ return
578
+ }
579
+
580
+ this.#pendingClickX = null
581
+
582
+ const pos = this.input.selectionStart
583
+ const ranges = this.getSegmentRanges()
584
+ const index = getCursorSegment(pos, ranges)
585
+
586
+ // If user clicked on an action segment, fire its onClick callback only when
587
+ // the cursor landed strictly inside the segment (exclusive end).
588
+ // A click at the right edge (trailing ZWS) or on the right margin routes to
589
+ // the nearest editable segment instead.
590
+ const clickedSeg = this.segments[index]
591
+ if (this.#isActionSegment(clickedSeg)) {
592
+ const r = ranges[index]
593
+ if (r && pos >= r.start && pos < r.end) {
594
+ if (typeof clickedSeg.onClick === 'function') {
595
+ clickedSeg.onClick(this, this.#getActionOption(clickedSeg, index))
596
+ }
597
+ } else {
598
+ const prev = this.#findEditable(index - 1, -1)
599
+ const fallback = prev ?? this.#findEditable(0, +1)
600
+ if (fallback !== null) {
601
+ this.#activeSegment = fallback
602
+ setTimeout(() => highlightSegment(this.input, fallback, this.getSegmentRanges()), 0)
603
+ }
604
+ }
605
+ return
606
+ }
607
+
608
+ this.#activeSegment = index
609
+ this.#segmentBuffer = '' // clear buffer when user clicks
610
+ // Use setTimeout to override any native selection that the browser
611
+ // applies after the click event fires.
612
+ setTimeout(() => highlightSegment(this.input, index, ranges), 0)
613
+ }
614
+
615
+ #onKeydown (event) {
616
+ // Intercept ALL printable characters: handle them ourselves so the segment
617
+ // always stays highlighted and we control overflow / auto-advance behavior.
618
+ if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
619
+ event.preventDefault()
620
+ this.#handleSegmentInput(event.key)
621
+ return
622
+ }
623
+
624
+ switch (event.key) {
625
+ case 'Backspace':
626
+ event.preventDefault()
627
+ // Reset the whole active segment to its placeholder value (matching the behavior
628
+ // of Chrome's <input type="date"> where Backspace clears the focused segment).
629
+ {
630
+ const placeholder = this.#placeholderValues[this.#activeSegment]
631
+ this.#segmentBuffer = ''
632
+ const values = this.#currentValues()
633
+ values[this.#activeSegment] = placeholder
634
+ this.input.value = this.#formatGuarded(values)
635
+ this.#dispatch('input')
636
+ this.#updateValidity()
637
+ this.#emit('segmentchange', { index: this.#activeSegment, value: placeholder })
638
+ highlightSegment(this.input, this.#activeSegment, this.getSegmentRanges())
639
+ }
640
+ break
641
+
642
+ case 'Enter': {
643
+ // Fire onClick on a selectable action segment when Enter is pressed.
644
+ // If the segment also has options, pass the currently selected option as the second argument.
645
+ const seg = this.segments[this.#activeSegment]
646
+ if (this.#isActionSegment(seg) && seg.selectable && typeof seg.onClick === 'function') {
647
+ event.preventDefault()
648
+ seg.onClick(this, this.#getActionOption(seg, this.#activeSegment))
649
+ }
650
+ break
651
+ }
652
+
653
+ case 'ArrowLeft': {
654
+ event.preventDefault()
655
+ const prev = this.#findNavigable(this.#activeSegment - 1, -1)
656
+ if (prev !== null) this.focusSegment(prev)
657
+ break
658
+ }
659
+
660
+ case 'ArrowRight': {
661
+ event.preventDefault()
662
+ const next = this.#findNavigable(this.#activeSegment + 1, +1)
663
+ if (next !== null) this.focusSegment(next)
664
+ break
665
+ }
666
+
667
+ case 'ArrowUp':
668
+ event.preventDefault()
669
+ this.increment()
670
+ break
671
+
672
+ case 'ArrowDown':
673
+ event.preventDefault()
674
+ this.decrement()
675
+ break
676
+
677
+ case 'Tab':
678
+ if (event.shiftKey) {
679
+ // Shift+Tab: move to previous navigable segment, or let the browser move focus out
680
+ const prev = this.#findNavigable(this.#activeSegment - 1, -1)
681
+ if (prev !== null) {
682
+ event.preventDefault()
683
+ this.focusSegment(prev)
684
+ }
685
+ } else {
686
+ // Tab: move to next navigable segment, or let the browser move focus out
687
+ const next = this.#findNavigable(this.#activeSegment + 1, +1)
688
+ if (next !== null) {
689
+ event.preventDefault()
690
+ this.focusSegment(next)
691
+ }
692
+ }
693
+ break
694
+
695
+ default:
696
+ break
697
+ }
698
+ }
699
+
700
+ /**
701
+ * Handle a single printable character typed by the user.
702
+ * Accumulates characters in the segment buffer, updates the input value,
703
+ * then auto-advances to the next segment when the maximum length is reached
704
+ * or when no further digit could produce a valid in-range value.
705
+ * @param {string} key - a single printable character
706
+ */
707
+ #handleSegmentInput (key) {
708
+ const seg = this.segments[this.#activeSegment]
709
+ if (!seg || (this.#isActionSegment(seg) && !(seg.selectable && seg.options))) return
710
+
711
+ // Ensure the placeholder is shown before we start reading/writing the value.
712
+ if (!this.input.value) this.input.value = this.#formattedPlaceholder
713
+
714
+ // Enum segments (options: [...]): match typed key to the first option that starts
715
+ // with it (case-insensitive), then immediately advance to the next segment.
716
+ // Single pass: prefer exact match, fall back to first prefix match.
717
+ if (seg.options) {
718
+ let match = null
719
+ for (const opt of seg.options) {
720
+ if (opt.toLowerCase() === key.toLowerCase()) { match = opt; break }
721
+ if (!match && opt.toLowerCase().startsWith(key.toLowerCase())) match = opt
722
+ }
723
+ if (match) {
724
+ const values = this.#currentValues()
725
+ values[this.#activeSegment] = match
726
+ this.input.value = this.#formatGuarded(values)
727
+ this.#dispatch('input')
728
+ this.#updateValidity()
729
+ this.#emit('segmentchange', { index: this.#activeSegment, value: match })
730
+ highlightSegment(this.input, this.#activeSegment, this.getSegmentRanges())
731
+ this.#advanceSegment()
732
+ }
733
+ return
734
+ }
735
+
736
+ // Reject characters that don't match the segment's allowed pattern
737
+ if (seg.pattern && !seg.pattern.test(key)) return
738
+
739
+ const radix = seg.radix ?? 10
740
+ const newBuffer = this.#segmentBuffer + key
741
+
742
+ // For numeric segments with a max, reject a digit that would make the
743
+ // value exceed the maximum; commit whatever is already buffered and advance.
744
+ if (seg.max !== undefined && !this.#isDecimalSegment(seg)) {
745
+ const numVal = parseInt(newBuffer, radix)
746
+ if (numVal > seg.max) {
747
+ // Current buffer is already a valid value; advance to the next segment.
748
+ this.#advanceSegment()
749
+ return
750
+ }
751
+ }
752
+
753
+ this.#segmentBuffer = newBuffer
754
+
755
+ // Write the buffered text into the active segment and reformat.
756
+ const values = this.#currentValues()
757
+ values[this.#activeSegment] = this.#segmentBuffer
758
+ this.input.value = this.#formatGuarded(values)
759
+ this.#dispatch('input')
760
+ this.#updateValidity()
761
+ this.#emit('segmentchange', { index: this.#activeSegment, value: this.#segmentBuffer })
762
+
763
+ // Re-highlight the segment (without clearing the buffer).
764
+ highlightSegment(this.input, this.#activeSegment, this.getSegmentRanges())
765
+
766
+ // Auto-advance when the buffer can no longer grow into a valid value.
767
+ if (this.#shouldAutoAdvance(seg, this.#segmentBuffer, radix)) {
768
+ this.#advanceSegment()
769
+ }
770
+ }
771
+
772
+ /**
773
+ * Returns true when the typed buffer should trigger auto-advance.
774
+ * Mirrors Chrome's `<input type=date>` behavior:
775
+ * - advance when the buffer is as long as the formatted maximum value; or
776
+ * - advance when the smallest possible next digit would already overflow max.
777
+ * @param {{max?: number, step?: number, maxLength?: number}} seg
778
+ * @param {string} buffer
779
+ * @param {number} radix
780
+ * @returns {boolean}
781
+ */
782
+ #shouldAutoAdvance (seg, buffer, radix) {
783
+ // Explicit maxLength always wins (used for non-numeric segments like UUID hex groups)
784
+ if (seg.maxLength !== undefined) return buffer.length >= seg.maxLength
785
+
786
+ if (seg.max === undefined) return false
787
+
788
+ if (this.#isDecimalSegment(seg)) {
789
+ // For decimal segments (e.g. alpha 0–1 step 0.1) derive max display length
790
+ const decimals = (String(seg.step).split('.')[1] || '').length
791
+ const maxLen = String(seg.max.toFixed(decimals)).length
792
+ return buffer.length >= maxLen
793
+ }
794
+
795
+ // Integer / hex segment
796
+ const maxLen = Math.floor(seg.max).toString(radix).length
797
+ if (buffer.length >= maxLen) return true
798
+
799
+ // Would the smallest possible next digit overflow? (e.g. "3" in a max=12 field:
800
+ // 3 * 10 = 30 > 12, so no two-digit number starting with 3 is valid → advance)
801
+ const val = parseInt(buffer, radix)
802
+ return val * radix > seg.max
803
+ }
804
+
805
+ /**
806
+ * Synchronise the input's custom validity with the current placeholder state.
807
+ *
808
+ * - Empty value (`""`) → clear custom validity; `required` handles it natively.
809
+ * - Any segment still shows its placeholder (partial or fully unfilled) →
810
+ * set a custom validity message so the form fails validation on submit.
811
+ * - All segments have real values → clear custom validity (input is valid).
812
+ */
813
+ #updateValidity () {
814
+ if (!this.input.value) {
815
+ this.input.setCustomValidity('')
816
+ return
817
+ }
818
+ const values = this.#currentValues()
819
+ const hasPlaceholder = this.#placeholderValues.some((p, i) =>
820
+ !this.#isActionSegment(this.segments[i]) && values[i] === p
821
+ )
822
+ this.input.setCustomValidity(hasPlaceholder ? this.#invalidMessage : '')
823
+ }
824
+
825
+ /**
826
+ * Returns true when every segment in the current input value shows its
827
+ * placeholder text, meaning the user has not entered any real data.
828
+ * Used by the blur handler to clear the value for constraint validation.
829
+ * @returns {boolean}
830
+ */
831
+ #isPlaceholderState () {
832
+ if (!this.input.value) return true
833
+ const values = this.#currentValues()
834
+ return this.#placeholderValues.every((p, i) => {
835
+ if (this.#isActionSegment(this.segments[i])) return true // action segs don't affect state
836
+ return values[i] === p
837
+ })
838
+ }
839
+
840
+ /**
841
+ * Returns true when the segment's step implies decimal values (e.g. step=0.1).
842
+ * @param {{step?: number}} seg
843
+ * @returns {boolean}
844
+ */
845
+ #isDecimalSegment (seg) {
846
+ return String(seg.step ?? 1).includes('.')
847
+ }
848
+
849
+ /**
850
+ * Returns true when the segment has `type: 'action'` or an `onClick` callback,
851
+ * making it an action (button) segment. Action segments cannot be focused,
852
+ * typed into, or incremented (unless they are `selectable` with an `options` array,
853
+ * in which case ↑/↓ cycles and Enter fires onClick); clicking them fires
854
+ * `onClick(instance, currentOption)` when the callback is present. They are also
855
+ * excluded from constraint-validity checks so a trailing icon never blocks form submission.
856
+ * @param {{type?: string, onClick?: Function}} seg
857
+ * @returns {boolean}
858
+ */
859
+ #isActionSegment (seg) {
860
+ return !!(seg && (seg.type === 'action' || typeof seg.onClick === 'function'))
861
+ }
862
+
863
+ /**
864
+ * When a segment is a selectable action segment with `options`, returns the
865
+ * currently selected option value from the live input value; otherwise returns
866
+ * `undefined`. Centralises the repeated `seg.options ? currentValues[i] : undefined`
867
+ * pattern used by both click and Enter handlers.
868
+ * @param {{options?: string[]}} seg
869
+ * @param {number} index
870
+ * @returns {string|undefined}
871
+ */
872
+ #getActionOption (seg, index) {
873
+ return seg.options ? this.#currentValues()[index] : undefined
874
+ }
875
+
876
+ /**
877
+ * Strip all U+200B zero-width-space characters from a string.
878
+ * Used to remove the ZWS guards that #formatGuarded inserts around action segments
879
+ * before passing the value to the developer's parse() function.
880
+ * @param {string} str
881
+ * @returns {string}
882
+ */
883
+ #stripZWS (str) {
884
+ return str.replace(/\u200B/g, '')
885
+ }
886
+
887
+ /**
888
+ * Like `this.#format(values)` but wraps each action segment's value in
889
+ * U+200B (zero-width space) guards so that:
890
+ * – clicking just to the LEFT of the icon routes to the preceding segment
891
+ * (ZWS creates equal-distance tie → tie-break picks left)
892
+ * – clicking at the right boundary or on the right margin routes to the
893
+ * preceding segment (handled by exclusive-end check in #onClickOrFocus)
894
+ * The ZWS chars have zero visual width and are invisible to the user.
895
+ * @param {string[]} values
896
+ * @returns {string}
897
+ */
898
+ #formatGuarded (values) {
899
+ const raw = this.#format(values)
900
+ if (!this.segments.some(s => this.#isActionSegment(s))) return raw
901
+
902
+ // Locate each action segment's value in the raw format output using the
903
+ // same left-to-right indexOf scan as getSegmentRanges.
904
+ let searchFrom = 0
905
+ const actionRanges = []
906
+ for (let i = 0; i < values.length; i++) {
907
+ const val = String(values[i])
908
+ const start = raw.indexOf(val, searchFrom)
909
+ if (start !== -1) {
910
+ if (this.#isActionSegment(this.segments[i])) {
911
+ actionRanges.push({ start, end: start + val.length })
912
+ }
913
+ searchFrom = start + val.length
914
+ }
915
+ }
916
+
917
+ // Wrap action values with ZWS guards (reverse order preserves offsets).
918
+ let result = raw
919
+ for (let j = actionRanges.length - 1; j >= 0; j--) {
920
+ const { start, end } = actionRanges[j]
921
+ result = result.slice(0, start) + '\u200B' + result.slice(start, end) + '\u200B' + result.slice(end)
922
+ }
923
+ return result
924
+ }
925
+
926
+ /**
927
+ * Parse the current input value into an array of segment value strings,
928
+ * stripping any U+200B ZWS guards first so the developer's parse() function
929
+ * never sees zero-width-space characters.
930
+ * @returns {string[]}
931
+ */
932
+ #currentValues () {
933
+ return this.#parse(this.#stripZWS(this.input.value))
934
+ }
935
+
936
+ /**
937
+ * Starting from `fromIndex`, scan in `direction` (+1 or -1) and return the
938
+ * index of the first non-action segment. Returns `null` if none is found.
939
+ * @param {number} fromIndex
940
+ * @param {number} direction - +1 (forward) or -1 (backward)
941
+ * @returns {number|null}
942
+ */
943
+ #findEditable (fromIndex, direction) {
944
+ let i = fromIndex
945
+ while (i >= 0 && i < this.segments.length) {
946
+ if (!this.#isActionSegment(this.segments[i])) return i
947
+ i += direction
948
+ }
949
+ return null
950
+ }
951
+
952
+ /**
953
+ * Like `#findEditable`, but also returns selectable action segments so that
954
+ * Arrow/Tab keyboard navigation can reach them. Non-selectable action segments
955
+ * are still skipped.
956
+ * @param {number} fromIndex
957
+ * @param {number} direction - +1 (forward) or -1 (backward)
958
+ * @returns {number|null}
959
+ */
960
+ #findNavigable (fromIndex, direction) {
961
+ let i = fromIndex
962
+ while (i >= 0 && i < this.segments.length) {
963
+ const seg = this.segments[i]
964
+ if (!this.#isActionSegment(seg) || seg.selectable) return i
965
+ i += direction
966
+ }
967
+ return null
968
+ }
969
+
970
+ /**
971
+ * Move to the next segment (clearing the buffer). Called after a segment
972
+ * value has been committed via typing or overflow.
973
+ */
974
+ #advanceSegment () {
975
+ const next = this.#findEditable(this.#activeSegment + 1, +1)
976
+ if (next !== null) {
977
+ this.focusSegment(next)
978
+ } else {
979
+ // Already on the last editable segment: just clear the buffer and re-highlight.
980
+ this.#segmentBuffer = ''
981
+ highlightSegment(this.input, this.#activeSegment, this.getSegmentRanges())
982
+ }
983
+ }
984
+ }
985
+
986
+ export {
987
+ getCursorSegment,
988
+ getSegmentRanges,
989
+ SegmentedInput,
990
+ }