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
package/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# segmented-input
|
|
2
|
+
|
|
3
|
+
A tiny, dependency-free JavaScript library that turns any `<input>` element into a **segmented picker** that works exactly like `<input type="date">`.
|
|
4
|
+
|
|
5
|
+
- Click a segment → it is highlighted automatically
|
|
6
|
+
- `←` / `→` arrow keys → move between segments
|
|
7
|
+
- `↑` / `↓` arrow keys → increment / decrement the active segment
|
|
8
|
+
- `Tab` / `Shift+Tab` → cycle through segments (or leave the field)
|
|
9
|
+
- Works for **any** custom format: IPv4, IPv6, RGBA, duration, UUID, MAC address, …
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```html
|
|
16
|
+
<!-- ES module – no build step required -->
|
|
17
|
+
<script type="module">
|
|
18
|
+
import { SegmentedInput } from './src/segmented-input.js'
|
|
19
|
+
import * as presets from './src/presets.js'
|
|
20
|
+
</script>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Or copy `src/segmented-input.js` and `src/presets.js` into your project.
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```html
|
|
30
|
+
<input id="color" value="rgba(125, 125, 125, 0.5)" />
|
|
31
|
+
|
|
32
|
+
<script type="module">
|
|
33
|
+
import { SegmentedInput } from './src/segmented-input.js'
|
|
34
|
+
import * as presets from './src/presets.js'
|
|
35
|
+
|
|
36
|
+
// Attach the RGBA preset – first click focuses the "r" segment
|
|
37
|
+
new SegmentedInput(document.getElementById('color'), presets.rgba)
|
|
38
|
+
</script>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
---
|
|
42
|
+
|
|
43
|
+
## Built-in presets
|
|
44
|
+
|
|
45
|
+
| Key | Example value |
|
|
46
|
+
|------------|----------------------------------------|
|
|
47
|
+
| `ipv4` | `192.168.1.1` |
|
|
48
|
+
| `ipv6` | `2001:0db8:85a3:0000:0000:8a2e:0370:7334` |
|
|
49
|
+
| `duration` | `01:30:00` |
|
|
50
|
+
| `rgba` | `rgba(125, 125, 125, 0.5)` |
|
|
51
|
+
| `uuid` | `550e8400-e29b-41d4-a716-446655440000` |
|
|
52
|
+
| `mac` | `00:1A:2B:3C:4D:5E` |
|
|
53
|
+
|
|
54
|
+
```js
|
|
55
|
+
import { SegmentedInput } from './src/segmented-input.js'
|
|
56
|
+
import * as presets from './src/presets.js'
|
|
57
|
+
|
|
58
|
+
new SegmentedInput(el, presets.ipv4)
|
|
59
|
+
new SegmentedInput(el, presets.rgba)
|
|
60
|
+
new SegmentedInput(el, presets.duration)
|
|
61
|
+
// …
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## Custom format
|
|
67
|
+
|
|
68
|
+
Supply a `format` function (array → string) and a `parse` function (string → array), plus one `segments` entry per segment:
|
|
69
|
+
|
|
70
|
+
```js
|
|
71
|
+
new SegmentedInput(el, {
|
|
72
|
+
// Segment metadata: default value, min/max clamping, and step size
|
|
73
|
+
segments: [
|
|
74
|
+
{ value: '125', min: 0, max: 255, step: 1 }, // r
|
|
75
|
+
{ value: '125', min: 0, max: 255, step: 1 }, // g
|
|
76
|
+
{ value: '125', min: 0, max: 255, step: 1 }, // b
|
|
77
|
+
{ value: '0.5', min: 0, max: 1, step: 0.1 }, // a
|
|
78
|
+
],
|
|
79
|
+
format: (v) => `rgba(${v[0]}, ${v[1]}, ${v[2]}, ${v[3]})`,
|
|
80
|
+
parse: (s) => {
|
|
81
|
+
const m = s.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)/)
|
|
82
|
+
return m ? [m[1], m[2], m[3], m[4] ?? '1'] : ['0', '0', '0', '1']
|
|
83
|
+
},
|
|
84
|
+
})
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## API
|
|
90
|
+
|
|
91
|
+
### `new SegmentedInput(input, options)`
|
|
92
|
+
|
|
93
|
+
| Option | Type | Description |
|
|
94
|
+
|--------|------|-------------|
|
|
95
|
+
| `segments` | `Array` | One entry per segment. Each entry may have `value` (default), `min`, `max`, `step`. |
|
|
96
|
+
| `format` | `(values: string[]) => string` | Build the display string from an array of segment values. |
|
|
97
|
+
| `parse` | `(str: string) => string[]` | Split the display string into segment values. Must always return the same number of elements as `segments`. |
|
|
98
|
+
|
|
99
|
+
#### Instance methods
|
|
100
|
+
|
|
101
|
+
| Method | Description |
|
|
102
|
+
|--------|-------------|
|
|
103
|
+
| `focusSegment(index)` | Highlight the segment at `index` (clamped). |
|
|
104
|
+
| `getSegmentValue(index)` | Return the current string value of segment `index`. |
|
|
105
|
+
| `setSegmentValue(index, value)` | Overwrite a segment value and reformat. Fires `input` + `change` events. |
|
|
106
|
+
| `increment()` | Increment the active segment. |
|
|
107
|
+
| `decrement()` | Decrement the active segment. |
|
|
108
|
+
| `getSegmentRanges()` | Return `{start, end, value}[]` for all segments in the current value. |
|
|
109
|
+
| `destroy()` | Remove event listeners. |
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
### Low-level helpers (also exported)
|
|
114
|
+
|
|
115
|
+
```js
|
|
116
|
+
import { getSegmentRanges, getCursorSegment, highlightSegment } from 'segmented-input'
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
| Function | Description |
|
|
120
|
+
|----------|-------------|
|
|
121
|
+
| `getSegmentRanges(value, parse, format)` | Compute `{start, end, value}[]` for each segment. |
|
|
122
|
+
| `getCursorSegment(cursorPos, segmentRanges)` | Return the index of the segment the cursor position falls in. |
|
|
123
|
+
| `highlightSegment(input, index, segmentRanges)` | Call `setSelectionRange` to highlight a segment. |
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
## Demo
|
|
128
|
+
|
|
129
|
+
**[👉 Live demo on GitHub Pages](https://jimmywarting.github.io/segmented-input/)**
|
|
130
|
+
|
|
131
|
+
Or run locally by serving the repo with any static file server, e.g. `npx serve .`.
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
## License
|
|
136
|
+
|
|
137
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "segmented-input",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A library for creating segmented text inputs that behave like <input type=\"date\">. Supports IPv4, IPv6, RGBA, duration, UUID, MAC addresses, and any custom format.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./src/*.js": "./src/*.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src/"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"segmented",
|
|
15
|
+
"input",
|
|
16
|
+
"ipv4",
|
|
17
|
+
"ipv6",
|
|
18
|
+
"rgba",
|
|
19
|
+
"duration",
|
|
20
|
+
"uuid",
|
|
21
|
+
"mac",
|
|
22
|
+
"picker"
|
|
23
|
+
],
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/jimmywarting/segmented-input.git"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/presets.js
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
/*! <segmented-input> MIT License. Jimmy Wärting <https://jimmy.warting.se/opensource> */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* presets.js
|
|
5
|
+
*
|
|
6
|
+
* Ready-to-use SegmentedInput configurations for common input formats.
|
|
7
|
+
* Each preset can be spread into the SegmentedInput constructor options:
|
|
8
|
+
*
|
|
9
|
+
* import { ipv4 } from 'segmented-input/presets.js'
|
|
10
|
+
* const picker = new SegmentedInput(el, { ...ipv4 })
|
|
11
|
+
*
|
|
12
|
+
* @license MIT
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// IPv4 – e.g. 192.168.0.1
|
|
17
|
+
// placeholder '--' uses a character blocked by pattern: /\d/, so it is never
|
|
18
|
+
// a real value and cleanly signals an unfilled segment to _updateValidity.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
const ipv4 = {
|
|
21
|
+
segments: [
|
|
22
|
+
{ value: '0', placeholder: '--', min: 0, max: 255, step: 1, pattern: /\d/ },
|
|
23
|
+
{ value: '0', placeholder: '--', min: 0, max: 255, step: 1, pattern: /\d/ },
|
|
24
|
+
{ value: '0', placeholder: '--', min: 0, max: 255, step: 1, pattern: /\d/ },
|
|
25
|
+
{ value: '0', placeholder: '--', min: 0, max: 255, step: 1, pattern: /\d/ },
|
|
26
|
+
],
|
|
27
|
+
format (values) {
|
|
28
|
+
return values.join('.')
|
|
29
|
+
},
|
|
30
|
+
parse (str) {
|
|
31
|
+
const parts = str.split('.')
|
|
32
|
+
// Ensure exactly 4 parts
|
|
33
|
+
while (parts.length < 4) parts.push('0')
|
|
34
|
+
return parts.slice(0, 4)
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// IPv6 – e.g. 2001:0db8:85a3:0000:0000:8a2e:0370:7334
|
|
40
|
+
// Segment values are stored as hex strings; radix: 16 for correct ↑/↓ counting.
|
|
41
|
+
// placeholder '----' uses '-' which is blocked by pattern, so '0000' is always valid.
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
const ipv6 = {
|
|
44
|
+
segments: Array.from({ length: 8 }, () => ({
|
|
45
|
+
value: '0000', placeholder: '----', min: 0, max: 0xFFFF, step: 1, radix: 16, pattern: /[0-9a-fA-F]/,
|
|
46
|
+
})),
|
|
47
|
+
format (values) {
|
|
48
|
+
return values.map(v => v.padStart(4, '0')).join(':')
|
|
49
|
+
},
|
|
50
|
+
parse (str) {
|
|
51
|
+
const parts = str.split(':')
|
|
52
|
+
while (parts.length < 8) parts.push('0000')
|
|
53
|
+
return parts.slice(0, 8).map(p => p.padStart(4, '0'))
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Duration – HH:MM:SS
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
const duration = {
|
|
61
|
+
segments: [
|
|
62
|
+
// Hours: no upper bound in a duration, but cap typing at 3 digits (0–999)
|
|
63
|
+
{ value: '00', placeholder: 'hh', min: 0, step: 1, maxLength: 3, pattern: /\d/ },
|
|
64
|
+
{ value: '00', placeholder: 'mm', min: 0, max: 59, step: 1, pattern: /\d/ },
|
|
65
|
+
{ value: '00', placeholder: 'ss', min: 0, max: 59, step: 1, pattern: /\d/ },
|
|
66
|
+
],
|
|
67
|
+
format (values) {
|
|
68
|
+
return values.map(v => String(v).padStart(2, '0')).join(':')
|
|
69
|
+
},
|
|
70
|
+
parse (str) {
|
|
71
|
+
const parts = str.split(':')
|
|
72
|
+
while (parts.length < 3) parts.push('00')
|
|
73
|
+
return parts.slice(0, 3).map(p => p.padStart(2, '0'))
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// RGBA – rgba(r, g, b, a) where r/g/b ∈ [0,255] and a ∈ [0,1]
|
|
79
|
+
// placeholder '--' uses '-' which is blocked by both numeric patterns (/\d/ and
|
|
80
|
+
// /[\d.]/), and '--' never appears inside the "rgba(...)" boilerplate, so
|
|
81
|
+
// getSegmentRanges always finds the correct positions via indexOf.
|
|
82
|
+
// parse uses a relaxed regex so placeholder strings round-trip correctly.
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
const rgba = {
|
|
85
|
+
segments: [
|
|
86
|
+
{ value: '0', placeholder: '--', min: 0, max: 255, step: 1, pattern: /\d/ },
|
|
87
|
+
{ value: '0', placeholder: '--', min: 0, max: 255, step: 1, pattern: /\d/ },
|
|
88
|
+
{ value: '0', placeholder: '--', min: 0, max: 255, step: 1, pattern: /\d/ },
|
|
89
|
+
{ value: '1', placeholder: '--', min: 0, max: 1, step: 0.1, pattern: /[\d.]/ },
|
|
90
|
+
],
|
|
91
|
+
format (values) {
|
|
92
|
+
return `rgba(${values[0]}, ${values[1]}, ${values[2]}, ${values[3]})`
|
|
93
|
+
},
|
|
94
|
+
parse (str) {
|
|
95
|
+
// Relaxed capture groups accept any non-comma/paren characters so that
|
|
96
|
+
// placeholder strings (e.g. 'r', 'g', '--') round-trip through parse/format.
|
|
97
|
+
const m = str.match(/rgba?\(\s*([^,)]+?)\s*,\s*([^,)]+?)\s*,\s*([^,)]+?)(?:\s*,\s*([^)]+?))?\s*\)/)
|
|
98
|
+
if (m) return [m[1], m[2], m[3], m[4] ?? '1']
|
|
99
|
+
return ['0', '0', '0', '1']
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// UUID – 550e8400-e29b-41d4-a716-446655440000
|
|
105
|
+
// Segment values are hex strings; no numeric min/max applies.
|
|
106
|
+
// placeholder uses 'x' characters (blocked by pattern) so all-zero UUIDs
|
|
107
|
+
// like 00000000-0000-0000-0000-000000000000 are never flagged as invalid.
|
|
108
|
+
// maxLength is used for auto-advance since there is no numeric max to derive from.
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
const uuid = {
|
|
111
|
+
segments: [
|
|
112
|
+
{ value: '00000000', placeholder: 'xxxxxxxx', maxLength: 8, pattern: /[0-9a-fA-F]/ },
|
|
113
|
+
{ value: '0000', placeholder: 'xxxx', maxLength: 4, pattern: /[0-9a-fA-F]/ },
|
|
114
|
+
{ value: '0000', placeholder: 'xxxx', maxLength: 4, pattern: /[0-9a-fA-F]/ },
|
|
115
|
+
{ value: '0000', placeholder: 'xxxx', maxLength: 4, pattern: /[0-9a-fA-F]/ },
|
|
116
|
+
{ value: '000000000000', placeholder: 'xxxxxxxxxxxx', maxLength: 12, pattern: /[0-9a-fA-F]/ },
|
|
117
|
+
],
|
|
118
|
+
format (values) {
|
|
119
|
+
return values.join('-')
|
|
120
|
+
},
|
|
121
|
+
parse (str) {
|
|
122
|
+
const parts = str.split('-')
|
|
123
|
+
// UUID has 5 groups
|
|
124
|
+
const defaults = ['00000000', '0000', '0000', '0000', '000000000000']
|
|
125
|
+
return defaults.map((d, i) => (parts[i] ?? d) || d)
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// MAC address – 00:1A:2B:3C:4D:5E
|
|
131
|
+
// Segment values are stored as uppercase hex strings (e.g. 'FF').
|
|
132
|
+
// radix: 16 makes ↑/↓ arrow keys count in hexadecimal (09 → 0A → 0B … → FF).
|
|
133
|
+
// placeholder '--' uses '-' (blocked by pattern) so '00' is always a valid value.
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
const mac = {
|
|
136
|
+
segments: Array.from({ length: 6 }, () => ({
|
|
137
|
+
value: '00', placeholder: '--', min: 0, max: 255, step: 1, radix: 16, pattern: /[0-9a-fA-F]/,
|
|
138
|
+
})),
|
|
139
|
+
format (values) {
|
|
140
|
+
return values.map(v => v.padStart(2, '0').toUpperCase()).join(':')
|
|
141
|
+
},
|
|
142
|
+
parse (str) {
|
|
143
|
+
const parts = str.split(':')
|
|
144
|
+
while (parts.length < 6) parts.push('00')
|
|
145
|
+
return parts.slice(0, 6).map(p => p.padStart(2, '0').toUpperCase())
|
|
146
|
+
},
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
// Time (24-hour clock) – HH:MM:SS
|
|
151
|
+
// Like duration but hours are capped at 23.
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
const time = {
|
|
154
|
+
segments: [
|
|
155
|
+
{ value: '00', placeholder: 'hh', min: 0, max: 23, step: 1, pattern: /\d/ },
|
|
156
|
+
{ value: '00', placeholder: 'mm', min: 0, max: 59, step: 1, pattern: /\d/ },
|
|
157
|
+
{ value: '00', placeholder: 'ss', min: 0, max: 59, step: 1, pattern: /\d/ },
|
|
158
|
+
],
|
|
159
|
+
format (values) {
|
|
160
|
+
return values.map(v => String(v).padStart(2, '0')).join(':')
|
|
161
|
+
},
|
|
162
|
+
parse (str) {
|
|
163
|
+
const parts = str.split(':')
|
|
164
|
+
while (parts.length < 3) parts.push('00')
|
|
165
|
+
return parts.slice(0, 3).map(p => p.padStart(2, '0'))
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Date – YYYY-MM-DD
|
|
171
|
+
// placeholder 'yyyy'/'mm'/'dd' uses letters blocked by pattern: /\d/
|
|
172
|
+
// and none of those strings appear in the '-' separators.
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
const date = {
|
|
175
|
+
segments: [
|
|
176
|
+
{ value: new Date().getFullYear(), placeholder: 'yyyy', min: 1, max: 9999, step: 1, maxLength: 4, pattern: /\d/ },
|
|
177
|
+
{ value: '01', placeholder: 'mm', min: 1, max: 12, step: 1, pattern: /\d/ },
|
|
178
|
+
{ value: '01', placeholder: 'dd', min: 1, max: 31, step: 1, pattern: /\d/ },
|
|
179
|
+
],
|
|
180
|
+
format (values) {
|
|
181
|
+
return `${String(values[0]).padStart(4, '0')}-${String(values[1]).padStart(2, '0')}-${String(values[2]).padStart(2, '0')}`
|
|
182
|
+
},
|
|
183
|
+
parse (str) {
|
|
184
|
+
const parts = str.split('-')
|
|
185
|
+
while (parts.length < 3) parts.push('01')
|
|
186
|
+
return [parts[0].padStart(4, '0'), parts[1].padStart(2, '0'), parts[2].padStart(2, '0')]
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Credit card number – 1234 5678 9012 3456
|
|
192
|
+
// 4 groups of 4 digits separated by spaces.
|
|
193
|
+
// placeholder 'nnnn' uses 'n' which is not a digit and not a space.
|
|
194
|
+
// ---------------------------------------------------------------------------
|
|
195
|
+
const creditCard = {
|
|
196
|
+
segments: Array.from({ length: 4 }, () => ({
|
|
197
|
+
value: '0000', placeholder: 'nnnn', min: 0, max: 9999, step: 1, maxLength: 4, pattern: /\d/,
|
|
198
|
+
})),
|
|
199
|
+
format (values) {
|
|
200
|
+
return values.map(v => String(v).padStart(4, '0')).join(' ')
|
|
201
|
+
},
|
|
202
|
+
parse (str) {
|
|
203
|
+
const parts = str.split(' ')
|
|
204
|
+
while (parts.length < 4) parts.push('0000')
|
|
205
|
+
return parts.slice(0, 4).map(p => p.padStart(4, '0'))
|
|
206
|
+
},
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ---------------------------------------------------------------------------
|
|
210
|
+
// Semantic version – MAJOR.MINOR.PATCH (e.g. 1.2.3)
|
|
211
|
+
// No upper bound on any segment; maxLength: 3 caps typing at 3 digits.
|
|
212
|
+
// placeholder 'n' uses a letter blocked by pattern, not '.' separator.
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
const semver = {
|
|
215
|
+
segments: [
|
|
216
|
+
{ value: '1', placeholder: 'n', min: 0, step: 1, maxLength: 3, pattern: /\d/ },
|
|
217
|
+
{ value: '0', placeholder: 'n', min: 0, step: 1, maxLength: 3, pattern: /\d/ },
|
|
218
|
+
{ value: '0', placeholder: 'n', min: 0, step: 1, maxLength: 3, pattern: /\d/ },
|
|
219
|
+
],
|
|
220
|
+
format (values) {
|
|
221
|
+
return values.join('.')
|
|
222
|
+
},
|
|
223
|
+
parse (str) {
|
|
224
|
+
const parts = str.split('.')
|
|
225
|
+
while (parts.length < 3) parts.push('0')
|
|
226
|
+
return parts.slice(0, 3)
|
|
227
|
+
},
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------
|
|
231
|
+
// Credit card expiry date – MM/YY
|
|
232
|
+
// placeholder 'mm'/'yy' uses letters not in '/\d/' and not in '/' separator.
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
const expiryDate = {
|
|
235
|
+
segments: [
|
|
236
|
+
{ value: '01', placeholder: 'mm', min: 1, max: 12, step: 1, pattern: /\d/ },
|
|
237
|
+
{ value: '25', placeholder: 'yy', min: 0, max: 99, step: 1, pattern: /\d/ },
|
|
238
|
+
],
|
|
239
|
+
format (values) {
|
|
240
|
+
return `${String(values[0]).padStart(2, '0')}/${String(values[1]).padStart(2, '0')}`
|
|
241
|
+
},
|
|
242
|
+
parse (str) {
|
|
243
|
+
const parts = str.split('/')
|
|
244
|
+
while (parts.length < 2) parts.push('00')
|
|
245
|
+
return [parts[0].padStart(2, '0'), parts[1].padStart(2, '0')]
|
|
246
|
+
},
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// US phone number – (NXX) NXX-XXXX
|
|
251
|
+
// Area code / exchange: placeholder 'nnn' (not a digit, not in '() -').
|
|
252
|
+
// Subscriber: placeholder 'xxxx' (not a digit, not in '() -').
|
|
253
|
+
// parse uses a relaxed regex so placeholder strings round-trip correctly.
|
|
254
|
+
// ---------------------------------------------------------------------------
|
|
255
|
+
const phone = {
|
|
256
|
+
segments: [
|
|
257
|
+
{ value: '555', placeholder: 'nnn', min: 0, max: 999, step: 1, maxLength: 3, pattern: /\d/ },
|
|
258
|
+
{ value: '555', placeholder: 'nnn', min: 0, max: 999, step: 1, maxLength: 3, pattern: /\d/ },
|
|
259
|
+
{ value: '5555', placeholder: 'xxxx', min: 0, max: 9999, step: 1, maxLength: 4, pattern: /\d/ },
|
|
260
|
+
],
|
|
261
|
+
format (values) {
|
|
262
|
+
return `(${values[0]}) ${values[1]}-${values[2]}`
|
|
263
|
+
},
|
|
264
|
+
parse (str) {
|
|
265
|
+
// Relaxed capture groups so placeholder strings (e.g. 'nnn', 'xxxx') round-trip.
|
|
266
|
+
const m = str.match(/\(([^)]*)\)\s*([^-]*)-(.*)/)
|
|
267
|
+
if (m) return [m[1].trim(), m[2].trim(), m[3].trim()]
|
|
268
|
+
return ['nnn', 'nnn', 'xxxx']
|
|
269
|
+
},
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// HSLA colour – hsla(H, S%, L%, A) where H ∈ [0,360], S/L ∈ [0,100], A ∈ [0,1]
|
|
274
|
+
// placeholder '--' never appears in the 'hsla(', '%, ', ')' boilerplate and
|
|
275
|
+
// is blocked by both /\d/ and /[\d.]/ patterns.
|
|
276
|
+
// parse uses a relaxed regex so placeholder strings round-trip correctly.
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
const hsla = {
|
|
279
|
+
segments: [
|
|
280
|
+
{ value: '0', placeholder: '--', min: 0, max: 360, step: 1, pattern: /\d/ },
|
|
281
|
+
{ value: '0', placeholder: '--', min: 0, max: 100, step: 1, pattern: /\d/ },
|
|
282
|
+
{ value: '0', placeholder: '--', min: 0, max: 100, step: 1, pattern: /\d/ },
|
|
283
|
+
{ value: '1', placeholder: '--', min: 0, max: 1, step: 0.1, pattern: /[\d.]/ },
|
|
284
|
+
],
|
|
285
|
+
format (values) {
|
|
286
|
+
return `hsla(${values[0]}, ${values[1]}%, ${values[2]}%, ${values[3]})`
|
|
287
|
+
},
|
|
288
|
+
parse (str) {
|
|
289
|
+
// Captures content before '%' separators; accepts placeholder strings like '--'.
|
|
290
|
+
const m = str.match(/hsla?\(\s*([^,]+),\s*([^%]+)%\s*,\s*([^%]+)%\s*(?:,\s*([^)]+))?\)/)
|
|
291
|
+
if (m) return [m[1].trim(), m[2].trim(), m[3].trim(), (m[4]?.trim() ?? '1')]
|
|
292
|
+
return ['0', '0', '0', '1']
|
|
293
|
+
},
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// Price – $NNN.CC (dollars and cents)
|
|
298
|
+
// placeholder '--' uses '-' which is blocked by pattern: /\d/
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
const price = {
|
|
301
|
+
segments: [
|
|
302
|
+
// Dollars: no upper bound; cap typing at 5 digits (0–99999)
|
|
303
|
+
{ value: '0', placeholder: '--', min: 0, step: 1, maxLength: 5, pattern: /\d/ },
|
|
304
|
+
// Cents: 00–99
|
|
305
|
+
{ value: '00', placeholder: '--', min: 0, max: 99, step: 1, pattern: /\d/ },
|
|
306
|
+
],
|
|
307
|
+
format (values) {
|
|
308
|
+
return `$${values[0]}.${String(values[1]).padStart(2, '0')}`
|
|
309
|
+
},
|
|
310
|
+
parse (str) {
|
|
311
|
+
const without$ = str.replace(/^\$/, '')
|
|
312
|
+
const dot = without$.indexOf('.')
|
|
313
|
+
if (dot === -1) return [without$ || '--', '--']
|
|
314
|
+
return [without$.slice(0, dot) || '--', without$.slice(dot + 1).padStart(2, '0')]
|
|
315
|
+
},
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
// Math expression – (x + y) / z
|
|
320
|
+
// Demonstrates arbitrary expression layouts; all three operands are integers.
|
|
321
|
+
// placeholder '--' uses '-' which is blocked by pattern: /\d/ and does not
|
|
322
|
+
// appear in the '(', ' + ', ') / ' boilerplate.
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
const mathExpr = {
|
|
325
|
+
segments: [
|
|
326
|
+
{ value: '0', placeholder: '--', min: 0, max: 999, step: 1, pattern: /\d/ },
|
|
327
|
+
{ value: '0', placeholder: '--', min: 0, max: 999, step: 1, pattern: /\d/ },
|
|
328
|
+
{ value: '1', placeholder: '--', min: 0, max: 999, step: 1, pattern: /\d/ },
|
|
329
|
+
],
|
|
330
|
+
format (values) {
|
|
331
|
+
return `(${values[0]} + ${values[1]}) / ${values[2]}`
|
|
332
|
+
},
|
|
333
|
+
parse (str) {
|
|
334
|
+
const m = str.match(/\(\s*([^+)]+?)\s*\+\s*([^)]+?)\s*\)\s*\/\s*(.+)/)
|
|
335
|
+
if (m) return [m[1].trim(), m[2].trim(), m[3].trim()]
|
|
336
|
+
return ['--', '--', '--']
|
|
337
|
+
},
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
// Full name – First Last
|
|
342
|
+
// Text segments (type: 'text') have no numeric meaning; up/down arrows are no-ops.
|
|
343
|
+
// placeholder '----------' uses '-' which is blocked by pattern: /\p{L}/u
|
|
344
|
+
// and does not appear in the space separator.
|
|
345
|
+
// maxLength caps each name at 20 characters before auto-advancing to last name.
|
|
346
|
+
// ---------------------------------------------------------------------------
|
|
347
|
+
const fullName = {
|
|
348
|
+
segments: [
|
|
349
|
+
{ value: '', type: 'text', placeholder: '----------', maxLength: 20, pattern: /\p{L}/u },
|
|
350
|
+
{ value: '', type: 'text', placeholder: '----------', maxLength: 20, pattern: /\p{L}/u },
|
|
351
|
+
],
|
|
352
|
+
format (values) {
|
|
353
|
+
return `${values[0]} ${values[1]}`
|
|
354
|
+
},
|
|
355
|
+
parse (str) {
|
|
356
|
+
const idx = str.indexOf(' ')
|
|
357
|
+
if (idx === -1) return [str, '----------']
|
|
358
|
+
return [str.slice(0, idx), str.slice(idx + 1)]
|
|
359
|
+
},
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// Calculator – x op y (e.g. "3 * 4")
|
|
364
|
+
// The operator segment uses options: [] so ↑/↓ cycles through +, -, *, /
|
|
365
|
+
// and typing any of those characters selects it.
|
|
366
|
+
// placeholder '?' for the operator (not a digit, not in any operand).
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
const calc = {
|
|
369
|
+
segments: [
|
|
370
|
+
{ value: '1', placeholder: '--', min: 0, max: 999, step: 1, pattern: /\d/ },
|
|
371
|
+
{ value: '+', placeholder: '?', options: ['+', '-', '*', '/'] },
|
|
372
|
+
{ value: '1', placeholder: '--', min: 0, max: 999, step: 1, pattern: /\d/ },
|
|
373
|
+
],
|
|
374
|
+
format (values) {
|
|
375
|
+
return `${values[0]} ${values[1]} ${values[2]}`
|
|
376
|
+
},
|
|
377
|
+
parse (str) {
|
|
378
|
+
// Match either a number (digits) or the placeholder '--'.
|
|
379
|
+
const m = str.match(/^(--|[\d]+)\s+([^\s]+)\s+(--|[\d]+)$/)
|
|
380
|
+
if (m) return [m[1], m[2], m[3]]
|
|
381
|
+
return ['--', '+', '--']
|
|
382
|
+
},
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// Currency – <symbol>NNN.CC (e.g. "$19.99", "€9.99")
|
|
387
|
+
// The currency-symbol segment uses options: [] so ↑/↓ cycles through $, €, £, ¥.
|
|
388
|
+
// Typing $ selects $ directly; others are reachable via arrow keys.
|
|
389
|
+
// placeholder '?' for the symbol (not a digit, not '.').
|
|
390
|
+
// ---------------------------------------------------------------------------
|
|
391
|
+
const currency = {
|
|
392
|
+
segments: [
|
|
393
|
+
{ value: '$', placeholder: '?', options: ['$', '€', '£', '¥'] },
|
|
394
|
+
{ value: '0', placeholder: '--', min: 0, step: 1, maxLength: 5, pattern: /\d/ },
|
|
395
|
+
{ value: '00', placeholder: '--', min: 0, max: 99, step: 1, pattern: /\d/ },
|
|
396
|
+
],
|
|
397
|
+
format (values) {
|
|
398
|
+
return `${values[0]}${values[1]}.${String(values[2]).padStart(2, '0')}`
|
|
399
|
+
},
|
|
400
|
+
parse (str) {
|
|
401
|
+
// Match optional leading currency symbol + (number or placeholder '--').(number or placeholder '--')
|
|
402
|
+
const m = str.match(/^([^\d\-]+)(--|\d+)\.(--|\d+)$/)
|
|
403
|
+
if (m) return [m[1], m[2], m[3].padStart(2, '0')]
|
|
404
|
+
return ['?', '--', '--']
|
|
405
|
+
},
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
// Date range – YYYY-MM-DD → YYYY-MM-DD (start date and end date in one field)
|
|
410
|
+
// Six segments: [startYear, startMonth, startDay, endYear, endMonth, endDay].
|
|
411
|
+
// The two date halves share the same placeholder strings ('yyyy', 'mm', 'dd');
|
|
412
|
+
// getSegmentRanges finds them in left-to-right order via cumulative indexOf so
|
|
413
|
+
// both halves are located correctly even when their values are identical.
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
const dateRange = {
|
|
416
|
+
segments: [
|
|
417
|
+
// Start date
|
|
418
|
+
{ value: String(new Date().getFullYear()), placeholder: 'yyyy', min: 1, max: 9999, step: 1, maxLength: 4, pattern: /\d/ },
|
|
419
|
+
{ value: '01', placeholder: 'mm', min: 1, max: 12, step: 1, pattern: /\d/ },
|
|
420
|
+
{ value: '01', placeholder: 'dd', min: 1, max: 31, step: 1, pattern: /\d/ },
|
|
421
|
+
// End date
|
|
422
|
+
{ value: String(new Date().getFullYear()), placeholder: 'yyyy', min: 1, max: 9999, step: 1, maxLength: 4, pattern: /\d/ },
|
|
423
|
+
{ value: '01', placeholder: 'mm', min: 1, max: 12, step: 1, pattern: /\d/ },
|
|
424
|
+
{ value: '01', placeholder: 'dd', min: 1, max: 31, step: 1, pattern: /\d/ },
|
|
425
|
+
],
|
|
426
|
+
format (values) {
|
|
427
|
+
const pad4 = v => String(v).padStart(4, '0')
|
|
428
|
+
const pad2 = v => String(v).padStart(2, '0')
|
|
429
|
+
return `${pad4(values[0])}-${pad2(values[1])}-${pad2(values[2])} \u2192 ${pad4(values[3])}-${pad2(values[4])}-${pad2(values[5])}`
|
|
430
|
+
},
|
|
431
|
+
parse (str) {
|
|
432
|
+
const halves = str.split(' \u2192 ')
|
|
433
|
+
// Parse a single YYYY-MM-DD string (or a partial/placeholder string) into [year, month, day].
|
|
434
|
+
// Non-digit placeholder values (e.g. 'yyyy', 'mm', 'dd') pass through unchanged so that
|
|
435
|
+
// _isPlaceholderState correctly identifies unfilled segments.
|
|
436
|
+
const parseDate = (s) => {
|
|
437
|
+
const parts = (s || '').split('-')
|
|
438
|
+
const y = parts[0] || 'yyyy'
|
|
439
|
+
const m = parts[1] || 'mm'
|
|
440
|
+
const d = parts[2] || 'dd'
|
|
441
|
+
// Only pad strings that look like real numeric values (avoid corrupting placeholder text).
|
|
442
|
+
const padIfNumeric = (v, len) => /^\d+$/.test(v) ? v.padStart(len, '0') : v
|
|
443
|
+
return [padIfNumeric(y, 4), padIfNumeric(m, 2), padIfNumeric(d, 2)]
|
|
444
|
+
}
|
|
445
|
+
// Guard against missing separator: treat the whole string as the start date.
|
|
446
|
+
return [...parseDate(halves[0]), ...parseDate(halves[1] ?? '')]
|
|
447
|
+
},
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// Date with picker button – YYYY-MM-DD ⏱︎
|
|
452
|
+
//
|
|
453
|
+
// Three editable date segments followed by an action segment whose value is '⏱︎'.
|
|
454
|
+
// The library automatically wraps the icon with U+200B zero-width-space guards so:
|
|
455
|
+
// • clicking just to the LEFT of the icon selects the day segment (equal-distance
|
|
456
|
+
// tie-break picks the left side)
|
|
457
|
+
// • clicking at the RIGHT boundary or the right margin of the input routes to the
|
|
458
|
+
// day segment (exclusive-end check in #onClickOrFocus)
|
|
459
|
+
// Neither the format() nor parse() functions need to handle \u200B.
|
|
460
|
+
//
|
|
461
|
+
// Supply an onClick handler when instantiating:
|
|
462
|
+
//
|
|
463
|
+
// new SegmentedInput(el, {
|
|
464
|
+
// ...presets.dateWithPicker,
|
|
465
|
+
// segments: presets.dateWithPicker.segments.map((s, i) =>
|
|
466
|
+
// i === 3 ? { ...s, onClick (inst) { /* set today's date */ } } : s
|
|
467
|
+
// ),
|
|
468
|
+
// })
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
const dateWithPicker = {
|
|
471
|
+
segments: [
|
|
472
|
+
{ value: String(new Date().getFullYear()), placeholder: 'yyyy', min: 1, max: 9999, step: 1, maxLength: 4, pattern: /\d/ },
|
|
473
|
+
{ value: '01', placeholder: 'mm', min: 1, max: 12, step: 1, pattern: /\d/ },
|
|
474
|
+
{ value: '01', placeholder: 'dd', min: 1, max: 31, step: 1, pattern: /\d/ },
|
|
475
|
+
// Action segment — type: 'action' marks it as non-editable; consumer adds onClick.
|
|
476
|
+
// selectable: true makes it reachable via Tab/Arrow; Enter fires onClick.
|
|
477
|
+
// The library injects \u200B guards around the icon automatically.
|
|
478
|
+
{ value: '⏱︎', placeholder: '⏱︎', type: 'action', selectable: true },
|
|
479
|
+
],
|
|
480
|
+
format (values) {
|
|
481
|
+
const pad4 = v => String(v).padStart(4, '0')
|
|
482
|
+
const pad2 = v => String(v).padStart(2, '0')
|
|
483
|
+
// Simple space separator between the date and the icon.
|
|
484
|
+
// The library adds \u200B guards around the icon automatically.
|
|
485
|
+
return `${pad4(values[0])}-${pad2(values[1])}-${pad2(values[2])} ${values[3]}`
|
|
486
|
+
},
|
|
487
|
+
parse (str) {
|
|
488
|
+
// The library strips \u200B before calling parse(), so str is e.g. "2024-01-15 ⏱︎".
|
|
489
|
+
// Strip the icon suffix, then split on '-'.
|
|
490
|
+
const dateStr = str.replace(/\s*⏱︎\s*$/, '').trim()
|
|
491
|
+
const parts = dateStr.split('-')
|
|
492
|
+
while (parts.length < 3) parts.push('01')
|
|
493
|
+
const padIfNum = (v, len) => /^\d+$/.test(v) ? v.padStart(len, '0') : v
|
|
494
|
+
return [padIfNum(parts[0], 4), padIfNum(parts[1], 2), padIfNum(parts[2], 2), '⏱︎']
|
|
495
|
+
},
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
export {
|
|
499
|
+
ipv4, ipv6, duration, rgba, uuid, mac,
|
|
500
|
+
time, date, dateRange, dateWithPicker, creditCard, semver, expiryDate, phone, hsla,
|
|
501
|
+
price, mathExpr, fullName, calc, currency,
|
|
502
|
+
}
|