feat: new date picker

This commit is contained in:
David Ralph
2026-01-27 20:42:19 +00:00
parent 4d8be5774f
commit 7f0b37c713
5 changed files with 477 additions and 43 deletions

View File

@@ -0,0 +1,196 @@
import { memo, useState, useCallback, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { MdExpandMore, MdChevronLeft, MdChevronRight } from 'react-icons/md';
import './DatePicker.scss';
const DatePicker = memo((props) => {
const [isOpen, setIsOpen] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0, width: 0 });
const [viewDate, setViewDate] = useState(props.value || new Date());
const containerRef = useRef(null);
const controlRef = useRef(null);
const menuRef = useRef(null);
const closeDropdown = useCallback(() => {
setIsClosing(true);
setTimeout(() => {
setIsOpen(false);
setIsClosing(false);
}, 200);
}, []);
useEffect(() => {
const handleClickOutside = (event) => {
if (
containerRef.current &&
!containerRef.current.contains(event.target) &&
menuRef.current &&
!menuRef.current.contains(event.target)
) {
closeDropdown();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [closeDropdown]);
const calculatePosition = useCallback(() => {
if (controlRef.current) {
const rect = controlRef.current.getBoundingClientRect();
const gap = 4;
const viewportHeight = window.innerHeight;
const estimatedMenuHeight = 320;
const spaceBelow = viewportHeight - rect.bottom - gap;
const spaceAbove = rect.top - gap;
const shouldFlipUp = spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow;
return {
top: shouldFlipUp ? rect.top - gap : rect.bottom + gap,
left: rect.left,
width: rect.width,
maxHeight: shouldFlipUp ? Math.min(320, spaceAbove) : Math.min(320, spaceBelow),
flipped: shouldFlipUp,
};
}
return { top: 0, left: 0, width: 0, maxHeight: 320, flipped: false };
}, []);
const openDropdown = useCallback(() => {
const position = calculatePosition();
setMenuPosition(position);
setIsOpen(true);
}, [calculatePosition]);
const formatDate = (date) => {
if (!date) return 'Select Date';
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const year = date.getFullYear();
return props.hideYear ? `${month}/${day}` : `${month}/${day}/${year}`;
};
const getDaysInMonth = (date) => {
return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate();
};
const getFirstDayOfMonth = (date) => {
return new Date(date.getFullYear(), date.getMonth(), 1).getDay();
};
const handleDateSelect = (day) => {
const newDate = new Date(viewDate.getFullYear(), viewDate.getMonth(), day);
if (props.onChange) {
props.onChange(newDate);
}
closeDropdown();
};
const handlePreviousMonth = () => {
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() - 1, 1));
};
const handleNextMonth = () => {
setViewDate(new Date(viewDate.getFullYear(), viewDate.getMonth() + 1, 1));
};
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'
];
const renderCalendar = () => {
const daysInMonth = getDaysInMonth(viewDate);
const firstDay = getFirstDayOfMonth(viewDate);
const days = [];
const today = new Date();
const selectedDate = props.value;
// Empty cells for days before the first day of the month
for (let i = 0; i < firstDay; i++) {
days.push(<div key={`empty-${i}`} className="calendar-day empty" />);
}
// Days of the month
for (let day = 1; day <= daysInMonth; day++) {
const date = new Date(viewDate.getFullYear(), viewDate.getMonth(), day);
const isToday = date.toDateString() === today.toDateString();
const isSelected = selectedDate && date.toDateString() === selectedDate.toDateString();
days.push(
<div
key={day}
className={`calendar-day ${isToday ? 'today' : ''} ${isSelected ? 'selected' : ''}`}
onClick={() => handleDateSelect(day)}
>
{day}
</div>
);
}
return days;
};
return (
<div className="datepicker" ref={containerRef} onClick={(e) => e.stopPropagation()}>
<div
ref={controlRef}
className="datepicker-control"
onClick={() => {
if (isOpen) {
closeDropdown();
} else {
openDropdown();
}
}}
>
<span className="datepicker-value">{formatDate(props.value)}</span>
<MdExpandMore className={`datepicker-arrow ${isOpen ? 'open' : ''}`} />
</div>
{(isOpen || isClosing) &&
createPortal(
<div
ref={menuRef}
className={`datepicker-menu ${isClosing ? 'closing' : ''} ${menuPosition.flipped ? 'flipped' : ''}`}
style={{
position: 'fixed',
top: `${menuPosition.top}px`,
left: `${menuPosition.left}px`,
transform: menuPosition.flipped ? 'translateY(-100%)' : 'none',
}}
>
<div className="calendar-header">
<button onClick={handlePreviousMonth} className="calendar-nav">
<MdChevronLeft />
</button>
<span className="calendar-month">
{monthNames[viewDate.getMonth()]}{props.hideYear ? '' : ` ${viewDate.getFullYear()}`}
</span>
<button onClick={handleNextMonth} className="calendar-nav">
<MdChevronRight />
</button>
</div>
<div className="calendar-weekdays">
<div>Su</div>
<div>Mo</div>
<div>Tu</div>
<div>We</div>
<div>Th</div>
<div>Fr</div>
<div>Sa</div>
</div>
<div className="calendar-grid">
{renderCalendar()}
</div>
</div>,
document.body,
)}
</div>
);
});
DatePicker.displayName = 'DatePicker';
export { DatePicker as default, DatePicker };

View File

