日付ピッカー(Date Picker) - Vibe Coding Showcase

日付ピッカー(Date Picker)

カレンダーUIで直感的に日付を選択できるコンポーネント。日付範囲選択、時間選択、カスタムスタイルなど多様な機能を提供

デザインプレビュー

基本的な日付選択

機能: シンプルな日付選択、今日ボタン、日本語ロケール対応

用途: 基本的なフォーム入力、プロフィール設定

日時選択

機能: 日付と時間の同時選択、15分間隔、未来の日付のみ

用途: イベント作成、予約システム、スケジュール管理

日付範囲選択

機能: 開始日と終了日の範囲選択、範囲ハイライト表示

用途: 宿泊予約、休暇申請、期間限定キャンペーン設定

ミニマルテーマ

機能: シンプルでクリーンなデザイン、最小限の装飾

用途: ビジネス文書、契約書、公的フォーム

モダンテーマ

機能: グラデーション、丸み、モダンなUI/UX

用途: 現代的なWebアプリ、クリエイティブサイト

コンパクトテーマ

機能: 小さなサイズ、狭いスペースに最適

用途: テーブル内フォーム、サイドバー、モバイル画面

予約システム(制限付き)

• 今日から3ヶ月先まで予約可能

• 土日は予約不可

• 特定の日付も無効化可能

機能: 日付制限、曜日制限、特定日無効化

用途: レストラン予約、医療予約、会議室予約

国際化対応

日本語

English

中文

機能: 多言語対応、各地域の週開始曜日設定

用途: グローバルアプリケーション、多言語サイト

カスタマイズ例

週番号表示

カレンダーに週番号を表示

エラー状態

この項目は必須です

バリデーションエラーの表示

高度な機能

主な機能

  • カレンダーポップオーバー表示
  • キーボードナビゲーション対応
  • アクセシビリティ準拠
  • 外部クリックで自動クローズ

カスタマイズ

  • カスタム日付フォーマット
  • 無効日付・曜日の設定
  • 最小/最大日付制限
  • 時間選択間隔の設定

日付ピッカー(Date Picker)の特徴

直感的なカレンダーUI

視覚的で分かりやすいカレンダー表示により、誤入力を防ぎスムーズな日付選択を実現

多様な入力形式

日付のみ、日時、期間選択など用途に応じた柔軟な入力モードを提供

国際化対応

各地域の日付形式、祝日、週の開始曜日など文化的な違いに対応

AI活用ガイド

予約システム向け日付選択

プロンプト例:

予約システム用の日付ピッカーを作成してください。過去の日付は選択不可、土日は異なる色で表示、選択日から最大3ヶ月先まで予約可能にしてください。

グローバル対応カレンダー

プロンプト例:

多言語対応の日付ピッカーを実装してください。日本語、英語、中国語に対応し、各国の祝日表示機能と週の開始曜日設定を含めてください。

会議室予約システム

プロンプト例:

会議室予約用の日時選択コンポーネントを作成してください。15分刻みの時間選択、既存予約との重複チェック、複数日程の一括選択機能を実装してください。

実装コード

ファイルサイズ: 16.9KB TypeScript

コンポーネント実装

import React, { useState, useRef, useEffect, useCallback } from 'react';

export interface DateValue {
  date: Date;
  time?: string;
}

export interface DateRange {
  start: DateValue | null;
  end: DateValue | null;
}

interface DatePickerProps {
  value?: Date | DateRange | null;
  onChange?: (value: Date | DateRange | null) => void;
  mode?: 'date' | 'datetime' | 'range' | 'time';
  theme?: 'default' | 'minimal' | 'modern' | 'compact';
  placeholder?: string;
  minDate?: Date;
  maxDate?: Date;
  disabledDates?: Date[];
  disabledDays?: number[]; // 0 = Sunday, 1 = Monday, etc.
  locale?: 'ja' | 'en' | 'zh';
  firstDayOfWeek?: 0 | 1; // 0 = Sunday, 1 = Monday
  showWeekNumbers?: boolean;
  showToday?: boolean;
  timeStep?: number; // minutes
  format?: string;
  disabled?: boolean;
  required?: boolean;
  error?: string;
  className?: string;
  onOpen?: () => void;
  onClose?: () => void;
}

