TimePicker — 時刻選択

フォーム 初級

このコンポーネントについて

業務WebアプリでよくつかわれるTimepickerのスピナー型実装です。 時と分のそれぞれに ▲ / ▼ ボタンを配置し、クリックで1分単位のインクリメント・デクリメントができます。 ボタンを押し続けると連続して値が変化するため、大きく時刻を動かしたいときもスムーズに操作できます。 数値欄は直接テキスト入力にも対応しており、キーボードから素早く時刻を打ち込むことも可能です。

  • ▲ / ▼ ボタン — クリックで時・分を1単位ずつ増減。23:59 → 00:00 のラップアラウンド対応
  • 長押し連続インクリメント — 押しっぱなしで400ms後から80ms間隔で連続変化。大きく動かすときに便利
  • テキスト直接入力 — 数値欄をクリックして直接入力可能。フォーカスが外れた時点でバリデーションと補正を実行
  • キーボード対応 — 入力欄フォーカス中に ↑↓ キーでもインクリメント可能

実装のポイント・注意点

長押し連続インクリメントは mousedown イベントで即時1回実行し、setTimeout(400ms)後に setInterval(80ms間隔)を起動する2段階で実装しています。 ボタンを離したタイミングを確実に検知するために mouseup イベントを document に登録するのがポイントです(ボタン上で離すとは限らないため)。 タッチデバイス対応として touchstart / touchend も同様に登録します。

mousedown イベントハンドラ内で e.preventDefault() を呼ぶと、ボタンクリック時に入力欄からフォーカスが外れる(blur が発火する)のを防げます。これにより、入力欄に数値を打ち込んでいる途中でボタンを操作しても入力中の値が確定・上書きされません。

ライブラリの利用も選択肢に
バニラJSだけで高機能なTimepickerを1から作るのは、ブラウザ差異の吸収・タッチ操作対応・アクセシビリティ確保など意外に工数がかかります。 本番プロダクトへの導入を検討している場合は、依存なしで軽量な Flatpickr や日付・時刻両対応の Pikaday などのライブラリも有力な選択肢です。CDN 1行で導入でき、バニラJSプロジェクトにもそのまま組み込めます。

デモ

サンプルソース

3つのファイルを同じフォルダに保存し、index.html をブラウザで開くとすぐに動作確認できます。
ファイル名:index.html / style.css / script.js — 保存時の文字コードは UTF-8 を指定してください(Shift-JISだと日本語が文字化けします)。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>TimePicker サンプル</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>

<div class="tp-spinner-wrap">
  <!-- 時フィールド -->
  <div class="tp-spinner-field">
    <button class="tp-spin-btn" id="tp-hour-up"   type="button" aria-label="時を増やす">▲</button>
    <input  type="number" class="tp-spin-input" id="tp-hour"
            min="0" max="23" step="1" autocomplete="off" aria-label="時">
    <button class="tp-spin-btn" id="tp-hour-down" type="button" aria-label="時を減らす">▼</button>
  </div>

  <span class="tp-colon" aria-hidden="true">:</span>

  <!-- 分フィールド -->
  <div class="tp-spinner-field">
    <button class="tp-spin-btn" id="tp-min-up"   type="button" aria-label="分を増やす">▲</button>
    <input  type="number" class="tp-spin-input" id="tp-minute"
            min="0" max="59" step="1" autocomplete="off" aria-label="分">
    <button class="tp-spin-btn" id="tp-min-down" type="button" aria-label="分を減らす">▼</button>
  </div>
</div>

<button class="tp-confirm-btn" id="tp-confirm" type="button">決定</button>
<p class="tp-result" id="tp-result"></p>

<script src="./script.js"></script>
</body>
</html>
/* === TimePicker スピナー型 ===
   --tp-accent の値を変えるだけで配色を一括変更できます */
:root {
  --tp-accent: #2B7FE8;
  --tp-text:   #1A2332;
  --tp-muted:  #5A6A7A;
  --tp-border: #D0D7E0;
  --tp-bg:     #F4F6F9;
}

*, *::before, *::after { box-sizing: border-box; }
body {
  font-family: sans-serif;
  padding: 24px;
  background: #fff;
  color: var(--tp-text);
}