@@ -0,0 +1,256 @@
@use 'scss/variables' as *;
@use 'scss/mixins' as *;
@include keyframes(datepickerSlideIn) {
0% {
opacity: 0;
transform: translateY(-10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@include keyframes(datepickerSlideOut) {
0% {
opacity: 1;
transform: translateY(0);
}
100% {
opacity: 0;
transform: translateY(-10px);
}
}
@include keyframes(datepickerSlideInUp) {
0% {
opacity: 0;
transform: translateY(-100%) translateY(10px);
}
100% {
opacity: 1;
transform: translateY(-100%);
}
}
@include keyframes(datepickerSlideOutUp) {
0% {
opacity: 1;
transform: translateY(-100%);
}
100% {
opacity: 0;
transform: translateY(-100%) translateY(10px);
}
}
.datepicker {
position: relative;
width: 100%;
min-width: 200px;
max-width: 300px;
.datepicker-control {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
height: 56px;
padding: 0 16px;
cursor: pointer;
transition: all 0.2s ease;
outline: none;
@include themed {
background: t($modal-sidebar);
border: 1px solid t($modal-sidebarActive);
border-radius: t($borderRadius);
color: t($color);
&:hover {
border-color: t($color);
}
}
&:focus-visible {
outline: none;
@include themed {
border-color: t($link);
box-shadow: 0 0 0 3px t($link);
}
}
}
.datepicker-value {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: color 0.2s ease;
}
.datepicker-arrow {
flex-shrink: 0;
font-size: 24px;
transition: all 0.2s ease;
cursor: pointer;
padding: 4px;
border-radius: 50%;
margin: -4px;
@include themed {
color: t($subColor);
&:hover {
background: t($modal-sidebarActive);
color: t($color);
}
}
&.open {
transform: rotate(180deg);
}
}
}
.datepicker-menu {
z-index: 9999;
@include animation(datepickerSlideIn 0.2s ease-out);
will-change: transform, opacity;
padding: 16px;
min-width: 280px;
box-sizing: border-box;
@include themed {
background: t($modal-background);
border: 1px solid t($modal-sidebarActive);
border-radius: t($borderRadius);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
&.flipped {
@include animation(datepickerSlideInUp 0.2s ease-out);
&.closing {
@include animation(datepickerSlideOutUp 0.2s ease-out forwards);
}
}
&.closing:not(.flipped) {
@include animation(datepickerSlideOut 0.2s ease-out forwards);
}
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
gap: 8px;
.calendar-nav {
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
@include themed {
color: t($subColor);
&:hover {
background: t($modal-sidebarActive);
color: t($color);
}
}
svg {
font-size: 20px;
}
}
.calendar-month {
font-weight: 600;
font-size: 14px;
flex: 1;
text-align: center;
@include themed {
color: t($color);
}
}
}
.calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
margin-bottom: 8px;
div {
text-align: center;
font-size: 12px;
font-weight: 500;
padding: 8px 4px;
@include themed {
color: t($subColor);
}
}
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px;
.calendar-day {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
border-radius: 50%;
font-size: 14px;
transition: all 0.15s ease;
min-width: 32px;
min-height: 32px;
@include themed {
color: t($color);
&:hover:not(.empty) {
background: t($modal-sidebarActive);
}
&.today {
border: 2px solid t($link);
}
&.selected {
background: t($link);
color: white;
font-weight: 600;
&:hover {
background: t($link);
opacity: 0.9;
}
}
&.empty {
cursor: default;
}
}
}
}

View File

@@ -0,0 +1 @@
export * from './DatePicker';

View File

@@ -75,6 +75,10 @@
color: t($subColor);
}
}
&.event-name-input {
max-width: 300px;
}
}
.text-field-textarea {

View File

@@ -10,6 +10,7 @@ import {
Section,
} from 'components/Layout/Settings';
import { Checkbox, Switch, Text } from 'components/Form/Settings';
import { DatePicker } from 'components/Form/Settings/DatePicker';
import { Button } from 'components/Elements';
import { toast } from 'react-toastify';
@@ -146,11 +147,12 @@ const GreetingOptions = ({ currentSubSection, onSubSectionChange, sectionName })
<p style={{ marginRight: 'auto' }}>
{variables.getMessage(`${GREETING_SECTION}.birthday_date`)}
</p>
<input
type="date"
onChange={changeDate}
value={birthday.toISOString().substring(0, 10)}
required
<DatePicker
value={birthday}
onChange={(newDate) => {
localStorage.setItem('birthday', newDate);
setBirthday(newDate);
}}
/>
</div>
</Action>
@@ -193,7 +195,7 @@ const GreetingOptions = ({ currentSubSection, onSubSectionChange, sectionName })
</span>
<input
type="text"
className="text-field-input"
className="text-field-input event-name-input"
value={event.name}
placeholder={variables.getMessage(`${GREETING_SECTION}.event_name`)}
onChange={(e) => {
@@ -205,43 +207,18 @@ const GreetingOptions = ({ currentSubSection, onSubSectionChange, sectionName })
</div>
<div>
<div className="messageAction">
<div className="eventDateSelection">
<label className="subtitle">
{variables.getMessage(`${GREETING_SECTION}.day`)}:
</label>
<input
id="day"
type="number"
min="1"
max="31"
value={event.date}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
if (value >= 1 && value <= 31) {
const updatedEvent = { ...event, date: value };
updateEvent(index, updatedEvent);
}
}}
/>
<hr />
<label className="subtitle">
{variables.getMessage(`${GREETING_SECTION}.month`)}:
</label>
<input
id="month"
type="number"
min="1"
max="12"
value={event.month}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
if (value >= 1 && value <= 12) {
const updatedEvent = { ...event, month: value };
updateEvent(index, updatedEvent);
}
}}
/>
</div>
<DatePicker
value={new Date(2000, event.month - 1, event.date)}
hideYear={true}
onChange={(newDate) => {
const updatedEvent = {
...event,
month: newDate.getMonth() + 1,
date: newDate.getDate(),
};
updateEvent(index, updatedEvent);
}}
/>
<Button
type="settings"
onClick={() => removeEvent(index)}