- 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>
182 lines
8.4 KiB
TypeScript
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;
|
|
}
|