const DatePicker: React.FC<DatePickerProps> = ({
  value,
  onChange,
  mode = 'date',
  theme = 'default',
  placeholder,
  minDate,
  maxDate,
  disabledDates = [],
  disabledDays = [],
  locale = 'ja',
  firstDayOfWeek = 0,
  showWeekNumbers = false,
  showToday = true,
  timeStep = 30,
  format,
  disabled = false,
  required = false,
  error,
  className = '',
  onOpen,
  onClose
}) => {
  const [isOpen, setIsOpen] = useState(false);
  const [currentMonth, setCurrentMonth] = useState(new Date());
  const [selectedTime, setSelectedTime] = useState('12:00');
  const [rangeStart, setRangeStart] = useState<Date | null>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  // ロケール設定
  const localeConfig = {
    ja: {
      months: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
      weekdays: ['日', '月', '火', '水', '木', '金', '土'],
      today: '今日',
      clear: 'クリア',
      selectTime: '時間を選択',
      startDate: '開始日',
      endDate: '終了日'
    },
    en: {
      months: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'],
      weekdays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
      today: 'Today',
      clear: 'Clear',
      selectTime: 'Select time',
      startDate: 'Start date',
      endDate: 'End date'
    },
    zh: {
      months: ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'],
      weekdays: ['日', '一', '二', '三', '四', '五', '六'],
      today: '今天',
      clear: '清除',
      selectTime: '选择时间',
      startDate: '开始日期',
      endDate: '结束日期'
    }
  };

  const i18n = localeConfig[locale];

  // 日付フォーマット
  const formatDate = useCallback((date: Date, includeTime = false) => {
    if (format) {
      // カスタムフォーマット(簡易実装)
      return format
        .replace('YYYY', date.getFullYear().toString())
        .replace('MM', (date.getMonth() + 1).toString().padStart(2, '0'))
        .replace('DD', date.getDate().toString().padStart(2, '0'));
    }

    const dateStr = locale === 'en' 
      ? date.toLocaleDateString('en-US')
      : date.toLocaleDateString('ja-JP');
    
    if (includeTime && mode === 'datetime') {
      return `${dateStr} ${selectedTime}`;
    }
    
    return dateStr;
  }, [format, locale, selectedTime, mode]);

  // 日付が無効かチェック
  const isDateDisabled = useCallback((date: Date) => {
    if (minDate && date < minDate) return true;
    if (maxDate && date > maxDate) return true;
    if (disabledDays.includes(date.getDay())) return true;
    if (disabledDates.some(d => d.toDateString() === date.toDateString())) return true;
    return false;
  }, [minDate, maxDate, disabledDays, disabledDates]);

  // 日付選択処理
  const handleDateSelect = useCallback((date: Date) => {
    if (isDateDisabled(date)) return;

    if (mode === 'range') {
      const rangeValue = value as DateRange;
      if (!rangeStart || (rangeValue?.start && rangeValue?.end)) {
        setRangeStart(date);
        onChange?.({ start: { date }, end: null });
      } else {
        const start = rangeStart;
        const end = date;
        if (start <= end) {
          onChange?.({ start: { date: start }, end: { date: end } });
        } else {
          onChange?.({ start: { date: end }, end: { date: start } });
        }
        setRangeStart(null);
        setIsOpen(false);
      }
    } else {
      const newValue = mode === 'datetime' 
        ? new Date(`${date.toDateString()} ${selectedTime}`)
        : date;
      onChange?.(newValue);
      if (mode !== 'datetime') {
        setIsOpen(false);
      }
    }
  }, [mode, value, rangeStart, selectedTime, onChange, isDateDisabled]);

  // カレンダー生成
  const generateCalendar = useCallback(() => {
    const year = currentMonth.getFullYear();
    const month = currentMonth.getMonth();
    const firstDay = new Date(year, month, 1);
    const lastDay = new Date(year, month + 1, 0);
    const startDate = new Date(firstDay);
    
    // 週の開始日に合わせて調整
    const dayOffset = (firstDay.getDay() - firstDayOfWeek + 7) % 7;
    startDate.setDate(startDate.getDate() - dayOffset);

    const weeks = [];
    const today = new Date();
    today.setHours(0, 0, 0, 0);

    for (let week = 0; week < 6; week++) {
      const days = [];
      for (let day = 0; day < 7; day++) {
        const date = new Date(startDate);
        date.setDate(startDate.getDate() + week * 7 + day);
        
        const isCurrentMonth = date.getMonth() === month;
        const isToday = date.getTime() === today.getTime();
        const isSelected = mode === 'range' 
          ? isDateInRange(date)
          : value instanceof Date && date.toDateString() === value.toDateString();
        const isDisabled = isDateDisabled(date);
        const isRangeStart = mode === 'range' && rangeStart?.toDateString() === date.toDateString();
        const isRangeEnd = mode === 'range' && isRangeEndDate(date);

        days.push({
          date,
          isCurrentMonth,
          isToday,
          isSelected,
          isDisabled,
          isRangeStart,
          isRangeEnd,
          isInRange: mode === 'range' && isDateInSelectedRange(date)
        });
      }
      weeks.push(days);
      
      // 次の月に入ったら終了
      if (week === 5 || days[6].date.getMonth() !== month) {
        break;
      }
    }

    return weeks;
  }, [currentMonth, firstDayOfWeek, mode, value, rangeStart, isDateDisabled]);

  // 範囲選択のヘルパー関数
  const isDateInRange = (date: Date) => {
    const rangeValue = value as DateRange;
    if (!rangeValue?.start || !rangeValue?.end) return false;
    return date >= rangeValue.start.date && date <= rangeValue.end.date;
  };

  const isRangeEndDate = (date: Date) => {
    const rangeValue = value as DateRange;
    return rangeValue?.end?.date.toDateString() === date.toDateString();
  };

  const isDateInSelectedRange = (date: Date) => {
    const rangeValue = value as DateRange;
    if (!rangeStart) return false;
    if (!rangeValue?.start || !rangeValue?.end) {
      return false;
    }
    return date >= rangeValue.start.date && date <= rangeValue.end.date;
  };

  // 時間選択オプション生成
  const generateTimeOptions = useCallback(() => {
    const options = [];
    for (let hour = 0; hour < 24; hour++) {
      for (let minute = 0; minute < 60; minute += timeStep) {
        const timeStr = `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`;
        options.push(timeStr);
      }
    }
    return options;
  }, [timeStep]);

  // 外部クリックでクローズ
  useEffect(() => {
    const handleClickOutside = (event: MouseEvent) => {
      if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
        setIsOpen(false);
        onClose?.();
      }
    };

    if (isOpen) {
      document.addEventListener('mousedown', handleClickOutside);
    }

    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
    };
  }, [isOpen, onClose]);

  // 表示値の取得
  const getDisplayValue = () => {
    if (mode === 'range') {
      const rangeValue = value as DateRange;
      if (rangeValue?.start && rangeValue?.end) {
        return `${formatDate(rangeValue.start.date)} - ${formatDate(rangeValue.end.date)}`;
      }
      if (rangeValue?.start) {
        return `${formatDate(rangeValue.start.date)} - ${i18n.endDate}`;
      }
      return '';
    }
    
    if (value instanceof Date) {
      return formatDate(value, mode === 'datetime');
    }
    
    return '';
  };

  // テーマスタイル
  const themeStyles = {
    default: {
      input: 'border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
      calendar: 'bg-white border border-gray-200 rounded-lg shadow-lg',
      header: 'bg-gray-50 px-4 py-3 border-b border-gray-200',
      dayCell: 'hover:bg-blue-50 text-gray-700',
      selectedDay: 'bg-blue-500 text-white hover:bg-blue-600',
      todayDay: 'bg-blue-100 text-blue-800',
      disabledDay: 'text-gray-300 cursor-not-allowed'
    },
    minimal: {
      input: 'border-b border-gray-300 px-2 py-1 focus:outline-none focus:border-gray-600',
      calendar: 'bg-white border border-gray-100 rounded shadow',
      header: 'px-3 py-2 border-b border-gray-100',
      dayCell: 'hover:bg-gray-100 text-gray-600',
      selectedDay: 'bg-gray-800 text-white',
      todayDay: 'bg-gray-200 text-gray-800',
      disabledDay: 'text-gray-300 cursor-not-allowed'
    },
    modern: {
      input: 'border-2 border-transparent bg-gray-100 rounded-xl px-4 py-3 focus:outline-none focus:bg-white focus:border-purple-500 transition-all',
      calendar: 'bg-white rounded-2xl shadow-xl border border-gray-100',
      header: 'bg-gradient-to-r from-purple-500 to-pink-500 text-white px-4 py-3 rounded-t-2xl',
      dayCell: 'hover:bg-purple-50 text-gray-700 rounded-lg m-0.5',
      selectedDay: 'bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-lg',
      todayDay: 'bg-purple-100 text-purple-800 rounded-lg',
      disabledDay: 'text-gray-300 cursor-not-allowed'
    },
    compact: {
      input: 'border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-blue-500',
      calendar: 'bg-white border border-gray-200 rounded shadow',
      header: 'px-2 py-1 text-sm border-b border-gray-200',
      dayCell: 'hover:bg-blue-50 text-gray-700 text-xs',
      selectedDay: 'bg-blue-500 text-white text-xs',
      todayDay: 'bg-blue-100 text-blue-800 text-xs',
      disabledDay: 'text-gray-300 cursor-not-allowed text-xs'
    }
  };

  const styles = themeStyles[theme];
  const weeks = generateCalendar();
  const timeOptions = mode === 'datetime' ? generateTimeOptions() : [];

  return (
    <div ref={containerRef} className={`relative ${className}`}>
      {/* 入力フィールド */}
      <div className="relative">
        <input
          ref={inputRef}
          type="text"
          value={getDisplayValue()}
          placeholder={placeholder || (mode === 'range' ? `${i18n.startDate} - ${i18n.endDate}` : '日付を選択')}
          readOnly
          disabled={disabled}
          required={required}
          onClick={() => {
            if (!disabled) {
              setIsOpen(!isOpen);
              onOpen?.();
            }
          }}
          className={`
            ${styles.input} w-full cursor-pointer
            ${disabled ? 'opacity-50 cursor-not-allowed' : ''}
            ${error ? 'border-red-500' : ''}
          `}
        />
        <div className="absolute right-3 top-1/2 transform -translate-y-1/2 pointer-events-none">
          <svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
          </svg>
        </div>
      </div>

      {/* エラーメッセージ */}
      {error && (
        <p className="mt-1 text-sm text-red-600">{error}</p>
      )}

      {/* カレンダーポップオーバー */}
      {isOpen && (
        <div className={`absolute top-full left-0 mt-1 z-50 ${styles.calendar}`} style={{ minWidth: '280px' }}>
          {/* ヘッダー */}
          <div className={`flex items-center justify-between ${styles.header}`}>
            <button
              onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() - 1))}
              className="p-1 hover:bg-black hover:bg-opacity-10 rounded"
            >
              <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
              </svg>
            </button>
            
            <div className="font-medium">
              {currentMonth.getFullYear()}年 {i18n.months[currentMonth.getMonth()]}
            </div>
            
            <button
              onClick={() => setCurrentMonth(new Date(currentMonth.getFullYear(), currentMonth.getMonth() + 1))}
              className="p-1 hover:bg-black hover:bg-opacity-10 rounded"
            >
              <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
              </svg>
            </button>
          </div>

          {/* 曜日ヘッダー */}
          <div className="grid grid-cols-7 border-b border-gray-200">
            {i18n.weekdays.map((day, index) => (
              <div key={index} className="p-2 text-center text-xs font-medium text-gray-500">
                {day}
              </div>
            ))}
          </div>

          {/* カレンダーグリッド */}
          <div className="p-2">
            {weeks.map((week, weekIndex) => (
              <div key={weekIndex} className="grid grid-cols-7">
                {week.map((day, dayIndex) => (
                  <button
                    key={dayIndex}
                    onClick={() => handleDateSelect(day.date)}
                    disabled={day.isDisabled}
                    className={`
                      p-2 text-center text-sm transition-colors relative
                      ${day.isCurrentMonth ? '' : 'text-gray-300'}
                      ${day.isDisabled ? styles.disabledDay : styles.dayCell}
                      ${day.isSelected ? styles.selectedDay : ''}
                      ${day.isToday && !day.isSelected ? styles.todayDay : ''}
                      ${day.isInRange && mode === 'range' ? 'bg-blue-100' : ''}
                      ${day.isRangeStart || day.isRangeEnd ? 'font-bold' : ''}
                    `}
                  >
                    {day.date.getDate()}
                    {day.isToday && (
                      <div className="absolute bottom-0 left-1/2 transform -translate-x-1/2 w-1 h-1 bg-current rounded-full"></div>
                    )}
                  </button>
                ))}
              </div>
            ))}
          </div>

          {/* 時間選択 */}
          {mode === 'datetime' && (
            <div className="border-t border-gray-200 p-3">
              <label className="block text-sm font-medium text-gray-700 mb-2">
                {i18n.selectTime}
              </label>
              <select
                value={selectedTime}
                onChange={(e) => setSelectedTime(e.target.value)}
                className="w-full border border-gray-300 rounded px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
              >
                {timeOptions.map(time => (
                  <option key={time} value={time}>{time}</option>
                ))}
              </select>
            </div>
          )}

          {/* フッター */}
          <div className="border-t border-gray-200 p-3 flex justify-between items-center">
            {showToday && (
              <button
                onClick={() => handleDateSelect(new Date())}
                className="text-sm text-blue-600 hover:text-blue-800"
              >
                {i18n.today}
              </button>
            )}
            <button
              onClick={() => {
                onChange?.(null);
                setIsOpen(false);
                setRangeStart(null);
              }}
              className="text-sm text-gray-600 hover:text-gray-800"
            >
              {i18n.clear}
            </button>
          </div>
        </div>
      )}
    </div>
  );
};

