モダンUIコンポーネントの実装パターン
実装ガイドReact、Vue、Angularで共通して使えるUIパターンを解説
カレンダーUIで直感的に日付を選択できるコンポーネント。日付範囲選択、時間選択、カスタムスタイルなど多様な機能を提供
機能: シンプルな日付選択、今日ボタン、日本語ロケール対応
用途: 基本的なフォーム入力、プロフィール設定
機能: 日付と時間の同時選択、15分間隔、未来の日付のみ
用途: イベント作成、予約システム、スケジュール管理
機能: 開始日と終了日の範囲選択、範囲ハイライト表示
用途: 宿泊予約、休暇申請、期間限定キャンペーン設定
機能: シンプルでクリーンなデザイン、最小限の装飾
用途: ビジネス文書、契約書、公的フォーム
機能: グラデーション、丸み、モダンなUI/UX
用途: 現代的なWebアプリ、クリエイティブサイト
機能: 小さなサイズ、狭いスペースに最適
用途: テーブル内フォーム、サイドバー、モバイル画面
• 今日から3ヶ月先まで予約可能
• 土日は予約不可
• 特定の日付も無効化可能
機能: 日付制限、曜日制限、特定日無効化
用途: レストラン予約、医療予約、会議室予約
機能: 多言語対応、各地域の週開始曜日設定
用途: グローバルアプリケーション、多言語サイト
カレンダーに週番号を表示
この項目は必須です
バリデーションエラーの表示
視覚的で分かりやすいカレンダー表示により、誤入力を防ぎスムーズな日付選択を実現
日付のみ、日時、期間選択など用途に応じた柔軟な入力モードを提供
各地域の日付形式、祝日、週の開始曜日など文化的な違いに対応
プロンプト例:
予約システム用の日付ピッカーを作成してください。過去の日付は選択不可、土日は異なる色で表示、選択日から最大3ヶ月先まで予約可能にしてください。
プロンプト例:
多言語対応の日付ピッカーを実装してください。日本語、英語、中国語に対応し、各国の祝日表示機能と週の開始曜日設定を含めてください。
プロンプト例:
会議室予約用の日時選択コンポーネントを作成してください。15分刻みの時間選択、既存予約との重複チェック、複数日程の一括選択機能を実装してください。
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>
);
}