text-input-guard 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,19 @@
1
- # TextInputGuard
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://natade-jp.github.io/text-input-guard/ogp-dark.svg">
4
+ <source media="(prefers-color-scheme: light)" srcset="https://natade-jp.github.io/text-input-guard/ogp-light.svg">
5
+ <img src="https://natade-jp.github.io/text-input-guard/ogp-light.svg" width="700" alt="TextInputGuard">
6
+ </picture>
7
+ </p>
2
8
 
3
- TextInputGuard は、**開発中**の日本向けの入力欄ガードライブラリです。
9
+ TextInputGuard は、**開発中**の日本語入力環境を前提に設計された入力補助ライブラリです。
4
10
 
5
- `<input>` / `<textarea>` に対して、数値入力や日本語特有の制約(全角混在、桁数、表示整形など)を扱いやすい形で提供します。
11
+ `<input>` / `<textarea>` に対して、全角混在・桁数制限・小数処理・表示整形など、日本語環境特有の入力制御を扱いやすい形で提供します。業務系フォームや金額入力など、IME の影響を受けやすい入力欄でも、表示用の値と送信用の値を分離しながら、安定した入力制御を実現できます。
6
12
 
7
- シンプルな設計のため、機能追加も簡単に行えます。
13
+ ---
8
14
 
9
- 詳細は以下をご確認ください
15
+ ## Documentation
10
16
 