/* スピナー全体 */
.tp-spinner-wrap {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  padding: 20px 24px;
  background: var(--tp-bg);
  border-radius: 10px;
}

/* 時・分フィールド */
.tp-spinner-field {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
}

/* ▲ / ▼ ボタン */
.tp-spin-btn {
  width: 44px; height: 30px;
  display: flex; align-items: center; justify-content: center;
  background: #fff; border: 1px solid var(--tp-border); border-radius: 6px;
  cursor: pointer; font-size: 13px; color: var(--tp-muted);
  user-select: none; transition: background 0.1s, border-color 0.1s;
  -webkit-tap-highlight-color: transparent;
}
.tp-spin-btn:hover  { background: #EFF4FD; border-color: var(--tp-accent); color: var(--tp-accent); }
.tp-spin-btn:active { background: #D0E4FD; }

/* 数値入力欄 */
.tp-spin-input {
  width: 72px; height: 56px;
  text-align: center; font-size: 28px; font-weight: 700;
  font-family: monospace; color: var(--tp-text);
  border: 1.5px solid var(--tp-border); border-radius: 6px;
  outline: none; background: #fff; transition: border-color 0.15s;
  -moz-appearance: textfield;
}
.tp-spin-input::-webkit-inner-spin-button,
.tp-spin-input::-webkit-outer-spin-button { -webkit-appearance: none; }
.tp-spin-input:focus { border-color: var(--tp-accent); }

/* ":" セパレーター */
.tp-colon {
  font-size: 32px; font-weight: 700; color: var(--tp-muted);
  line-height: 1; margin-top: 36px;
}

/* 決定ボタン・結果 */
.tp-confirm-btn {
  display: inline-block; margin-top: 16px; padding: 9px 28px;
  background: var(--tp-accent); color: #fff; border: none;
  border-radius: 6px; font-size: 14px; font-weight: 600;
  cursor: pointer; font-family: sans-serif; transition: opacity 0.15s;
}
.tp-confirm-btn:hover { opacity: 0.85; }

.tp-result { margin: 10px 0 0; font-size: 14px; min-height: 20px; }
.tp-result.has-value {
  padding: 10px 14px; background: #F0F7FF;
  border: 1px solid var(--tp-accent); border-radius: 6px;
  color: #1A5EA8; font-weight: 600;
}

@media (max-width: 480px) {
  .tp-spinner-wrap { padding: 16px; gap: 8px; }
  .tp-spin-input   { width: 64px; height: 52px; font-size: 24px; }
}
// ===== 状態管理 =====
var state = { hour: 9, minute: 0 };

function pad2(n) { return String(n).padStart(2, '0'); }

// 入力欄に現在の状態を反映する
function updateDisplay() {
  document.getElementById('tp-hour').value   = pad2(state.hour);
  document.getElementById('tp-minute').value = pad2(state.minute);
}

// 時・分を delta 分だけ増減(ラップアラウンドあり)
function changeValue(field, delta) {
  if (field === 'hour') {
    state.hour   = (state.hour   + delta + 24) % 24;
  } else {
    state.minute = (state.minute + delta + 60) % 60;
  }
  updateDisplay();
}


// ===== 長押し連続インクリメント =====
var pressTimer    = null;
var pressInterval = null;

// 押し始め: 即時1回 → 400ms後から80ms間隔で連続変化
function startSpin(field, delta) {
  changeValue(field, delta);
  pressTimer = setTimeout(function () {
    pressInterval = setInterval(function () {
      changeValue(field, delta);
    }, 80);
  }, 400);
}

// 離したとき: タイマーをすべて停止する
function stopSpin() {
  clearTimeout(pressTimer);
  clearInterval(pressInterval);
  pressTimer = pressInterval = null;
}

function setupSpinBtn(btnId, field, delta) {
  var btn = document.getElementById(btnId);
  btn.addEventListener('mousedown', function (e) {
    e.preventDefault(); // blur を防いで入力中の値を保護する
    startSpin(field, delta);
  });
  btn.addEventListener('touchstart', function (e) {
    e.preventDefault();
    startSpin(field, delta);
  }, { passive: false });
  btn.addEventListener('mouseleave', stopSpin);
}

setupSpinBtn('tp-hour-up',   'hour',    1);
setupSpinBtn('tp-hour-down', 'hour',   -1);
setupSpinBtn('tp-min-up',    'minute',  1);
setupSpinBtn('tp-min-down',  'minute', -1);

// マウスボタンを離したらどこでも停止する
document.addEventListener('mouseup',  stopSpin);
document.addEventListener('touchend', stopSpin);


// ===== テキスト直接入力 =====

// フォーカス時に全選択(上書き入力しやすくする)
document.getElementById('tp-hour').addEventListener('focus',   function () { this.select(); });
document.getElementById('tp-minute').addEventListener('focus', function () { this.select(); });

// blur 時にバリデーション+補正
function validateField(inputEl, field, max) {
  var val = parseInt(inputEl.value, 10);
  if (isNaN(val)) { inputEl.value = pad2(state[field]); return; }
  state[field]  = Math.max(0, Math.min(max, val));
  inputEl.value = pad2(state[field]);
}

document.getElementById('tp-hour').addEventListener('blur', function () {
  validateField(this, 'hour', 23);
});
document.getElementById('tp-minute').addEventListener('blur', function () {
  validateField(this, 'minute', 59);
});

// Enter で確定 / ↑↓ キーでインクリメント
document.getElementById('tp-hour').addEventListener('keydown', function (e) {
  if (e.key === 'Enter')     { e.preventDefault(); this.blur(); }
  if (e.key === 'ArrowUp')   { e.preventDefault(); changeValue('hour',  1); }
  if (e.key === 'ArrowDown') { e.preventDefault(); changeValue('hour', -1); }
});
document.getElementById('tp-minute').addEventListener('keydown', function (e) {
  if (e.key === 'Enter')     { e.preventDefault(); this.blur(); }
  if (e.key === 'ArrowUp')   { e.preventDefault(); changeValue('minute',  1); }
  if (e.key === 'ArrowDown') { e.preventDefault(); changeValue('minute', -1); }
});


// ===== 決定ボタン =====
document.getElementById('tp-confirm').addEventListener('click', function () {
  var r = document.getElementById('tp-result');
  r.textContent = '選択時刻: ' + pad2(state.hour) + ':' + pad2(state.minute);
  r.className   = 'tp-result has-value';
});


// ===== 初期表示 =====
updateDisplay();

AI用プロンプト

以下のプロンプトをコピーしてAIに渡すと、同様のコンポーネントを生成できます。

ChatGPTやClaudeにこのプロンプトを渡すと、同様のコンポーネントをゼロから生成・カスタマイズできます。ライブラリ指定や要件の追記をして使うのがおすすめです。

※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。

💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。

# TimePicker(スピナー型)作成依頼

## 概要
時と分を上下ボタンで増減できるスピナー型の時刻選択コンポーネントを実装してください。24時間表記(00:00〜23:59)で動作します。

## 要件
- 時(0〜23)と分(0〜59)をそれぞれ独立したスピナーフィールドで表示する
- 各フィールドに ▲(増加)と ▼(減少)ボタンを配置する
- ボタンクリックで1単位増減する。23:59 → 00:00 のラップアラウンドに対応する
- ボタン長押しで連続インクリメントする(400ms後から80ms間隔で繰り返す)
- 数値欄は直接テキスト入力もできる。フォーカスが外れたとき値を0〜最大値の範囲にクランプする
- 数値欄をフォーカスしたとき全選択状態にする(上書き入力しやすくする)
- 入力欄にフォーカスした状態で ↑↓ キーを押すと増減できる
- 「決定」ボタン押下で選択時刻を「選択時刻: HH:MM」形式で表示する

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- レスポンシブ対応:必要

## 動作詳細
- 長押しは mousedown で即時1回実行 → setTimeout(400ms)後に setInterval(80ms)を起動する
- mouseup イベントを document に登録してボタンから離れても確実に停止する
- touchstart / touchend にも同様の処理を登録してタッチデバイスに対応する
- mousedown ハンドラ内で e.preventDefault() を呼び、blur が発火しないようにする

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。