My-Weekly-ToDo-List/src/components/SimpleDatePicker.tsx
mARTin 9ac7198b24 feat: localization (FR/ES/IT), portal date picker, font matching, quote cleanup
- Add French, Spanish, and Italian translations for full UI localization
- Add Italian to language selector; show native language names
- Fix Jump to Date calendar: use React portal with fixed positioning
  to prevent clipping by overflow:hidden ancestors
- Fix font matching: time grid tasks inherit from task font settings
  when at default values (0.75rem/500), matching someday list fonts
- CSS fallback chain: time-slot-task vars fall through to task vars
- Remove broken recite.vercel.app default quote URL
- Add date-fns locales (fr, es, it) for SimpleDatePicker

v1.21.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 20:54:13 +01:00

182 lines
8.4 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { format, addMonths, subMonths, startOfMonth, endOfMonth, startOfWeek, endOfWeek, addDays, isSameMonth, isSameDay } from 'date-fns';
import { enUS, de, fr, es, it } from 'date-fns/locale';
interface SimpleDatePickerProps {
selected: Date;
onSelect: (date: Date) => void;
onClose: () => void;
language?: string;
anchorRef?: React.RefObject<HTMLElement | null>;
}
export default function SimpleDatePicker({ selected, onSelect, onClose, language = 'en', anchorRef }: SimpleDatePickerProps) {
const [currentMonth, setCurrentMonth] = useState(new Date(selected));
const modalRef = useRef<HTMLDivElement>(null);
const localeMap: Record<string, typeof enUS> = { de, fr, es, it };
const locale = localeMap[language] || enUS;
const [position, setPosition] = useState<{ top: number; left: number } | null>(null);
const updatePosition = useCallback(() => {
if (anchorRef?.current) {
const rect = anchorRef.current.getBoundingClientRect();
setPosition({
top: rect.bottom + 8,
left: rect.left + rect.width / 2 - 144, // 144 = half of 18rem (288px)
});
}
}, [anchorRef]);
useEffect(() => {
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, true);
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition, true);
};
}, [updatePosition]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
// Also check if click is on the anchor/trigger button
if (anchorRef?.current && anchorRef.current.contains(event.target as Node)) return;
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [onClose, anchorRef]);
const renderHeader = () => (
<div className="datepicker-header" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem', padding: '0 0.5rem' }}>
<button onClick={() => setCurrentMonth(subMonths(currentMonth, 1))} style={{ padding: '0.25rem', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--weekly-text-light, #9ca3af)', transition: 'color 0.2s' }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
</button>
<div style={{ fontWeight: 'bold', fontSize: '0.875rem', letterSpacing: '0.05em', textTransform: 'uppercase', color: 'var(--weekly-text, #374151)' }}>
{format(currentMonth, 'MMMM yyyy', { locale })}
</div>
<button onClick={() => setCurrentMonth(addMonths(currentMonth, 1))} style={{ padding: '0.25rem', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--weekly-text-light, #9ca3af)', transition: 'color 0.2s' }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6"></polyline></svg>
</button>
</div>
);
const renderDays = () => {
const days = [];
const startDate = startOfWeek(currentMonth, { weekStartsOn: 1 });
for (let i = 0; i < 7; i++) {
days.push(
<div key={i} style={{ textAlign: 'center', fontSize: '0.75rem', fontWeight: 'bold', color: 'var(--weekly-text-light, #9ca3af)', padding: '0.5rem 0' }}>
{format(addDays(startDate, i), 'EEEEEE', { locale })}
</div>
);
}
return <div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', marginBottom: '0.5rem' }}>{days}</div>;
};
const renderCells = () => {
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(monthStart);
const startDate = startOfWeek(monthStart, { weekStartsOn: 1 });
const endDate = endOfWeek(monthEnd, { weekStartsOn: 1 });
const cells = [];
let day = startDate;
while (day <= endDate) {
const row = [];
for (let i = 0; i < 7; i++) {
const cloneDay = day;
const isSelected = isSameDay(day, selected);
const isToday = isSameDay(day, new Date());
const isCurrentMonth = isSameMonth(day, monthStart);
const isDaySelected = isSelected;
row.push(
<div
key={day.toString()}
onClick={() => {
onSelect(cloneDay);
onClose();
}}
style={{
height: '2rem',
width: '2rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '0.875rem',
borderRadius: '50%',
cursor: 'pointer',
transition: 'all 0.2s',
margin: '0 auto',
color: !isCurrentMonth ? 'var(--weekly-text-light, #d1d5db)' : isDaySelected ? 'white' : 'var(--weekly-text, #374151)',
background: isDaySelected ? 'black' : 'transparent',
fontWeight: isDaySelected ? 'bold' : 'normal',
boxShadow: isDaySelected ? '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)' : 'none',
transform: isDaySelected ? 'scale(1.1)' : 'none',
...(isToday && !isDaySelected ? { color: '#ef4444', fontWeight: 'bold' } : {})
}}
onMouseEnter={(e) => {
if (!isDaySelected) e.currentTarget.style.backgroundColor = 'var(--weekly-hover-bg, #f3f4f6)';
}}
onMouseLeave={(e) => {
if (!isDaySelected) e.currentTarget.style.backgroundColor = 'transparent';
}}
>
{format(day, 'd')}
</div>
);
day = addDays(day, 1);
}
cells.push(row);
}
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '0.5rem 0' }}>
{cells.flat()}
</div>
);
};
// Use fixed positioning via portal if we have an anchor, otherwise fallback to absolute
const usePortal = !!anchorRef?.current && !!position;
const pickerContent = (
<div ref={modalRef} style={{
position: usePortal ? 'fixed' : 'absolute',
top: usePortal ? position!.top : '100%',
left: usePortal ? position!.left : '50%',
transform: usePortal ? 'none' : 'translateX(-50%)',
marginTop: usePortal ? 0 : '0.5rem',
backgroundColor: 'var(--weekly-bg, white)',
borderRadius: '0.5rem',
boxShadow: '0 20px 25px -5px rgba(0, 0, 0, 0.15), 0 10px 10px -5px rgba(0, 0, 0, 0.08)',
padding: '1rem',
zIndex: 9999,
width: '18rem',
border: '1px solid var(--weekly-border, #e5e7eb)',
animation: 'simpleDatePickerFadeIn 0.15s ease-out'
}}>
{renderHeader()}
{renderDays()}
{renderCells()}
<style>{`
@keyframes simpleDatePickerFadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
`}</style>
</div>
);
if (usePortal) {
return createPortal(pickerContent, document.body);
}
return pickerContent;
}