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