HTML Form で Enter キーによるサブミットを防止する方法、JavaScript で POST する方法

# はじめに

この記事は [2023 年の MDN 翻訳 Advent Calendar](https://qiita.com/advent-calendar/2023/mdn-translation-community-japan) 向けに作成したものです。

こんにちは。debiru です。ネタが尽きたのでアドベントカレンダーが空いてしまっています。

今日は適当に思いついた HTML Form 周りの挙動についてお話したいと思います。

# `input[type="text"]` で Enter キーを押すとサブミットされるのを防止する

```html
<form action="https://params.lavoscore.org/" method="post">
  <ul>
    <li><input type="text" name="name" value="Alice"></li>
    <li><button type="submit">submit</button></li>
  </ul>
</form>
```

このような HTML フォームがあるとき、`input[type="text"]` の編集中に Enter キーを押すと、フォームがサブミットされてしまいます。この挙動を防ぐにはどうしたらよいでしょうか。

## `input` 要素の `keydown` イベントを監視する

Enter キーが押されてもサブミットしないようにイベントハンドラで制御するという方法です。

```js
(function() {
  'use strict';

  function delegateEvent(selector, type, listener, options) {
    if (options == null) options = false;
    document.addEventListener(type, evt => {
      for (let elem = evt.target; elem && elem !== document; elem = elem.parentNode) {
        if (elem.matches(selector)) return listener.call(elem, evt);
      }
    }, options);
  }

  delegateEvent('input', 'keydown', evt => {
    if (evt.key === 'Enter') evt.preventDefault();
  });
}());
```

この方法が一般的には用いられているかと思います。しかし別の方法も存在します。

## `disabled` なサブミットボタンを先頭に設置する

<p class="codepen" data-height="400" data-default-tab="html,result" data-slug-hash="QWoLXBo" data-user="arcxor" style="height: 400px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/arcxor/pen/QWoLXBo">
  Form prevent submit by Enter key</a> by arcxor (<a href="https://codepen.io/arcxor">@arcxor</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

`<form>` の子孫要素として最初に登場するサブミットボタンを `disabled` にするだけです。

```html
<button type="submit" disabled style="display: none;"></button>
```

`form` にはデフォルトボタンという概念があり、`form` 要素内に最初に登場するサブミットボタンがデフォルトボタンとして扱われます。

- https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#implicit-submission

> - A form element's default button is the first submit button in tree order whose form owner is that form element.
> - If the user agent supports letting the user submit a form implicitly (for example, on some platforms hitting the "enter" key while a text control is focused implicitly submits the form), then doing so for a form, whose default button has activation behavior and is not disabled, must cause the user agent to fire a click event at that default button.

拙訳：

- フォーム要素のデフォルトボタンは、そのフォーム要素がルートとなる構文木の順序において最初に登場する送信ボタンです。
- ユーザーエージェントが、ユーザーに暗黙的にフォームを送信させることをサポートしている場合（例えば、あるプラットフォームでは、テキストコントロールがフォーカスされている間に "enter" キーを押すと、暗黙的にフォームが送信されます）、デフォルトボタンがアクティブな振る舞いを持ち、無効化されていないフォームに対してこれを行うと、ユーザーエージェントはそのデフォルトボタンでクリックイベントを発生させなければなりません。

つまり、デフォルトボタンを `disabled` にしておくことで、Enter キーが押された場合にフォームのサブミットが行われないようにするという方法です。

しかし、どうやら Safari ではこの方法は効果がないようです。

以下の関連記事もありますが、これ、仕様的にどうなっているのか、なぜ Safari では無視されるのかについてお分かりの方がいたらぜひコメント欄で教えてください。

### 関連記事

- [送信ボタンがdisabledのときにテキスト入力欄でEnterを押すと、フォームはどうなるか](https://qiita.com/kazutosato/items/886fc3d19e65c68d1824)

# HTML Form の POST リクエストを JavaScript で再現する方法

```html
<form action="https://params.lavoscore.org/" method="post">
  <ul>
    <li><input type="text" name="name" value="Alice"></li>
    <li><input type="text" name="message" value="&lt;xmp&gt;"></li>
    <li><button type="submit">submit</button></li>
  </ul>
</form>
```

上記のような `name`, `message` をパラメータとして持つフォームを単純にサブミットすると、サーバーサイドでは次のようなパラメータが受け取れます。以下は PHP で処理した例です。

```json
{
  "headers": {
    "Content-Type": "application/x-www-form-urlencoded"
  },
  "body": "name=Alice&message=%3Cxmp%3E",
  "post": {
    "name": "Alice",
    "message": "<xmp>"
  },
  "get": [],
  "files": []
}
```

JavaScript の処理で、この POST リクエストと同等のリクエストを送る方法は分かるでしょうか。今どきは `XMLHttpRequest (XHR)` ではなく `fetch` という新しい API が存在しているので `fetch` を使いましょう。

## `fetch` の第 2 引数に `{ body: params }` を与えてみる

```js
(function() {
  'use strict';

  async function doFetch() {
    const url = 'https://params.lavoscore.org/';

    const params = {
      name: 'Alice',
      message: '<xmp>',
    };

    const options = {
      method: 'POST',
      body: params,
    };

    const obj = await fetch(url, options).then(response => response.json());
    console.log(obj);
  }

  document.addEventListener('DOMContentLoaded', doFetch);
}());
```

`body` に `params` オブジェクトを直接指定した例ですが、これはうまくいきません。実行してみると、以下のようなレスポンスが返ります。HTTP リクエストボディが `[object Object]` という文字列になってしまっています。

```json
{
  "headers": {
    "Content-Type": "text/plain;charset=UTF-8"
  },
  "body": "[object Object]",
  "post": [],
  "get": [],
  "files": []
}
```

`body` パラメータには、文字列またはサポートされている適切なデータ型を与える必要があります。

## `application/json` な値を与えてみる

```js
(function() {
  'use strict';

  async function doFetch() {
    const url = 'https://params.lavoscore.org/';

    const params = {
      name: 'Alice',
      message: '<xmp>',
    };

    const options = {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(params),
    };

    const obj = await fetch(url, options).then(response => response.json());
    console.log(obj);
  }

  document.addEventListener('DOMContentLoaded', doFetch);
}());
```

`options` に `headers['Content-Type']` を追加して、`body` の値を文字列（JSON）とすることで、JSON データを HTTP リクエストボディに与えることができます。サーバーサイドでは以下のように値を受け取ることができます。

```json
{
  "headers": {
    "Content-Type": "application/json"
  },
  "body": "{\"name\":\"Alice\",\"message\":\"<xmp>\"}",
  "post": [],
  "get": [],
  "files": []
}
```

しかしこれでは PHP の場合、`$_POST` としては値を受け取ることができませんね。

### Blob として JSON データを与える

`fetch` の第 2 引数 `options` として `headers['Content-Type']` を明記したくない場合は、次のような方法もあります。

```js
(function() {
  'use strict';

  async function doFetch() {
    const url = 'https://params.lavoscore.org/';

    const params = {
      name: 'Alice',
      message: '<xmp>',
    };

    const options = {
      method: 'POST',
      body: new Blob([JSON.stringify(params)], {type: 'application/json'}),
    };

    const obj = await fetch(url, options).then(response => response.json());
    console.log(obj);
  }

  document.addEventListener('DOMContentLoaded', doFetch);
}());
```

## `multipart/form-data` な `FormData` を使ってみる

JavaScript には `FormData` インターフェイスが存在しています。

```js
(function() {
  'use strict';

  function makeDataAsFormData(obj) {
    const formData = new FormData();
    Object.keys(obj).forEach(key => formData.append(key, obj[key]));
    return formData;
  }

  async function doFetch(makeDataFunc) {
    const url = 'https://params.lavoscore.org/';

    const params = {
      name: 'Alice',
      message: '<xmp>',
    };

    const options = {
      method: 'POST',
      body: makeDataAsFormData(params),
    };

    const obj = await fetch(url, options).then(response => response.json());
    console.log(obj);
  }

  document.addEventListener('DOMContentLoaded', doFetch);
}());
```

これを介すと、次のようなレスポンスが受け取れます。

```json
{
  "headers": {
    "Content-Type": "multipart/form-data; boundary=---------------------------29960240541939911991501528013"
  },
  "body": "",
  "post": {
    "name": "Alice",
    "message": "<xmp>"
  },
  "get": [],
  "files": []
}
```

`FormData` を `body` として与えると、`Content-Type` は `multipart/form-data` として扱われます。この方法ならサーバーサイドで PHP の場合 `$_POST` として値を処理することができます。

なお、この方法を使う場合、明示的に `fetch` の第 2 引数として `headers['Content-Type']` を指定してはいけません。指定した場合、`boundary` が設定されず、サーバーサイドで値を適切に受け取ることができなくなってしまいます。

### 関連記事

- [fetch で multipart/form-data を送る時は Content-Type を指定してはいけない](https://zenn.dev/kariya_mitsuru/articles/25c9aeb27059e7)

## `application/x-www-form-urlencoded` な `URLSearchParams` を使う

```js
(function() {
  'use strict';

  async function doFetch(makeDataFunc) {
    const url = 'https://params.lavoscore.org/';

    const params = {
      name: 'Alice',
      message: '<xmp>',
    };

    const options = {
      method: 'POST',
      body: new URLSearchParams(params),
    };

    const obj = await fetch(url, options).then(response => response.json());
    console.log(obj);
  }

  document.addEventListener('DOMContentLoaded', doFetch);
}());
```

`URLSearchParams` を `body` として与えると、`Content-Type` は `application/x-www-form-urlencoded` として扱われます。この方法ならサーバーサイドで PHP の場合 `$_POST` として値を処理することができます。

これを介すと、次のようなレスポンスが受け取れます。

```json
{
  "headers": {
    "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
  },
  "body": "name=Alice&message=%3Cxmp%3E",
  "post": {
    "name": "Alice",
    "message": "<xmp>"
  },
  "get": [],
  "files": []
}
```

このレスポンスは、JavaScript を介さずに HTML Form をサブミットしたときと同等のレスポンスになっています。つまり、JavaScript で `application/x-www-form-urlencoded` な POST リクエストを送りたい場合は、この方法が適しています。

## 試してみる

<p class="codepen" data-height="600" data-default-tab="html,result" data-slug-hash="PoLYrEe" data-user="arcxor" style="height: 600px; box-sizing: border-box; display: flex; align-items: center; justify-content: center; border: 2px solid; margin: 1em 0; padding: 1em;">
  <span>See the Pen <a href="https://codepen.io/arcxor/pen/PoLYrEe">
  POST Request by JavaScript</a> by arcxor (<a href="https://codepen.io/arcxor">@arcxor</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

# さいごに

HTML Form 関連のお話でした。JavaScript で連想配列（オブジェクト）のパラメータを POST（または GET）として `fetch` でリクエストするには、実は少し工夫が必要だったのです。自分で `FormData` なり `URLSearchParams` を用意して `body` に値を与えなければなりません。

というわけで、既に 12 月 21 日ですが、この記事は [2023 年の MDN 翻訳 Advent Calendar](https://qiita.com/advent-calendar/2023/mdn-translation-community-japan) の 20 日の記事ということで公開しておこうと思います。21 日の記事はどうするんでしょうねぇ。

おわり。