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.
- package/README.md +137 -0
- package/package.json +29 -0
- package/src/presets.js +502 -0
- package/src/segmented-input.js +990 -0
|
@@ -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
|
+
}
|