text-input-guard 0.0.1
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/LICENSE.md +21 -0
- package/README.md +137 -0
- package/dist/cjs/text-input-guard.cjs +2141 -0
- package/dist/esm/text-input-guard.js +2132 -0
- package/dist/types/text-input-guard.d.ts +260 -0
- package/dist/umd/text-input-guard.js +2147 -0
- package/dist/umd/text-input-guard.min.js +6 -0
- package/package.json +68 -0
|
@@ -0,0 +1,2132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The script is part of JPInputGuard.
|
|
3
|
+
*
|
|
4
|
+
* AUTHOR:
|
|
5
|
+
* natade-jp (https://github.com/natade-jp)
|
|
6
|
+
*
|
|
7
|
+
* LICENSE:
|
|
8
|
+
* The MIT license https://opensource.org/licenses/MIT
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 対象要素の種別(現在は input と textarea のみ対応)
|
|
13
|
+
* @typedef {"input"|"textarea"} ElementKind
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* ルール実行フェーズ名(パイプラインの固定順)
|
|
18
|
+
* normalize.char → normalize.structure → validate → fix → format
|
|
19
|
+
* @typedef {"normalize.char"|"normalize.structure"|"validate"|"fix"|"format"} PhaseName
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* バリデーションエラー情報を表すオブジェクト
|
|
24
|
+
* @typedef {Object} TigError
|
|
25
|
+
* @property {string} code - エラー識別子(例: "digits.int_overflow")
|
|
26
|
+
* @property {string} rule - エラーを発生させたルール名
|
|
27
|
+
* @property {PhaseName} phase - 発生したフェーズ
|
|
28
|
+
* @property {any} [detail] - 追加情報(制限値など)
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* attach() が返す公開API(利用者が触れる最小インターフェース)
|
|
33
|
+
* @typedef {Object} Guard
|
|
34
|
+
* @property {() => void} detach - ガード解除(イベント削除・swap復元)
|
|
35
|
+
* @property {() => boolean} isValid - 現在エラーが無いかどうか
|
|
36
|
+
* @property {() => TigError[]} getErrors - エラー一覧を取得
|
|
37
|
+
* @property {() => string} getRawValue - 送信用の正規化済み値を取得
|
|
38
|
+
* @property {() => HTMLInputElement|HTMLTextAreaElement} getDisplayElement - ユーザーが実際に操作している要素(swap時はdisplay側)
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 各ルールに渡される実行コンテキスト
|
|
43
|
+
* - DOM参照や状態、エラー登録用関数などをまとめたもの
|
|
44
|
+
* @typedef {Object} GuardContext
|
|
45
|
+
* @property {HTMLElement} hostElement - 元の要素(swap時はraw側)
|
|
46
|
+
* @property {HTMLElement} displayElement - ユーザーが操作する表示要素
|
|
47
|
+
* @property {HTMLInputElement|null} rawElement - 送信用hidden要素(swap時のみ)
|
|
48
|
+
* @property {ElementKind} kind - 要素種別(input / textarea)
|
|
49
|
+
* @property {boolean} warn - warnログを出すかどうか
|
|
50
|
+
* @property {string} invalidClass - エラー時に付与するclass名
|
|
51
|
+
* @property {boolean} composing - IME変換中かどうか
|
|
52
|
+
* @property {(e: TigError) => void} pushError - エラーを登録する関数
|
|
53
|
+
* @property {(req: RevertRequest) => void} requestRevert - 入力を直前の受理値へ巻き戻す要求
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 1つの入力制御ルール定義
|
|
58
|
+
* - 各フェーズの処理を必要に応じて実装する
|
|
59
|
+
* @typedef {Object} Rule
|
|
60
|
+
* @property {string} name - ルール名(識別用)
|
|
61
|
+
* @property {("input"|"textarea")[]} targets - 適用可能な要素種別
|
|
62
|
+
* @property {(value: string, ctx: GuardContext) => string} [normalizeChar] - 文字単位の正規化(全角→半角など)
|
|
63
|
+
* @property {(value: string, ctx: GuardContext) => string} [normalizeStructure] - 構造の正規化(-位置修正など)
|
|
64
|
+
* @property {(value: string, ctx: GuardContext) => void} [validate] - エラー判定(値は変更しない)
|
|
65
|
+
* @property {(value: string, ctx: GuardContext) => string} [fix] - 確定時の穏やか補正(切り捨て等)
|
|
66
|
+
* @property {(value: string, ctx: GuardContext) => string} [format] - 表示整形(カンマ付与など)
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 表示値(display)と内部値(raw)の分離設定
|
|
71
|
+
* @typedef {Object} SeparateValueOptions
|
|
72
|
+
* @property {"auto"|"swap"|"off"} [mode="auto"]
|
|
73
|
+
* - "auto": format系ルールがある場合のみ自動でswapする(既定)
|
|
74
|
+
* - "swap": 常にswapする(inputのみ対応)
|
|
75
|
+
* - "off": 分離しない(displayとrawを同一に扱う)
|
|
76
|
+
*/
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* attach() に渡す設定オプション
|
|
80
|
+
* @typedef {Object} AttachOptions
|
|
81
|
+
* @property {Rule[]} [rules] - 適用するルール配列(順番がフェーズ内実行順になる)
|
|
82
|
+
* @property {boolean} [warn] - 非対応ルールなどを console.warn するか
|
|
83
|
+
* @property {string} [invalidClass] - エラー時に付けるclass名
|
|
84
|
+
* @property {SeparateValueOptions} [separateValue] - 表示値と内部値の分離設定
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* swap時に退避する元inputの情報
|
|
89
|
+
* detach時に元の状態へ復元するために使用する
|
|
90
|
+
* @typedef {Object} SwapState
|
|
91
|
+
* @property {string} originalType - 元のinput.type
|
|
92
|
+
* @property {string|null} originalId - 元のid属性
|
|
93
|
+
* @property {string|null} originalName - 元のname属性
|
|
94
|
+
* @property {string} originalClass - 元のclass文字列
|
|
95
|
+
* @property {HTMLInputElement} createdDisplay - 生成した表示用input
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* selection(カーソル/選択範囲)の退避情報
|
|
100
|
+
* @typedef {Object} SelectionState
|
|
101
|
+
* @property {number|null} start - selectionStart
|
|
102
|
+
* @property {number|null} end - selectionEnd
|
|
103
|
+
* @property {"forward"|"backward"|"none"|null} direction - selectionDirection
|
|
104
|
+
*/
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* revert要求(入力を巻き戻す指示)
|
|
108
|
+
* @typedef {Object} RevertRequest
|
|
109
|
+
* @property {string} reason - ルール名や理由(例: "digits.int_overflow")
|
|
110
|
+
* @property {any} [detail] - デバッグ用の詳細
|
|
111
|
+
*/
|
|
112
|
+
|
|
113
|
+
const DEFAULT_INVALID_CLASS = "is-invalid";
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 対象要素が input / textarea のどちらかを判定する(対応外なら null)
|
|
117
|
+
* @param {HTMLElement} el
|
|
118
|
+
* @returns {ElementKind|null}
|
|
119
|
+
*/
|
|
120
|
+
function detectKind(el) {
|
|
121
|
+
if (el instanceof HTMLInputElement) {
|
|
122
|
+
return "input";
|
|
123
|
+
}
|
|
124
|
+
if (el instanceof HTMLTextAreaElement) {
|
|
125
|
+
return "textarea";
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* warn が true のときだけ console.warn を出す
|
|
132
|
+
* @param {string} msg
|
|
133
|
+
* @param {boolean} warn
|
|
134
|
+
*/
|
|
135
|
+
function warnLog(msg, warn) {
|
|
136
|
+
if (warn) {
|
|
137
|
+
console.warn(msg);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 指定した1要素に対してガードを適用し、Guard API を返す
|
|
143
|
+
* @param {HTMLInputElement|HTMLTextAreaElement} element
|
|
144
|
+
* @param {AttachOptions} [options]
|
|
145
|
+
* @returns {Guard}
|
|
146
|
+
*/
|
|
147
|
+
function attach(element, options = {}) {
|
|
148
|
+
const guard = new InputGuard(element, options);
|
|
149
|
+
guard.init();
|
|
150
|
+
return guard.toGuard();
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @typedef {Object} GuardGroup
|
|
155
|
+
* @property {() => void} detach - 全部 detach
|
|
156
|
+
* @property {() => boolean} isValid - 全部 valid なら true
|
|
157
|
+
* @property {() => TigError[]} getErrors - 全部のエラーを集約
|
|
158
|
+
* @property {() => Guard[]} getGuards - 個別Guard配列
|
|
159
|
+
*/
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* @param {Iterable<HTMLInputElement|HTMLTextAreaElement>} elements
|
|
163
|
+
* @param {AttachOptions} [options]
|
|
164
|
+
* @returns {GuardGroup}
|
|
165
|
+
*/
|
|
166
|
+
function attachAll(elements, options = {}) {
|
|
167
|
+
/** @type {Guard[]} */
|
|
168
|
+
const guards = [];
|
|
169
|
+
for (const el of elements) {
|
|
170
|
+
guards.push(attach(el, options));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
detach: () => { for (const g of guards) { g.detach(); } },
|
|
175
|
+
isValid: () => guards.every((g) => g.isValid()),
|
|
176
|
+
getErrors: () => guards.flatMap((g) => g.getErrors()),
|
|
177
|
+
getGuards: () => guards
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
class InputGuard {
|
|
182
|
+
/**
|
|
183
|
+
* InputGuard の内部状態を初期化する(DOM/設定/イベント/パイプラインを持つ)
|
|
184
|
+
* @param {HTMLInputElement|HTMLTextAreaElement} element
|
|
185
|
+
* @param {AttachOptions} options
|
|
186
|
+
*/
|
|
187
|
+
constructor(element, options) {
|
|
188
|
+
/**
|
|
189
|
+
* attach対象の元の要素(swap前の原本)
|
|
190
|
+
* detach時の復元や基準参照に使う
|
|
191
|
+
* @type {HTMLInputElement|HTMLTextAreaElement}
|
|
192
|
+
*/
|
|
193
|
+
this.originalElement = element;
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* attach時に渡された設定オブジェクト
|
|
197
|
+
* @type {AttachOptions}
|
|
198
|
+
*/
|
|
199
|
+
this.options = options;
|
|
200
|
+
|
|
201
|
+
const kind = detectKind(element);
|
|
202
|
+
if (!kind) {
|
|
203
|
+
throw new TypeError("[jp-input-guard] attach() expects an <input> or <textarea> element.");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 対象要素の種別("input" または "textarea")
|
|
208
|
+
* @type {ElementKind}
|
|
209
|
+
*/
|
|
210
|
+
this.kind = kind;
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* 非対応ルールなどの警告を console.warn するかどうか
|
|
214
|
+
* @type {boolean}
|
|
215
|
+
*/
|
|
216
|
+
this.warn = options.warn ?? true;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* エラー時に displayElement に付与するCSSクラス名
|
|
220
|
+
* @type {string}
|
|
221
|
+
*/
|
|
222
|
+
this.invalidClass = options.invalidClass ?? DEFAULT_INVALID_CLASS;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 適用するルールの一覧(attach時に渡されたもの)
|
|
226
|
+
* @type {Rule[]}
|
|
227
|
+
*/
|
|
228
|
+
this.rules = Array.isArray(options.rules) ? options.rules : [];
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 実際に送信を担う要素(swap時は hidden(raw) 側)
|
|
232
|
+
* swapしない場合は originalElement と同一
|
|
233
|
+
* @type {HTMLElement}
|
|
234
|
+
*/
|
|
235
|
+
this.hostElement = element;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* ユーザーが直接入力する表示側要素
|
|
239
|
+
* swapしない場合は originalElement と同一
|
|
240
|
+
* @type {HTMLElement}
|
|
241
|
+
*/
|
|
242
|
+
this.displayElement = element;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* swap時に生成される hidden(raw) input
|
|
246
|
+
* swapしない場合は null
|
|
247
|
+
* @type {HTMLInputElement|null}
|
|
248
|
+
*/
|
|
249
|
+
this.rawElement = null;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* IME変換中かどうかのフラグ
|
|
253
|
+
* true の間は input処理を行わない
|
|
254
|
+
* @type {boolean}
|
|
255
|
+
*/
|
|
256
|
+
this.composing = false;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 現在発生しているエラー一覧
|
|
260
|
+
* evaluateごとにリセットされる
|
|
261
|
+
* @type {TigError[]}
|
|
262
|
+
*/
|
|
263
|
+
this.errors = [];
|
|
264
|
+
|
|
265
|
+
// --------------------------------------------------
|
|
266
|
+
// pipeline(フェーズごとのルール配列)
|
|
267
|
+
// --------------------------------------------------
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* normalize.char フェーズ用ルール配列
|
|
271
|
+
* (文字単位の正規化)
|
|
272
|
+
* @type {Rule[]}
|
|
273
|
+
*/
|
|
274
|
+
this.normalizeCharRules = [];
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* normalize.structure フェーズ用ルール配列
|
|
278
|
+
* (構造の正規化)
|
|
279
|
+
* @type {Rule[]}
|
|
280
|
+
*/
|
|
281
|
+
this.normalizeStructureRules = [];
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* validate フェーズ用ルール配列
|
|
285
|
+
* (エラー判定)
|
|
286
|
+
* @type {Rule[]}
|
|
287
|
+
*/
|
|
288
|
+
this.validateRules = [];
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* fix フェーズ用ルール配列
|
|
292
|
+
* (確定時の穏やか補正)
|
|
293
|
+
* @type {Rule[]}
|
|
294
|
+
*/
|
|
295
|
+
this.fixRules = [];
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* format フェーズ用ルール配列
|
|
299
|
+
* (表示整形)
|
|
300
|
+
* @type {Rule[]}
|
|
301
|
+
*/
|
|
302
|
+
this.formatRules = [];
|
|
303
|
+
|
|
304
|
+
// --------------------------------------------------
|
|
305
|
+
// bind handlers(removeEventListener のため参照固定)
|
|
306
|
+
// --------------------------------------------------
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* IME開始イベントハンドラ(this固定)
|
|
310
|
+
*/
|
|
311
|
+
this.onCompositionStart = this.onCompositionStart.bind(this);
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* IME終了イベントハンドラ(this固定)
|
|
315
|
+
*/
|
|
316
|
+
this.onCompositionEnd = this.onCompositionEnd.bind(this);
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* inputイベントハンドラ(this固定)
|
|
320
|
+
*/
|
|
321
|
+
this.onInput = this.onInput.bind(this);
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* blurイベントハンドラ(this固定)
|
|
325
|
+
*/
|
|
326
|
+
this.onBlur = this.onBlur.bind(this);
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* focusイベントハンドラ(this固定)
|
|
330
|
+
*/
|
|
331
|
+
this.onFocus = this.onFocus.bind(this);
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* キャレット/選択範囲の変化イベントハンドラ(this固定)
|
|
335
|
+
*/
|
|
336
|
+
this.onSelectionChange = this.onSelectionChange.bind(this);
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* swap時に退避しておく元要素情報
|
|
340
|
+
* detach時に復元するために使用
|
|
341
|
+
* @type {SwapState|null}
|
|
342
|
+
*/
|
|
343
|
+
this.swapState = null;
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* IME変換後のinputイベントが来ない環境向けのフラグ
|
|
347
|
+
* @type {boolean}
|
|
348
|
+
*/
|
|
349
|
+
this.pendingCompositionCommit = false;
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* 直前に受理した表示値(block時の戻し先)
|
|
353
|
+
* @type {string}
|
|
354
|
+
*/
|
|
355
|
+
this.lastAcceptedValue = "";
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* 直前に受理したselection(block時の戻し先)
|
|
359
|
+
* @type {SelectionState}
|
|
360
|
+
*/
|
|
361
|
+
this.lastAcceptedSelection = { start: null, end: null, direction: null };
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* ルールからのrevert要求
|
|
365
|
+
* @type {RevertRequest|null}
|
|
366
|
+
*/
|
|
367
|
+
this.revertRequest = null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* 初期化処理(swap適用 → パイプライン構築 → イベント登録 → 初回評価)
|
|
372
|
+
* @returns {void}
|
|
373
|
+
*/
|
|
374
|
+
init() {
|
|
375
|
+
// 指定されたオプションを確認するためにも先に実施
|
|
376
|
+
this.buildPipeline();
|
|
377
|
+
this.applySeparateValue();
|
|
378
|
+
this.bindEvents();
|
|
379
|
+
// 初期値を評価
|
|
380
|
+
this.evaluateInput();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* display要素のselection情報を読む
|
|
385
|
+
* @param {HTMLInputElement|HTMLTextAreaElement} el
|
|
386
|
+
* @returns {SelectionState}
|
|
387
|
+
*/
|
|
388
|
+
readSelection(el) {
|
|
389
|
+
return {
|
|
390
|
+
start: el.selectionStart,
|
|
391
|
+
end: el.selectionEnd,
|
|
392
|
+
direction: el.selectionDirection
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* display要素のselection情報を復元する
|
|
398
|
+
* @param {HTMLInputElement|HTMLTextAreaElement} el
|
|
399
|
+
* @param {SelectionState} sel
|
|
400
|
+
* @returns {void}
|
|
401
|
+
*/
|
|
402
|
+
writeSelection(el, sel) {
|
|
403
|
+
if (sel.start == null || sel.end == null) { return; }
|
|
404
|
+
try {
|
|
405
|
+
// direction は未対応環境があるので try で包む
|
|
406
|
+
if (sel.direction) {
|
|
407
|
+
el.setSelectionRange(sel.start, sel.end, sel.direction);
|
|
408
|
+
} else {
|
|
409
|
+
el.setSelectionRange(sel.start, sel.end);
|
|
410
|
+
}
|
|
411
|
+
} catch (_e) {
|
|
412
|
+
// type=hidden などでは例外になることがある(今回は display が text 想定)
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* separateValue.mode="swap" のとき、input を hidden(raw) にして display(input[type=text]) を生成する
|
|
418
|
+
* - textarea は非対応(warnして無視)
|
|
419
|
+
* @returns {void}
|
|
420
|
+
*/
|
|
421
|
+
applySeparateValue() {
|
|
422
|
+
const userMode = this.options.separateValue?.mode ?? "auto";
|
|
423
|
+
|
|
424
|
+
// autoの場合:format系ルールがあるときだけswap
|
|
425
|
+
const mode =
|
|
426
|
+
userMode === "auto"
|
|
427
|
+
? (this.formatRules.length > 0 ? "swap" : "off")
|
|
428
|
+
: userMode;
|
|
429
|
+
|
|
430
|
+
if (mode !== "swap") {
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (this.kind !== "input") {
|
|
435
|
+
warnLog('[jp-input-guard] separateValue.mode="swap" is not supported for <textarea>. ignored.', this.warn);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const input = /** @type {HTMLInputElement} */ (this.originalElement);
|
|
440
|
+
|
|
441
|
+
// 退避(detachで戻すため)
|
|
442
|
+
/** @type {SwapState} */
|
|
443
|
+
this.swapState = {
|
|
444
|
+
originalType: input.type,
|
|
445
|
+
originalId: input.getAttribute("id"),
|
|
446
|
+
originalName: input.getAttribute("name"),
|
|
447
|
+
originalClass: input.className,
|
|
448
|
+
// 必要になったらここに placeholder/aria/data を追加していく
|
|
449
|
+
createdDisplay: null
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// raw化(送信担当)
|
|
453
|
+
input.type = "hidden";
|
|
454
|
+
input.removeAttribute("id"); // displayに引き継ぐため
|
|
455
|
+
input.dataset.tigRole = "raw";
|
|
456
|
+
|
|
457
|
+
// 元idのメタを残す(デバッグ/参照用)
|
|
458
|
+
if (this.swapState.originalId) {
|
|
459
|
+
input.dataset.tigOriginalId = this.swapState.originalId;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
if (this.swapState.originalName) {
|
|
463
|
+
input.dataset.tigOriginalName = this.swapState.originalName;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// display生成(ユーザー入力担当)
|
|
467
|
+
const display = document.createElement("input");
|
|
468
|
+
display.type = "text";
|
|
469
|
+
display.dataset.tigRole = "display";
|
|
470
|
+
|
|
471
|
+
// id は display に移す
|
|
472
|
+
if (this.swapState.originalId) {
|
|
473
|
+
display.id = this.swapState.originalId;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// name は付けない(送信しない)
|
|
477
|
+
display.removeAttribute("name");
|
|
478
|
+
|
|
479
|
+
// class は display に
|
|
480
|
+
display.className = this.swapState.originalClass;
|
|
481
|
+
input.className = "";
|
|
482
|
+
|
|
483
|
+
// value 初期同期
|
|
484
|
+
display.value = input.value;
|
|
485
|
+
|
|
486
|
+
// DOMに挿入(rawの直後)
|
|
487
|
+
input.after(display);
|
|
488
|
+
|
|
489
|
+
// elements更新
|
|
490
|
+
this.hostElement = input;
|
|
491
|
+
this.displayElement = display;
|
|
492
|
+
this.rawElement = input;
|
|
493
|
+
|
|
494
|
+
this.swapState.createdDisplay = display;
|
|
495
|
+
|
|
496
|
+
// revert 機構
|
|
497
|
+
this.lastAcceptedValue = display.value;
|
|
498
|
+
this.lastAcceptedSelection = this.readSelection(display);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* swapしていた場合、元のinputへ復元する(detach用)
|
|
503
|
+
* @returns {void}
|
|
504
|
+
*/
|
|
505
|
+
restoreSeparateValue() {
|
|
506
|
+
// swapしていないならここで終わり
|
|
507
|
+
if (!this.swapState) {
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const state = this.swapState;
|
|
512
|
+
|
|
513
|
+
// rawは元の input(hidden化されている)
|
|
514
|
+
const raw = /** @type {HTMLInputElement} */ (this.hostElement);
|
|
515
|
+
const display = state.createdDisplay;
|
|
516
|
+
|
|
517
|
+
// displayが存在するなら、最新表示値をrawに同期してから消す(安全策)
|
|
518
|
+
// ※ rawは常に正規化済みを持つ設計だけど、念のため
|
|
519
|
+
if (display) {
|
|
520
|
+
try {
|
|
521
|
+
raw.value = raw.value || display.value;
|
|
522
|
+
} catch (_e) {
|
|
523
|
+
// ここは落とさない(復元を優先)
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// display削除
|
|
528
|
+
if (display && display.parentNode) {
|
|
529
|
+
display.parentNode.removeChild(display);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// rawを元に戻す(type)
|
|
533
|
+
raw.type = state.originalType;
|
|
534
|
+
|
|
535
|
+
// id を戻す
|
|
536
|
+
if (state.originalId) {
|
|
537
|
+
raw.setAttribute("id", state.originalId);
|
|
538
|
+
} else {
|
|
539
|
+
raw.removeAttribute("id");
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// name を戻す(swap中は残している想定だが、念のため)
|
|
543
|
+
if (state.originalName) {
|
|
544
|
+
raw.setAttribute("name", state.originalName);
|
|
545
|
+
} else {
|
|
546
|
+
raw.removeAttribute("name");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// class を戻す
|
|
550
|
+
raw.className = state.originalClass ?? "";
|
|
551
|
+
|
|
552
|
+
// data属性(tig用)は消しておく
|
|
553
|
+
delete raw.dataset.tigRole;
|
|
554
|
+
delete raw.dataset.tigOriginalId;
|
|
555
|
+
delete raw.dataset.tigOriginalName;
|
|
556
|
+
|
|
557
|
+
// elements参照を original に戻す
|
|
558
|
+
this.hostElement = this.originalElement;
|
|
559
|
+
this.displayElement = this.originalElement;
|
|
560
|
+
this.rawElement = null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* ガード解除
|
|
565
|
+
* @returns {void}
|
|
566
|
+
*/
|
|
567
|
+
detach() {
|
|
568
|
+
// イベント解除(displayElementがswap後の可能性があるので先に外す)
|
|
569
|
+
this.unbindEvents();
|
|
570
|
+
// swap復元
|
|
571
|
+
this.restoreSeparateValue();
|
|
572
|
+
// swapState破棄
|
|
573
|
+
this.swapState = null;
|
|
574
|
+
// 以後このインスタンスは利用不能にしてもいいが、今回は明示しない
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* rules をフェーズ別に振り分けてパイプラインを構築する
|
|
579
|
+
* - targets が合わないルールは warn してスキップ
|
|
580
|
+
* @returns {void}
|
|
581
|
+
*/
|
|
582
|
+
buildPipeline() {
|
|
583
|
+
this.normalizeCharRules = [];
|
|
584
|
+
this.normalizeStructureRules = [];
|
|
585
|
+
this.validateRules = [];
|
|
586
|
+
this.fixRules = [];
|
|
587
|
+
this.formatRules = [];
|
|
588
|
+
|
|
589
|
+
for (const rule of this.rules) {
|
|
590
|
+
const supports =
|
|
591
|
+
(this.kind === "input" && rule.targets.includes("input")) ||
|
|
592
|
+
(this.kind === "textarea" && rule.targets.includes("textarea"));
|
|
593
|
+
|
|
594
|
+
if (!supports) {
|
|
595
|
+
warnLog(
|
|
596
|
+
`[jp-input-guard] Rule "${rule.name}" is not supported for <${this.kind}>. skipped.`,
|
|
597
|
+
this.warn
|
|
598
|
+
);
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
if (rule.normalizeChar) {
|
|
603
|
+
this.normalizeCharRules.push(rule);
|
|
604
|
+
}
|
|
605
|
+
if (rule.normalizeStructure) {
|
|
606
|
+
this.normalizeStructureRules.push(rule);
|
|
607
|
+
}
|
|
608
|
+
if (rule.validate) {
|
|
609
|
+
this.validateRules.push(rule);
|
|
610
|
+
}
|
|
611
|
+
if (rule.fix) {
|
|
612
|
+
this.fixRules.push(rule);
|
|
613
|
+
}
|
|
614
|
+
if (rule.format) {
|
|
615
|
+
this.formatRules.push(rule);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* displayElement にイベントを登録する(IME・input・blur)
|
|
622
|
+
* @returns {void}
|
|
623
|
+
*/
|
|
624
|
+
bindEvents() {
|
|
625
|
+
this.displayElement.addEventListener("compositionstart", this.onCompositionStart);
|
|
626
|
+
this.displayElement.addEventListener("compositionend", this.onCompositionEnd);
|
|
627
|
+
this.displayElement.addEventListener("input", this.onInput);
|
|
628
|
+
this.displayElement.addEventListener("blur", this.onBlur);
|
|
629
|
+
|
|
630
|
+
// フォーカスで編集用に戻す
|
|
631
|
+
this.displayElement.addEventListener("focus", this.onFocus);
|
|
632
|
+
|
|
633
|
+
// キャレット/選択範囲の変化を拾う(block時の不自然ジャンプ対策)
|
|
634
|
+
this.displayElement.addEventListener("keyup", this.onSelectionChange);
|
|
635
|
+
this.displayElement.addEventListener("mouseup", this.onSelectionChange);
|
|
636
|
+
this.displayElement.addEventListener("select", this.onSelectionChange);
|
|
637
|
+
this.displayElement.addEventListener("focus", this.onSelectionChange);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* displayElement からイベントを解除する(detach用)
|
|
642
|
+
* @returns {void}
|
|
643
|
+
*/
|
|
644
|
+
unbindEvents() {
|
|
645
|
+
this.displayElement.removeEventListener("compositionstart", this.onCompositionStart);
|
|
646
|
+
this.displayElement.removeEventListener("compositionend", this.onCompositionEnd);
|
|
647
|
+
this.displayElement.removeEventListener("input", this.onInput);
|
|
648
|
+
this.displayElement.removeEventListener("blur", this.onBlur);
|
|
649
|
+
this.displayElement.removeEventListener("focus", this.onFocus);
|
|
650
|
+
this.displayElement.removeEventListener("keyup", this.onSelectionChange);
|
|
651
|
+
this.displayElement.removeEventListener("mouseup", this.onSelectionChange);
|
|
652
|
+
this.displayElement.removeEventListener("select", this.onSelectionChange);
|
|
653
|
+
this.displayElement.removeEventListener("focus", this.onSelectionChange);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* 直前の受理値へ巻き戻す(表示値+raw同期+selection復元)
|
|
658
|
+
* - block用途なので、余計な正規化/formatは走らせずに戻す
|
|
659
|
+
* @param {RevertRequest} req
|
|
660
|
+
* @returns {void}
|
|
661
|
+
*/
|
|
662
|
+
revertDisplay(req) {
|
|
663
|
+
const display = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (this.displayElement);
|
|
664
|
+
|
|
665
|
+
// いまの入力を取り消して、直前の受理値へ戻す
|
|
666
|
+
display.value = this.lastAcceptedValue;
|
|
667
|
+
|
|
668
|
+
// selection復元(取れている場合のみ)
|
|
669
|
+
this.writeSelection(display, this.lastAcceptedSelection);
|
|
670
|
+
|
|
671
|
+
// raw も同じ値へ(swapでも整合する)
|
|
672
|
+
this.syncRaw(this.lastAcceptedValue);
|
|
673
|
+
|
|
674
|
+
// block なので、エラー表示は基本クリア(「入らなかった」だけにする)
|
|
675
|
+
this.clearErrors();
|
|
676
|
+
this.applyInvalidClass();
|
|
677
|
+
|
|
678
|
+
// 連鎖防止(次の処理に持ち越さない)
|
|
679
|
+
this.revertRequest = null;
|
|
680
|
+
|
|
681
|
+
if (this.warn) {
|
|
682
|
+
console.log(`[jp-input-guard] reverted: ${req.reason}`, req.detail);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* ルール実行に渡すコンテキストを作る(pushErrorで errors に積める)
|
|
688
|
+
* @returns {GuardContext}
|
|
689
|
+
*/
|
|
690
|
+
createCtx() {
|
|
691
|
+
return {
|
|
692
|
+
hostElement: this.hostElement,
|
|
693
|
+
displayElement: this.displayElement,
|
|
694
|
+
rawElement: this.rawElement,
|
|
695
|
+
kind: this.kind,
|
|
696
|
+
warn: this.warn,
|
|
697
|
+
invalidClass: this.invalidClass,
|
|
698
|
+
composing: this.composing,
|
|
699
|
+
pushError: (e) => this.errors.push(e),
|
|
700
|
+
requestRevert: (req) => {
|
|
701
|
+
// 1回でもrevert要求が出たら採用(最初の理由を保持)
|
|
702
|
+
if (!this.revertRequest) {
|
|
703
|
+
this.revertRequest = req;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* errors を初期化する(評価のたびに呼ぶ)
|
|
711
|
+
* @returns {void}
|
|
712
|
+
*/
|
|
713
|
+
clearErrors() {
|
|
714
|
+
this.errors = [];
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* normalize.char フェーズを実行する(文字の正規化)
|
|
719
|
+
* @param {string} value
|
|
720
|
+
* @param {GuardContext} ctx
|
|
721
|
+
* @returns {string}
|
|
722
|
+
*/
|
|
723
|
+
runNormalizeChar(value, ctx) {
|
|
724
|
+
let v = value;
|
|
725
|
+
for (const rule of this.normalizeCharRules) {
|
|
726
|
+
v = rule.normalizeChar ? rule.normalizeChar(v, ctx) : v;
|
|
727
|
+
}
|
|
728
|
+
return v;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* normalize.structure フェーズを実行する(構造の正規化)
|
|
733
|
+
* @param {string} value
|
|
734
|
+
* @param {GuardContext} ctx
|
|
735
|
+
* @returns {string}
|
|
736
|
+
*/
|
|
737
|
+
runNormalizeStructure(value, ctx) {
|
|
738
|
+
let v = value;
|
|
739
|
+
for (const rule of this.normalizeStructureRules) {
|
|
740
|
+
v = rule.normalizeStructure ? rule.normalizeStructure(v, ctx) : v;
|
|
741
|
+
}
|
|
742
|
+
return v;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* validate フェーズを実行する(エラーを積むだけで、値は変えない想定)
|
|
747
|
+
* @param {string} value
|
|
748
|
+
* @param {GuardContext} ctx
|
|
749
|
+
* @returns {void}
|
|
750
|
+
*/
|
|
751
|
+
runValidate(value, ctx) {
|
|
752
|
+
for (const rule of this.validateRules) {
|
|
753
|
+
if (rule.validate) {
|
|
754
|
+
rule.validate(value, ctx);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* fix フェーズを実行する(commit時のみ:切り捨て/四捨五入などの穏やか補正)
|
|
761
|
+
* @param {string} value
|
|
762
|
+
* @param {GuardContext} ctx
|
|
763
|
+
* @returns {string}
|
|
764
|
+
*/
|
|
765
|
+
runFix(value, ctx) {
|
|
766
|
+
let v = value;
|
|
767
|
+
for (const rule of this.fixRules) {
|
|
768
|
+
v = rule.fix ? rule.fix(v, ctx) : v;
|
|
769
|
+
}
|
|
770
|
+
return v;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* format フェーズを実行する(commit時のみ:カンマ付与など表示整形)
|
|
775
|
+
* @param {string} value
|
|
776
|
+
* @param {GuardContext} ctx
|
|
777
|
+
* @returns {string}
|
|
778
|
+
*/
|
|
779
|
+
runFormat(value, ctx) {
|
|
780
|
+
let v = value;
|
|
781
|
+
for (const rule of this.formatRules) {
|
|
782
|
+
v = rule.format ? rule.format(v, ctx) : v;
|
|
783
|
+
}
|
|
784
|
+
return v;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* errors の有無で invalidClass を displayElement に付け外しする
|
|
789
|
+
* @returns {void}
|
|
790
|
+
*/
|
|
791
|
+
applyInvalidClass() {
|
|
792
|
+
const el = /** @type {HTMLElement} */ (this.displayElement);
|
|
793
|
+
if (this.errors.length > 0) {
|
|
794
|
+
el.classList.add(this.invalidClass);
|
|
795
|
+
} else {
|
|
796
|
+
el.classList.remove(this.invalidClass);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* rawElement(hidden)がある場合、そこへ正規化済み値を同期する
|
|
802
|
+
* @param {string} normalized
|
|
803
|
+
* @returns {void}
|
|
804
|
+
*/
|
|
805
|
+
syncRaw(normalized) {
|
|
806
|
+
if (this.rawElement) {
|
|
807
|
+
this.rawElement.value = normalized;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/**
|
|
812
|
+
* displayElement へ値を書き戻す(normalize/fix/format で値が変わったとき)
|
|
813
|
+
* @param {string} normalized
|
|
814
|
+
* @returns {void}
|
|
815
|
+
*/
|
|
816
|
+
syncDisplay(normalized) {
|
|
817
|
+
if (this.displayElement instanceof HTMLInputElement || this.displayElement instanceof HTMLTextAreaElement) {
|
|
818
|
+
this.displayElement.value = normalized;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* IME変換開始:composition中フラグを立てる(input処理で触らないため)
|
|
824
|
+
* @returns {void}
|
|
825
|
+
*/
|
|
826
|
+
onCompositionStart() {
|
|
827
|
+
console.log("[jp-input-guard] compositionstart");
|
|
828
|
+
this.composing = true;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* IME変換終了:composition中フラグを下ろす
|
|
833
|
+
* - 環境によって input が飛ばない/遅れるので、ここでフォールバック評価を入れる
|
|
834
|
+
* @returns {void}
|
|
835
|
+
*/
|
|
836
|
+
onCompositionEnd() {
|
|
837
|
+
console.log("[jp-input-guard] compositionend");
|
|
838
|
+
this.composing = false;
|
|
839
|
+
|
|
840
|
+
// compositionend後に input が来ない環境向けのフォールバック
|
|
841
|
+
this.pendingCompositionCommit = true;
|
|
842
|
+
|
|
843
|
+
queueMicrotask(() => {
|
|
844
|
+
// その後 input で処理済みなら何もしない
|
|
845
|
+
if (!this.pendingCompositionCommit) { return; }
|
|
846
|
+
|
|
847
|
+
this.pendingCompositionCommit = false;
|
|
848
|
+
this.evaluateInput();
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* inputイベント:入力中評価(normalize → validate、表示/raw同期、class更新)
|
|
854
|
+
* @returns {void}
|
|
855
|
+
*/
|
|
856
|
+
onInput() {
|
|
857
|
+
console.log("[jp-input-guard] input");
|
|
858
|
+
// compositionend後に input が来た場合、フォールバックを無効化
|
|
859
|
+
this.pendingCompositionCommit = false;
|
|
860
|
+
this.evaluateInput();
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* blurイベント:確定時評価(normalize → validate → fix → format、同期、class更新)
|
|
865
|
+
* @returns {void}
|
|
866
|
+
*/
|
|
867
|
+
onBlur() {
|
|
868
|
+
console.log("[jp-input-guard] blur");
|
|
869
|
+
this.evaluateCommit();
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* focusイベント:表示整形(カンマ等)を剥がして編集しやすい状態にする
|
|
874
|
+
* - validate は走らせない(触っただけで赤くしたくないため)
|
|
875
|
+
* @returns {void}
|
|
876
|
+
*/
|
|
877
|
+
onFocus() {
|
|
878
|
+
if (this.composing) { return; }
|
|
879
|
+
|
|
880
|
+
const display = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (this.displayElement);
|
|
881
|
+
const current = display.value;
|
|
882
|
+
|
|
883
|
+
const ctx = this.createCtx();
|
|
884
|
+
|
|
885
|
+
let v = current;
|
|
886
|
+
v = this.runNormalizeChar(v, ctx); // カンマ除去が効く
|
|
887
|
+
v = this.runNormalizeStructure(v, ctx);
|
|
888
|
+
|
|
889
|
+
if (v !== current) {
|
|
890
|
+
this.setDisplayValuePreserveCaret(display, v, ctx);
|
|
891
|
+
this.syncRaw(v);
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// 受理値更新(blockで戻す位置も自然になる)
|
|
895
|
+
this.lastAcceptedValue = v;
|
|
896
|
+
this.lastAcceptedSelection = this.readSelection(display);
|
|
897
|
+
|
|
898
|
+
// キャレット/選択範囲の変化も反映しておく(blockで戻す位置も自然になる)
|
|
899
|
+
this.onSelectionChange();
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* キャレット/選択範囲の変化を lastAcceptedSelection に反映する
|
|
904
|
+
* - 値が変わっていない状態でもキャレットは動くため、block時に自然な位置へ戻すために使う
|
|
905
|
+
* @returns {void}
|
|
906
|
+
*/
|
|
907
|
+
onSelectionChange() {
|
|
908
|
+
// IME変換中は無視(この間はキャレット位置が不安定になることがあるため)
|
|
909
|
+
if (this.composing) {
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
const el = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (this.displayElement);
|
|
913
|
+
this.lastAcceptedSelection = this.readSelection(el);
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* display.value を更新しつつ、可能ならカーソル位置を保つ(入力中用)
|
|
918
|
+
* - 文字が削除される/増える可能性があるので、左側だけ正規化した長さで補正する
|
|
919
|
+
* @param {HTMLInputElement|HTMLTextAreaElement} el
|
|
920
|
+
* @param {string} nextValue
|
|
921
|
+
* @param {GuardContext} ctx
|
|
922
|
+
* @returns {void}
|
|
923
|
+
*/
|
|
924
|
+
setDisplayValuePreserveCaret(el, nextValue, ctx) {
|
|
925
|
+
const prevValue = el.value;
|
|
926
|
+
if (prevValue === nextValue) { return; }
|
|
927
|
+
|
|
928
|
+
const start = el.selectionStart;
|
|
929
|
+
const end = el.selectionEnd;
|
|
930
|
+
|
|
931
|
+
// selectionが取れないなら単純代入
|
|
932
|
+
if (start == null || end == null) {
|
|
933
|
+
el.value = nextValue;
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// 左側の文字列を「同じ正規化」で処理して、新しいカーソル位置を推定
|
|
938
|
+
const leftPrev = prevValue.slice(0, start);
|
|
939
|
+
let leftNext = leftPrev;
|
|
940
|
+
leftNext = this.runNormalizeChar(leftNext, ctx);
|
|
941
|
+
leftNext = this.runNormalizeStructure(leftNext, ctx);
|
|
942
|
+
|
|
943
|
+
el.value = nextValue;
|
|
944
|
+
|
|
945
|
+
const newPos = Math.min(leftNext.length, nextValue.length);
|
|
946
|
+
try {
|
|
947
|
+
el.setSelectionRange(newPos, newPos);
|
|
948
|
+
} catch (_e) {
|
|
949
|
+
// type=hidden/number などでは例外の可能性があるが、ここはtext想定
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* 入力中の評価(IME中は何もしない)
|
|
955
|
+
* - 固定順:normalize.char → normalize.structure → validate
|
|
956
|
+
* - 値が変わったら display に反映し、raw も同期する
|
|
957
|
+
* @returns {void}
|
|
958
|
+
*/
|
|
959
|
+
evaluateInput() {
|
|
960
|
+
if (this.composing) {
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
this.clearErrors();
|
|
965
|
+
this.revertRequest = null;
|
|
966
|
+
|
|
967
|
+
const display = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (this.displayElement);
|
|
968
|
+
const current = display.value;
|
|
969
|
+
|
|
970
|
+
const ctx = this.createCtx();
|
|
971
|
+
|
|
972
|
+
// raw候補(入力中は表示値=rawとして扱う)
|
|
973
|
+
let raw = current;
|
|
974
|
+
|
|
975
|
+
raw = this.runNormalizeChar(raw, ctx);
|
|
976
|
+
raw = this.runNormalizeStructure(raw, ctx);
|
|
977
|
+
|
|
978
|
+
// normalizeで変わったら反映(selection補正)
|
|
979
|
+
if (raw !== current) {
|
|
980
|
+
this.setDisplayValuePreserveCaret(display, raw, ctx);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// validate(入力中:エラー出すだけ)
|
|
984
|
+
this.runValidate(raw, ctx);
|
|
985
|
+
|
|
986
|
+
// revert要求が出たら巻き戻して終了
|
|
987
|
+
if (this.revertRequest) {
|
|
988
|
+
this.revertDisplay(this.revertRequest);
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// rawは常に最新に(swapでも非swapでもOK)
|
|
993
|
+
this.syncRaw(raw);
|
|
994
|
+
|
|
995
|
+
this.applyInvalidClass();
|
|
996
|
+
|
|
997
|
+
// 受理値は常にrawとして保存(revert先・getRawValueの一貫性)
|
|
998
|
+
this.lastAcceptedValue = raw;
|
|
999
|
+
this.lastAcceptedSelection = this.readSelection(display);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* 確定時(blur)の評価(IME中は何もしない)
|
|
1004
|
+
* - 固定順:normalize.char → normalize.structure → validate → fix → format
|
|
1005
|
+
* - raw は format 前、display は format 後
|
|
1006
|
+
* @returns {void}
|
|
1007
|
+
*/
|
|
1008
|
+
evaluateCommit() {
|
|
1009
|
+
if (this.composing) {
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
this.clearErrors();
|
|
1014
|
+
this.revertRequest = null;
|
|
1015
|
+
|
|
1016
|
+
const display = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (this.displayElement);
|
|
1017
|
+
const ctx = this.createCtx();
|
|
1018
|
+
|
|
1019
|
+
// 1) raw候補(displayから取得)
|
|
1020
|
+
let raw = display.value;
|
|
1021
|
+
|
|
1022
|
+
// 2) 正規化(rawとして扱う形に揃える)
|
|
1023
|
+
raw = this.runNormalizeChar(raw, ctx);
|
|
1024
|
+
raw = this.runNormalizeStructure(raw, ctx);
|
|
1025
|
+
|
|
1026
|
+
// 3) 入力内容の検査(fix前)
|
|
1027
|
+
this.runValidate(raw, ctx);
|
|
1028
|
+
|
|
1029
|
+
// block要求があれば戻す(将来用)
|
|
1030
|
+
if (this.revertRequest) {
|
|
1031
|
+
this.revertDisplay(this.revertRequest);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
// 4) commitのみの補正(丸め・切り捨て・繰り上がりなど)
|
|
1036
|
+
raw = this.runFix(raw, ctx);
|
|
1037
|
+
|
|
1038
|
+
// 5) 最終rawで検査し直す(fixで値が変わった場合に対応)
|
|
1039
|
+
this.clearErrors();
|
|
1040
|
+
this.revertRequest = null;
|
|
1041
|
+
this.runValidate(raw, ctx);
|
|
1042
|
+
|
|
1043
|
+
if (this.revertRequest) {
|
|
1044
|
+
this.revertDisplay(this.revertRequest);
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
// 6) raw同期(format前を入れる)
|
|
1049
|
+
this.syncRaw(raw);
|
|
1050
|
+
|
|
1051
|
+
// 7) 表示用は format 後(カンマ等)
|
|
1052
|
+
let shown = raw;
|
|
1053
|
+
shown = this.runFormat(shown, ctx);
|
|
1054
|
+
|
|
1055
|
+
this.syncDisplay(shown);
|
|
1056
|
+
|
|
1057
|
+
this.applyInvalidClass();
|
|
1058
|
+
|
|
1059
|
+
// 8) 受理値は raw を保持(revertやgetRawValueが安定する)
|
|
1060
|
+
this.lastAcceptedValue = raw;
|
|
1061
|
+
this.lastAcceptedSelection = this.readSelection(display);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* 現在のエラー有無を返す(errorsが空なら true)
|
|
1066
|
+
* @returns {boolean}
|
|
1067
|
+
*/
|
|
1068
|
+
isValid() {
|
|
1069
|
+
return this.errors.length === 0;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* エラー配列のコピーを返す(外から破壊されないように slice)
|
|
1074
|
+
* @returns {TigError[]}
|
|
1075
|
+
*/
|
|
1076
|
+
getErrors() {
|
|
1077
|
+
return this.errors.slice();
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* 送信用の値(rawがあれば raw、なければ display の値)を返す
|
|
1082
|
+
* @returns {string}
|
|
1083
|
+
*/
|
|
1084
|
+
getRawValue() {
|
|
1085
|
+
if (this.rawElement) {
|
|
1086
|
+
return this.rawElement.value;
|
|
1087
|
+
}
|
|
1088
|
+
return /** @type {HTMLInputElement|HTMLTextAreaElement} */ (this.displayElement).value;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
/**
|
|
1092
|
+
* 外部に公開する Guard API を生成して返す
|
|
1093
|
+
* - InputGuard 自体を公開せず、最小の操作だけを渡す
|
|
1094
|
+
* @returns {Guard}
|
|
1095
|
+
*/
|
|
1096
|
+
toGuard() {
|
|
1097
|
+
return {
|
|
1098
|
+
detach: () => this.detach(),
|
|
1099
|
+
isValid: () => this.isValid(),
|
|
1100
|
+
getErrors: () => this.getErrors(),
|
|
1101
|
+
getRawValue: () => this.getRawValue(),
|
|
1102
|
+
getDisplayElement: () => /** @type {HTMLInputElement|HTMLTextAreaElement} */ (this.displayElement)
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/**
|
|
1108
|
+
* The script is part of TextInputGuard.
|
|
1109
|
+
*
|
|
1110
|
+
* AUTHOR:
|
|
1111
|
+
* natade-jp (https://github.com/natade-jp)
|
|
1112
|
+
*
|
|
1113
|
+
* LICENSE:
|
|
1114
|
+
* The MIT license https://opensource.org/licenses/MIT
|
|
1115
|
+
*/
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* @typedef {GuardGroup} GuardGroup
|
|
1119
|
+
* @typedef {Guard} Guard
|
|
1120
|
+
* @typedef {AttachOptions} AttachOptions
|
|
1121
|
+
* @typedef {Rule} Rule
|
|
1122
|
+
*/
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* data属性からルールを生成できるルールファクトリ
|
|
1126
|
+
* @typedef {Object} RuleFactory
|
|
1127
|
+
* @property {string} name
|
|
1128
|
+
* @property {(dataset: DOMStringMap, el: HTMLInputElement|HTMLTextAreaElement) => Rule|null} fromDataset
|
|
1129
|
+
*/
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* Boolean系のdata値を解釈する(未指定なら undefined を返す)
|
|
1133
|
+
* @param {string|undefined} v
|
|
1134
|
+
* @returns {boolean|undefined}
|
|
1135
|
+
*/
|
|
1136
|
+
function parseBool(v) {
|
|
1137
|
+
if (v == null) { return; }
|
|
1138
|
+
const s = String(v).trim().toLowerCase();
|
|
1139
|
+
if (s === "" || s === "true" || s === "1" || s === "yes" || s === "on") { return true; }
|
|
1140
|
+
if (s === "false" || s === "0" || s === "no" || s === "off") { return false; }
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* separate mode を解釈する(未指定は "auto")
|
|
1146
|
+
* @param {string|undefined} v
|
|
1147
|
+
* @returns {"auto"|"swap"|"off"}
|
|
1148
|
+
*/
|
|
1149
|
+
function parseSeparateMode(v) {
|
|
1150
|
+
if (v == null || String(v).trim() === "") { return "auto"; }
|
|
1151
|
+
const s = String(v).trim().toLowerCase();
|
|
1152
|
+
if (s === "auto" || s === "swap" || s === "off") { return /** @type {any} */ (s); }
|
|
1153
|
+
return "auto";
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* その要素が autoAttach の対象かを判定する
|
|
1158
|
+
* - 設定系(data-tig-separate / warn / invalid-class)
|
|
1159
|
+
* - ルール系(data-tig-rules-* が1つでもある)
|
|
1160
|
+
* @param {DOMStringMap} ds
|
|
1161
|
+
* @returns {boolean}
|
|
1162
|
+
*/
|
|
1163
|
+
function hasAnyJpigConfig(ds) {
|
|
1164
|
+
// attach設定系
|
|
1165
|
+
if (ds.tigSeparate != null) { return true; }
|
|
1166
|
+
if (ds.tigWarn != null) { return true; }
|
|
1167
|
+
if (ds.tigInvalidClass != null) { return true; }
|
|
1168
|
+
|
|
1169
|
+
// ルール系(data-tig-rules-*)
|
|
1170
|
+
for (const k in ds) {
|
|
1171
|
+
// data-tig-rules-numeric -> ds.tigRulesNumeric
|
|
1172
|
+
if (k.startsWith("tigRules")) {
|
|
1173
|
+
return true;
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
return false;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* autoAttach の実体(attach関数とルールレジストリを保持する)
|
|
1181
|
+
*/
|
|
1182
|
+
class InputGuardAutoAttach {
|
|
1183
|
+
/**
|
|
1184
|
+
* @param {(el: HTMLInputElement|HTMLTextAreaElement, options: AttachOptions) => Guard} attachFn
|
|
1185
|
+
* @param {RuleFactory[]} ruleFactories
|
|
1186
|
+
*/
|
|
1187
|
+
constructor(attachFn, ruleFactories) {
|
|
1188
|
+
/** @type {(el: HTMLInputElement|HTMLTextAreaElement, options: AttachOptions) => Guard} */
|
|
1189
|
+
this.attachFn = attachFn;
|
|
1190
|
+
|
|
1191
|
+
/** @type {RuleFactory[]} */
|
|
1192
|
+
this.ruleFactories = Array.isArray(ruleFactories) ? ruleFactories : [];
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* ルールファクトリを追加登録(将来用:必要なら使う)
|
|
1197
|
+
* @param {RuleFactory} factory
|
|
1198
|
+
* @returns {void}
|
|
1199
|
+
*/
|
|
1200
|
+
register(factory) {
|
|
1201
|
+
this.ruleFactories.push(factory);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* root 配下の input/textarea を data属性から自動で attach する
|
|
1206
|
+
* - 既に `data-tig-attached` が付いているものはスキップ
|
|
1207
|
+
* - `data-tig-*`(設定)と `data-tig-rules-*`(ルール)を拾って options を生成
|
|
1208
|
+
*
|
|
1209
|
+
* @param {Document|DocumentFragment|ShadowRoot|Element} [root=document]
|
|
1210
|
+
* @returns {GuardGroup}
|
|
1211
|
+
*/
|
|
1212
|
+
autoAttach(root = document) {
|
|
1213
|
+
/** @type {Guard[]} */
|
|
1214
|
+
const guards = [];
|
|
1215
|
+
|
|
1216
|
+
/** @type {(HTMLInputElement|HTMLTextAreaElement)[]} */
|
|
1217
|
+
const elements = [];
|
|
1218
|
+
|
|
1219
|
+
// root配下
|
|
1220
|
+
if (/** @type {any} */ (root).querySelectorAll) {
|
|
1221
|
+
const nodeList = /** @type {any} */ (root).querySelectorAll("input, textarea");
|
|
1222
|
+
for (const el of nodeList) {
|
|
1223
|
+
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
|
|
1224
|
+
elements.push(el);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// root自身
|
|
1230
|
+
if (root instanceof HTMLInputElement || root instanceof HTMLTextAreaElement) {
|
|
1231
|
+
if (!elements.includes(root)) { elements.push(root); }
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
for (const el of elements) {
|
|
1235
|
+
const ds = el.dataset;
|
|
1236
|
+
|
|
1237
|
+
// 二重attach防止
|
|
1238
|
+
if (ds.tigAttached === "true") { continue; }
|
|
1239
|
+
|
|
1240
|
+
// JPIGの設定が何も無ければ対象外
|
|
1241
|
+
if (!hasAnyJpigConfig(ds)) { continue; }
|
|
1242
|
+
|
|
1243
|
+
/** @type {AttachOptions} */
|
|
1244
|
+
const options = {};
|
|
1245
|
+
|
|
1246
|
+
// warn / invalidClass
|
|
1247
|
+
const warn = parseBool(ds.tigWarn);
|
|
1248
|
+
if (warn != null) { options.warn = warn; }
|
|
1249
|
+
|
|
1250
|
+
if (ds.tigInvalidClass != null && String(ds.tigInvalidClass).trim() !== "") {
|
|
1251
|
+
options.invalidClass = String(ds.tigInvalidClass);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// separateValue(未指定は auto)
|
|
1255
|
+
options.separateValue = { mode: parseSeparateMode(ds.tigSeparate) };
|
|
1256
|
+
|
|
1257
|
+
// ルール収集
|
|
1258
|
+
/** @type {Rule[]} */
|
|
1259
|
+
const rules = [];
|
|
1260
|
+
for (const fac of this.ruleFactories) {
|
|
1261
|
+
try {
|
|
1262
|
+
const rule = fac.fromDataset(ds, el);
|
|
1263
|
+
if (rule) { rules.push(rule); }
|
|
1264
|
+
} catch (e) {
|
|
1265
|
+
const w = options.warn ?? true;
|
|
1266
|
+
if (w) {
|
|
1267
|
+
console.warn(`[jp-input-guard] autoAttach: rule "${fac.name}" fromDataset() threw an error.`, e);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
if (rules.length > 0) { options.rules = rules; }
|
|
1272
|
+
|
|
1273
|
+
// ルールが無いなら attach しない(v0.1方針)
|
|
1274
|
+
if (!options.rules || options.rules.length === 0) { continue; }
|
|
1275
|
+
|
|
1276
|
+
// attach(init内で auto/swap 判定も完了)
|
|
1277
|
+
const guard = this.attachFn(el, options);
|
|
1278
|
+
guards.push(guard);
|
|
1279
|
+
|
|
1280
|
+
// 二重attach防止フラグ
|
|
1281
|
+
el.dataset.tigAttached = "true";
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
return {
|
|
1285
|
+
detach: () => { for (const g of guards) { g.detach(); } },
|
|
1286
|
+
isValid: () => guards.every((g) => g.isValid()),
|
|
1287
|
+
getErrors: () => guards.flatMap((g) => g.getErrors()),
|
|
1288
|
+
getGuards: () => guards
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* The script is part of TextInputGuard.
|
|
1295
|
+
*
|
|
1296
|
+
* AUTHOR:
|
|
1297
|
+
* natade-jp (https://github.com/natade-jp)
|
|
1298
|
+
*
|
|
1299
|
+
* LICENSE:
|
|
1300
|
+
* The MIT license https://opensource.org/licenses/MIT
|
|
1301
|
+
*/
|
|
1302
|
+
|
|
1303
|
+
/**
|
|
1304
|
+
* datasetのboolean値を解釈する
|
|
1305
|
+
* - 未指定なら undefined
|
|
1306
|
+
* - "" / "true" / "1" / "yes" / "on" は true
|
|
1307
|
+
* - "false" / "0" / "no" / "off" は false
|
|
1308
|
+
* @param {string|undefined} v
|
|
1309
|
+
* @returns {boolean|undefined}
|
|
1310
|
+
*/
|
|
1311
|
+
function parseDatasetBool(v) {
|
|
1312
|
+
if (v == null) { return; }
|
|
1313
|
+
const s = String(v).trim().toLowerCase();
|
|
1314
|
+
if (s === "" || s === "true" || s === "1" || s === "yes" || s === "on") { return true; }
|
|
1315
|
+
if (s === "false" || s === "0" || s === "no" || s === "off") { return false; }
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* datasetのnumber値を解釈する(整数想定)
|
|
1321
|
+
* - 未指定/空なら undefined
|
|
1322
|
+
* - 数値でなければ undefined
|
|
1323
|
+
* @param {string|undefined} v
|
|
1324
|
+
* @returns {number|undefined}
|
|
1325
|
+
*/
|
|
1326
|
+
function parseDatasetNumber(v) {
|
|
1327
|
+
if (v == null) { return; }
|
|
1328
|
+
const s = String(v).trim();
|
|
1329
|
+
if (s === "") { return; }
|
|
1330
|
+
const n = Number(s);
|
|
1331
|
+
return Number.isFinite(n) ? n : undefined;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* enumを解釈する(未指定なら undefined)
|
|
1336
|
+
* @template {string} T
|
|
1337
|
+
* @param {string|undefined} v
|
|
1338
|
+
* @param {readonly T[]} allowed
|
|
1339
|
+
* @returns {T|undefined}
|
|
1340
|
+
*/
|
|
1341
|
+
function parseDatasetEnum(v, allowed) {
|
|
1342
|
+
if (v == null) { return; }
|
|
1343
|
+
const s = String(v).trim();
|
|
1344
|
+
if (s === "") { return; }
|
|
1345
|
+
// 大文字小文字を区別したいならここを変える(今は厳密一致)
|
|
1346
|
+
return /** @type {T|undefined} */ (allowed.includes(/** @type {any} */ (s)) ? s : undefined);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* The script is part of TextInputGuard.
|
|
1351
|
+
*
|
|
1352
|
+
* AUTHOR:
|
|
1353
|
+
* natade-jp (https://github.com/natade-jp)
|
|
1354
|
+
*
|
|
1355
|
+
* LICENSE:
|
|
1356
|
+
* The MIT license https://opensource.org/licenses/MIT
|
|
1357
|
+
*/
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* numeric ルールのオプション
|
|
1362
|
+
* @typedef {Object} NumericRuleOptions
|
|
1363
|
+
* @property {boolean} [allowFullWidth=true] - 全角数字/記号を許可して半角へ正規化する
|
|
1364
|
+
* @property {boolean} [allowMinus=false] - マイナス記号を許可する(先頭のみ)
|
|
1365
|
+
* @property {boolean} [allowDecimal=false] - 小数点を許可する(1つだけ)
|
|
1366
|
+
*/
|
|
1367
|
+
|
|
1368
|
+
/**
|
|
1369
|
+
* 数値入力向けルールを生成する
|
|
1370
|
+
* - normalize.char: 全角→半角、記号統一、不要文字の除去
|
|
1371
|
+
* - normalize.structure: 「-は先頭のみ」「.は1つだけ」など構造を整える
|
|
1372
|
+
* - fix: 確定時(blur)に「-」「.」「-.」や末尾の「.」を空/削除にする
|
|
1373
|
+
*
|
|
1374
|
+
* @param {NumericRuleOptions} [options]
|
|
1375
|
+
* @returns {Rule}
|
|
1376
|
+
*/
|
|
1377
|
+
function numeric(options = {}) {
|
|
1378
|
+
const opt = {
|
|
1379
|
+
allowFullWidth: options.allowFullWidth ?? true,
|
|
1380
|
+
allowMinus: options.allowMinus ?? false,
|
|
1381
|
+
allowDecimal: options.allowDecimal ?? false
|
|
1382
|
+
};
|
|
1383
|
+
|
|
1384
|
+
/** @type {Set<string>} */
|
|
1385
|
+
const minusLike = new Set([
|
|
1386
|
+
"ー", // KATAKANA-HIRAGANA PROLONGED SOUND MARK
|
|
1387
|
+
"-", // FULLWIDTH HYPHEN-MINUS
|
|
1388
|
+
"−", // MINUS SIGN
|
|
1389
|
+
"‐", // HYPHEN
|
|
1390
|
+
"-", // NON-BREAKING HYPHEN
|
|
1391
|
+
"‒", // FIGURE DASH
|
|
1392
|
+
"–", // EN DASH
|
|
1393
|
+
"—", // EM DASH
|
|
1394
|
+
"―" // HORIZONTAL BAR
|
|
1395
|
+
]);
|
|
1396
|
+
|
|
1397
|
+
/** @type {Set<string>} */
|
|
1398
|
+
const dotLike = new Set([
|
|
1399
|
+
".", // FULLWIDTH FULL STOP
|
|
1400
|
+
"。", // IDEOGRAPHIC FULL STOP
|
|
1401
|
+
"。" // HALFWIDTH IDEOGRAPHIC FULL STOP
|
|
1402
|
+
]);
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* 全角数字(0〜9)を半角へ
|
|
1406
|
+
* @param {string} ch
|
|
1407
|
+
* @returns {string|null} 変換した1文字(対象外ならnull)
|
|
1408
|
+
*/
|
|
1409
|
+
function toHalfWidthDigit(ch) {
|
|
1410
|
+
const code = ch.charCodeAt(0);
|
|
1411
|
+
// '0'(FF10) .. '9'(FF19)
|
|
1412
|
+
if (0xFF10 <= code && code <= 0xFF19) {
|
|
1413
|
+
return String.fromCharCode(code - 0xFF10 + 0x30);
|
|
1414
|
+
}
|
|
1415
|
+
return null;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* 1文字を「数字 / - / .」へ正規化する(許可されない場合は空)
|
|
1420
|
+
* @param {string} ch
|
|
1421
|
+
* @returns {string} 正規化後の文字(除去なら "")
|
|
1422
|
+
*/
|
|
1423
|
+
function normalizeChar1(ch) {
|
|
1424
|
+
// 半角数字
|
|
1425
|
+
if (ch >= "0" && ch <= "9") {
|
|
1426
|
+
return ch;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
// 全角数字
|
|
1430
|
+
if (opt.allowFullWidth) {
|
|
1431
|
+
const d = toHalfWidthDigit(ch);
|
|
1432
|
+
if (d) {
|
|
1433
|
+
return d;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
// 小数点
|
|
1438
|
+
if (ch === ".") {
|
|
1439
|
+
return opt.allowDecimal ? "." : "";
|
|
1440
|
+
}
|
|
1441
|
+
if (opt.allowFullWidth && dotLike.has(ch)) {
|
|
1442
|
+
return opt.allowDecimal ? "." : "";
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// マイナス
|
|
1446
|
+
if (ch === "-") {
|
|
1447
|
+
return opt.allowMinus ? "-" : "";
|
|
1448
|
+
}
|
|
1449
|
+
if (opt.allowFullWidth && minusLike.has(ch)) {
|
|
1450
|
+
return opt.allowMinus ? "-" : "";
|
|
1451
|
+
}
|
|
1452
|
+
// 明示的に不要(+ や指数表記など)
|
|
1453
|
+
if (ch === "+" || ch === "+") {
|
|
1454
|
+
return "";
|
|
1455
|
+
}
|
|
1456
|
+
if (ch === "e" || ch === "E" || ch === "e" || ch === "E") {
|
|
1457
|
+
return "";
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
// その他は全部除去
|
|
1461
|
+
return "";
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
return {
|
|
1465
|
+
name: "numeric",
|
|
1466
|
+
targets: ["input"],
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* 文字単位の正規化(全角→半角、記号統一、不要文字の除去)
|
|
1470
|
+
* @param {string} value
|
|
1471
|
+
* @returns {string}
|
|
1472
|
+
*/
|
|
1473
|
+
normalizeChar(value) {
|
|
1474
|
+
let v = String(value);
|
|
1475
|
+
|
|
1476
|
+
// 表示専用装飾の除去(format対策)
|
|
1477
|
+
v = v.replace(/,/g, "");
|
|
1478
|
+
|
|
1479
|
+
let out = "";
|
|
1480
|
+
for (const ch of v) {
|
|
1481
|
+
out += normalizeChar1(ch);
|
|
1482
|
+
}
|
|
1483
|
+
return out;
|
|
1484
|
+
},
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* 構造正規化(-は先頭のみ、.は1つだけ)
|
|
1488
|
+
* @param {string} value
|
|
1489
|
+
* @returns {string}
|
|
1490
|
+
*/
|
|
1491
|
+
normalizeStructure(value) {
|
|
1492
|
+
let out = "";
|
|
1493
|
+
let seenMinus = false;
|
|
1494
|
+
let seenDot = false;
|
|
1495
|
+
|
|
1496
|
+
for (const ch of String(value)) {
|
|
1497
|
+
if (ch >= "0" && ch <= "9") {
|
|
1498
|
+
out += ch;
|
|
1499
|
+
continue;
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
if (ch === "-" && opt.allowMinus) {
|
|
1503
|
+
// マイナスは先頭のみ、1回だけ
|
|
1504
|
+
if (!seenMinus && out.length === 0) {
|
|
1505
|
+
out += "-";
|
|
1506
|
+
seenMinus = true;
|
|
1507
|
+
}
|
|
1508
|
+
continue;
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
if (ch === "." && opt.allowDecimal) {
|
|
1512
|
+
// 小数点は1つだけ(位置制約は設けない:digits側で精度などを管理)
|
|
1513
|
+
if (!seenDot) {
|
|
1514
|
+
out += ".";
|
|
1515
|
+
seenDot = true;
|
|
1516
|
+
}
|
|
1517
|
+
continue;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// その他は捨てる(normalizeCharでほぼ落ちてる想定)
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
return out;
|
|
1524
|
+
},
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* 確定時にだけ消したい “未完成な数値” を整える
|
|
1528
|
+
* - "-" / "." / "-." は空にする
|
|
1529
|
+
* - 末尾の "." は削除する("12." → "12")
|
|
1530
|
+
* - ".1" → "0.1"
|
|
1531
|
+
* - "-.1" → "-0.1"
|
|
1532
|
+
* - 整数部の不要な先頭ゼロを除去("00" → "0", "-0" → "0")
|
|
1533
|
+
* @param {string} value
|
|
1534
|
+
* @returns {string}
|
|
1535
|
+
*/
|
|
1536
|
+
fix(value) {
|
|
1537
|
+
let v = String(value);
|
|
1538
|
+
|
|
1539
|
+
// 未完成な数値は空にする
|
|
1540
|
+
if (v === "-" || v === "." || v === "-.") {
|
|
1541
|
+
return "";
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
// "-.1" → "-0.1"
|
|
1545
|
+
if (v.startsWith("-.")) {
|
|
1546
|
+
v = "-0" + v.slice(1);
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
// ".1" → "0.1"
|
|
1550
|
+
if (v.startsWith(".")) {
|
|
1551
|
+
v = "0" + v;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// "12." → "12"
|
|
1555
|
+
if (v.endsWith(".")) {
|
|
1556
|
+
v = v.slice(0, -1);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
// ---- ここからゼロ正規化 ----
|
|
1560
|
+
|
|
1561
|
+
// 符号分離
|
|
1562
|
+
let sign = "";
|
|
1563
|
+
if (v.startsWith("-")) {
|
|
1564
|
+
sign = "-";
|
|
1565
|
+
v = v.slice(1);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
const dotIndex = v.indexOf(".");
|
|
1569
|
+
let intPart = dotIndex >= 0 ? v.slice(0, dotIndex) : v;
|
|
1570
|
+
const fracPart = dotIndex >= 0 ? v.slice(dotIndex + 1) : "";
|
|
1571
|
+
|
|
1572
|
+
// 先頭ゼロ削除(全部ゼロなら "0")
|
|
1573
|
+
intPart = intPart.replace(/^0+/, "");
|
|
1574
|
+
if (intPart === "") {
|
|
1575
|
+
intPart = "0";
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
// "-0" は "0" にする
|
|
1579
|
+
if (sign === "-" && intPart === "0" && (!fracPart || /^0*$/.test(fracPart))) {
|
|
1580
|
+
sign = "";
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
// 再構築
|
|
1584
|
+
if (dotIndex >= 0) {
|
|
1585
|
+
return `${sign}${intPart}.${fracPart}`;
|
|
1586
|
+
}
|
|
1587
|
+
return `${sign}${intPart}`;
|
|
1588
|
+
},
|
|
1589
|
+
|
|
1590
|
+
/**
|
|
1591
|
+
* numeric単体では基本エラーを出さない(入力途中を許容するため)
|
|
1592
|
+
* ここでエラーにしたい場合は、将来オプションで強制できるようにしてもOK
|
|
1593
|
+
* @param {string} _value
|
|
1594
|
+
* @param {any} _ctx
|
|
1595
|
+
* @returns {void}
|
|
1596
|
+
*/
|
|
1597
|
+
validate(_value, _ctx) {
|
|
1598
|
+
// no-op
|
|
1599
|
+
}
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
/**
|
|
1604
|
+
* datasetから numeric ルールを生成する
|
|
1605
|
+
* - data-tig-rules-numeric が無ければ null
|
|
1606
|
+
* - オプションは data-tig-rules-numeric-xxx から読む
|
|
1607
|
+
*
|
|
1608
|
+
* 対応する data 属性(dataset 名)
|
|
1609
|
+
* - data-tig-rules-numeric -> dataset.tigRulesNumeric
|
|
1610
|
+
* - data-tig-rules-numeric-allow-full-width -> dataset.tigRulesNumericAllowFullWidth
|
|
1611
|
+
* - data-tig-rules-numeric-allow-minus -> dataset.tigRulesNumericAllowMinus
|
|
1612
|
+
* - data-tig-rules-numeric-allow-decimal -> dataset.tigRulesNumericAllowDecimal
|
|
1613
|
+
*
|
|
1614
|
+
* @param {DOMStringMap} dataset
|
|
1615
|
+
* @param {HTMLInputElement|HTMLTextAreaElement} _el
|
|
1616
|
+
* @returns {Rule|null}
|
|
1617
|
+
*/
|
|
1618
|
+
numeric.fromDataset = function fromDataset(dataset, _el) {
|
|
1619
|
+
// ON判定:data-tig-rules-numeric が無ければ対象外
|
|
1620
|
+
if (dataset.tigRulesNumeric == null) {
|
|
1621
|
+
return null;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
/** @type {NumericRuleOptions} */
|
|
1625
|
+
const options = {};
|
|
1626
|
+
|
|
1627
|
+
// allowFullWidth(未指定なら numeric側デフォルト true)
|
|
1628
|
+
const allowFullWidth = parseDatasetBool(dataset.tigRulesNumericAllowFullWidth);
|
|
1629
|
+
if (allowFullWidth != null) {
|
|
1630
|
+
options.allowFullWidth = allowFullWidth;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// allowMinus(未指定なら numeric側デフォルト false)
|
|
1634
|
+
const allowMinus = parseDatasetBool(dataset.tigRulesNumericAllowMinus);
|
|
1635
|
+
if (allowMinus != null) {
|
|
1636
|
+
options.allowMinus = allowMinus;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
// allowDecimal(未指定なら numeric側デフォルト false)
|
|
1640
|
+
const allowDecimal = parseDatasetBool(dataset.tigRulesNumericAllowDecimal);
|
|
1641
|
+
if (allowDecimal != null) {
|
|
1642
|
+
options.allowDecimal = allowDecimal;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
return numeric(options);
|
|
1646
|
+
};
|
|
1647
|
+
|
|
1648
|
+
/**
|
|
1649
|
+
* The script is part of TextInputGuard.
|
|
1650
|
+
*
|
|
1651
|
+
* AUTHOR:
|
|
1652
|
+
* natade-jp (https://github.com/natade-jp)
|
|
1653
|
+
*
|
|
1654
|
+
* LICENSE:
|
|
1655
|
+
* The MIT license https://opensource.org/licenses/MIT
|
|
1656
|
+
*/
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
/**
|
|
1660
|
+
* digits ルールのオプション
|
|
1661
|
+
* @typedef {Object} DigitsRuleOptions
|
|
1662
|
+
* @property {number} [int] - 整数部の最大桁数(省略可)
|
|
1663
|
+
* @property {number} [frac] - 小数部の最大桁数(省略可)
|
|
1664
|
+
* @property {boolean} [countLeadingZeros=true] - 整数部の先頭ゼロを桁数に含める
|
|
1665
|
+
* @property {"none"|"truncateLeft"|"truncateRight"|"clamp"} [fixIntOnBlur="none"] - blur時の整数部補正
|
|
1666
|
+
* @property {"none"|"truncate"|"round"} [fixFracOnBlur="none"] - blur時の小数部補正
|
|
1667
|
+
* @property {"none"|"block"} [overflowInputInt="none"] - 入力中:整数部が最大桁を超える入力をブロックする
|
|
1668
|
+
* @property {"none"|"block"} [overflowInputFrac="none"] - 入力中:小数部が最大桁を超える入力をブロックする
|
|
1669
|
+
*/
|
|
1670
|
+
|
|
1671
|
+
/**
|
|
1672
|
+
* 数値文字列を「符号・整数部・小数部」に分解する
|
|
1673
|
+
* - numericルール後の値(数字/./-のみ)を想定
|
|
1674
|
+
* @param {string} value
|
|
1675
|
+
* @returns {{ sign: ""|"-", intPart: string, fracPart: string, hasDot: boolean }}
|
|
1676
|
+
*/
|
|
1677
|
+
function splitNumber(value) {
|
|
1678
|
+
const v = String(value);
|
|
1679
|
+
|
|
1680
|
+
/** @type {""|"-"} */
|
|
1681
|
+
let sign = "";
|
|
1682
|
+
let s = v;
|
|
1683
|
+
|
|
1684
|
+
if (s.startsWith("-")) {
|
|
1685
|
+
sign = "-";
|
|
1686
|
+
s = s.slice(1);
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
const dotIndex = s.indexOf(".");
|
|
1690
|
+
const hasDot = dotIndex >= 0;
|
|
1691
|
+
|
|
1692
|
+
if (!hasDot) {
|
|
1693
|
+
return { sign, intPart: s, fracPart: "", hasDot: false };
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
const intPart = s.slice(0, dotIndex);
|
|
1697
|
+
const fracPart = s.slice(dotIndex + 1);
|
|
1698
|
+
|
|
1699
|
+
return { sign, intPart, fracPart, hasDot: true };
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* 整数部の桁数を数える(先頭ゼロを含める/含めないを選べる)
|
|
1704
|
+
* @param {string} intPart
|
|
1705
|
+
* @param {boolean} countLeadingZeros
|
|
1706
|
+
* @returns {number}
|
|
1707
|
+
*/
|
|
1708
|
+
function countIntDigits(intPart, countLeadingZeros) {
|
|
1709
|
+
const s = intPart ?? "";
|
|
1710
|
+
if (s.length === 0) { return 0; }
|
|
1711
|
+
|
|
1712
|
+
if (countLeadingZeros) { return s.length; }
|
|
1713
|
+
|
|
1714
|
+
// 先頭ゼロを除外して数える(全部ゼロなら 1 として扱う)
|
|
1715
|
+
const trimmed = s.replace(/^0+/, "");
|
|
1716
|
+
return trimmed.length === 0 ? 1 : trimmed.length;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
/**
|
|
1720
|
+
* 任意桁の「+1」加算(10進文字列、非負のみ)
|
|
1721
|
+
* @param {string} dec
|
|
1722
|
+
* @returns {string}
|
|
1723
|
+
*/
|
|
1724
|
+
function addOne(dec) {
|
|
1725
|
+
let carry = 1;
|
|
1726
|
+
const arr = dec.split("");
|
|
1727
|
+
|
|
1728
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
1729
|
+
const n = arr[i].charCodeAt(0) - 48 + carry;
|
|
1730
|
+
if (n >= 10) {
|
|
1731
|
+
arr[i] = "0";
|
|
1732
|
+
carry = 1;
|
|
1733
|
+
} else {
|
|
1734
|
+
arr[i] = String.fromCharCode(48 + n);
|
|
1735
|
+
carry = 0;
|
|
1736
|
+
break;
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
if (carry === 1) { arr.unshift("1"); }
|
|
1741
|
+
return arr.join("");
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
/**
|
|
1745
|
+
* 小数を指定桁に四捨五入する(文字列ベース、浮動小数点を使わない)
|
|
1746
|
+
* @param {string} intPart
|
|
1747
|
+
* @param {string} fracPart
|
|
1748
|
+
* @param {number} fracLimit
|
|
1749
|
+
* @returns {{ intPart: string, fracPart: string }}
|
|
1750
|
+
*/
|
|
1751
|
+
function roundFraction(intPart, fracPart, fracLimit) {
|
|
1752
|
+
const f = fracPart ?? "";
|
|
1753
|
+
if (f.length <= fracLimit) {
|
|
1754
|
+
return { intPart, fracPart: f };
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
const keep = f.slice(0, fracLimit);
|
|
1758
|
+
const nextDigit = f.charCodeAt(fracLimit) - 48; // 0..9
|
|
1759
|
+
|
|
1760
|
+
if (nextDigit < 5) {
|
|
1761
|
+
return { intPart, fracPart: keep };
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
// 繰り上げ
|
|
1765
|
+
if (fracLimit === 0) {
|
|
1766
|
+
const newInt = addOne(intPart.length ? intPart : "0");
|
|
1767
|
+
return { intPart: newInt, fracPart: "" };
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
// 小数部を +1(桁あふれをcarryで扱う)
|
|
1771
|
+
let carry = 1;
|
|
1772
|
+
const arr = keep.split("");
|
|
1773
|
+
|
|
1774
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
1775
|
+
const n = (arr[i].charCodeAt(0) - 48) + carry;
|
|
1776
|
+
if (n >= 10) {
|
|
1777
|
+
arr[i] = "0";
|
|
1778
|
+
carry = 1;
|
|
1779
|
+
} else {
|
|
1780
|
+
arr[i] = String.fromCharCode(48 + n);
|
|
1781
|
+
carry = 0;
|
|
1782
|
+
break;
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
const newFrac = arr.join("");
|
|
1787
|
+
let newInt = intPart;
|
|
1788
|
+
|
|
1789
|
+
if (carry === 1) {
|
|
1790
|
+
newInt = addOne(intPart.length ? intPart : "0");
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
return { intPart: newInt, fracPart: newFrac };
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
/**
|
|
1797
|
+
* digits ルールを生成する
|
|
1798
|
+
* @param {DigitsRuleOptions} [options]
|
|
1799
|
+
* @returns {Rule}
|
|
1800
|
+
*/
|
|
1801
|
+
function digits(options = {}) {
|
|
1802
|
+
const opt = {
|
|
1803
|
+
int: typeof options.int === "number" ? options.int : undefined,
|
|
1804
|
+
frac: typeof options.frac === "number" ? options.frac : undefined,
|
|
1805
|
+
countLeadingZeros: options.countLeadingZeros ?? true,
|
|
1806
|
+
fixIntOnBlur: options.fixIntOnBlur ?? "none",
|
|
1807
|
+
fixFracOnBlur: options.fixFracOnBlur ?? "none",
|
|
1808
|
+
overflowInputInt: options.overflowInputInt ?? "none",
|
|
1809
|
+
overflowInputFrac: options.overflowInputFrac ?? "none"
|
|
1810
|
+
};
|
|
1811
|
+
|
|
1812
|
+
return {
|
|
1813
|
+
name: "digits",
|
|
1814
|
+
targets: ["input"],
|
|
1815
|
+
|
|
1816
|
+
/**
|
|
1817
|
+
* 桁数チェック(入力中:エラーを積むだけ)
|
|
1818
|
+
* @param {string} value
|
|
1819
|
+
* @param {GuardContext} ctx
|
|
1820
|
+
* @returns {void}
|
|
1821
|
+
*/
|
|
1822
|
+
validate(value, ctx) {
|
|
1823
|
+
const v = String(value);
|
|
1824
|
+
|
|
1825
|
+
// 入力途中は極力うるさくしない(numericのfixに任せる)
|
|
1826
|
+
if (v === "" || v === "-" || v === "." || v === "-.") { return; }
|
|
1827
|
+
|
|
1828
|
+
const { intPart, fracPart } = splitNumber(v);
|
|
1829
|
+
|
|
1830
|
+
// 整数部桁数
|
|
1831
|
+
if (typeof opt.int === "number") {
|
|
1832
|
+
const intDigits = countIntDigits(intPart, opt.countLeadingZeros);
|
|
1833
|
+
if (intDigits > opt.int) {
|
|
1834
|
+
// 入力ブロック(int)
|
|
1835
|
+
if (opt.overflowInputInt === "block") {
|
|
1836
|
+
ctx.requestRevert({
|
|
1837
|
+
reason: "digits.int_overflow",
|
|
1838
|
+
detail: { limit: opt.int, actual: intDigits }
|
|
1839
|
+
});
|
|
1840
|
+
return; // もう戻すので、以降は触らない
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
// エラー積むだけ(従来どおり)
|
|
1844
|
+
ctx.pushError({
|
|
1845
|
+
code: "digits.int_overflow",
|
|
1846
|
+
rule: "digits",
|
|
1847
|
+
phase: "validate",
|
|
1848
|
+
detail: { limit: opt.int, actual: intDigits }
|
|
1849
|
+
});
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
// 小数部桁数
|
|
1854
|
+
if (typeof opt.frac === "number") {
|
|
1855
|
+
const fracDigits = (fracPart ?? "").length;
|
|
1856
|
+
if (fracDigits > opt.frac) {
|
|
1857
|
+
// 入力ブロック(frac)
|
|
1858
|
+
if (opt.overflowInputFrac === "block") {
|
|
1859
|
+
ctx.requestRevert({
|
|
1860
|
+
reason: "digits.frac_overflow",
|
|
1861
|
+
detail: { limit: opt.frac, actual: fracDigits }
|
|
1862
|
+
});
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
ctx.pushError({
|
|
1867
|
+
code: "digits.frac_overflow",
|
|
1868
|
+
rule: "digits",
|
|
1869
|
+
phase: "validate",
|
|
1870
|
+
detail: { limit: opt.frac, actual: fracDigits }
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
},
|
|
1875
|
+
|
|
1876
|
+
/**
|
|
1877
|
+
* blur時の穏やか補正(整数部/小数部)
|
|
1878
|
+
* - 整数部: truncateLeft / truncateRight / clamp
|
|
1879
|
+
* - 小数部: truncate / round
|
|
1880
|
+
* @param {string} value
|
|
1881
|
+
* @param {GuardContext} _ctx
|
|
1882
|
+
* @returns {string}
|
|
1883
|
+
*/
|
|
1884
|
+
fix(value, _ctx) {
|
|
1885
|
+
const v = String(value);
|
|
1886
|
+
if (v === "" || v === "-" || v === "." || v === "-.") { return v; }
|
|
1887
|
+
|
|
1888
|
+
const parts = splitNumber(v);
|
|
1889
|
+
let { intPart, fracPart } = parts;
|
|
1890
|
+
const { sign, hasDot } = parts;
|
|
1891
|
+
|
|
1892
|
+
// --- 整数部補正 ---
|
|
1893
|
+
if (typeof opt.int === "number" && opt.fixIntOnBlur !== "none") {
|
|
1894
|
+
// ※ 補正は「見た目の桁数」で判定(先頭ゼロ含む)
|
|
1895
|
+
const actual = (intPart ?? "").length;
|
|
1896
|
+
|
|
1897
|
+
if (actual > opt.int) {
|
|
1898
|
+
if (opt.fixIntOnBlur === "truncateLeft") {
|
|
1899
|
+
// 末尾 opt.int 桁を残す(先頭=大きい桁を削る)
|
|
1900
|
+
intPart = intPart.slice(intPart.length - opt.int);
|
|
1901
|
+
} else if (opt.fixIntOnBlur === "truncateRight") {
|
|
1902
|
+
// 先頭 opt.int 桁を残す(末尾=小さい桁を削る)
|
|
1903
|
+
intPart = intPart.slice(0, opt.int);
|
|
1904
|
+
} else if (opt.fixIntOnBlur === "clamp") {
|
|
1905
|
+
intPart = "9".repeat(opt.int);
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
// --- 小数部補正 ---
|
|
1911
|
+
if (typeof opt.frac === "number" && opt.fixFracOnBlur !== "none" && hasDot) {
|
|
1912
|
+
const limit = opt.frac;
|
|
1913
|
+
const f = fracPart ?? "";
|
|
1914
|
+
|
|
1915
|
+
if (f.length > limit) {
|
|
1916
|
+
if (opt.fixFracOnBlur === "truncate") {
|
|
1917
|
+
fracPart = f.slice(0, limit);
|
|
1918
|
+
} else if (opt.fixFracOnBlur === "round") {
|
|
1919
|
+
const rounded = roundFraction(intPart, f, limit);
|
|
1920
|
+
intPart = rounded.intPart;
|
|
1921
|
+
fracPart = rounded.fracPart;
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// 組み立て(frac=0 のときは "." を残すか?は方針次第だが、ここでは消す)
|
|
1927
|
+
if (!hasDot || typeof opt.frac !== "number") {
|
|
1928
|
+
return `${sign}${intPart}`;
|
|
1929
|
+
}
|
|
1930
|
+
if (opt.frac === 0) {
|
|
1931
|
+
return `${sign}${intPart}`;
|
|
1932
|
+
}
|
|
1933
|
+
return `${sign}${intPart}.${fracPart}`;
|
|
1934
|
+
}
|
|
1935
|
+
};
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
/**
|
|
1939
|
+
* datasetから digits ルールを生成する
|
|
1940
|
+
* - data-tig-rules-digits が無ければ null
|
|
1941
|
+
* - オプションは data-tig-rules-digits-xxx から読む
|
|
1942
|
+
*
|
|
1943
|
+
* 対応する data 属性(dataset 名)
|
|
1944
|
+
* - data-tig-rules-digits -> dataset.tigRulesDigits
|
|
1945
|
+
* - data-tig-rules-digits-int -> dataset.tigRulesDigitsInt
|
|
1946
|
+
* - data-tig-rules-digits-frac -> dataset.tigRulesDigitsFrac
|
|
1947
|
+
* - data-tig-rules-digits-count-leading-zeros -> dataset.tigRulesDigitsCountLeadingZeros
|
|
1948
|
+
* - data-tig-rules-digits-fix-int-on-blur -> dataset.tigRulesDigitsFixIntOnBlur
|
|
1949
|
+
* - data-tig-rules-digits-fix-frac-on-blur -> dataset.tigRulesDigitsFixFracOnBlur
|
|
1950
|
+
* - data-tig-rules-digits-overflow-input-int -> dataset.tigRulesDigitsOverflowInputInt
|
|
1951
|
+
* - data-tig-rules-digits-overflow-input-frac -> dataset.tigRulesDigitsOverflowInputFrac
|
|
1952
|
+
*
|
|
1953
|
+
* @param {DOMStringMap} dataset
|
|
1954
|
+
* @param {HTMLInputElement|HTMLTextAreaElement} _el
|
|
1955
|
+
* @returns {Rule|null}
|
|
1956
|
+
*/
|
|
1957
|
+
digits.fromDataset = function fromDataset(dataset, _el) {
|
|
1958
|
+
// ON判定
|
|
1959
|
+
if (dataset.tigRulesDigits == null) {
|
|
1960
|
+
return null;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
/** @type {DigitsRuleOptions} */
|
|
1964
|
+
const options = {};
|
|
1965
|
+
|
|
1966
|
+
// int / frac
|
|
1967
|
+
const intN = parseDatasetNumber(dataset.tigRulesDigitsInt);
|
|
1968
|
+
if (intN != null) {
|
|
1969
|
+
options.int = intN;
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
const fracN = parseDatasetNumber(dataset.tigRulesDigitsFrac);
|
|
1973
|
+
if (fracN != null) {
|
|
1974
|
+
options.frac = fracN;
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
// countLeadingZeros
|
|
1978
|
+
const clz = parseDatasetBool(dataset.tigRulesDigitsCountLeadingZeros);
|
|
1979
|
+
if (clz != null) {
|
|
1980
|
+
options.countLeadingZeros = clz;
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// fixIntOnBlur / fixFracOnBlur
|
|
1984
|
+
const fixInt = parseDatasetEnum(dataset.tigRulesDigitsFixIntOnBlur, [
|
|
1985
|
+
"none",
|
|
1986
|
+
"truncateLeft",
|
|
1987
|
+
"truncateRight",
|
|
1988
|
+
"clamp"
|
|
1989
|
+
]);
|
|
1990
|
+
if (fixInt != null) {
|
|
1991
|
+
options.fixIntOnBlur = fixInt;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
const fixFrac = parseDatasetEnum(dataset.tigRulesDigitsFixFracOnBlur, [
|
|
1995
|
+
"none",
|
|
1996
|
+
"truncate",
|
|
1997
|
+
"round"
|
|
1998
|
+
]);
|
|
1999
|
+
if (fixFrac != null) {
|
|
2000
|
+
options.fixFracOnBlur = fixFrac;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
// overflowInputInt / overflowInputFrac
|
|
2004
|
+
const ovInt = parseDatasetEnum(dataset.tigRulesDigitsOverflowInputInt, ["none", "block"]);
|
|
2005
|
+
if (ovInt != null) {
|
|
2006
|
+
options.overflowInputInt = ovInt;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
const ovFrac = parseDatasetEnum(dataset.tigRulesDigitsOverflowInputFrac, ["none", "block"]);
|
|
2010
|
+
if (ovFrac != null) {
|
|
2011
|
+
options.overflowInputFrac = ovFrac;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
return digits(options);
|
|
2015
|
+
};
|
|
2016
|
+
|
|
2017
|
+
/**
|
|
2018
|
+
* The script is part of TextInputGuard.
|
|
2019
|
+
*
|
|
2020
|
+
* AUTHOR:
|
|
2021
|
+
* natade-jp (https://github.com/natade-jp)
|
|
2022
|
+
*
|
|
2023
|
+
* LICENSE:
|
|
2024
|
+
* The MIT license https://opensource.org/licenses/MIT
|
|
2025
|
+
*/
|
|
2026
|
+
|
|
2027
|
+
/**
|
|
2028
|
+
* カンマ付与ルール
|
|
2029
|
+
* - blur時のみ整数部に3桁区切りカンマを付与する
|
|
2030
|
+
* @returns {Rule}
|
|
2031
|
+
*/
|
|
2032
|
+
function comma() {
|
|
2033
|
+
return {
|
|
2034
|
+
name: "comma",
|
|
2035
|
+
targets: ["input"],
|
|
2036
|
+
|
|
2037
|
+
/**
|
|
2038
|
+
* 表示整形(確定時のみ)
|
|
2039
|
+
* @param {string} value
|
|
2040
|
+
* @returns {string}
|
|
2041
|
+
*/
|
|
2042
|
+
format(value) {
|
|
2043
|
+
const v = String(value);
|
|
2044
|
+
if (v === "" || v === "-" || v === "." || v === "-.") {
|
|
2045
|
+
return v;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
let sign = "";
|
|
2049
|
+
let s = v;
|
|
2050
|
+
|
|
2051
|
+
if (s.startsWith("-")) {
|
|
2052
|
+
sign = "-";
|
|
2053
|
+
s = s.slice(1);
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
const dotIndex = s.indexOf(".");
|
|
2057
|
+
const intPart = dotIndex >= 0 ? s.slice(0, dotIndex) : s;
|
|
2058
|
+
const fracPart = dotIndex >= 0 ? s.slice(dotIndex + 1) : null;
|
|
2059
|
+
|
|
2060
|
+
// 整数部にカンマ
|
|
2061
|
+
const withComma = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
2062
|
+
|
|
2063
|
+
if (fracPart != null) {
|
|
2064
|
+
return `${sign}${withComma}.${fracPart}`;
|
|
2065
|
+
}
|
|
2066
|
+
return `${sign}${withComma}`;
|
|
2067
|
+
}
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
/**
|
|
2071
|
+
* datasetから comma ルールを生成する
|
|
2072
|
+
* - data-tig-rules-comma が無ければ null
|
|
2073
|
+
*
|
|
2074
|
+
* 対応する data 属性(dataset 名)
|
|
2075
|
+
* - data-tig-rules-comma -> dataset.tigRulesComma
|
|
2076
|
+
*
|
|
2077
|
+
* @param {DOMStringMap} dataset
|
|
2078
|
+
* @param {HTMLInputElement|HTMLTextAreaElement} _el
|
|
2079
|
+
* @returns {Rule|null}
|
|
2080
|
+
*/
|
|
2081
|
+
comma.fromDataset = function fromDataset(dataset, _el) {
|
|
2082
|
+
// ON判定:data-tig-rules-comma が無ければ対象外
|
|
2083
|
+
if (dataset.tigRulesComma == null) {
|
|
2084
|
+
return null;
|
|
2085
|
+
}
|
|
2086
|
+
return comma();
|
|
2087
|
+
};
|
|
2088
|
+
|
|
2089
|
+
/**
|
|
2090
|
+
* TextInputGuard - Public Entry
|
|
2091
|
+
* - ESM/CJS: named exports (attach / autoAttach / rules / numeric / digits / comma / version)
|
|
2092
|
+
* - UMD: exposed to global (e.g. window.TextInputGuard) with the same shape
|
|
2093
|
+
*
|
|
2094
|
+
* AUTHOR:
|
|
2095
|
+
* natade-jp (https://github.com/natade-jp)
|
|
2096
|
+
*
|
|
2097
|
+
* LICENSE:
|
|
2098
|
+
* The MIT license https://opensource.org/licenses/MIT
|
|
2099
|
+
*/
|
|
2100
|
+
|
|
2101
|
+
|
|
2102
|
+
// ---- autoAttach ----
|
|
2103
|
+
const auto = new InputGuardAutoAttach(attach, [
|
|
2104
|
+
{ name: "numeric", fromDataset: numeric.fromDataset },
|
|
2105
|
+
{ name: "digits", fromDataset: digits.fromDataset },
|
|
2106
|
+
{ name: "comma", fromDataset: comma.fromDataset }
|
|
2107
|
+
]);
|
|
2108
|
+
|
|
2109
|
+
/**
|
|
2110
|
+
* data属性から自動で attach する
|
|
2111
|
+
* @param {Document|DocumentFragment|ShadowRoot|Element} [root=document]
|
|
2112
|
+
*/
|
|
2113
|
+
const autoAttach = (root) => auto.autoAttach(root);
|
|
2114
|
+
|
|
2115
|
+
/**
|
|
2116
|
+
* ルール生成関数の名前空間(rules.xxx(...) で使う)
|
|
2117
|
+
*/
|
|
2118
|
+
const rules = {
|
|
2119
|
+
numeric,
|
|
2120
|
+
digits,
|
|
2121
|
+
comma
|
|
2122
|
+
};
|
|
2123
|
+
|
|
2124
|
+
/**
|
|
2125
|
+
* バージョン(ビルド時に置換したいならここを差し替える)
|
|
2126
|
+
* 例: rollup replace で ""0.0.1"" を package.json の version に置換
|
|
2127
|
+
*/
|
|
2128
|
+
// @ts-ignore
|
|
2129
|
+
// eslint-disable-next-line no-undef
|
|
2130
|
+
const version = "0.0.1" ;
|
|
2131
|
+
|
|
2132
|
+
export { attach, attachAll, autoAttach, comma, digits, numeric, rules, version };
|