Dev and Test run fixed. New Datepicker. Tests fixed. Message bookmarking fixed.
This commit is contained in:
@@ -16,6 +16,7 @@ import { TopBar } from './ui/TopBar';
|
||||
import { Modal } from './ui/Modal';
|
||||
import { SideSheet } from './ui/SideSheet';
|
||||
import { Checkbox } from './ui/Checkbox';
|
||||
import { DatePicker } from './ui/DatePicker';
|
||||
|
||||
interface ProfileProps {
|
||||
user: User;
|
||||
@@ -314,9 +315,13 @@ const Profile: React.FC<ProfileProps> = ({ user, onLogout, lang, onLanguageChang
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Ruler size={10} /> {t('height', lang)}</label>
|
||||
<input data-testid="profile-height-input" type="number" value={height} onChange={(e) => setHeight(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none" />
|
||||
</div>
|
||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><Calendar size={10} /> {t('birth_date', lang)}</label>
|
||||
<input data-testid="profile-birth-date" type="date" value={birthDate} onChange={(e) => setBirthDate(e.target.value)} className="w-full bg-transparent text-on-surface focus:outline-none text-sm py-1" />
|
||||
<div>
|
||||
<DatePicker
|
||||
label={t('birth_date', lang)}
|
||||
value={birthDate}
|
||||
onChange={(val) => setBirthDate(val)}
|
||||
testId="profile-birth-date"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-surface-container-high rounded-t-lg border-b border-outline-variant px-3 py-2">
|
||||
<label className="text-[10px] text-on-surface-variant flex gap-1 items-center"><PersonStanding size={10} /> {t('gender', lang)}</label>
|
||||
|
||||
281
src/components/ui/DatePicker.tsx
Normal file
281
src/components/ui/DatePicker.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
import React, { useState, useRef, useEffect, useId } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Calendar as CalendarIcon, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
import { Ripple } from './Ripple';
|
||||
|
||||
interface DatePickerProps {
|
||||
value: string; // YYYY-MM-DD
|
||||
onChange: (value: string) => void;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
icon?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
export const DatePicker: React.FC<DatePickerProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
label,
|
||||
placeholder = 'Select date',
|
||||
icon = <CalendarIcon size={16} />,
|
||||
disabled = false,
|
||||
testId
|
||||
}) => {
|
||||
const id = useId();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [viewDate, setViewDate] = useState(value ? new Date(value) : new Date());
|
||||
const [popoverPos, setPopoverPos] = useState({ top: 0, left: 0, width: 320 });
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Update popover position when opening or when window resizes
|
||||
const updatePosition = () => {
|
||||
if (containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const scrollX = window.scrollX;
|
||||
const scrollY = window.scrollY;
|
||||
|
||||
// Try to align with the right side of the input for better visibility
|
||||
let left = rect.left + rect.width - 320 + scrollX;
|
||||
// But don't go off-screen to the left
|
||||
if (left < scrollX + 16) {
|
||||
left = rect.left + scrollX;
|
||||
}
|
||||
|
||||
setPopoverPos({
|
||||
top: rect.bottom + scrollY + 4,
|
||||
left: left,
|
||||
width: 320
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
updatePosition();
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Ensure viewDate is valid
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
const date = new Date(value);
|
||||
if (!isNaN(date.getTime())) {
|
||||
setViewDate(date);
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
containerRef.current && !containerRef.current.contains(event.target as Node) &&
|
||||
popoverRef.current && !popoverRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
} else {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleInputClick = () => {
|
||||
if (!disabled) {
|
||||
const nextOpen = !isOpen;
|
||||
if (nextOpen) {
|
||||
updatePosition();
|
||||
}
|
||||
setIsOpen(nextOpen);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevMonth = () => {
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1));
|
||||
};
|
||||
|
||||
const handleNextMonth = () => {
|
||||
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1));
|
||||
};
|
||||
|
||||
const handleDateSelect = (day: number) => {
|
||||
const selectedDate = new Date(viewDate.getFullYear(), viewDate.getMonth(), day);
|
||||
const year = selectedDate.getFullYear();
|
||||
const month = String(selectedDate.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(selectedDate.getDate()).padStart(2, '0');
|
||||
onChange(`${year}-${month}-${d}`);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const daysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
|
||||
const firstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
|
||||
|
||||
const renderCalendar = () => {
|
||||
const year = viewDate.getFullYear();
|
||||
const month = viewDate.getMonth();
|
||||
const daysCount = daysInMonth(year, month);
|
||||
const startingDay = firstDayOfMonth(year, month);
|
||||
const monthName = viewDate.toLocaleString('default', { month: 'long' });
|
||||
|
||||
const days = [];
|
||||
for (let i = 0; i < startingDay; i++) {
|
||||
days.push(<div key={`empty-${i}`} className="w-10 h-10" />);
|
||||
}
|
||||
|
||||
const selectedDate = value ? new Date(value) : null;
|
||||
const isSelected = (d: number) => {
|
||||
return selectedDate &&
|
||||
selectedDate.getFullYear() === year &&
|
||||
selectedDate.getMonth() === month &&
|
||||
selectedDate.getDate() === d;
|
||||
};
|
||||
|
||||
const today = new Date();
|
||||
const isToday = (d: number) => {
|
||||
return today.getFullYear() === year &&
|
||||
today.getMonth() === month &&
|
||||
today.getDate() === d;
|
||||
};
|
||||
|
||||
for (let d = 1; d <= daysCount; d++) {
|
||||
days.push(
|
||||
<button
|
||||
key={d}
|
||||
type="button"
|
||||
onClick={() => handleDateSelect(d)}
|
||||
className={`
|
||||
relative w-10 h-10 rounded-full flex items-center justify-center text-sm transition-colors
|
||||
${isSelected(d)
|
||||
? 'bg-primary text-on-primary font-bold'
|
||||
: isToday(d)
|
||||
? 'text-primary border border-primary font-medium'
|
||||
: 'text-on-surface hover:bg-surface-container-high'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Ripple color={isSelected(d) ? 'rgba(255,255,255,0.3)' : 'rgba(var(--primary-rgb), 0.1)'} />
|
||||
{d}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const calendarContent = (
|
||||
<div
|
||||
ref={popoverRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: popoverPos.top,
|
||||
left: popoverPos.left,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
className="p-4 w-[320px] bg-surface-container-high rounded-2xl shadow-xl border border-outline-variant animate-in fade-in zoom-in duration-200 origin-top"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium text-on-surface">{monthName} {year}</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={handlePrevMonth} className="h-8 w-8">
|
||||
<ChevronLeft size={18} />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={handleNextMonth} className="h-8 w-8">
|
||||
<ChevronRight size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day, i) => (
|
||||
<div key={i} className="text-center text-[10px] font-bold text-on-surface-variant h-8 flex items-center justify-center uppercase tracking-wider">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-2 border-t border-outline-variant flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => setIsOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return createPortal(calendarContent, document.body);
|
||||
};
|
||||
|
||||
const formattedValue = value ? new Date(value).toLocaleDateString() : '';
|
||||
|
||||
return (
|
||||
<div className="relative w-full" ref={containerRef}>
|
||||
<div
|
||||
className={`
|
||||
relative group bg-surface-container-high rounded-t-[4px] border-b transition-all min-h-[56px] cursor-pointer
|
||||
${isOpen ? 'border-primary border-b-2' : 'border-on-surface-variant hover:bg-on-surface/10'}
|
||||
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
onClick={handleInputClick}
|
||||
>
|
||||
<label className={`
|
||||
absolute top-2 left-4 text-label-sm font-medium transition-colors flex items-center gap-1
|
||||
${isOpen ? 'text-primary' : 'text-on-surface-variant'}
|
||||
`}>
|
||||
{icon} {label}
|
||||
</label>
|
||||
|
||||
<div className="w-full h-[56px] pt-5 pb-1 pl-4 pr-10 text-body-lg text-on-surface flex items-center">
|
||||
{formattedValue || <span className="text-on-surface-variant/50">{placeholder}</span>}
|
||||
</div>
|
||||
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-on-surface-variant">
|
||||
<CalendarIcon size={20} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Hidden input for Playwright test compatibility */}
|
||||
<input
|
||||
type="date"
|
||||
data-testid={testId}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="sr-only"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: '1px',
|
||||
height: '1px',
|
||||
padding: '0',
|
||||
margin: '-1px',
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
borderWidth: '0',
|
||||
pointerEvents: 'none',
|
||||
opacity: 0
|
||||
}}
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{isOpen && renderCalendar()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user