SCALE — Build Lab
UI部品 · REACT COMPONENT

推定時間ピッカー

CATEGORYUI部品 TYPEReact Component EFFORT30〜60分 DIFFICULTY
PRIMARY CODE
tsx · components/tasks/EstimateMinutesPicker.tsx
"use client";

import { useEffect, useState } from "react";

const PRESETS = [5, 10, 15, 20, 30, 45, 60, 90, 120, 150, 180] as const;

export function fmtEstimateLabel(m: number): string {
  if (m < 60) return `${m}分`;
  const h = Math.floor(m / 60);
  const r = m % 60;
  if (r === 0) return `${h}時間`;
  if (r === 30) return `${h}時間半`;
  return `${h}時間${r}分`;
}

interface Props {
  value: number | undefined;
  onChange: (v: number | undefined) => void;
  selectClassName?: string;
  inputClassName?: string;
  className?: string;
  disabled?: boolean;
  /** 初期未設定時に表示する選択肢のラベル */
  emptyLabel?: string;
}

/**
 * 共通の目安時間プルダウン。
 * プリセット(5/10/15/20/30/45/60/90/120/150/180)+ 「記入式(自由入力)」。
 * value がプリセット外なら自動で「記入式」にスイッチして数値入力欄を表示。
 */
export default function EstimateMinutesPicker({
  value,
  onChange,
  selectClassName,
  inputClassName,
  className,
  disabled,
  emptyLabel = "未設定",
}: Props) {
  const isPreset = value === undefined || (typeof value === 'number' && (PRESETS as readonly number[]).includes(value));
  const [custom, setCustom] = useState<boolean>(value !== undefined && !isPreset);
  // value が外部から更新されたら custom 判定を更新
  useEffect(() => {
    if (value === undefined) return;
    if (!(PRESETS as readonly number[]).includes(value)) setCustom(true);
  }, [value]);

  const baseSelect = "bg-bg3 border border-border rounded-lg px-2 py-1.5 text-sm text-text focus:outline-none focus:border-text3";
  const baseInput = "bg-bg3 border border-border rounded-lg px-2 py-1.5 text-sm text-text placeholder:text-text3 focus:outline-none focus:border-text3 w-24";

  return (
    <div className={`flex items-center gap-2 ${className ?? ""}`}>
      <select
        disabled={disabled}
        value={custom ? "__custom__" : (typeof value === 'number' ? String(value) : "")}
        onChange={(e) => {
          const v = e.target.value;
          if (v === "__custom__") {
            setCustom(true);
            // 切り替えた瞬間は値を変えない
            return;
          }
          setCustom(false);
          if (v === "") onChange(undefined);
          else onChange(Number(v));
        }}
        className={selectClassName || baseSelect}
      >
        <option value="">{emptyLabel}</option>
        {PRESETS.map((m) => (
          <option key={m} value={String(m)}>{fmtEstimateLabel(m)}</option>
        ))}
        <option value="__custom__">記入式(自由入力)</option>
      </select>
      {custom && (() => {
        // 自由入力は「時間」「分」の2フィールドに分けて扱う(2026-05-04 仕様変更)
        // 旧仕様: 単一の数値入力で「分」固定 → 「8」と入れると 8分になり、8時間にしたいケースで困る
        // 新仕様: 時間と分を別フィールドで明示。内部値は分単位で保持
        const totalMin = typeof value === 'number' ? value : 0;
        const h = Math.floor(totalMin / 60);
        const m = totalMin % 60;
        const updateFromHM = (newH: number, newM: number) => {
          const total = Math.max(0, newH * 60 + newM);
          onChange(total === 0 && typeof value !== 'number' ? undefined : total);
        };
        const hourInputCls = inputClassName || (baseInput + ' w-16');
        const minInputCls = inputClassName || (baseInput + ' w-16');
        return (
          <div className="flex items-center gap-1">
            <input
              type="number"
              min={0}
              step={1}
              disabled={disabled}
              value={typeof value === 'number' ? h : ""}
              onChange={(e) => {
                const v = e.target.value;
                const newH = v === "" ? 0 : Math.max(0, Number(v));
                updateFromHM(newH, m);
              }}
              placeholder="0"
              className={hourInputCls}
              title="時間"
            />
            <span className="text-text2 text-xs shrink-0">時間</span>
            <input
              type="number"
              min={0}
              max={59}
              step={5}
              disabled={disabled}
              value={typeof value === 'number' ? m : ""}
              onChange={(e) => {
                const v = e.target.value;
                const newM = v === "" ? 0 : Math.max(0, Math.min(59, Number(v)));
                updateFromHM(h, newM);
              }}
              placeholder="0"
              className={minInputCls}
              title="分"
            />
            <span className="text-text2 text-xs shrink-0">分</span>
          </div>
        );
      })()}
    </div>
  );
}

前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • タスク推定
  • タイマー設定
  • 工数入力

推定時間ピッカー

:LiTarget: 用途

「時間+分」を別フィールドで入力する推定時間UI。クイックボタン付き。

:LiSparkle: 特徴

  • 時間/分の2フィールド
  • クイック入力ボタン
  • バリデーション

