@ngneers/controls 0.0.1-next.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/fesm2022/ngneers-controls-accordion.mjs +234 -0
- package/fesm2022/ngneers-controls-accordion.mjs.map +1 -0
- package/fesm2022/ngneers-controls-api-ng.mjs +959 -0
- package/fesm2022/ngneers-controls-api-ng.mjs.map +1 -0
- package/fesm2022/ngneers-controls-api-resize.mjs +712 -0
- package/fesm2022/ngneers-controls-api-resize.mjs.map +1 -0
- package/fesm2022/ngneers-controls-api.mjs +347 -0
- package/fesm2022/ngneers-controls-api.mjs.map +1 -0
- package/fesm2022/ngneers-controls-avatar.mjs +77 -0
- package/fesm2022/ngneers-controls-avatar.mjs.map +1 -0
- package/fesm2022/ngneers-controls-base.mjs +620 -0
- package/fesm2022/ngneers-controls-base.mjs.map +1 -0
- package/fesm2022/ngneers-controls-breadcrumb.mjs +100 -0
- package/fesm2022/ngneers-controls-breadcrumb.mjs.map +1 -0
- package/fesm2022/ngneers-controls-button-group.mjs +110 -0
- package/fesm2022/ngneers-controls-button-group.mjs.map +1 -0
- package/fesm2022/ngneers-controls-button.mjs +83 -0
- package/fesm2022/ngneers-controls-button.mjs.map +1 -0
- package/fesm2022/ngneers-controls-calendar-time-CJT5AFlA.mjs +74 -0
- package/fesm2022/ngneers-controls-calendar-time-CJT5AFlA.mjs.map +1 -0
- package/fesm2022/ngneers-controls-calendar.mjs +801 -0
- package/fesm2022/ngneers-controls-calendar.mjs.map +1 -0
- package/fesm2022/ngneers-controls-checkbox.mjs +63 -0
- package/fesm2022/ngneers-controls-checkbox.mjs.map +1 -0
- package/fesm2022/ngneers-controls-chip.mjs +55 -0
- package/fesm2022/ngneers-controls-chip.mjs.map +1 -0
- package/fesm2022/ngneers-controls-default-icons.mjs +82 -0
- package/fesm2022/ngneers-controls-default-icons.mjs.map +1 -0
- package/fesm2022/ngneers-controls-defer.mjs +72 -0
- package/fesm2022/ngneers-controls-defer.mjs.map +1 -0
- package/fesm2022/ngneers-controls-dialog.mjs +407 -0
- package/fesm2022/ngneers-controls-dialog.mjs.map +1 -0
- package/fesm2022/ngneers-controls-directives.mjs +498 -0
- package/fesm2022/ngneers-controls-directives.mjs.map +1 -0
- package/fesm2022/ngneers-controls-drawer.mjs +271 -0
- package/fesm2022/ngneers-controls-drawer.mjs.map +1 -0
- package/fesm2022/ngneers-controls-edit-inplace.mjs +127 -0
- package/fesm2022/ngneers-controls-edit-inplace.mjs.map +1 -0
- package/fesm2022/ngneers-controls-errors.mjs +329 -0
- package/fesm2022/ngneers-controls-errors.mjs.map +1 -0
- package/fesm2022/ngneers-controls-filter.mjs +708 -0
- package/fesm2022/ngneers-controls-filter.mjs.map +1 -0
- package/fesm2022/ngneers-controls-hint.mjs +153 -0
- package/fesm2022/ngneers-controls-hint.mjs.map +1 -0
- package/fesm2022/ngneers-controls-i18n-translations-de.mjs +170 -0
- package/fesm2022/ngneers-controls-i18n-translations-de.mjs.map +1 -0
- package/fesm2022/ngneers-controls-i18n-translations-en.mjs +170 -0
- package/fesm2022/ngneers-controls-i18n-translations-en.mjs.map +1 -0
- package/fesm2022/ngneers-controls-i18n.mjs +57 -0
- package/fesm2022/ngneers-controls-i18n.mjs.map +1 -0
- package/fesm2022/ngneers-controls-icon.mjs +195 -0
- package/fesm2022/ngneers-controls-icon.mjs.map +1 -0
- package/fesm2022/ngneers-controls-inplace.mjs +150 -0
- package/fesm2022/ngneers-controls-inplace.mjs.map +1 -0
- package/fesm2022/ngneers-controls-input-field.mjs +165 -0
- package/fesm2022/ngneers-controls-input-field.mjs.map +1 -0
- package/fesm2022/ngneers-controls-input.mjs +72 -0
- package/fesm2022/ngneers-controls-input.mjs.map +1 -0
- package/fesm2022/ngneers-controls-item-view.mjs +473 -0
- package/fesm2022/ngneers-controls-item-view.mjs.map +1 -0
- package/fesm2022/ngneers-controls-list-box.mjs +281 -0
- package/fesm2022/ngneers-controls-list-box.mjs.map +1 -0
- package/fesm2022/ngneers-controls-mask-input.mjs +888 -0
- package/fesm2022/ngneers-controls-mask-input.mjs.map +1 -0
- package/fesm2022/ngneers-controls-menu.mjs +371 -0
- package/fesm2022/ngneers-controls-menu.mjs.map +1 -0
- package/fesm2022/ngneers-controls-message.mjs +30 -0
- package/fesm2022/ngneers-controls-message.mjs.map +1 -0
- package/fesm2022/ngneers-controls-number-input.mjs +366 -0
- package/fesm2022/ngneers-controls-number-input.mjs.map +1 -0
- package/fesm2022/ngneers-controls-paginator.mjs +129 -0
- package/fesm2022/ngneers-controls-paginator.mjs.map +1 -0
- package/fesm2022/ngneers-controls-popover.mjs +215 -0
- package/fesm2022/ngneers-controls-popover.mjs.map +1 -0
- package/fesm2022/ngneers-controls-progress.mjs +98 -0
- package/fesm2022/ngneers-controls-progress.mjs.map +1 -0
- package/fesm2022/ngneers-controls-radio.mjs +152 -0
- package/fesm2022/ngneers-controls-radio.mjs.map +1 -0
- package/fesm2022/ngneers-controls-roving-focus.mjs +323 -0
- package/fesm2022/ngneers-controls-roving-focus.mjs.map +1 -0
- package/fesm2022/ngneers-controls-scroll-shadow.mjs +65 -0
- package/fesm2022/ngneers-controls-scroll-shadow.mjs.map +1 -0
- package/fesm2022/ngneers-controls-scroller.mjs +308 -0
- package/fesm2022/ngneers-controls-scroller.mjs.map +1 -0
- package/fesm2022/ngneers-controls-select-button.mjs +58 -0
- package/fesm2022/ngneers-controls-select-button.mjs.map +1 -0
- package/fesm2022/ngneers-controls-select.mjs +417 -0
- package/fesm2022/ngneers-controls-select.mjs.map +1 -0
- package/fesm2022/ngneers-controls-slider.mjs +167 -0
- package/fesm2022/ngneers-controls-slider.mjs.map +1 -0
- package/fesm2022/ngneers-controls-snackbar.mjs +414 -0
- package/fesm2022/ngneers-controls-snackbar.mjs.map +1 -0
- package/fesm2022/ngneers-controls-spin-buttons.mjs +126 -0
- package/fesm2022/ngneers-controls-spin-buttons.mjs.map +1 -0
- package/fesm2022/ngneers-controls-spinner.mjs +184 -0
- package/fesm2022/ngneers-controls-spinner.mjs.map +1 -0
- package/fesm2022/ngneers-controls-splitter.mjs +604 -0
- package/fesm2022/ngneers-controls-splitter.mjs.map +1 -0
- package/fesm2022/ngneers-controls-state.mjs +185 -0
- package/fesm2022/ngneers-controls-state.mjs.map +1 -0
- package/fesm2022/ngneers-controls-switch.mjs +39 -0
- package/fesm2022/ngneers-controls-switch.mjs.map +1 -0
- package/fesm2022/ngneers-controls-table.mjs +2634 -0
- package/fesm2022/ngneers-controls-table.mjs.map +1 -0
- package/fesm2022/ngneers-controls-tabs.mjs +336 -0
- package/fesm2022/ngneers-controls-tabs.mjs.map +1 -0
- package/fesm2022/ngneers-controls-tag.mjs +30 -0
- package/fesm2022/ngneers-controls-tag.mjs.map +1 -0
- package/fesm2022/ngneers-controls-toast.mjs +350 -0
- package/fesm2022/ngneers-controls-toast.mjs.map +1 -0
- package/fesm2022/ngneers-controls-toggle-button.mjs +70 -0
- package/fesm2022/ngneers-controls-toggle-button.mjs.map +1 -0
- package/fesm2022/ngneers-controls-tooltip.mjs +506 -0
- package/fesm2022/ngneers-controls-tooltip.mjs.map +1 -0
- package/fesm2022/ngneers-controls-tree.mjs +599 -0
- package/fesm2022/ngneers-controls-tree.mjs.map +1 -0
- package/fesm2022/ngneers-controls-upload.mjs +377 -0
- package/fesm2022/ngneers-controls-upload.mjs.map +1 -0
- package/fesm2022/ngneers-controls-utils-ng.mjs +411 -0
- package/fesm2022/ngneers-controls-utils-ng.mjs.map +1 -0
- package/fesm2022/ngneers-controls-utils.mjs +261 -0
- package/fesm2022/ngneers-controls-utils.mjs.map +1 -0
- package/fesm2022/ngneers-controls.mjs +6 -0
- package/fesm2022/ngneers-controls.mjs.map +1 -0
- package/package.json +302 -0
- package/types/ngneers-controls-accordion.d.ts +139 -0
- package/types/ngneers-controls-api-ng.d.ts +364 -0
- package/types/ngneers-controls-api-resize.d.ts +318 -0
- package/types/ngneers-controls-api.d.ts +145 -0
- package/types/ngneers-controls-avatar.d.ts +54 -0
- package/types/ngneers-controls-base.d.ts +277 -0
- package/types/ngneers-controls-breadcrumb.d.ts +272 -0
- package/types/ngneers-controls-button-group.d.ts +46 -0
- package/types/ngneers-controls-button.d.ts +48 -0
- package/types/ngneers-controls-calendar.d.ts +466 -0
- package/types/ngneers-controls-checkbox.d.ts +40 -0
- package/types/ngneers-controls-chip.d.ts +202 -0
- package/types/ngneers-controls-default-icons.d.ts +5 -0
- package/types/ngneers-controls-defer.d.ts +48 -0
- package/types/ngneers-controls-dialog.d.ts +399 -0
- package/types/ngneers-controls-directives.d.ts +171 -0
- package/types/ngneers-controls-drawer.d.ts +308 -0
- package/types/ngneers-controls-edit-inplace.d.ts +263 -0
- package/types/ngneers-controls-errors.d.ts +155 -0
- package/types/ngneers-controls-filter.d.ts +609 -0
- package/types/ngneers-controls-hint.d.ts +88 -0
- package/types/ngneers-controls-i18n-translations-de.d.ts +164 -0
- package/types/ngneers-controls-i18n-translations-en.d.ts +164 -0
- package/types/ngneers-controls-i18n.d.ts +186 -0
- package/types/ngneers-controls-icon.d.ts +68 -0
- package/types/ngneers-controls-inplace.d.ts +98 -0
- package/types/ngneers-controls-input-field.d.ts +248 -0
- package/types/ngneers-controls-input.d.ts +30 -0
- package/types/ngneers-controls-item-view.d.ts +215 -0
- package/types/ngneers-controls-list-box.d.ts +334 -0
- package/types/ngneers-controls-mask-input.d.ts +434 -0
- package/types/ngneers-controls-menu.d.ts +192 -0
- package/types/ngneers-controls-message.d.ts +21 -0
- package/types/ngneers-controls-number-input.d.ts +146 -0
- package/types/ngneers-controls-paginator.d.ts +255 -0
- package/types/ngneers-controls-popover.d.ts +116 -0
- package/types/ngneers-controls-progress.d.ts +49 -0
- package/types/ngneers-controls-radio.d.ts +91 -0
- package/types/ngneers-controls-roving-focus.d.ts +107 -0
- package/types/ngneers-controls-scroll-shadow.d.ts +25 -0
- package/types/ngneers-controls-scroller.d.ts +151 -0
- package/types/ngneers-controls-select-button.d.ts +51 -0
- package/types/ngneers-controls-select.d.ts +409 -0
- package/types/ngneers-controls-slider.d.ts +57 -0
- package/types/ngneers-controls-snackbar.d.ts +330 -0
- package/types/ngneers-controls-spin-buttons.d.ts +69 -0
- package/types/ngneers-controls-spinner.d.ts +117 -0
- package/types/ngneers-controls-splitter.d.ts +465 -0
- package/types/ngneers-controls-state.d.ts +102 -0
- package/types/ngneers-controls-switch.d.ts +18 -0
- package/types/ngneers-controls-table.d.ts +3127 -0
- package/types/ngneers-controls-tabs.d.ts +118 -0
- package/types/ngneers-controls-tag.d.ts +21 -0
- package/types/ngneers-controls-toast.d.ts +298 -0
- package/types/ngneers-controls-toggle-button.d.ts +48 -0
- package/types/ngneers-controls-tooltip.d.ts +189 -0
- package/types/ngneers-controls-tree.d.ts +409 -0
- package/types/ngneers-controls-upload.d.ts +347 -0
- package/types/ngneers-controls-utils-ng.d.ts +129 -0
- package/types/ngneers-controls-utils.d.ts +130 -0
- package/types/ngneers-controls.d.ts +2 -0
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
import * as i0 from '@angular/core';
|
|
2
|
+
import { inject, input, computed, signal, viewChild, effect, untracked, Component } from '@angular/core';
|
|
3
|
+
import { domEventHandler } from '@ngneers/controls/api/ng';
|
|
4
|
+
import { ValueControlBase, NgnPt, provideSelf } from '@ngneers/controls/base';
|
|
5
|
+
import { I18n } from '@ngneers/controls/i18n';
|
|
6
|
+
import { NgnRovingGroup, NgnRovingItem } from '@ngneers/controls/roving-focus';
|
|
7
|
+
import { maskInputControlTemplate } from '@ngneers/controls-themes/templates/mask-input';
|
|
8
|
+
|
|
9
|
+
const phoneNumber = [
|
|
10
|
+
'(',
|
|
11
|
+
{ kind: 'number', segment: 'areaCode', min: 0, max: 999, length: 3, placeholder: '000' },
|
|
12
|
+
')',
|
|
13
|
+
' ',
|
|
14
|
+
{ kind: 'number', segment: 'exchange', min: 0, max: 999, length: 3, placeholder: '000' },
|
|
15
|
+
'-',
|
|
16
|
+
{ kind: 'number', segment: 'subscriber', min: 0, max: 9999, length: 4, placeholder: '0000' },
|
|
17
|
+
];
|
|
18
|
+
const creditCard = [
|
|
19
|
+
{ kind: 'number', segment: 'cardPart1', min: 0, max: 9999, length: 4, placeholder: '0000' },
|
|
20
|
+
' ',
|
|
21
|
+
{ kind: 'number', segment: 'cardPart2', min: 0, max: 9999, length: 4, placeholder: '0000' },
|
|
22
|
+
' ',
|
|
23
|
+
{ kind: 'number', segment: 'cardPart3', min: 0, max: 9999, length: 4, placeholder: '0000' },
|
|
24
|
+
' ',
|
|
25
|
+
{ kind: 'number', segment: 'cardPart4', min: 0, max: 9999, length: 4, placeholder: '0000' },
|
|
26
|
+
];
|
|
27
|
+
const ssn = [
|
|
28
|
+
{ kind: 'number', segment: 'areaNumber', min: 0, max: 999, length: 3, placeholder: '000' },
|
|
29
|
+
'-',
|
|
30
|
+
{ kind: 'number', segment: 'groupNumber', min: 0, max: 99, length: 2, placeholder: '00' },
|
|
31
|
+
'-',
|
|
32
|
+
{ kind: 'number', segment: 'serialNumber', min: 0, max: 9999, length: 4, placeholder: '0000' },
|
|
33
|
+
];
|
|
34
|
+
const zipCode = [
|
|
35
|
+
{ kind: 'number', segment: 'zipBase', min: 0, max: 99999, length: 5, placeholder: '00000' },
|
|
36
|
+
'-',
|
|
37
|
+
{ kind: 'number', segment: 'zipPlus4', min: 0, max: 9999, length: 4, placeholder: '0000' },
|
|
38
|
+
];
|
|
39
|
+
const currency = [
|
|
40
|
+
'$',
|
|
41
|
+
{
|
|
42
|
+
kind: 'number',
|
|
43
|
+
segment: 'dollars',
|
|
44
|
+
min: 0,
|
|
45
|
+
max: 999999999,
|
|
46
|
+
length: 9,
|
|
47
|
+
placeholder: '000000000',
|
|
48
|
+
},
|
|
49
|
+
'.',
|
|
50
|
+
{ kind: 'number', segment: 'cents', min: 0, max: 99, length: 2, placeholder: '00' },
|
|
51
|
+
];
|
|
52
|
+
const percentage = [
|
|
53
|
+
{ kind: 'number', segment: 'percent', min: 0, max: 100, length: 3, placeholder: '000' },
|
|
54
|
+
'%',
|
|
55
|
+
];
|
|
56
|
+
const MASKS = {
|
|
57
|
+
phoneNumber,
|
|
58
|
+
creditCard,
|
|
59
|
+
ssn,
|
|
60
|
+
zipCode,
|
|
61
|
+
currency,
|
|
62
|
+
percentage,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const formatSymbols = {
|
|
66
|
+
d: 'day',
|
|
67
|
+
M: 'month',
|
|
68
|
+
y: 'year',
|
|
69
|
+
H: 'hour24',
|
|
70
|
+
h: 'hour12',
|
|
71
|
+
m: 'minute',
|
|
72
|
+
s: 'second',
|
|
73
|
+
a: 'period',
|
|
74
|
+
S: 'fractionalSecond',
|
|
75
|
+
};
|
|
76
|
+
/**
|
|
77
|
+
* Returns the input mask configuration for a given date or time mask.
|
|
78
|
+
* @param mask The mask string representing the date or time format.
|
|
79
|
+
* For example, 'MM/dd/yyyy' for date, 'HH:mm:ss' for time, 'hh:mm:ss a' for 12-hour time with period.
|
|
80
|
+
* @returns The input mask configuration that can be used with the input mask component.
|
|
81
|
+
*/
|
|
82
|
+
function getDateOrTimeMask(mask) {
|
|
83
|
+
const cfg = [];
|
|
84
|
+
let i = 0;
|
|
85
|
+
while (i < mask.length) {
|
|
86
|
+
const char = mask[i];
|
|
87
|
+
const symbol = formatSymbols[char];
|
|
88
|
+
if (symbol) {
|
|
89
|
+
// Count how many times this symbol repeats
|
|
90
|
+
let length = 1;
|
|
91
|
+
while (i + length < mask.length && mask[i + length] === char) {
|
|
92
|
+
length++;
|
|
93
|
+
}
|
|
94
|
+
cfg.push({ symbol, length });
|
|
95
|
+
i += length;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
cfg.push({ literal: char });
|
|
99
|
+
i++;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const result = cfg.map(segment => {
|
|
103
|
+
if ('literal' in segment) {
|
|
104
|
+
return segment.literal;
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
const { symbol, length } = segment;
|
|
108
|
+
// A single-letter token (length 1) yields a variable-length, non-padded field
|
|
109
|
+
// (`length` omitted); a doubled token (length >= 2) yields a fixed, zero-padded field.
|
|
110
|
+
const len = length >= 2 ? length : undefined;
|
|
111
|
+
switch (symbol) {
|
|
112
|
+
case 'day':
|
|
113
|
+
return {
|
|
114
|
+
segment: 'day',
|
|
115
|
+
kind: 'number',
|
|
116
|
+
min: 1,
|
|
117
|
+
max: 31,
|
|
118
|
+
length: len,
|
|
119
|
+
placeholder: 'DD',
|
|
120
|
+
};
|
|
121
|
+
case 'month':
|
|
122
|
+
return {
|
|
123
|
+
segment: 'month',
|
|
124
|
+
kind: 'number',
|
|
125
|
+
min: 1,
|
|
126
|
+
max: 12,
|
|
127
|
+
length: len,
|
|
128
|
+
placeholder: 'MM',
|
|
129
|
+
};
|
|
130
|
+
case 'year':
|
|
131
|
+
return {
|
|
132
|
+
segment: 'year',
|
|
133
|
+
kind: 'number',
|
|
134
|
+
min: 0,
|
|
135
|
+
max: 9999,
|
|
136
|
+
length: len,
|
|
137
|
+
placeholder: 'YYYY',
|
|
138
|
+
};
|
|
139
|
+
case 'hour24':
|
|
140
|
+
return {
|
|
141
|
+
segment: 'hour24',
|
|
142
|
+
kind: 'number',
|
|
143
|
+
min: 0,
|
|
144
|
+
max: 23,
|
|
145
|
+
length: len,
|
|
146
|
+
placeholder: 'HH',
|
|
147
|
+
};
|
|
148
|
+
case 'hour12':
|
|
149
|
+
return {
|
|
150
|
+
segment: 'hour12',
|
|
151
|
+
kind: 'number',
|
|
152
|
+
min: 1,
|
|
153
|
+
max: 12,
|
|
154
|
+
length: len,
|
|
155
|
+
placeholder: 'HH',
|
|
156
|
+
};
|
|
157
|
+
case 'minute':
|
|
158
|
+
return {
|
|
159
|
+
segment: 'minute',
|
|
160
|
+
kind: 'number',
|
|
161
|
+
min: 0,
|
|
162
|
+
max: 59,
|
|
163
|
+
length: len,
|
|
164
|
+
placeholder: 'MM',
|
|
165
|
+
};
|
|
166
|
+
case 'second':
|
|
167
|
+
return {
|
|
168
|
+
segment: 'second',
|
|
169
|
+
kind: 'number',
|
|
170
|
+
min: 0,
|
|
171
|
+
max: 59,
|
|
172
|
+
length: len,
|
|
173
|
+
placeholder: 'SS',
|
|
174
|
+
};
|
|
175
|
+
case 'period':
|
|
176
|
+
return {
|
|
177
|
+
segment: 'period',
|
|
178
|
+
kind: 'enum',
|
|
179
|
+
values: ['AM', 'PM'],
|
|
180
|
+
};
|
|
181
|
+
case 'fractionalSecond':
|
|
182
|
+
return {
|
|
183
|
+
segment: 'fractionalSecond',
|
|
184
|
+
kind: 'number',
|
|
185
|
+
min: 0,
|
|
186
|
+
max: 999,
|
|
187
|
+
length: len,
|
|
188
|
+
placeholder: 'SSS',
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
return result;
|
|
194
|
+
}
|
|
195
|
+
const DATE_TIME_MASKS = {
|
|
196
|
+
date: getDateOrTimeMask('MM/dd/yyyy'),
|
|
197
|
+
time: getDateOrTimeMask('HH:mm:ss'),
|
|
198
|
+
timeShort: getDateOrTimeMask('HH:mm'),
|
|
199
|
+
time12: getDateOrTimeMask('hh:mm:ss a'),
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
function resolveMask(input) {
|
|
203
|
+
if (!input)
|
|
204
|
+
return null;
|
|
205
|
+
const cfg = typeof input === 'string'
|
|
206
|
+
? (MASKS[input] ??
|
|
207
|
+
DATE_TIME_MASKS[input])
|
|
208
|
+
: input;
|
|
209
|
+
if (!cfg)
|
|
210
|
+
return null; // unknown raw string → unsupported
|
|
211
|
+
return cfg.map(item => {
|
|
212
|
+
if (typeof item === 'string')
|
|
213
|
+
return { kind: 'sep', text: item };
|
|
214
|
+
if (item.kind === 'number') {
|
|
215
|
+
const maxLen = item.length ?? String(item.max).length;
|
|
216
|
+
const field = {
|
|
217
|
+
kind: 'number',
|
|
218
|
+
name: item.segment,
|
|
219
|
+
min: item.min,
|
|
220
|
+
max: item.max,
|
|
221
|
+
maxLen,
|
|
222
|
+
pad: item.length != null,
|
|
223
|
+
placeholder: item.placeholder ?? '-'.repeat(maxLen),
|
|
224
|
+
};
|
|
225
|
+
return field;
|
|
226
|
+
}
|
|
227
|
+
// Guard an empty values array: Math.max(...[]) is -Infinity, which would
|
|
228
|
+
// throw in '-'.repeat(maxLen). An empty enum is degenerate but must not crash.
|
|
229
|
+
const maxLen = item.values.length === 0 ? 0 : Math.max(...item.values.map(v => v.length));
|
|
230
|
+
const field = {
|
|
231
|
+
kind: 'enum',
|
|
232
|
+
name: item.segment,
|
|
233
|
+
values: [...item.values],
|
|
234
|
+
maxLen,
|
|
235
|
+
placeholder: item.placeholder ?? (maxLen > 0 ? '-'.repeat(maxLen) : ''),
|
|
236
|
+
};
|
|
237
|
+
return field;
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
function fieldList(parts) {
|
|
241
|
+
return parts.filter((p) => p.kind !== 'sep');
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Index of the section whose horizontal box is nearest `clientX`.
|
|
245
|
+
* - `clientX` left of the first section → 0
|
|
246
|
+
* - `clientX` right of the last section → last index
|
|
247
|
+
* - inside a section → that section
|
|
248
|
+
* - in a gap/separator between two sections → the nearer one
|
|
249
|
+
* Returns -1 for an empty list.
|
|
250
|
+
*/
|
|
251
|
+
function nearestSectionIndex(rects, clientX) {
|
|
252
|
+
if (rects.length === 0)
|
|
253
|
+
return -1;
|
|
254
|
+
let best = 0;
|
|
255
|
+
let bestDist = Infinity;
|
|
256
|
+
for (let i = 0; i < rects.length; i++) {
|
|
257
|
+
const r = rects[i];
|
|
258
|
+
if (!r)
|
|
259
|
+
continue;
|
|
260
|
+
const dist = clientX < r.left ? r.left - clientX : clientX > r.right ? clientX - r.right : 0;
|
|
261
|
+
if (dist < bestDist) {
|
|
262
|
+
bestDist = dist;
|
|
263
|
+
best = i;
|
|
264
|
+
}
|
|
265
|
+
if (dist === 0)
|
|
266
|
+
break; // inside a section — exact hit
|
|
267
|
+
}
|
|
268
|
+
return best;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Enforce the no-gaps invariant: scanning from index 0, once the first empty
|
|
272
|
+
* field is seen, blank every field after it. A delete that empties an earlier
|
|
273
|
+
* field therefore clears the now-orphaned later fields too.
|
|
274
|
+
*/
|
|
275
|
+
function truncateGaps(values) {
|
|
276
|
+
let seenEmpty = false;
|
|
277
|
+
return values.map(v => {
|
|
278
|
+
if (seenEmpty)
|
|
279
|
+
return '';
|
|
280
|
+
if (!v) {
|
|
281
|
+
seenEmpty = true;
|
|
282
|
+
return '';
|
|
283
|
+
}
|
|
284
|
+
return v;
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
// ---- New per-section pure functions ----
|
|
288
|
+
/**
|
|
289
|
+
* Type a char into ONE section. Returns the new section value + whether focus
|
|
290
|
+
* should auto-advance to the next section, or null if the char is rejected.
|
|
291
|
+
*
|
|
292
|
+
* For number fields:
|
|
293
|
+
* - Non-digit → null.
|
|
294
|
+
* - If current is already complete (length===maxLen or value*10>max) a digit
|
|
295
|
+
* starts a fresh value (replace).
|
|
296
|
+
* - Otherwise appending is attempted. If the appended candidate exceeds max or
|
|
297
|
+
* maxLen, restart with the bare digit instead.
|
|
298
|
+
* - advance:true when the resulting value is isSectionComplete.
|
|
299
|
+
*
|
|
300
|
+
* For enum fields:
|
|
301
|
+
* - Match by case-insensitive first char; on match returns {value, advance:true}.
|
|
302
|
+
* - No match → null.
|
|
303
|
+
*
|
|
304
|
+
* NOTE: padding is NOT applied to the returned value — that is a display concern.
|
|
305
|
+
*/
|
|
306
|
+
function typeIntoSection(field, current, ch) {
|
|
307
|
+
if (field.kind === 'enum') {
|
|
308
|
+
const match = field.values.find(val => val[0]?.toLowerCase() === ch.toLowerCase());
|
|
309
|
+
if (!match)
|
|
310
|
+
return null;
|
|
311
|
+
return { value: match, advance: true };
|
|
312
|
+
}
|
|
313
|
+
// number field — a single digit only (multi-char data is rejected outright).
|
|
314
|
+
if (!/^\d$/.test(ch))
|
|
315
|
+
return null;
|
|
316
|
+
// If current section is already complete, start fresh
|
|
317
|
+
const currentComplete = current !== '' && isSectionComplete(field, current);
|
|
318
|
+
let candidate;
|
|
319
|
+
if (currentComplete) {
|
|
320
|
+
candidate = ch;
|
|
321
|
+
}
|
|
322
|
+
else {
|
|
323
|
+
const appended = current + ch;
|
|
324
|
+
// If appending would exceed max or length, restart with bare digit
|
|
325
|
+
if (appended.length > field.maxLen || Number(appended) > field.max) {
|
|
326
|
+
// A single digit that is itself out of range can never be a valid value
|
|
327
|
+
// for this field (only possible for tiny fields with max < 10) → reject.
|
|
328
|
+
if (Number(ch) > field.max)
|
|
329
|
+
return null;
|
|
330
|
+
candidate = ch;
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
candidate = appended;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
// A full-width candidate below the field minimum (e.g. '00' for a day/month
|
|
337
|
+
// whose min is 1) is invalid — reject rather than letting it "complete".
|
|
338
|
+
if (candidate.length === field.maxLen && Number(candidate) < field.min)
|
|
339
|
+
return null;
|
|
340
|
+
const advance = isSectionComplete(field, candidate);
|
|
341
|
+
return { value: candidate, advance };
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Step/cycle a section value up (1) or down (-1). Empty defaults sensibly.
|
|
345
|
+
*
|
|
346
|
+
* For number fields:
|
|
347
|
+
* - empty → field.min (no extra dir step on the empty→min transition).
|
|
348
|
+
* - Non-empty: n = Number(current) + dir, wrap at >max → min, <min → max.
|
|
349
|
+
* - Returns padded (padStart maxLen '0') iff field.pad.
|
|
350
|
+
*
|
|
351
|
+
* For enum fields:
|
|
352
|
+
* - Find current (case-insensitive) in values; step by dir with wraparound.
|
|
353
|
+
* - empty/not-found → first value (dir:1) or last value (dir:-1).
|
|
354
|
+
*/
|
|
355
|
+
function stepSection(field, current, dir) {
|
|
356
|
+
if (field.kind === 'enum') {
|
|
357
|
+
const i = field.values.findIndex(v => v.toLowerCase() === current.toLowerCase());
|
|
358
|
+
if (i === -1) {
|
|
359
|
+
return (dir === 1 ? field.values[0] : field.values[field.values.length - 1]) ?? '';
|
|
360
|
+
}
|
|
361
|
+
const next = (i + dir + field.values.length) % field.values.length;
|
|
362
|
+
return field.values[next] ?? field.values[0] ?? '';
|
|
363
|
+
}
|
|
364
|
+
// number field
|
|
365
|
+
if (current === '') {
|
|
366
|
+
const min = field.min;
|
|
367
|
+
return field.pad ? String(min).padStart(field.maxLen, '0') : String(min);
|
|
368
|
+
}
|
|
369
|
+
let n = Number(current) + dir;
|
|
370
|
+
if (n > field.max)
|
|
371
|
+
n = field.min;
|
|
372
|
+
if (n < field.min)
|
|
373
|
+
n = field.max;
|
|
374
|
+
return field.pad ? String(n).padStart(field.maxLen, '0') : String(n);
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Whether a single section value is "done" (would auto-advance or is a fully
|
|
378
|
+
* matched enum). Empty → always false.
|
|
379
|
+
*
|
|
380
|
+
* number: value.length===maxLen OR Number(value)*10 > max.
|
|
381
|
+
* enum: value is literally in the values list (case-sensitive).
|
|
382
|
+
*/
|
|
383
|
+
function isSectionComplete(field, value) {
|
|
384
|
+
if (value === '')
|
|
385
|
+
return false;
|
|
386
|
+
if (field.kind === 'enum')
|
|
387
|
+
return field.values.includes(value);
|
|
388
|
+
if (!/^\d+$/.test(value))
|
|
389
|
+
return false;
|
|
390
|
+
const n = Number(value);
|
|
391
|
+
// A complete number value must be within range AND either fill the width or be
|
|
392
|
+
// unable to grow further (value*10 would exceed max).
|
|
393
|
+
return n >= field.min && n <= field.max && (value.length === field.maxLen || n * 10 > field.max);
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* Whether all fields in the mask have a complete value.
|
|
397
|
+
* Empty field list → false.
|
|
398
|
+
*/
|
|
399
|
+
function isComplete(parts, values) {
|
|
400
|
+
const fields = fieldList(parts);
|
|
401
|
+
if (fields.length === 0)
|
|
402
|
+
return false;
|
|
403
|
+
return fields.every((field, i) => isSectionComplete(field, values[i] ?? ''));
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Render each part as a RenderToken for display. No offsets.
|
|
407
|
+
*
|
|
408
|
+
* Separator → {kind:'sep', text}.
|
|
409
|
+
* Field → {kind:'section', ord, text, placeholder}:
|
|
410
|
+
* - empty value → text=field.placeholder, placeholder=true
|
|
411
|
+
* - filled → text=padded(number&&pad) or raw value, placeholder=false
|
|
412
|
+
*
|
|
413
|
+
* `ord` is the 0-based field ordinal (index among fields, not among all parts).
|
|
414
|
+
*
|
|
415
|
+
* When `activeOrd` is provided, a padded number field that is currently active
|
|
416
|
+
* AND not yet complete renders its raw typed digits instead of zero-padded text.
|
|
417
|
+
* This matches native date-field behaviour: padding is deferred until the section
|
|
418
|
+
* is committed (complete or navigated away from).
|
|
419
|
+
* When `activeOrd` is omitted, all non-empty padded fields are padded (preserving
|
|
420
|
+
* the "fully padded" output for callers that don't track the active section).
|
|
421
|
+
*/
|
|
422
|
+
function composeDisplay(parts, values, activeOrd) {
|
|
423
|
+
const tokens = [];
|
|
424
|
+
let fieldOrd = 0;
|
|
425
|
+
for (const part of parts) {
|
|
426
|
+
if (part.kind === 'sep') {
|
|
427
|
+
tokens.push({ kind: 'sep', text: part.text });
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
const o = fieldOrd;
|
|
431
|
+
const value = values[o] ?? '';
|
|
432
|
+
if (value === '') {
|
|
433
|
+
tokens.push({ kind: 'section', ord: o, text: part.placeholder, placeholder: true });
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
const padded = part.kind === 'number' && part.pad && (o !== activeOrd || isSectionComplete(part, value));
|
|
437
|
+
const text = padded ? value.padStart(part.maxLen, '0') : value;
|
|
438
|
+
tokens.push({ kind: 'section', ord: o, text, placeholder: false });
|
|
439
|
+
}
|
|
440
|
+
fieldOrd++;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return tokens;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Compose the full serialized output string (padded per field definition).
|
|
447
|
+
* Separators are appended verbatim. Number fields are padded iff field.pad.
|
|
448
|
+
* Enum fields are verbatim. Empty values are included as-is (empty string).
|
|
449
|
+
*/
|
|
450
|
+
function serialize(parts, values) {
|
|
451
|
+
let result = '';
|
|
452
|
+
let fieldOrd = 0;
|
|
453
|
+
for (const part of parts) {
|
|
454
|
+
if (part.kind === 'sep') {
|
|
455
|
+
result += part.text;
|
|
456
|
+
}
|
|
457
|
+
else {
|
|
458
|
+
const value = values[fieldOrd] ?? '';
|
|
459
|
+
if (part.kind === 'number' && part.pad) {
|
|
460
|
+
result += value.padStart(part.maxLen, '0');
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
result += value;
|
|
464
|
+
}
|
|
465
|
+
fieldOrd++;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return result;
|
|
469
|
+
}
|
|
470
|
+
/** Consume the maximal in-range digit run for a number field at `cursor`. */
|
|
471
|
+
function takeNumberField(field, str, cursor) {
|
|
472
|
+
let take = '';
|
|
473
|
+
while (take.length < field.maxLen) {
|
|
474
|
+
const c = str[cursor] ?? '';
|
|
475
|
+
if (!/\d/.test(c))
|
|
476
|
+
break;
|
|
477
|
+
take += c;
|
|
478
|
+
cursor++;
|
|
479
|
+
}
|
|
480
|
+
if (take === '')
|
|
481
|
+
return { value: '', cursor, ok: true, stop: true };
|
|
482
|
+
const n = Number(take);
|
|
483
|
+
if (n < field.min || n > field.max)
|
|
484
|
+
return { value: '', cursor, ok: false, stop: false };
|
|
485
|
+
return { value: take, cursor, ok: true, stop: false };
|
|
486
|
+
}
|
|
487
|
+
/** Match an enum value (exact case-insensitive, else first char) at `cursor`. */
|
|
488
|
+
function takeEnumField(field, str, cursor) {
|
|
489
|
+
const rest = str.slice(cursor);
|
|
490
|
+
if (rest === '')
|
|
491
|
+
return { value: '', cursor, ok: true, stop: true };
|
|
492
|
+
const exact = field.values.find(v => v.toUpperCase() === rest.slice(0, v.length).toUpperCase());
|
|
493
|
+
const match = exact ?? field.values.find(v => v[0]?.toUpperCase() === rest[0]?.toUpperCase());
|
|
494
|
+
if (!match)
|
|
495
|
+
return { value: '', cursor, ok: false, stop: false };
|
|
496
|
+
return { value: match, cursor: cursor + match.length, ok: true, stop: false };
|
|
497
|
+
}
|
|
498
|
+
function feedString(parts, str) {
|
|
499
|
+
const fields = fieldList(parts);
|
|
500
|
+
const values = fields.map(() => '');
|
|
501
|
+
let cursor = 0;
|
|
502
|
+
let o = 0; // field ordinal
|
|
503
|
+
let ok = true;
|
|
504
|
+
for (const part of parts) {
|
|
505
|
+
if (part.kind === 'sep') {
|
|
506
|
+
if (str.startsWith(part.text, cursor))
|
|
507
|
+
cursor += part.text.length;
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
const res = part.kind === 'number'
|
|
511
|
+
? takeNumberField(part, str, cursor)
|
|
512
|
+
: takeEnumField(part, str, cursor);
|
|
513
|
+
if (!res.ok) {
|
|
514
|
+
ok = false;
|
|
515
|
+
break;
|
|
516
|
+
}
|
|
517
|
+
if (res.stop)
|
|
518
|
+
break; // partial input — leave this and the rest empty
|
|
519
|
+
values[o] = res.value;
|
|
520
|
+
cursor = res.cursor;
|
|
521
|
+
o++;
|
|
522
|
+
}
|
|
523
|
+
// Any unconsumed trailing input means the string didn't fully match the mask.
|
|
524
|
+
// `paste` treats this as invalid (returns null); `deserialize` ignores `ok`
|
|
525
|
+
// and keeps the best-effort prefix.
|
|
526
|
+
if (cursor < str.length)
|
|
527
|
+
ok = false;
|
|
528
|
+
return { values: truncateGaps(values), ok };
|
|
529
|
+
}
|
|
530
|
+
function paste(parts, text) {
|
|
531
|
+
const { values, ok } = feedString(parts, text);
|
|
532
|
+
if (!ok)
|
|
533
|
+
return null; // any invalid field rejects the whole paste
|
|
534
|
+
// offset: count the committed characters in a simple left-to-right walk
|
|
535
|
+
let offset = 0;
|
|
536
|
+
let fieldOrd = 0;
|
|
537
|
+
for (const part of parts) {
|
|
538
|
+
if (part.kind === 'sep') {
|
|
539
|
+
// include separator only if the next field has a value
|
|
540
|
+
const nextValue = values[fieldOrd] ?? '';
|
|
541
|
+
if (nextValue)
|
|
542
|
+
offset += part.text.length;
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
const v = values[fieldOrd] ?? '';
|
|
546
|
+
if (v)
|
|
547
|
+
offset += v.length;
|
|
548
|
+
fieldOrd++;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
return { values, offset };
|
|
552
|
+
}
|
|
553
|
+
function deserialize(parts, str) {
|
|
554
|
+
// Best-effort separator-aware parse. On an invalid/out-of-range field we keep
|
|
555
|
+
// the valid prefix (feedString already stopped there, leaving the rest empty)
|
|
556
|
+
// rather than producing garbage. Never throws.
|
|
557
|
+
return feedString(parts, str).values;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* @category control
|
|
562
|
+
*/
|
|
563
|
+
class NgnMaskInput extends ValueControlBase {
|
|
564
|
+
isFieldControl = true;
|
|
565
|
+
theme = this.injectThemeTemplate(maskInputControlTemplate, 'root');
|
|
566
|
+
i18n = inject(I18n).translations;
|
|
567
|
+
/**
|
|
568
|
+
* The mask to apply to the input. Can be a predefined mask key or a custom mask configuration.
|
|
569
|
+
*/
|
|
570
|
+
mask = input(null, /* @ts-ignore */
|
|
571
|
+
...(ngDevMode ? [{ debugName: "mask" }] : /* istanbul ignore next */ []));
|
|
572
|
+
_parts = computed(() => resolveMask(this.mask()), /* @ts-ignore */
|
|
573
|
+
...(ngDevMode ? [{ debugName: "_parts" }] : /* istanbul ignore next */ []));
|
|
574
|
+
_fields = computed(() => {
|
|
575
|
+
const p = this._parts();
|
|
576
|
+
return p ? fieldList(p) : [];
|
|
577
|
+
}, /* @ts-ignore */
|
|
578
|
+
...(ngDevMode ? [{ debugName: "_fields" }] : /* istanbul ignore next */ []));
|
|
579
|
+
/**
|
|
580
|
+
* Structural signature of the resolved mask — field kinds/names/widths and
|
|
581
|
+
* separators, but deliberately NOT numeric min/max. The section values are
|
|
582
|
+
* reset only when this changes, so a consumer narrowing a field's range (e.g.
|
|
583
|
+
* a calendar shrinking the day field's max to the current month's length) does
|
|
584
|
+
* not wipe what the user has already typed.
|
|
585
|
+
*/
|
|
586
|
+
_structureSig = computed(() => {
|
|
587
|
+
const parts = this._parts();
|
|
588
|
+
if (!parts)
|
|
589
|
+
return '';
|
|
590
|
+
return parts
|
|
591
|
+
.map(p => p.kind === 'sep'
|
|
592
|
+
? `sep:${p.text}`
|
|
593
|
+
: p.kind === 'number'
|
|
594
|
+
? `num:${p.name}:${p.maxLen}:${p.pad}`
|
|
595
|
+
: `enum:${p.name}:${p.values.join('|')}`)
|
|
596
|
+
.join(';');
|
|
597
|
+
}, /* @ts-ignore */
|
|
598
|
+
...(ngDevMode ? [{ debugName: "_structureSig" }] : /* istanbul ignore next */ []));
|
|
599
|
+
/** Source of truth: one raw string per field ordinal. */
|
|
600
|
+
values = signal([], /* @ts-ignore */
|
|
601
|
+
...(ngDevMode ? [{ debugName: "values" }] : /* istanbul ignore next */ []));
|
|
602
|
+
/** Whether the proxy input currently holds focus. The active-section
|
|
603
|
+
* highlight and "actively edited" treatment only apply while focused. */
|
|
604
|
+
focused = signal(false, /* @ts-ignore */
|
|
605
|
+
...(ngDevMode ? [{ debugName: "focused" }] : /* istanbul ignore next */ []));
|
|
606
|
+
/** RenderTokens for template `@for` loop. While unfocused there is no active
|
|
607
|
+
* section, so padded fields render in their resting (padded) form. */
|
|
608
|
+
tokens = computed(() => {
|
|
609
|
+
const p = this._parts();
|
|
610
|
+
if (!p)
|
|
611
|
+
return [];
|
|
612
|
+
return composeDisplay(p, this.values(), this.focused() ? this.activeOrd() : undefined);
|
|
613
|
+
}, /* @ts-ignore */
|
|
614
|
+
...(ngDevMode ? [{ debugName: "tokens" }] : /* istanbul ignore next */ []));
|
|
615
|
+
/** The roving group on the proxy input element. */
|
|
616
|
+
rovingGroup = viewChild.required(NgnRovingGroup);
|
|
617
|
+
/** The proxy input element reference. */
|
|
618
|
+
_proxyRef = viewChild.required('proxy');
|
|
619
|
+
/** Active field ordinal (identity: item index === field ordinal). */
|
|
620
|
+
activeOrd = computed(() => this.rovingGroup().activeIndex(), /* @ts-ignore */
|
|
621
|
+
...(ngDevMode ? [{ debugName: "activeOrd" }] : /* istanbul ignore next */ []));
|
|
622
|
+
proxyInputMode = computed(() => {
|
|
623
|
+
const f = this._fields()[this.activeOrd()];
|
|
624
|
+
return f?.kind === 'enum' ? 'text' : 'numeric';
|
|
625
|
+
}, /* @ts-ignore */
|
|
626
|
+
...(ngDevMode ? [{ debugName: "proxyInputMode" }] : /* istanbul ignore next */ []));
|
|
627
|
+
liveAnnouncement = signal('', /* @ts-ignore */
|
|
628
|
+
...(ngDevMode ? [{ debugName: "liveAnnouncement" }] : /* istanbul ignore next */ []));
|
|
629
|
+
// ---- A11y helpers ----
|
|
630
|
+
sectionValueNow(ord) {
|
|
631
|
+
const f = this._fields()[ord];
|
|
632
|
+
const v = this.values()[ord] ?? '';
|
|
633
|
+
if (!f || v === '')
|
|
634
|
+
return null;
|
|
635
|
+
if (f.kind === 'number')
|
|
636
|
+
return Number(v);
|
|
637
|
+
// enum: expose the option index so the spinbutton has a valid numeric value.
|
|
638
|
+
const idx = f.values.indexOf(v);
|
|
639
|
+
return idx >= 0 ? idx : null;
|
|
640
|
+
}
|
|
641
|
+
sectionMin(ord) {
|
|
642
|
+
const f = this._fields()[ord];
|
|
643
|
+
if (!f)
|
|
644
|
+
return null;
|
|
645
|
+
return f.kind === 'number' ? f.min : 0;
|
|
646
|
+
}
|
|
647
|
+
sectionMax(ord) {
|
|
648
|
+
const f = this._fields()[ord];
|
|
649
|
+
if (!f)
|
|
650
|
+
return null;
|
|
651
|
+
return f.kind === 'number' ? f.max : f.values.length - 1;
|
|
652
|
+
}
|
|
653
|
+
/** Accessible name for a section's `role="spinbutton"` — the field's name. */
|
|
654
|
+
sectionName(ord) {
|
|
655
|
+
return this._fields()[ord]?.name ?? '';
|
|
656
|
+
}
|
|
657
|
+
sectionValueText(ord) {
|
|
658
|
+
const f = this._fields()[ord];
|
|
659
|
+
const v = this.values()[ord] ?? '';
|
|
660
|
+
if (!f)
|
|
661
|
+
return '';
|
|
662
|
+
if (v === '')
|
|
663
|
+
return f.placeholder;
|
|
664
|
+
return v;
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Whether every section holds a finished value — i.e. the mask is fully
|
|
668
|
+
* filled and a serialized output value has been emitted.
|
|
669
|
+
*/
|
|
670
|
+
complete = computed(() => this.value() !== null, /* @ts-ignore */
|
|
671
|
+
...(ngDevMode ? [{ debugName: "complete" }] : /* istanbul ignore next */ []));
|
|
672
|
+
/** Whether no section holds any value (the whole field is blank). Lets a host
|
|
673
|
+
* (e.g. calendar) distinguish a fully-cleared field from mid-typing, both of
|
|
674
|
+
* which emit a `null` value. */
|
|
675
|
+
empty = computed(() => this.values().every(v => v === ''), /* @ts-ignore */
|
|
676
|
+
...(ngDevMode ? [{ debugName: "empty" }] : /* istanbul ignore next */ []));
|
|
677
|
+
/**
|
|
678
|
+
* Returns the resolved CSS class string for `section-active` from the theme,
|
|
679
|
+
* used to highlight the currently active section span in the template.
|
|
680
|
+
*/
|
|
681
|
+
activeSectionClass = computed(() => this.theme.class('section-active'), /* @ts-ignore */
|
|
682
|
+
...(ngDevMode ? [{ debugName: "activeSectionClass" }] : /* istanbul ignore next */ []));
|
|
683
|
+
// ---- Segment descriptions for sr ----
|
|
684
|
+
segmentDescriptions = computed(() => {
|
|
685
|
+
const parts = this._parts();
|
|
686
|
+
if (!parts)
|
|
687
|
+
return [];
|
|
688
|
+
return fieldList(parts).map(field => ({
|
|
689
|
+
id: `seg-${field.name}-${this.inputId()}`,
|
|
690
|
+
text: field.kind === 'number'
|
|
691
|
+
? this.i18n['maskInput_segmentRange']({
|
|
692
|
+
name: field.name,
|
|
693
|
+
min: field.min,
|
|
694
|
+
max: field.max,
|
|
695
|
+
})
|
|
696
|
+
: field.values.join(this.i18n['maskInput_optionSeparator']()),
|
|
697
|
+
}));
|
|
698
|
+
}, /* @ts-ignore */
|
|
699
|
+
...(ngDevMode ? [{ debugName: "segmentDescriptions" }] : /* istanbul ignore next */ []));
|
|
700
|
+
/** Returns the description span id for a given field ordinal, or null if none. */
|
|
701
|
+
sectionDescId(ord) {
|
|
702
|
+
return this.segmentDescriptions()[ord]?.id ?? null;
|
|
703
|
+
}
|
|
704
|
+
constructor() {
|
|
705
|
+
super();
|
|
706
|
+
// Reset values only when the mask STRUCTURE changes (not a min/max-only
|
|
707
|
+
// tweak), synchronously before the next render, so stale values from a
|
|
708
|
+
// different mask are never composed against the new parts.
|
|
709
|
+
let prevStructureSig = null;
|
|
710
|
+
effect(() => {
|
|
711
|
+
const sig = this._structureSig();
|
|
712
|
+
if (prevStructureSig !== null && prevStructureSig !== sig) {
|
|
713
|
+
this.values.set([]);
|
|
714
|
+
}
|
|
715
|
+
prevStructureSig = sig;
|
|
716
|
+
});
|
|
717
|
+
// External value → internal: guard against write-loop by comparing to serialized form.
|
|
718
|
+
// Use untracked() when reading `values` so that internal typing changes do NOT re-trigger
|
|
719
|
+
// this effect — it must only fire when the external `value` model (from the parent) changes.
|
|
720
|
+
effect(() => {
|
|
721
|
+
const parts = this._parts();
|
|
722
|
+
if (!parts)
|
|
723
|
+
return;
|
|
724
|
+
const ext = this.value() ?? '';
|
|
725
|
+
const current = untracked(() => isComplete(parts, this.values()) ? serialize(parts, this.values()) : '');
|
|
726
|
+
if (ext !== current) {
|
|
727
|
+
this.values.set(deserialize(parts, ext));
|
|
728
|
+
}
|
|
729
|
+
});
|
|
730
|
+
// Internal values → external value model.
|
|
731
|
+
effect(() => {
|
|
732
|
+
const parts = this._parts();
|
|
733
|
+
if (!parts)
|
|
734
|
+
return;
|
|
735
|
+
const vals = this.values();
|
|
736
|
+
if (isComplete(parts, vals)) {
|
|
737
|
+
this.value.set(serialize(parts, vals));
|
|
738
|
+
}
|
|
739
|
+
else {
|
|
740
|
+
this.value.set(null);
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
// Wire DOM events on the proxy input.
|
|
744
|
+
domEventHandler(this._proxyRef, 'beforeinput', event => this._onBeforeInput(event));
|
|
745
|
+
domEventHandler(this._proxyRef, 'keydown', event => this._onKeyDown(event));
|
|
746
|
+
// Track focus so the active-section highlight only shows while focused.
|
|
747
|
+
domEventHandler(this._proxyRef, 'focus', () => this.focused.set(true));
|
|
748
|
+
domEventHandler(this._proxyRef, 'blur', () => this.focused.set(false));
|
|
749
|
+
// Pointer down anywhere on the control: focus the proxy and select the
|
|
750
|
+
// section nearest the pointer. Shared with field-delegated clicks via
|
|
751
|
+
// `focusFromPointer`. Works standalone (no surrounding input-field).
|
|
752
|
+
domEventHandler(this.element, 'pointerdown', event => {
|
|
753
|
+
if (this.disabled())
|
|
754
|
+
return;
|
|
755
|
+
event.preventDefault();
|
|
756
|
+
this.focusFromPointer(event);
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Places focus on the proxy input and selects the section nearest the
|
|
761
|
+
* pointer's horizontal position. Invoked both by the control's own
|
|
762
|
+
* `pointerdown` and by a surrounding `ngn-input-field` when its padding is
|
|
763
|
+
* clicked (so a click "above" a section selects that section). Returns `true`
|
|
764
|
+
* to signal the field it handled focus.
|
|
765
|
+
*/
|
|
766
|
+
focusFromPointer(event) {
|
|
767
|
+
if (this.disabled())
|
|
768
|
+
return false;
|
|
769
|
+
this._proxyRef().nativeElement.focus();
|
|
770
|
+
const ord = this._sectionIndexAt(event.clientX);
|
|
771
|
+
if (ord >= 0)
|
|
772
|
+
this.rovingGroup().setActive(ord);
|
|
773
|
+
return true;
|
|
774
|
+
}
|
|
775
|
+
/** Clears every section (invoked by a surrounding field's clear button). */
|
|
776
|
+
clearValue() {
|
|
777
|
+
if (this.disabled() || this.readonly())
|
|
778
|
+
return false;
|
|
779
|
+
this.values.set([]);
|
|
780
|
+
return true;
|
|
781
|
+
}
|
|
782
|
+
/** Field ordinal of the section nearest `clientX`, or -1 when there are none. */
|
|
783
|
+
_sectionIndexAt(clientX) {
|
|
784
|
+
// Section spans carry role="spinbutton" and appear in DOM order = field
|
|
785
|
+
// ordinal order (matching the activeOrd identity mapping).
|
|
786
|
+
const spans = this.element.nativeElement.querySelectorAll('[role="spinbutton"]');
|
|
787
|
+
const rects = Array.from(spans, el => el.getBoundingClientRect());
|
|
788
|
+
return nearestSectionIndex(rects, clientX);
|
|
789
|
+
}
|
|
790
|
+
_onBeforeInput(event) {
|
|
791
|
+
const parts = this._parts();
|
|
792
|
+
if (!parts)
|
|
793
|
+
return;
|
|
794
|
+
if (this.disabled() || this.readonly())
|
|
795
|
+
return;
|
|
796
|
+
event.preventDefault();
|
|
797
|
+
if (event.inputType === 'insertFromPaste' || event.inputType === 'insertFromDrop') {
|
|
798
|
+
const r = event.data ? paste(parts, event.data) : null;
|
|
799
|
+
if (r) {
|
|
800
|
+
this.values.set(r.values);
|
|
801
|
+
// Advance to the next empty section or stay on last
|
|
802
|
+
const fields = this._fields();
|
|
803
|
+
const nextEmpty = r.values.findIndex(v => v === '');
|
|
804
|
+
const targetOrd = nextEmpty >= 0 ? nextEmpty : fields.length - 1;
|
|
805
|
+
this.rovingGroup().setActive(targetOrd);
|
|
806
|
+
}
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
if (event.data) {
|
|
810
|
+
const ord = this.activeOrd();
|
|
811
|
+
const fields = this._fields();
|
|
812
|
+
const f = fields[ord];
|
|
813
|
+
if (!f)
|
|
814
|
+
return;
|
|
815
|
+
const current = this.values()[ord] ?? '';
|
|
816
|
+
const r = typeIntoSection(f, current, event.data);
|
|
817
|
+
if (r) {
|
|
818
|
+
// Immutable update of the values array
|
|
819
|
+
const newValues = [...this.values()];
|
|
820
|
+
newValues[ord] = r.value;
|
|
821
|
+
this.values.set(newValues);
|
|
822
|
+
if (r.advance) {
|
|
823
|
+
this.rovingGroup().next();
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
_onKeyDown(event) {
|
|
829
|
+
const parts = this._parts();
|
|
830
|
+
if (!parts || event.ctrlKey || event.metaKey || event.altKey)
|
|
831
|
+
return;
|
|
832
|
+
if (this.disabled() || this.readonly())
|
|
833
|
+
return;
|
|
834
|
+
const ord = this.activeOrd();
|
|
835
|
+
const fields = this._fields();
|
|
836
|
+
const f = fields[ord];
|
|
837
|
+
switch (event.key) {
|
|
838
|
+
case 'Backspace':
|
|
839
|
+
case 'Delete': {
|
|
840
|
+
event.preventDefault();
|
|
841
|
+
const currentVal = this.values()[ord] ?? '';
|
|
842
|
+
if (currentVal === '') {
|
|
843
|
+
// Already empty — step to previous section
|
|
844
|
+
this.rovingGroup().prev();
|
|
845
|
+
}
|
|
846
|
+
else {
|
|
847
|
+
// Clear the active section; then enforce no-gaps so any filled
|
|
848
|
+
// fields after the newly-emptied one are also cleared.
|
|
849
|
+
const newValues = [...this.values()];
|
|
850
|
+
newValues[ord] = '';
|
|
851
|
+
this.values.set(truncateGaps(newValues));
|
|
852
|
+
}
|
|
853
|
+
break;
|
|
854
|
+
}
|
|
855
|
+
case 'ArrowUp':
|
|
856
|
+
case 'ArrowDown': {
|
|
857
|
+
// The roving group is configured with horizontal orientation, so it
|
|
858
|
+
// deliberately does NOT consume ArrowUp/ArrowDown — the mask owns them
|
|
859
|
+
// for value stepping. This division is intentional, not coincidental:
|
|
860
|
+
// Left/Right move between sections, Up/Down change the active value.
|
|
861
|
+
event.preventDefault();
|
|
862
|
+
if (!f)
|
|
863
|
+
break;
|
|
864
|
+
const dir = event.key === 'ArrowUp' ? 1 : -1;
|
|
865
|
+
const newVal = stepSection(f, this.values()[ord] ?? '', dir);
|
|
866
|
+
const newValues = [...this.values()];
|
|
867
|
+
newValues[ord] = newVal;
|
|
868
|
+
this.values.set(newValues);
|
|
869
|
+
this.liveAnnouncement.set(`${f.name}: ${newVal}`);
|
|
870
|
+
break;
|
|
871
|
+
}
|
|
872
|
+
// ArrowLeft/Right/Home/End are handled by the roving group — do not preventDefault
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: NgnMaskInput, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
876
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.0.5", type: NgnMaskInput, isStandalone: true, selector: "ngn-mask-input", inputs: { mask: { classPropertyName: "mask", publicName: "mask", isSignal: true, isRequired: false, transformFunction: null } }, providers: [provideSelf(NgnMaskInput)], viewQueries: [{ propertyName: "rovingGroup", first: true, predicate: NgnRovingGroup, descendants: true, isSignal: true }, { propertyName: "_proxyRef", first: true, predicate: ["proxy"], descendants: true, isSignal: true }], usesInheritance: true, ngImport: i0, template: "<div [ptInt]=\"this\" [ptClass]=\"'root'\">\n <input\n #proxy\n #pg=\"ngnRovingGroup\"\n [ptInt]=\"this\"\n [ptClass]=\"'proxy'\"\n ngnRovingGroup\n rovingMode=\"activedescendant\"\n orientation=\"horizontal\"\n [attr.inputmode]=\"proxyInputMode()\"\n [attr.aria-roledescription]=\"i18n['maskInput_roleDescription']()\"\n [attr.aria-label]=\"label()\"\n [attr.aria-labelledby]=\"labelledBy()\"\n [disabled]=\"disabled()\"\n [attr.readonly]=\"readonly() || null\"\n tabindex=\"0\"\n />\n @for (tok of tokens(); track $index) {\n @if (tok.kind === 'sep') {\n <span [ptInt]=\"this\" [ptClass]=\"'separator'\" aria-hidden=\"true\">{{ tok.text }}</span>\n } @else {\n <span\n [ngnRovingItem]=\"pg\"\n role=\"spinbutton\"\n [attr.aria-label]=\"sectionName(tok.ord)\"\n [ptInt]=\"this\"\n [ptClass]=\"tok.placeholder ? 'section-placeholder' : 'section'\"\n [class]=\"focused() && activeOrd() === tok.ord ? activeSectionClass() : ''\"\n [attr.aria-valuenow]=\"sectionValueNow(tok.ord)\"\n [attr.aria-valuemin]=\"sectionMin(tok.ord)\"\n [attr.aria-valuemax]=\"sectionMax(tok.ord)\"\n [attr.aria-valuetext]=\"sectionValueText(tok.ord)\"\n [attr.aria-describedby]=\"sectionDescId(tok.ord)\"\n >{{ tok.text }}</span\n >\n }\n }\n @for (seg of segmentDescriptions(); track seg.id) {\n <span [id]=\"seg.id\" [ptInt]=\"this\" [ptClass]=\"'sr-only'\">{{ seg.text }}</span>\n }\n <span aria-live=\"polite\" [ptInt]=\"this\" [ptClass]=\"'sr-only'\">{{ liveAnnouncement() }}</span>\n</div>\n", dependencies: [{ kind: "directive", type: NgnPt, selector: "[ptInt]", inputs: ["ptInt", "ptClass", "ptDep"] }, { kind: "directive", type: NgnRovingGroup, selector: "[ngnRovingGroup]", inputs: ["orientation", "rovingMode", "rovingWrap"], outputs: ["activeItemChange"], exportAs: ["ngnRovingGroup"] }, { kind: "directive", type: NgnRovingItem, selector: "[ngnRovingItem]", inputs: ["ngnRovingItem"], exportAs: ["ngnRovingItem"] }] });
|
|
877
|
+
}
|
|
878
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.0.5", ngImport: i0, type: NgnMaskInput, decorators: [{
|
|
879
|
+
type: Component,
|
|
880
|
+
args: [{ selector: 'ngn-mask-input', imports: [NgnPt, NgnRovingGroup, NgnRovingItem], providers: [provideSelf(NgnMaskInput)], template: "<div [ptInt]=\"this\" [ptClass]=\"'root'\">\n <input\n #proxy\n #pg=\"ngnRovingGroup\"\n [ptInt]=\"this\"\n [ptClass]=\"'proxy'\"\n ngnRovingGroup\n rovingMode=\"activedescendant\"\n orientation=\"horizontal\"\n [attr.inputmode]=\"proxyInputMode()\"\n [attr.aria-roledescription]=\"i18n['maskInput_roleDescription']()\"\n [attr.aria-label]=\"label()\"\n [attr.aria-labelledby]=\"labelledBy()\"\n [disabled]=\"disabled()\"\n [attr.readonly]=\"readonly() || null\"\n tabindex=\"0\"\n />\n @for (tok of tokens(); track $index) {\n @if (tok.kind === 'sep') {\n <span [ptInt]=\"this\" [ptClass]=\"'separator'\" aria-hidden=\"true\">{{ tok.text }}</span>\n } @else {\n <span\n [ngnRovingItem]=\"pg\"\n role=\"spinbutton\"\n [attr.aria-label]=\"sectionName(tok.ord)\"\n [ptInt]=\"this\"\n [ptClass]=\"tok.placeholder ? 'section-placeholder' : 'section'\"\n [class]=\"focused() && activeOrd() === tok.ord ? activeSectionClass() : ''\"\n [attr.aria-valuenow]=\"sectionValueNow(tok.ord)\"\n [attr.aria-valuemin]=\"sectionMin(tok.ord)\"\n [attr.aria-valuemax]=\"sectionMax(tok.ord)\"\n [attr.aria-valuetext]=\"sectionValueText(tok.ord)\"\n [attr.aria-describedby]=\"sectionDescId(tok.ord)\"\n >{{ tok.text }}</span\n >\n }\n }\n @for (seg of segmentDescriptions(); track seg.id) {\n <span [id]=\"seg.id\" [ptInt]=\"this\" [ptClass]=\"'sr-only'\">{{ seg.text }}</span>\n }\n <span aria-live=\"polite\" [ptInt]=\"this\" [ptClass]=\"'sr-only'\">{{ liveAnnouncement() }}</span>\n</div>\n" }]
|
|
881
|
+
}], ctorParameters: () => [], propDecorators: { mask: [{ type: i0.Input, args: [{ isSignal: true, alias: "mask", required: false }] }], rovingGroup: [{ type: i0.ViewChild, args: [i0.forwardRef(() => NgnRovingGroup), { isSignal: true }] }], _proxyRef: [{ type: i0.ViewChild, args: ['proxy', { isSignal: true }] }] } });
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* Generated bundle index. Do not edit.
|
|
885
|
+
*/
|
|
886
|
+
|
|
887
|
+
export { DATE_TIME_MASKS, MASKS, NgnMaskInput, getDateOrTimeMask };
|
|
888
|
+
//# sourceMappingURL=ngneers-controls-mask-input.mjs.map
|