Webアプリでの長押し機能の実装は意外と難しい

## Webアプリケーションにおいてはタップやクリックの長押しはそもそも一般的ではない？

あなたはWebアプリケーションのUIで、タップやクリックの「長押し」をした経験がありますか？

ネイティブアプリでは日常的に使われる長押しですが、Webアプリケーションではあまり見かけません。

Webアプリケーションにおける長押しの用途として、例えばブラウザ上で画像をダウンロードしたり、リンクを別タブで開いたりといった操作が考えられます。

それ以外で、長押しをするようなUIって何があるでしょうか？
例えば、以下のようなインクリメントボタンなんかが当てはまりそうです。

<p class="codepen" data-height="300" data-default-tab="result" data-slug-hash="oNrrMpg" data-pen-title="QIITA_LONG_PRESS_INCREMENT_BUTTON_1" data-user="yoruaki" style="height: 300px; 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/yoruaki/pen/oNrrMpg">
  QIITA_LONG_PRESS_INCREMENT_BUTTON_1</a> by Yoruaki (<a href="https://codepen.io/yoruaki">@yoruaki</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>
<script async src="https://cpwebassets.codepen.io/assets/embed/ei.js"></script>

これ、普通にクリックイベントを付与するだけでも、`button`要素に対してだったらキーボードイベント、つまり<kbd>Enter</kbd>キーや<kbd>Space</kbd>キーにも対応してくれます。
また、その際長押しにも対応してくれます。

```html:HTML
<div id="counter">0</div>
<button type="button" id="incrementBtn">+</button>
```

```js:JavaScript
const counter = document.getElementById('counter');
const incrementBtn = document.getElementById('incrementBtn');

incrementBtn.addEventListener('click', () => {
    counter.textContent = Number(counter.textContent) + 1;
});
```

これは、多くのブラウザやWebアプリケーションは、キーボードアクセシビリティを重視しているためです。

> ガイドライン 2.1 キーボード操作可能: すべての機能をキーボードから利用できるようにすること。

> すべての機能がキーボードを用いて実現できる場合、キーボードの利用者、(キーボード入力を生成する) 音声入力、(オンスクリーンキーボードを使用する) マウス、及び出力として疑似的なキーストロークを生成する様々な支援技術により、その機能を実現できる。キーボード入力が時間に依存しない限り、この柔軟性がある、又はあまねくサポートされる、及び様々な障害のある人が操作可能な入力形態は他にはない。
>
> <cite>[ガイドライン 2.1: キーボード操作可能を理解する](https://waic.jp/translations/WCAG21/Understanding/keyboard-accessible.html)</cite>

なので多くのブラウザでは、<kbd>Enter</kbd>キーによる長押しをデフォルトでサポートしています。つまりキーボード操作における長押しはUIとしては一般的と言えそうです。
ただし、この挙動に対する標準仕様は存在しないので、ブラウザによって多少の違いはあります。

そんな中、現時点で多くのブラウザでクリックの長押しには対応していません。

:::note
Chrome 129.0.6668.59
Safari 17.6
Firefox 130.0.1
Edge 128.0.2739.79
Opera 113.0.5230.86
:::

また、Webアプリケーションでタップやクリックの長押し機能を実装する場合、いくつかの問題点があります。以下にその内容をまとめました。

## 長押し機能を実装する際に発生するいくつかの問題点

### 1. キーボード操作の無効化

長押し機能を実装すると、既存のキーボード操作（特に<kbd>Enter</kbd>キーや<kbd>Space</kbd>キー）が期待通りに動作しないことがあります。
そのため、アクセシビリティを考慮してキーボード操作を再実装する必要があります。

### 2. アクセシビリティの堅牢性

通常のHTMLが持つ機能を活用することで、アクセシビリティ基準を満たすことができます。
しかしながら、長押しのようなカスタム動作を追加することで、デフォルトの機能が阻害される場合があり、これらの基準を満たすことが難しくなる可能性があります。

### 3. 一貫したユーザー体験の提供

<kbd>Enter</kbd>キーの長押しとタップの長押しを同様に動作させる必要がありますが、仕様が明確ではなく、実装者の感覚によるところが大きくなります。

### 4. 長押し機能の必要性

> この機能はネイティブアプリケーションで一般的に用いられるUIであり、Webアプリケーションでは必ずしも一般的とは言えません。Webでは他の操作方法が一般的であるため、使用する場面を慎重に考える必要があります。
via: ChatGPT-4

以上の点から、Webアプリケーションでの長押し機能の実装は慎重に行う必要があります。
他の操作方法で代替可能かどうかを検討することも重要です。

## 長押し機能の代替案

というわけで、長押し機能に対する代替案をいくつか挙げてみます。

:::note info
この記事ではインクリメントボタンに対する提案をしていますが、例えばカルーセルのページ送りにおける長押しなど、他の長押し機能には当てはまらない場合もあります。
:::

### 1. スライダーの実装

長押しによって連続して数値を増減させたいユーザーの目的は、素早く目当ての数値に到達したい場合が考えられます。

そこで、スライダー機能を代替案として提案します。

<p class="codepen" data-height="300" data-slug-hash="vYoBpRx" data-pen-title="Untitled" data-user="yoruaki" style="height: 300px; 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/yoruaki/pen/vYoBpRx">
  Untitled</a> by Yoruaki (<a href="https://codepen.io/yoruaki">@yoruaki</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>

```html:HTML
<div id="counter">0</div>
<input type="range" id="slider" min="0" max="100" value="0">
```

```js:JavaScript
const counter = document.getElementById('counter');
const slider = document.getElementById('slider');

slider.addEventListener('input', () => {
    counter.textContent = slider.value;
});
```

スライダーは、視覚的に数値を調整でき、ユーザーが目標とする値に素早く到達できる優れたUIです。

### 2. 直接入力の受け入れ

素早く目当ての数値に到達するもう一つの方法として、ユーザーに直接数値を入力してもらう方法があります。
ただし、入力欄だけではユーザーが気づかない可能性もあるため、スライダーと組み合わせるとより親切でしょう。

<p class="codepen" data-height="300" data-default-tab="result" data-slug-hash="dyxbJjO" data-pen-title="QIITA_LONG_PRESS_ALTERNATIVE_1" data-user="yoruaki" style="height: 300px; 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/yoruaki/pen/dyxbJjO">
  QIITA_LONG_PRESS_ALTERNATIVE_1</a> by Yoruaki (<a href="https://codepen.io/yoruaki">@yoruaki</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>

```html:HTML
<input type="number" id="counter" value="0" min="0" max="100">
<br>
<input type="range" id="slider" min="0" max="100" value="0">
```

```js:JavaScript
const counter = document.getElementById('counter');
const slider = document.getElementById('slider');

slider.addEventListener('input', () => {
    counter.value = slider.value;
});

counter.addEventListener('input', () => {
    if (counter.value >= 0 && counter.value <= 100) {
        slider.value = counter.value;
    } else {
        alert('0から100の間で入力してください');
    }
});
```

また、ブラウザによっては`<input type="number">`を使用すると、入力欄にフォーカスした際にスピンボタンが表示され、クリックによる長押しにデフォルトで対応しています。

以上の代替案を試すことで、よりユーザーフレンドリーなUIを提供できるかもしれません。

## 長押し機能を実装してみる

とは言え、ネイティブアプリの感覚でWebアプリケーションを触りたいという要望もあると思いますし、今後はそれが一般的になってくる可能性もあります。

なのでまずは実装してみます。
クリックやタップで長押しをしてみてください。

<p class="codepen" data-height="300" data-default-tab="result" data-slug-hash="ExBBmav" data-pen-title="QIITA_LONG_PRESS_INCREMENT_BUTTON_2" data-user="yoruaki" style="height: 300px; 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/yoruaki/pen/ExBBmav">
  QIITA_LONG_PRESS_INCREMENT_BUTTON_2</a> by Yoruaki (<a href="https://codepen.io/yoruaki">@yoruaki</a>)
  on <a href="https://codepen.io">CodePen</a>.</span>
</p>

### 実際のコード

```html:HTML
<div id="counter">0</div>
<button type="button" id="incrementBtn">+</button>
```
```css:CSS
#incrementBtn {
    user-select: none;
}
```

```js:JavaScript
const counter = document.getElementById('counter');
const incrementBtn = document.getElementById('incrementBtn');
let intervalId = null;
let longPressTimeoutId = null;
let isLongPress = false;
let isKeyDown = false;

const incrementCount = () => {
    counter.textContent = Number(counter.textContent) + 1;
};

const startCounting = (action, interval) => {
    if (!intervalId) {
        intervalId = setInterval(action, interval);
    }
};

const triggerLongPress = (action) => {
    if (!isLongPress) {
        isLongPress = true;
        startCounting(action, 75);
    }
};

const stopCounting = () => {
    clearInterval(intervalId);
    intervalId = null;
    isLongPress = false;
    clearTimeout(longPressTimeoutId);
    isKeyDown = false;
};

const handlePointerDown = (evt) => {
    longPressTimeoutId = setTimeout(() => {
        triggerLongPress(incrementCount);
    }, 500);
};

const handlePointerUp = (evt) => {
    if (!isLongPress) {
        incrementCount();
    }
    stopCounting();
};

const handleKeydown = (evt) => {
    if ((evt.key === 'Enter' || evt.key === ' ') && !isKeyDown) {
        isKeyDown = true;
        incrementCount();
        longPressTimeoutId = setTimeout(() => {
            triggerLongPress(incrementCount);
        }, 500);
    }
};

const handleKeyup = (evt) => {
    if (evt.key === 'Enter' || evt.key === ' ') {
        stopCounting();
    }
};

incrementBtn.addEventListener('pointerdown', handlePointerDown);
incrementBtn.addEventListener('pointerup', handlePointerUp);
incrementBtn.addEventListener('pointerleave', stopCounting);
incrementBtn.addEventListener('pointercancel', stopCounting);
incrementBtn.addEventListener('keydown', handleKeydown);
incrementBtn.addEventListener('keyup', handleKeyup);
```

結構行数が増えてしまいました。

### 関数の説明

|関数名|説明|
|---|---|
|`incrementCount`|カウンターの値を1増加させる関数。<br>`counter`要素の`textContent`を数値として取得し、1を足して再設定する。|
|`startCounting`|一定間隔でアクションを繰り返すためのインターバルを開始する関数。<br>`action`は実行される関数（`incrementCount`）で、`interval`はそのインターバル時間（75ミリ秒）。|
|`triggerLongPress`|長押しが認識されたことを示し、指定された`action`を75ミリ秒で繰り返す関数。|
|`stopCounting`|現在動作中のインターバルを停止し、長押しとボタン押下状態もリセットし、設定されていたタイムアウトもクリアする関数。|
|`handlePointerDown`|ポインタがボタンを押し始めた時に呼び出される関数。<br>500ミリ秒後に`triggerLongPress`を呼ぶ。|
|`handlePointerUp`|ポインタがボタンを離した時に呼び出される関数。<br>その際長押し判定されていなければカウンターを1増やし、`stopCounting`を呼ぶ。|
|`handleKeydown`|キーボード用の関数。<br><kbd>Enter</kbd>キーか<kbd>Space</kbd>キーが押された時かつボタン押下状態（`isKeyDown`）が`false`だったら、`true`をセットし挙動を一回のみに制限する。<br>そして長押し判定（500ミリ秒）されたら、`triggerLongPress`の引数に`incrementCount`をセットして呼び出す。|
|`handleKeyup`|キーボード用の関数。<br><kbd>Enter</kbd>キーか<kbd>Space</kbd>キーが離された時に`stopCounting`を呼ぶ。|

ちなみに、長押し判定の500ミリ秒と、長押し時に75ミリ秒毎に+1されるのは完全に私の感覚値です。

また、キーボードでのボタン押下時は`keydown`時（ボタンを押した瞬間）に+1されるのに対し、クリックやタップでのボタン押下時は`pointerup`時（ボタンから離れた瞬間）に+1されるという風に、デフォルトで挙動のタイミングがそれぞれバラバラです。
<br>

**これはつらい**
<br>

また、現在は改善されたようですが、以前は[Safariだけクリック時にフォーカスがボタンに移動しなかった](https://developer.mozilla.org/ja/docs/Web/HTML/Element/button#%E3%82%AF%E3%83%AA%E3%83%83%E3%82%AF%E3%81%A8%E3%83%95%E3%82%A9%E3%83%BC%E3%82%AB%E3%82%B9)みたいです。

### CSSによるタッチ操作での選択防止

また、CSSでボタンに`user-select: none;`をセットしているのは、タッチデバイスでボタンを長押しすると、ボタン内のテキストを選択してしまい「コピー、共有、すべて選択、読み上げる」などのコンテクストメニューが表示されてしまうのを防ぐためです。

しかし、これにより「読み上げる」などのアクセシビリティ機能が使えなくなると、目が不自由な方には内容が伝わらないかもしれません。
また、ボタン内のテキストが異なる言語の場合、そのテキストをコピーして翻訳することもできなくなります。

これらの機能を無効化することは、アクセシビリティの観点から課題があると言えます。
そのため、防ぎたい操作とアクセシビリティのバランスをどう取るかが重要です

## まとめ

結論として、長押し機能を実装する際には、タップ、クリック、キーボード、フォーカス、アクセシビリティ、ブラウザ間の挙動の違いなど、多くの課題を考慮する必要があります。
現状、これらの課題をクリアするのはかなりの労力を要します。

そこで最後に、問題提起です。
<br>
**WebアプリケーションのUIにおけるその長押し機能、本当に必要ですか？**
<br>
この記事が、少しでもWebアプリケーションのUI/UXデザインにおける意思決定の一助となれば幸いです。