:LiCode: 実コード(SCALE Base より自動抽出)

:LiInfo: components/tasks/EstimateMinutesPicker.tsx の中身そのもの。コピペ即可。

"use client";

import { useEffect, useState } from "react";

const PRESETS = [5, 10, 15, 20, 30, 45, 60, 90, 120, 150, 180] as const;

export function fmtEstimateLabel(m: number): string {
  if (m < 60) return `${m}分`;
  const h = Math.floor(m / 60);
  const r = m % 60;
  if (r === 0) return `${h}時間`;
  if (r === 30) return `${h}時間半`;
  return `${h}時間${r}分`;
}

interface Props {
  value: number | undefined;
  onChange: (v: number | undefined) => void;
  selectClassName?: string;
  inputClassName?: string;
  className?: string;
  disabled?: boolean;
  /** 初期未設定時に表示する選択肢のラベル */
  emptyLabel?: string;
}

/**
 * 共通の目安時間プルダウン。
 * プリセット(5/10/15/20/30/45/60/90/120/150/180)+ 「記入式(自由入力)」。
 * value がプリセット外なら自動で「記入式」にスイッチして数値入力欄を表示。
 */
export default function EstimateMinutesPicker({
  value,
  onChange,
  selectClassName,
  inputClassName,
  className,
  disabled,
  emptyLabel = "未設定",
}: Props) {
  const isPreset = value === undefined || (typeof value === 'number' && (PRESETS as readonly number[]).includes(value));
  const [custom, setCustom] = useState<boolean>(value !== undefined && !isPreset);
  // value が外部から更新されたら custom 判定を更新
  useEffect(() => {
    if (value === undefined) return;
    if (!(PRESETS as readonly number[]).includes(value)) setCustom(true);
  }, [value]);

  const baseSelect = "bg-bg3 border border-border rounded-lg px-2 py-1.5 text-sm text-text focus:outline-none focus:border-text3";
  const baseInput = "bg-bg3 border border-border rounded-lg px-2 py-1.5 text-sm text-text placeholder:text-text3 focus:outline-none focus:border-text3 w-24";

  return (
    <div className={`flex items-center gap-2 ${className ?? ""}`}>
      <select
        disabled={disabled}
        value={custom ? "__custom__" : (typeof value === 'number' ? String(value) : "")}
        onChange={(e) => {
          const v = e.target.value;
          if (v === "__custom__") {
            setCustom(true);
            // 切り替えた瞬間は値を変えない
            return;
          }
          setCustom(false);
          if (v === "") onChange(undefined);
          else onChange(Number(v));
        }}
        className={selectClassName || baseSelect}
      >
        <option value="">{emptyLabel}</option>
        {PRESETS.map((m) => (
          <option key={m} value={String(m)}>{fmtEstimateLabel(m)}</option>
        ))}
        <option value="__custom__">記入式(自由入力)</option>
      </select>
      {custom && (() => {
        // 自由入力は「時間」「分」の2フィールドに分けて扱う(2026-05-04 仕様変更)
        // 旧仕様: 単一の数値入力で「分」固定 → 「8」と入れると 8分になり、8時間にしたいケースで困る
        // 新仕様: 時間と分を別フィールドで明示。内部値は分単位で保持
        const totalMin = typeof value === 'number' ? value : 0;
        const h = Math.floor(totalMin / 60);
        const m = totalMin % 60;
        const updateFromHM = (newH: number, newM: number) => {
          const total = Math.max(0, newH * 60 + newM);
          onChange(total === 0 && typeof value !== 'number' ? undefined : total);
        };
        const hourInputCls = inputClassName || (baseInput + ' w-16');
        const minInputCls = inputClassName || (baseInput + ' w-16');
        return (
          <div className="flex items-center gap-1">
            <input
              type="number"
              min={0}
              step={1}
              disabled={disabled}
              value={typeof value === 'number' ? h : ""}
              onChange={(e) => {
                const v = e.target.value;
                const newH = v === "" ? 0 : Math.max(0, Number(v));
                updateFromHM(newH, m);
              }}
              placeholder="0"
              className={hourInputCls}
              title="時間"
            />
            <span className="text-text2 text-xs shrink-0">時間</span>
            <input
              type="number"
              min={0}
              max={59}
              step={5}
              disabled={disabled}
              value={typeof value === 'number' ? m : ""}
              onChange={(e) => {
                const v = e.target.value;
                const newM = v === "" ? 0 : Math.max(0, Math.min(59, Number(v)));
                updateFromHM(h, newM);
              }}
              placeholder="0"
              className={minInputCls}
              title="分"
            />
            <span className="text-text2 text-xs shrink-0">分</span>
          </div>
        );
      })()}
    </div>
  );
}

:LiFolder: ソースファイルのパス

/Users/oogushiyuuki/Library/CloudStorage/GoogleDrive-y-ogushi@scale-group.co.jp/マイドライブ/AI/scale-base/components/tasks/EstimateMinutesPicker.tsx

:LiHandPointer: 使い方

対象プロジェクトに該当ファイルをコピーして、props を流し込むだけ。

:LiAlertCircle: 注意事項

  • 依存パッケージを忘れず追加