11
- ### **[TextInputGuard 紹介](https://natade-jp.github.io/text-input-guard/)**
17
+ 詳しいドキュメントはこちら
18
+
19
+ 👉 https://natade-jp.github.io/text-input-guard/
@@ -165,6 +165,7 @@ class SwapState {
165
165
  // raw化(送信担当)
166
166
  input.type = "hidden";
167
167
  input.removeAttribute("id");
168
+ input.removeAttribute("class");
168
169
  input.className = "";
169
170
  input.dataset.tigRole = "raw";
170
171
 
@@ -172,6 +173,9 @@ class SwapState {
172
173
  if (this.originalId) {
173
174
  input.dataset.tigOriginalId = this.originalId;
174
175
  }
176
+ if (this.originalClass) {
177
+ input.dataset.tigOriginalClass = this.originalClass;
178
+ }
175
179
  if (this.originalName) {
176
180
  input.dataset.tigOriginalName = this.originalName;
177
181
  }
@@ -192,7 +196,10 @@ class SwapState {
192
196
  display.id = this.originalId;
193
197
  }
194
198
 
195
- display.className = this.originalClass ?? "";
199
+ if (this.originalClass) {
200
+ display.className = this.originalClass;
201
+ }
202
+
196
203
  display.value = raw.value;
197
204
 
198
205
  for (const [name, v] of Object.entries(this.originalUiAttrs)) {
@@ -253,6 +260,7 @@ class SwapState {
253
260
 
254
261
  delete raw.dataset.tigRole;
255
262
  delete raw.dataset.tigOriginalId;
263
+ delete raw.dataset.tigOriginalClass;
256
264
  delete raw.dataset.tigOriginalName;
257
265
  }
258
266
  }
@@ -398,7 +406,7 @@ class SwapState {
398
406
  * @typedef {Object} AttachOptions
399
407
  * @property {Rule[]} [rules] - 適用するルール配列(順番がフェーズ内実行順になる)
400
408
  * @property {boolean} [warn] - 非対応ルールなどを console.warn するか
401
- * @property {string} [invalidClass] - エラー時に付けるclass名
409
+ * @property {string} [invalidClass="is-invalid"] - エラー時に付けるclass名
402
410
  * @property {SeparateValueOptions} [separateValue] - 表示値と内部値の分離設定
403
411
  * @property {(result: ValidateResult) => void} [onValidate] - 評価完了時の通知(input/commitごと)
404
412
  */
@@ -446,6 +454,28 @@ function warnLog(msg, warn) {
446
454
  }
447
455
  }
448
456
 
457
+ /**
458
+ * input / textarea 要素と内部 Guard インスタンスの対応表
459
+ *
460
+ * - key: displayElement
461
+ * - value: InputGuard(内部実装)
462
+ *
463
+ * @type {WeakMap<HTMLInputElement|HTMLTextAreaElement, InputGuard>}
464
+ */
465
+ const guardMap = new WeakMap();
466
+
467
+ document.addEventListener("selectionchange", () => {
468
+ const el = document.activeElement;
469
+ if (!(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) {
470
+ return;
471
+ }
472
+ const inputGuard = guardMap.get(el);
473
+ if (!inputGuard) {
474
+ return;
475
+ }
476
+ inputGuard.onSelectionChange();
477
+ });
478
+
449
479
  /**
450
480
  * 指定した1要素に対してガードを適用し、Guard API を返す
451
481
  * @param {HTMLInputElement|HTMLTextAreaElement} element
@@ -453,9 +483,12 @@ function warnLog(msg, warn) {
453
483
  * @returns {Guard}
454
484
  */
455
485
  function attach(element, options = {}) {
456
- const guard = new InputGuard(element, options);
457
- guard.init();
458
- return guard.getGuard();
486
+ const inputGuard = new InputGuard(element, options);
487
+ inputGuard.init();
488
+ const guard = inputGuard.getGuard();
489
+ const display = guard.getDisplayElement();
490
+ guardMap.set(display, inputGuard);
491
+ return guard;
459
492
  }
460
493
 
461
494
  /**
@@ -551,7 +584,7 @@ class InputGuard {
551
584
  /**
552
585
  * ユーザーが直接入力する表示側要素
553
586
  * swapしない場合は originalElement と同一
554
- * @type {HTMLElement}
587
+ * @type {HTMLInputElement|HTMLTextAreaElement}
555
588
  */
556
589
  this.displayElement = element;
557
590
 
@@ -673,6 +706,12 @@ class InputGuard {
673
706
  */
674
707
  this.pendingCompositionCommit = false;
675
708
 
709
+ /**
710
+ * selection 更新のフレーム予約ID
711
+ * @type {number|null}
712
+ */
713
+ this.selectionFrameId = null;
714
+
676
715
  /**
677
716
  * 直前に受理した表示値、正しい情報のスナップショットのような情報(block時の戻し先)
678
717
  * @type {string}
@@ -718,9 +757,11 @@ class InputGuard {
718
757
  * @returns {SelectionState}
719
758
  */
720
759
  readSelection(el) {
760
+ const start = el.selectionStart ?? 0;
761
+ const end = el.selectionEnd ?? start;
721
762
  return {
722
- start: el.selectionStart,
723
- end: el.selectionEnd,
763
+ start,
764
+ end,
724
765
  direction: el.selectionDirection
725
766
  };
726
767
  }
@@ -834,6 +875,8 @@ class InputGuard {
834
875
  * @returns {void}
835
876
  */
836
877
  detach() {
878
+ // 管理マップから削除
879
+ guardMap.delete(this.displayElement);
837
880
  // イベント解除(displayElementがswap後の可能性があるので先に外す)
838
881
  this.unbindEvents();
839
882
  // swap復元
@@ -894,15 +937,7 @@ class InputGuard {
894
937
  this.displayElement.addEventListener("input", this.onInput);
895
938
  this.displayElement.addEventListener("beforeinput", this.onBeforeInput);
896
939
  this.displayElement.addEventListener("blur", this.onBlur);
897
-
898
- // フォーカスで編集用に戻す
899
940
  this.displayElement.addEventListener("focus", this.onFocus);
900
-
901
- // キャレット/選択範囲の変化を拾う(block時の不自然ジャンプ対策)
902
- this.displayElement.addEventListener("keyup", this.onSelectionChange);
903
- this.displayElement.addEventListener("mouseup", this.onSelectionChange);
904
- this.displayElement.addEventListener("select", this.onSelectionChange);
905
- this.displayElement.addEventListener("focus", this.onSelectionChange);
906
941
  }
907
942
 
908
943
  /**
@@ -916,10 +951,6 @@ class InputGuard {
916
951
  this.displayElement.removeEventListener("beforeinput", this.onBeforeInput);
917
952
  this.displayElement.removeEventListener("blur", this.onBlur);
918
953
  this.displayElement.removeEventListener("focus", this.onFocus);
919
- this.displayElement.removeEventListener("keyup", this.onSelectionChange);
920
- this.displayElement.removeEventListener("mouseup", this.onSelectionChange);
921
- this.displayElement.removeEventListener("select", this.onSelectionChange);
922
- this.displayElement.removeEventListener("focus", this.onSelectionChange);
923
954
  }
924
955
 
925
956
  /**
@@ -1287,12 +1318,41 @@ class InputGuard {
1287
1318
  * @returns {void}
1288
1319
  */
1289
1320
  onSelectionChange() {
1290
- // IME変換中は無視(この間はキャレット位置が不安定になることがあるため)
1291
- if (this.composing) {
1292
- return;
1321
+ const requestFrame =
1322
+ typeof requestAnimationFrame === "function"
1323
+ ? requestAnimationFrame
1324
+ : (
1325
+ /** @param {FrameRequestCallback} cb */
1326
+ (cb) => setTimeout(cb, 0)
1327
+ );
1328
+
1329
+ const cancelFrame =
1330
+ typeof cancelAnimationFrame === "function"
1331
+ ? cancelAnimationFrame
1332
+ : clearTimeout;
1333
+
1334
+ // すでに予約済みならキャンセル(selectionchange は連続発火するため)
1335
+ if (this.selectionFrameId != null) {
1336
+ cancelFrame(this.selectionFrameId);
1293
1337
  }
1294
- const el = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (this.displayElement);
1295
- this.lastAcceptedSelection = this.readSelection(el);
1338
+
1339
+ this.selectionFrameId = requestFrame(() => {
1340
+ this.selectionFrameId = null;
1341
+
1342
+ // IME変換中は無視(キャレット位置が不安定になるため)
1343
+ if (this.composing) {
1344
+ return;
1345
+ }
1346
+
1347
+ const el = /** @type {HTMLInputElement|HTMLTextAreaElement} */ (this.displayElement);
1348
+
1349
+ // 要素がフォーカスされていない場合は無視
1350
+ if (document.activeElement !== el) {
1351
+ return;
1352
+ }
1353
+
1354
+ this.lastAcceptedSelection = this.readSelection(el);
1355
+ });
1296
1356
  }
1297
1357
 
1298
1358
  /**
@@ -2701,6 +2761,104 @@ comma.fromDataset = function fromDataset(dataset, _el) {
2701
2761
  return comma();
2702
2762
  };
2703
2763
 
2764
+ /**
2765
+ * The script is part of TextInputGuard.
2766
+ *
2767
+ * AUTHOR:
2768
+ * natade-jp (https://github.com/natade-jp)
2769
+ *
2770
+ * LICENSE:
2771
+ * The MIT license https://opensource.org/licenses/MIT
2772
+ */
2773
+
2774
+ /**
2775
+ * IMEオフ入力相当の文字変換テーブル
2776
+ * @type {Record<string, string>}
2777
+ */
2778
+ /* eslint-disable quote-props */
2779
+ const IME_OFF_MAP = {
2780
+ "\u3000": "\u0020", // 全角スペース → space
2781
+ "\u3001": "\u002C", // 、 → ,
2782
+ "\u3002": "\u002E", // 。 → .
2783
+ "\u300C": "\u005B", // 「 → [
2784
+ "\u300D": "\u005D", // 」 → ]
2785
+ "\u301C": "\u007E", // 〜 → ~
2786
+ "\u30FC": "\u002D", // ー → -
2787
+ "\uFFE5": "\u005C" // ¥ → \
2788
+ };
2789
+ /* eslint-enable quote-props */
2790
+
2791
+ /**
2792
+ * ASCII入力欄に日本語IMEで入った文字をASCIIへ矯正する
2793
+ * @param {string} text - 変換したいテキスト
2794
+ * @returns {string} 変換後のテキスト
2795
+ */
2796
+ const toImeOff = function (text) {
2797
+ return Array.from(String(text), (ch) => {
2798
+ // 個別マップ
2799
+ if (ch in IME_OFF_MAP) {
2800
+ return IME_OFF_MAP[ch];
2801
+ }
2802
+
2803
+ const code = ch.charCodeAt(0);
2804
+
2805
+ // 全角ASCII
2806
+ if (code >= 0xFF01 && code <= 0xFF5E) {
2807
+ return String.fromCharCode(code - 0xFEE0);
2808
+ }
2809
+
2810
+ // シングルクォート系
2811
+ if (code >= 0x2018 && code <= 0x201B) {
2812
+ return "'";
2813
+ }
2814
+
2815
+ // ダブルクォート系
2816
+ if (code >= 0x201C && code <= 0x201F) {
2817
+ return '"';
2818
+ }
2819
+
2820
+ return ch;
2821
+ }).join("");
2822
+ };
2823
+
2824
+ /**
2825
+ * ASCII入力欄に日本語IMEで入った文字をASCIIへ矯正する
2826
+ *
2827
+ * 注意:
2828
+ * - これは「半角化」ではなく「IMEオフ入力相当への寄せ」
2829
+ * - ascii() とは責務が異なる
2830
+ *
2831
+ * @returns {Rule}
2832
+ */
2833
+ function imeOff() {
2834
+ return {
2835
+ name: "imeOff",
2836
+ targets: ["input", "textarea"],
2837
+
2838
+ normalizeChar(value, ctx) {
2839
+ return toImeOff(value);
2840
+ }
2841
+ };
2842
+ }
2843
+
2844
+ /**
2845
+ * dataset から imeOff ルールを生成する
2846
+ *
2847
+ * 対応する data 属性
2848
+ * - data-tig-rules-ime-off
2849
+ *
2850
+ * @param {DOMStringMap} dataset
2851
+ * @param {HTMLInputElement|HTMLTextAreaElement} _el
2852
+ * @returns {Rule|null}
2853
+ */
2854
+ imeOff.fromDataset = function fromDataset(dataset, _el) {
2855
+ if (dataset.tigRulesImeOff == null) {
2856
+ return null;
2857
+ }
2858
+
2859
+ return imeOff();
2860
+ };
2861
+
2704
2862
  /**
2705
2863
  * The script is part of Mojix for TextInputGuard.
2706
2864
  *
@@ -5922,7 +6080,7 @@ kana.fromDataset = function fromDataset(dataset, _el) {
5922
6080
  function ascii(options = {}) {
5923
6081
  /** @type {AsciiRuleOptions} */
5924
6082
  const opt = {
5925
- case: options.case ?? null
6083
+ case: options.case ?? "none"
5926
6084
  };
5927
6085
 
5928
6086
  return {
@@ -5935,6 +6093,10 @@ function ascii(options = {}) {
5935
6093
  // まず半角へ正規化
5936
6094
  s = Mojix.toHalfWidthAsciiCode(s);
5937
6095
 
6096
+ // toHalfWidthAsciiCode で対応できていない文字も実施
6097
+ s = s.replace(/\uFFE5/g, "\u005C"); //¥
6098
+ s = s.replace(/[\u2010-\u2015\u2212\u30FC\uFF0D\uFF70]/g, "\u002D"); //ハイフンに似ている記号
6099
+
5938
6100
  // 英字の大文字/小文字統一
5939
6101
  if (opt.case === "upper") {
5940
6102
  s = s.toUpperCase();
@@ -6929,7 +7091,7 @@ function bytes(options = {}) {
6929
7091
  code: "bytes.max_overflow",
6930
7092
  rule: "bytes",
6931
7093
  phase: "validate",
6932
- detail: { max: opt.max, actual: len }
7094
+ detail: { limit: opt.max, actual: len }
6933
7095
  });
6934
7096
  }
6935
7097
  }
@@ -7236,6 +7398,7 @@ const auto = new InputGuardAutoAttach(attach, [
7236
7398
  { name: "numeric", fromDataset: numeric.fromDataset },
7237
7399
  { name: "digits", fromDataset: digits.fromDataset },
7238
7400
  { name: "comma", fromDataset: comma.fromDataset },
7401
+ { name: "imeOff", fromDataset: imeOff.fromDataset },
7239
7402
  { name: "kana", fromDataset: kana.fromDataset },
7240
7403
  { name: "ascii", fromDataset: ascii.fromDataset },
7241
7404
  { name: "filter", fromDataset: filter.fromDataset },
@@ -7260,6 +7423,7 @@ const rules = {
7260
7423
  numeric,
7261
7424
  digits,
7262
7425
  comma,
7426
+ imeOff,
7263
7427
  kana,
7264
7428
  ascii,
7265
7429
  filter,
@@ -7273,11 +7437,11 @@ const rules = {
7273
7437
 
7274
7438
  /**
7275
7439
  * バージョン(ビルド時に置換したいならここを差し替える)
7276
- * 例: rollup replace で ""0.2.0"" を package.json の version に置換
7440
+ * 例: rollup replace で ""0.2.2"" を package.json の version に置換
7277
7441
  */
7278
7442
  // @ts-ignore
7279
7443
  // eslint-disable-next-line no-undef
7280
- const version = "0.2.0" ;
7444
+ const version = "0.2.2" ;
7281
7445
 
7282
7446
  exports.ascii = ascii;
7283
7447
  exports.attach = attach;
@@ -7287,6 +7451,7 @@ exports.bytes = bytes;
7287
7451
  exports.comma = comma;
7288
7452
  exports.digits = digits;
7289
7453
  exports.filter = filter;
7454
+ exports.imeOff = imeOff;
7290
7455
  exports.kana = kana;
7291
7456
  exports.length = length;
7292
7457
  exports.numeric = numeric;