SCALE — Build Lab
UI部品 · REACT COMPONENT

日付ピッカー(軽量)

CATEGORYUI部品 TYPEReact Component EFFORT60〜120分 DIFFICULTY
PRIMARY CODE
tsx · components/tasks/DatePicker.tsx
"use client";

import { useEffect, useMemo, useState } from "react";

interface DatePickerProps {
  value: string;
  onChange: (val: string) => void;
  label?: string;
  hideQuickPresets?: boolean; // クイックプリセットを隠す
}

function addDays(base: Date, d: number): string {
  const x = new Date(base);
  x.setDate(x.getDate() + d);
  return x.toISOString().slice(0, 10);
}

/**
 * 月/日 select + クイックプリセット(今日/明日/2日後/3日後/1週間後)の期日ピッカー。
 * Value format: "YYYY-MM-DD". Year auto-fills to current year.
 *
 * 月と日の両方が揃ってから onChange を発火する仕様。
 * 月だけ選んだ時点で onChange が走ると親が自動で閉じてしまうので、
 * ピッカーを開いたまま続けて日を選べるようにする。
 */
export default function DatePicker({ value, onChange, label, hideQuickPresets = false }: DatePickerProps) {
  const year = new Date().getFullYear();
  const today = useMemo(() => new Date(), []);
  const presets = useMemo(() => [
    { label: '今日', value: addDays(today, 0) },
    { label: '明日', value: addDays(today, 1) },
    { label: '2日後', value: addDays(today, 2) },
    { label: '3日後', value: addDays(today, 3) },
    { label: '1週間後', value: addDays(today, 7) },
  ], [today]);

  // 内部 state(月・日を独立に保持)
  const [month, setMonth] = useState<string>("");
  const [day, setDay] = useState<string>("");

  // 外部 value が変わったら内部 state を同期
  useEffect(() => {
    if (!value) { setMonth(""); setDay(""); return; }
    const parts = value.split("-");
    if (parts.length < 3) { setMonth(""); setDay(""); return; }
    setMonth(String(parseInt(parts[1], 10)));
    setDay(String(parseInt(parts[2], 10)));
  }, [value]);

  const handleMonth = (m: string) => {
    setMonth(m);
    if (!m) { onChange(""); return; }
    if (!day) {
      // 日が未選択なら確定させない(ピッカーを開いたままにする)
      return;
    }
    onChange(`${year}-${m.padStart(2, "0")}-${day.padStart(2, "0")}`);
  };

  const handleDay = (d: string) => {
    setDay(d);
    if (!d) { onChange(""); return; }
    if (!month) {
      // 月が未選択なら確定させない
      return;
    }
    onChange(`${year}-${month.padStart(2, "0")}-${d.padStart(2, "0")}`);
  };

  const sel =
    "bg-[#0c0c0f] border border-[#2a2a36] rounded-lg text-white text-sm px-2 py-1.5 outline-none focus:border-blue-500/60 transition-colors appearance-none cursor-pointer";

  return (
    <div className="flex flex-col gap-1.5">
      {label && <label className="text-xs text-[#888] font-medium">{label}</label>}
      {/* クイックプリセット */}
      {!hideQuickPresets && (
        <div className="flex flex-wrap items-center gap-1 mb-1">
          {presets.map(p => {
            const active = value === p.value;
            return (
              <button
                key={p.label}
                type="button"
                onClick={() => onChange(p.value)}
                className={`px-2 py-0.5 text-[11px] rounded-md border transition-colors ${
                  active
                    ? 'bg-blue-500 border-blue-500 text-white'
                    : 'bg-[#0c0c0f] border-[#2a2a36] text-[#aaa] hover:text-white hover:border-blue-500/50'
                }`}
              >
                {p.label}
              </button>
            );
          })}
        </div>
      )}
      {/* 月日セレクト */}
      <div className="flex items-center gap-1">
        <select value={month} onChange={(e) => handleMonth(e.target.value)} className={`${sel} w-[72px]`}>
          <option value="">月</option>
          {Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
            <option key={m} value={String(m)}>{m}月</option>
          ))}
        </select>
        <span className="text-[#888] text-sm">/</span>
        <select value={day} onChange={(e) => handleDay(e.target.value)} className={`${sel} w-[72px]`}>
          <option value="">日</option>
          {Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
            <option key={d} value={String(d)}>{d}日</option>
          ))}
        </select>
        {value && !hideQuickPresets && (
          <button
            type="button"
            onClick={() => { setMonth(""); setDay(""); onChange(''); }}
            className="text-[#888] hover:text-red-400 text-xs ml-1"
            title="クリア"
          >
            ✕
          </button>
        )}
      </div>
      {/* 月だけ・日だけ選んだ状態のヒント */}
      {((month && !day) || (!month && day)) && (
        <p className="text-[10px] text-amber-400/80">
          {month && !day ? '日も選んでください' : '月も選んでください'}
        </p>
      )}
    </div>
  );
}

前提条件
Tailwind CSS v4TypeScript 5
USE CASES
  • タスク期日
  • 商談予定
  • イベント設定

日付ピッカー(軽量)

