mirror of
https://github.com/mue/mue.git
synced 2026-07-19 06:54:10 +02:00
feat: new date picker
This commit is contained in:
196
src/components/Form/Settings/DatePicker/DatePicker.jsx
Normal file
196
src/components/Form/Settings/DatePicker/DatePicker.jsx
Normal 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 };
|
||||
256
src/components/Form/Settings/DatePicker/DatePicker.scss
Normal file
256
src/components/Form/Settings/DatePicker/DatePicker.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/components/Form/Settings/DatePicker/index.jsx
Normal file
1
src/components/Form/Settings/DatePicker/index.jsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from './DatePicker';
|
||||
@@ -75,6 +75,10 @@
|
||||
color: t($subColor);
|
||||
}
|
||||
}
|
||||
|
||||
&.event-name-input {
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.text-field-textarea {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user