テックブログ
スマホの長押しで文字が青くなり1回で選べない原因と、Web問診での直し方
目次
Web問診をスマホで答えてもらうと、「選択肢を押したのに選ばれず、もう一度押したら選ばれた」ということが起こりえます。よくある形は、選択肢をしっかり押し続けてから離したときです。指を置いたままにしていると、ブラウザがボタンの中の文字を選択し始め(文字が青く反転し、両端に持ち手が出ます)、そのタッチは「押した」と扱われないことがあります。タップが2回目でないと反応しない、と感じられるのはこのためです。この記事では、Web問診の長押しで文字選択が始まる仕組みと、MENTRAの問診でそれをどう直したかを、図と実装のコードでご説明します。
直し方は二段構えです。一段目は CSS で、長押ししても文字が選択されないようにし、タッチ端末に残る飾り(hover)を消しました。二段目は判定で、指を離した瞬間に「選ぶ/選ばない」をこちらで決め、ブラウザの click を出させないようにしました。CSS だけに頼らない理由と、スクロールや置きっぱなしの指をどう見分けているかが、この記事の中心です。
先に、言葉の整理
以降で使う言葉を先にまとめます。
| 言葉 | どういうものか |
|---|---|
| 文字選択(user-select) | 長押しで文字が青く反転し、両端に持ち手が出る状態。CSS の user-select で、要素の文字を選択できるかどうかを決める |
| touchstart/touchend | 指が画面に触れた瞬間/離れた瞬間に、ブラウザがページに知らせる合図(イベント) |
| click | 「押した」の合図。指で触れたときは、touchend のあとに、ブラウザが「軽く押した」と判断したときだけ出る |
| hover と (hover: hover) | hover はポインタが要素の上に乗っている状態。(hover: hover) は「主に使う入力が、要素の上にポインタを重ねられる(マウスなど)」端末だけに当てる条件 |
| preventDefault | ブラウザが続けて行う標準の動き(ここでは click を出すこと)を取り消す命令 |
| cancelable | その合図を preventDefault で取り消せるかどうか。ブラウザがスクロールを始めたあとは取り消せないことがある |
| 慣性スクロール | 指で画面を弾いたあと、指を離しても画面が滑り続ける動き |
長押しで文字選択が始まると、なぜ1回目が効かないのか
軽く押したときと、押し続けてから離したときでは、ブラウザの中で起きることが違います。指が触れてから click が届くまでを時間の流れで並べると、次のようになります。
指を置いたままにすると、ブラウザは「この人は文字を選びたいのかもしれない」と判断して文字選択を始めます。いったん文字選択に切り替わったタッチは、指を離しても「軽く押した」とは扱われず、click が出ないことがあります。そうなると、ページの側から見ると何も押されていないのと同じです。もう一度軽く押すと、今度は click が出て選ばれます。
ここに、見た目の紛らわしさが重なります。選択肢には、マウスを乗せたときの飾り(枠と背景の色が変わる・少し大きくなる)を付けることがよくあります。この hover の飾りは、タッチ端末では指で触れたあとも残ることがあり、しかも「選んだときの見た目」に近い色で作られていることが多いのです。すると、1回目の空振りが「選ばれたのに消えた」「青くなったのに次に進まない」に見えます。
1回ごとに見れば、まれな出来事です。ただ、問診は1回押せば終わる画面ではありません。設問の多い問診では、1件のあいだに選択肢を120回ほど押します。1回あたりの確率が小さくても、1件の中のどこかで一度は当たることが起こりえます。
精神科・心療内科の問診では、受診の前で緊張していて指先に力が入る方もいれば、押したあとしばらく指を置いたまま次の設問を読む方もいます。押し方は人それぞれで、iPhone にも、画面に触れてからタッチとして受け付けるまでの時間を調整する設定が用意されています(タッチ調整の「保持継続時間」)。しかも問診は、待合で患者さんご本人のスマホから答えていただくことが多く、端末の設定を揃えることも、「軽く押してください」と一人ずつ説明することもできません。だから、どんな押し方でも1回で選ばれることを、画面の側で引き受けることにしました。
CSS でできること — 文字選択と hover の残りを止める
一段目は CSS です。患者さんが指で押す選択肢には、次の class を必ず付けています(Tailwind CSS で書いています)。
/**
* 指で押して選ぶ部品に必ず付ける class。
* - select-none / -webkit-touch-callout:none: 長押しで文字が青く選択されない・iPhone の長押しメニューを出さない
* - touch-manipulation: iPhone のダブルタップ拡大を止める(2 回押しで画面が拡大しない)
* 🔴 入力欄を含む親には付けない(iOS では user-select:none を付けた親の中の入力欄に文字が打てなくなる)
*/
export const TAP_SELECT_CLASS = 'select-none [-webkit-touch-callout:none] touch-manipulation';それぞれの指定が何を止め、どこで効くかは次のとおりです。
| class | 出力される CSS | 止めるもの | 効く環境 |
|---|---|---|---|
| select-none | user-select: none(Safari 向けの -webkit-user-select は autoprefixer が足す) | 長押しでの文字選択(青い反転と持ち手) | 主要なブラウザ |
| [-webkit-touch-callout:none] | -webkit-touch-callout: none | iPhone でリンクや画像を長押ししたときに出るメニュー(リンクのプレビューなど) | WebKit(iPhone の Safari など)だけ。標準ではない指定 |
| touch-manipulation | touch-action: manipulation | ダブルタップでの拡大(スクロールとピンチでの拡大は残す) | 主要なブラウザ |
注意が1つあります。user-select: none は、付けた要素の中にある要素の文字にも効きます(iPhone の Safari などでは子の要素に受け継がれます)。そのため、文字を打つ欄を含む親に付けると、iPhone ではその欄に文字が打てなくなることがあります。class は選択肢のボタンや、ラジオボタンを包む label にだけ付け、入力欄の親には付けないことにしています。
hover の飾りは、マウスなどポインタを重ねられる端末だけに出すようにしました。CSS のメディア特性 (hover: hover) は、主に使う入力が要素の上にポインタを重ねられる端末で当てはまり、多くのスマホでは当てはまりません。Tailwind の任意の条件として書くと、実際の選択肢は次のようになります。
<TapSelectButton
key={index}
onSelect={() => answerQuestion(question.id, option)}
disabled={isTransitioning || isCompleting}
className={`
w-full text-left p-4 rounded-xl border-2 transition-all duration-200 transform [@media(hover:hover)]:hover:scale-[1.02]
${currentResponse === option
? 'border-[#00c4cc] bg-[#f0fafa] shadow-md'
: 'border-gray-200 bg-white [@media(hover:hover)]:hover:border-[#00c4cc] [@media(hover:hover)]:hover:bg-[#f0fafa]'
}
focus:outline-none focus-visible:ring-2 focus-visible:ring-[#007a80] disabled:opacity-50 disabled:cursor-not-allowed
`}
>選んだときの見た目(border-[#00c4cc] bg-[#f0fafa])と hover の飾りが同じ色であることが分かります。タッチ端末で hover が残ると、ここが見分けられなくなります。Tailwind には hover を全体で (hover: hover) の端末に限る設定もありますが、それを使うと職員が使う画面の hover まで一斉に変わります。今回は患者さんが押す選択肢だけに条件を付けました(考え方は CSS-Tricks の解説と同じです)。
ここまでで文字の反転は止まります。それでも CSS だけでは足りないと考えました。理由は2つです。
- -webkit-touch-callout は WebKit 独自の、標準ではない指定です(MDN)。MDN には書かれていませんが、Apple の開発者フォーラムには、iOS 26.1 で効かないという開発者の投稿も出ています
- 文字選択を止めても、「長押しは押したことにしない」というブラウザの判定そのものは残ります。長押しのタッチを click に変えるかどうかは、ブラウザの判断のままです
そこで二段目として、指を離した瞬間に、選ぶかどうかをこちらで決めることにしました。
指を離した瞬間に選ぶ — touchend と click の二重を防ぐ
二段目の考え方は単純で、指で触れたときは touchend で選び、その場で preventDefault して click を出させない、です。マウス・キーボード・読み上げ機能の操作はタッチの合図を伴わないので、これまでどおり click で選びます。2つの経路が、同じ「選ぶ」処理を1回だけ呼びます。
部品としては、ふつうの button の代わりに使う小さな部品にまとめています。onClick の代わりに onSelect を渡すと、指の経路と click の経路の両方からそれが呼ばれます。
export function TapSelectButton({ onSelect, disabled, className, children, ...rest }: TapSelectButtonProps): JSX.Element {
const touch = useTouchTapSelect(onSelect, Boolean(disabled));
return (
<button
type="button"
{...rest}
{...touch}
disabled={disabled}
onClick={onSelect}
className={`${TAP_SELECT_CLASS} ${className ?? ''}`}
>
{children}
</button>
);
}指を離したときの受け口は次のとおりです。離した位置にある要素を document.elementFromPoint で調べ、押した選択肢の中かどうかを判定に渡します。
const onTouchEnd = (e: React.TouchEvent): void => {
const r = endTapGesture(gesture.current, {
changed: points(e.changedTouches),
cancelable: e.cancelable,
now: performance.now(),
lastScrollAt,
lastDragEndAt,
maxTouchesAnywhere,
isInsideTarget: (x, y) => {
const under = document.elementFromPoint(x, y);
return under !== null && e.currentTarget.contains(under);
},
disabled,
});
gesture.current = r.next;
// 指で押した操作はここで決め切る。選ばない場合もブラウザの click を出させない
// (出させると、ここで「選ばない」と決めたものがブラウザの判定で選ばれてしまう)。
// 取り消せない touchend(ブラウザがスクロール中)は preventDefault も選ぶこともせず、ブラウザに任せる(判定は browser_scrolling)
if (r.preventDefault) e.preventDefault();
if (r.decision === 'select') {
// click を止めるとフォーカスも動かない。文字の欄にフォーカスが残ると、キーボードが開いたままになり
// 欄の onBlur(ふりがなをカタカナに揃える等)も走らない。ブラウザ本来の動き(押した所へフォーカスが移る)に合わせて離す
const active = document.activeElement;
if (active instanceof HTMLElement && !e.currentTarget.contains(active) && active.matches('input, textarea, select, [contenteditable="true"]')) {
active.blur();
}
onSelect();
}
};なぜ click を「捨てる」のではなく「出させない」のか。最初に考えたのは、指が離れた瞬間(pointerup)に選び、直後に来る click を一定時間(700ミリ秒)だけ無視する方法でした。これを採らなかったのは、選んだ直後に画面の配置が変わるからです。
- 答えていない設問に出していた赤い注意書きが、選んだ瞬間に消えて、選択肢が上へずれる
- 選んだ瞬間に次の画面へ切り替わる(動きを減らす設定の端末では、切り替えの動きも省かれて即座に替わる)
タッチの click は、指を離したあとにその位置で改めて当たり判定をして届きます。配置がずれていれば、遅れて来た click は1つ下の選択肢や、次の画面のボタンに落ちます。いわゆる touchend と click の二重の問題です。捨てる側で追いかけ続けるより、touchend で preventDefault して click そのものを出させないほうが、構造として起きようがありません(W3C の Touch Events Level 2(標準化の作業は止まっている草案)でも、touchstart・touchmove・touchend のどれかを取り消したタッチでは、続くマウスの合図と click は出ないとされています)。
「選ばない」と決めたときも click は出させません。出させると、こちらが「スクロールのつもりだった」と判断したタッチを、ブラウザの判定で選んでしまうからです。例外は、ブラウザがすでにスクロールを始めていて touchend を取り消せない(cancelable が false)ときだけで、このときは選ぶかどうかをブラウザに任せます。
この形は、アクセシビリティの指針とも向きが揃っています。WCAG 2.2 の達成基準 2.5.2「ポインタのキャンセル」(レベル A)は、指1本などで操作する機能について、いくつかの条件のうちどれか1つを満たすことを求めています。押し下げた瞬間には実行しない、離したときに完了させて完了前に取りやめられる(または完了後に取り消せる)、などです。今回の判定は、触れた瞬間には何もせず、離したときに決め、選択肢の外で離せば選ばないので、このうち最初の2つに当たります。押し間違えた指は、そのまま横へ逃がせば取りやめられます。
スクロールや置きっぱなしの指と、どう見分けるか
指を離した瞬間に選ぶようにすると、今度は「選んではいけないタッチ」まで選びかねません。問診では、スクロールのつもりで誤って選ぶほうが、1回の空振りより重いと考えています。選び間違えた回答は、そのまま診察前の資料になるからです。そこで判定は、「選ぶ条件」ではなく「選ばない条件」を上から順に確かめ、どれにも当たらなかったときだけ選ぶ形にしました。
実装はこの順番のまま、1つの純関数になっています。画面に依存しないので、判定だけを試験で細かく確かめられます。
/** 指の移動の許容量。長押しの間に指の腹がつぶれて少し動いても選ぶ*/
export const TAP_MOVE_LIMIT_PX = 16;
/** 押していた時間の上限。1〜2 秒の長押しは選び、置きっぱなしの指は選ばない*/
export const TAP_HOLD_LIMIT_MS = 5000;
/** 押し始める直前のスクロールを「慣性スクロールを止めるタップ」とみなす時間 */
export const TAP_AFTER_SCROLL_MS = 300;
/** 指で引いた操作の後、慣性で画面が滑り続けうる時間 */
export const TAP_FLING_WINDOW_MS = 3000;
export const TAP_SCROLL_SLOP_PX = 4;
export function decideTouchTap(input: TouchTapInput): TouchTapDecision {
if (input.disabled) return 'disabled';
if (!input.cancelable) return 'browser_scrolling';
if (input.maxTouches > 1) return 'multi_touch';
const distance = Math.hypot(input.endX - input.startX, input.endY - input.startY);
if (distance > TAP_MOVE_LIMIT_PX) return 'moved';
if (input.endedAt - input.startedAt > TAP_HOLD_LIMIT_MS) return 'held_too_long';
if (!input.releasedInside) return 'released_outside';
if (input.lastScrollAt !== null && input.lastScrollAt >= input.startedAt && distance > TAP_SCROLL_SLOP_PX) return 'scrolled_while_pressing';
if (
input.lastScrollAt !== null && input.lastScrollAt >= input.startedAt - TAP_AFTER_SCROLL_MS &&
input.lastDragEndAt !== null && input.lastDragEndAt >= input.startedAt - TAP_FLING_WINDOW_MS
) return 'after_scroll';
return 'select';
}しきい値と、それぞれが何を見分けているかを表にまとめます。
| 見るもの | しきい値 | 見分けているもの | 当たったとき |
|---|---|---|---|
| 指の移動量 | 16px | 長押しで指の腹がつぶれる程度のずれ/スクロールのつもりの動き | 選ばない |
| 押していた時間 | 5秒 | 1〜2秒の長押し/画面に置いたままの指 | 選ばない |
| 押す直前のスクロール | 300ms 以内 | 慣性で滑っている画面を止めるためのタップ | 選ばない(指で弾いたあと3秒以内のときだけ) |
| 指で弾いた操作が終わってから | 3秒 以内 | 慣性で画面が滑り続けうる時間 | 上の行と組で使う |
| 押している間のスクロールと指の移動 | 4px | 指で動かしたスクロール/自動のスクロール | 選ばない |
許容する移動量を広げれば空振りは減りますが、そのぶんスクロールのつもりの指で選ばれやすくなります。16px は「長押しで指の腹が少しつぶれても選ぶ」ための幅で、それ以上は広げていません。慣性スクロールの行を「指で弾いたあと」に限っているのにも理由があります。文字を打ち終えてキーボードが閉じたときや、ページが自動で位置を合わせたときにもスクロールは起きます。区別しないと、文字を打った直後に押した選択肢だけ1回目が効かない、という別の空振りが生まれるからです。
いちばん気づきにくいのは、押している間に画面が自動でスクロールしたときです。指は画面に対して動いていないので、移動量は0のままです。けれども画面の中身が動いたので、指の下にあるのは別の選択肢になっています。
押しているあいだに一律でスクロールを弾く方法も考えましたが、採りませんでした。キーボードが閉じたあとの位置合わせのように、指と関係のない小さなスクロールはよく起きるので、それで弾くと1回目の空振りが戻ってしまいます。代わりに、受け口で elementFromPoint を使って離した位置にある要素が押した選択肢の中かを確かめ、外なら選ばないようにしました。中身が少しだけ動いて指の下が同じ選択肢のままなら、そのまま選びます。
指の本数は、押した選択肢の上だけでなく、画面全体で数えています。スマホを片手で持つと、親指が画面の端や別の選択肢の上に乗ったままになることがあります。触れた要素ごとに数えると、別の選択肢の上に置いた親指が数に入らず、親指を離した瞬間に親指の下の選択肢が選ばれてしまいます。画面全体の touchstart をひとつの場所で聞き、1本目の指が触れたら数え直す作りにしました。
タッチペンを使えば押す面が小さくなり、指のぶれは減るかもしれません。ただ、長押しはペンでも同じように起きますし、患者さんご本人のスマホでは使えません。道具を足すより、いつもどおり押せば選ばれる画面にするほうが、現場の手間が増えないと考えました。
守りを試験で確かめる
押し方の判定は、壊れても誰も気づきにくい場所です。「選ばない条件」を1つずつ足すたびに、わざと壊して試験が落ちるかまで確かめました。
| 確かめたこと | 数 | 結果 |
|---|---|---|
| 判定の試験(純関数) | 26本 | すべて合格 |
| 判定をわざと壊して、試験が落ちるか | 30種 | 30種すべてで試験が落ちた |
| 部品の試験台(実際のブラウザでタッチの合図を送る) | 15項目 | 15/15 合格 |
| 実際の問診画面を最初から最後まで通す | 13項目 | 13/13 合格 |
途中で1つ、試験の作り方を替えました。親指を別の選択肢に置いたまま押す順番の試験を、最初はブラウザの操作を外から送る仕組み(Chrome DevTools Protocol の Input.dispatchTouchEvent)で書いたところ、手を入れる前のコードでも合格してしまいました。合格しても何も確かめていない、判別力の無い試験です。そこで、ページの中から TouchEvent を順番どおりに送る方式に替え、手を入れる前のコードでは落ちることを確かめてから採用しました。押している間に自動でスクロールする場面は、次のように作っています。
const holdWhileScrolling = (h, id, dy) => h.page.evaluate(async ([id, dy]) => {
const A = document.querySelector(`[data-testid="${id}"]`);
const r = A.getBoundingClientRect();
const x = r.x + 20, y = r.y + r.height / 2;
const t = () => new Touch({ identifier: 3, target: A, clientX: x, clientY: y });
const fire = (type, touches) => A.dispatchEvent(new TouchEvent(type, { bubbles: true, cancelable: true, composed: true, touches, targetTouches: touches, changedTouches: [t()] }));
fire('touchstart', [t()]);
await new Promise((res) => setTimeout(res, 50));
window.scrollBy(0, dy);
await new Promise((res) => setTimeout(res, 1000));
fire('touchend', []);
}, [id, dy]);この場面は、140px 動かして指の下が別の選択肢になる場合(選ばれないのが正しい)と、4px だけ動いて同じ選択肢のままの場合(選ばれるのが正しい)を対にして流しています。片方だけだと、「何があっても選ばない」壊れ方でも合格してしまうからです。
待合で「押したのに進まない」と受付に聞きに来られる、職員が横について「もう一度押してください」と声をかける、選び直したつもりの回答がひとつ前のまま残っている。Web問診を入れた医療機関では、こうした小さな手間が毎日少しずつ積み重なります。MENTRAは、精神科・心療内科の問診で、患者さんご本人のスマホからいつもどおり押せば1回で選ばれるところまで作り込み、実際の外来で動かしています。問診の全体の流れはAI問診にまとめました。いまお使いの問診の画面で同じことが起きていないか、見比べるところからでもご相談ください。
よくあるご質問
user-select none だけでは足りないのですか
文字の反転は止まりますが、長押しでブラウザが出すメニューや、タッチを click に変える判定そのものはブラウザに残ります。iPhone の長押しのメニューを止める -webkit-touch-callout は WebKit 独自の指定で、Apple の開発者フォーラムには iOS 26.1 で効かないという開発者の投稿もあります。そこで CSS は一段目にとどめ、指を離した時点で自分で選ぶ判定を二段目に置きました。
touchend で preventDefault すると、読み上げ機能やキーボードで選べなくなりませんか
なりません。preventDefault を呼ぶのは指で触れたときの touchend の中だけで、マウス・キーボード・読み上げ機能の操作はタッチの合図を伴わないので、これまでどおり click で選ばれます。部品の中で、指の経路と click の経路が同じ選ぶ処理を呼ぶ形にしています。
touch-action manipulation は何のために付けているのですか
ダブルタップでの拡大を止めるためです。選択肢を素早く2回押したときに画面が拡大すると、高い位置にあった選択肢が指の下から外れます。ピンチでの拡大は残るので、文字を大きくして読みたい方の操作は妨げません。