:LiTarget: 用途

モバイル対応の軽量日付選択UI。外部ライブラリ非依存。

:LiSparkle: 特徴

  • カレンダー表示
  • キーボード入力
  • 今日/明日のクイック選択

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

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

"use client";

import { useEffect, useMemo, useState } from "react";

interface DatePickerProps {
  value: string;
  onChange: (val: string) => void;
  label?: string;
  hideQuickPresets?: boolean; // クイックプリセットを隠す
}

function addDays(base: Date, d: number): string {
  const x = new Date(base);
  x.setDate(x.getDate() + d);
  return x.toISOString().slice(0, 10);
}

/**
 * 月/日 select + クイックプリセット(今日/明日/2日後/3日後/1週間後)の期日ピッカー。
 * Value format: "YYYY-MM-DD". Year auto-fills to current year.
 *
 * 月と日の両方が揃ってから onChange を発火する仕様。
 * 月だけ選んだ時点で onChange が走ると親が自動で閉じてしまうので、
 * ピッカーを開いたまま続けて日を選べるようにする。
 */
export default function DatePicker({ value, onChange, label, hideQuickPresets = false }: DatePickerProps) {
  const year = new Date().getFullYear();
  const today = useMemo(() => new Date(), []);
  const presets = useMemo(() => [
    { label: '今日', value: addDays(today, 0) },
    { label: '明日', value: addDays(today, 1) },
    { label: '2日後', value: addDays(today, 2) },
    { label: '3日後', value: addDays(today, 3) },
    { label: '1週間後', value: addDays(today, 7) },
  ], [today]);

  // 内部 state(月・日を独立に保持)
  const [month, setMonth] = useState<string>("");
  const [day, setDay] = useState<string>("");

  // 外部 value が変わったら内部 state を同期
  useEffect(() => {
    if (!value) { setMonth(""); setDay(""); return; }
    const parts = value.split("-");
    if (parts.length < 3) { setMonth(""); setDay(""); return; }
    setMonth(String(parseInt(parts[1], 10)));
    setDay(String(parseInt(parts[2], 10)));
  }, [value]);

  const handleMonth = (m: string) => {
    setMonth(m);
    if (!m) { onChange(""); return; }
    if (!day) {
      // 日が未選択なら確定させない(ピッカーを開いたままにする)
      return;
    }
    onChange(`${year}-${m.padStart(2, "0")}-${day.padStart(2, "0")}`);
  };

  const handleDay = (d: string) => {
    setDay(d);
    if (!d) { onChange(""); return; }
    if (!month) {
      // 月が未選択なら確定させない
      return;
    }
    onChange(`${year}-${month.padStart(2, "0")}-${d.padStart(2, "0")}`);
  };

  const sel =
    "bg-[#0c0c0f] border border-[#2a2a36] rounded-lg text-white text-sm px-2 py-1.5 outline-none focus:border-blue-500/60 transition-colors appearance-none cursor-pointer";

  return (
    <div className="flex flex-col gap-1.5">
      {label && <label className="text-xs text-[#888] font-medium">{label}</label>}
      {/* クイックプリセット */}
      {!hideQuickPresets && (
        <div className="flex flex-wrap items-center gap-1 mb-1">
          {presets.map(p => {
            const active = value === p.value;
            return (
              <button
                key={p.label}
                type="button"
                onClick={() => onChange(p.value)}
                className={`px-2 py-0.5 text-[11px] rounded-md border transition-colors ${
                  active
                    ? 'bg-blue-500 border-blue-500 text-white'
                    : 'bg-[#0c0c0f] border-[#2a2a36] text-[#aaa] hover:text-white hover:border-blue-500/50'
                }`}
              >
                {p.label}
              </button>
            );
          })}
        </div>
      )}
      {/* 月日セレクト */}
      <div className="flex items-center gap-1">
        <select value={month} onChange={(e) => handleMonth(e.target.value)} className={`${sel} w-[72px]`}>
          <option value="">月</option>
          {Array.from({ length: 12 }, (_, i) => i + 1).map((m) => (
            <option key={m} value={String(m)}>{m}月</option>
          ))}
        </select>
        <span className="text-[#888] text-sm">/</span>
        <select value={day} onChange={(e) => handleDay(e.target.value)} className={`${sel} w-[72px]`}>
          <option value="">日</option>
          {Array.from({ length: 31 }, (_, i) => i + 1).map((d) => (
            <option key={d} value={String(d)}>{d}日</option>
          ))}
        </select>
        {value && !hideQuickPresets && (
          <button
            type="button"
            onClick={() => { setMonth(""); setDay(""); onChange(''); }}
            className="text-[#888] hover:text-red-400 text-xs ml-1"
            title="クリア"
          >

          </button>
        )}
      </div>
      {/* 月だけ・日だけ選んだ状態のヒント */}
      {((month && !day) || (!month && day)) && (
        <p className="text-[10px] text-amber-400/80">
          {month && !day ? '日も選んでください' : '月も選んでください'}
        </p>
      )}
    </div>
  );
}

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

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

:LiHandPointer: 使い方

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

:LiAlertCircle: 注意事項

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