export default DatePicker;

使用例

import { DatePicker } from './DatePicker';
import { addDays, addMonths } from 'date-fns';

function App() {
  const [selectedDate, setSelectedDate] = useState<Date | undefined>();
  const [startDate, setStartDate] = useState<Date | undefined>();
  const [endDate, setEndDate] = useState<Date | undefined>();

  // 今日から3ヶ月先までの予約可能期間を設定
  const today = new Date();
  const maxDate = addMonths(today, 3);

  return (
    <div className="space-y-8">
      {/* 基本的な日付選択 */}
      <div className="max-w-sm">
        <label className="block text-sm font-medium text-gray-700 mb-2">
          日付を選択
        </label>
        <DatePicker
          value={selectedDate}
          onChange={setSelectedDate}
          placeholder="年/月/日"
        />
      </div>

      {/* 予約システム向け(過去の日付は選択不可) */}
      <div className="max-w-sm">
        <label className="block text-sm font-medium text-gray-700 mb-2">
          予約日
        </label>
        <DatePicker
          value={selectedDate}
          onChange={setSelectedDate}
          minDate={today}
          maxDate={maxDate}
          placeholder="予約日を選択"
        />
      </div>

      {/* 日付範囲選択 */}
      <div className="flex gap-4">
        <div className="flex-1">
          <label className="block text-sm font-medium text-gray-700 mb-2">
            開始日
          </label>
          <DatePicker
            value={startDate}
            onChange={setStartDate}
            maxDate={endDate}
          />
        </div>
        <div className="flex-1">
          <label className="block text-sm font-medium text-gray-700 mb-2">
            終了日
          </label>
          <DatePicker
            value={endDate}
            onChange={setEndDate}
            minDate={startDate || today}
          />
        </div>
      </div>
    </div>
  );
}