diff --git a/src/components/Form/Settings/DatePicker/DatePicker.jsx b/src/components/Form/Settings/DatePicker/DatePicker.jsx new file mode 100644 index 00000000..5970a2cf --- /dev/null +++ b/src/components/Form/Settings/DatePicker/DatePicker.jsx @@ -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(
); + } + + // 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( +
handleDateSelect(day)} + > + {day} +
+ ); + } + + return days; + }; + + return ( +
e.stopPropagation()}> +
{ + if (isOpen) { + closeDropdown(); + } else { + openDropdown(); + } + }} + > + {formatDate(props.value)} + +
+ {(isOpen || isClosing) && + createPortal( +
+
+ + + {monthNames[viewDate.getMonth()]}{props.hideYear ? '' : ` ${viewDate.getFullYear()}`} + + +
+
+
Su
+
Mo
+
Tu
+
We
+
Th
+
Fr
+
Sa
+
+
+ {renderCalendar()} +
+
, + document.body, + )} +
+ ); +}); + +DatePicker.displayName = 'DatePicker'; + +export { DatePicker as default, DatePicker }; diff --git a/src/components/Form/Settings/DatePicker/DatePicker.scss b/src/components/Form/Settings/DatePicker/DatePicker.scss new file mode 100644 index 00000000..6429e40f --- /dev/null +++ b/src/components/Form/Settings/DatePicker/DatePicker.scss @@ -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; + } + } + } +} diff --git a/src/components/Form/Settings/DatePicker/index.jsx b/src/components/Form/Settings/DatePicker/index.jsx new file mode 100644 index 00000000..a48b62e4 --- /dev/null +++ b/src/components/Form/Settings/DatePicker/index.jsx @@ -0,0 +1 @@ +export * from './DatePicker'; diff --git a/src/components/Form/Settings/Text/Text.scss b/src/components/Form/Settings/Text/Text.scss index eb44c009..8e20c128 100644 --- a/src/components/Form/Settings/Text/Text.scss +++ b/src/components/Form/Settings/Text/Text.scss @@ -75,6 +75,10 @@ color: t($subColor); } } + + &.event-name-input { + max-width: 300px; + } } .text-field-textarea { diff --git a/src/features/greeting/options/GreetingOptions.jsx b/src/features/greeting/options/GreetingOptions.jsx index 15606e9a..6776b3bf 100644 --- a/src/features/greeting/options/GreetingOptions.jsx +++ b/src/features/greeting/options/GreetingOptions.jsx @@ -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 })

{variables.getMessage(`${GREETING_SECTION}.birthday_date`)}

- { + localStorage.setItem('birthday', newDate); + setBirthday(newDate); + }} />
@@ -193,7 +195,7 @@ const GreetingOptions = ({ currentSubSection, onSubSectionChange, sectionName }) { @@ -205,43 +207,18 @@ const GreetingOptions = ({ currentSubSection, onSubSectionChange, sectionName })
-
- - { - const value = parseInt(e.target.value, 10); - if (value >= 1 && value <= 31) { - const updatedEvent = { ...event, date: value }; - updateEvent(index, updatedEvent); - } - }} - /> -
- - { - const value = parseInt(e.target.value, 10); - if (value >= 1 && value <= 12) { - const updatedEvent = { ...event, month: value }; - updateEvent(index, updatedEvent); - } - }} - /> -
+ { + const updatedEvent = { + ...event, + month: newDate.getMonth() + 1, + date: newDate.getDate(), + }; + updateEvent(index, updatedEvent); + }} + />