UNPKG

appointment-scheduler

Version:

A reusable appointment scheduler UI component library

592 lines (567 loc) 17.9 kB
// src/components/WeekSlider/WeekSlider.tsx import { ChevronLeft, ChevronRight } from "lucide-react"; // src/components/WeekSlider/styles.ts import styled from "@emotion/styled"; var Container = styled.div` display: flex; align-items: center; gap: 1rem; opacity: ${({ disabled }) => disabled ? 0.6 : 1}; pointer-events: ${({ disabled }) => disabled ? "none" : "auto"}; `; var DateGrid = styled.div` display: flex; gap: 0.75rem; @media (max-width: 640px) { gap: 0.5rem; } `; var DateCard = styled.div` min-width: 4rem; padding: 0.5rem; display: flex; flex-direction: column; align-items: center; border-radius: 0.5rem; box-shadow: 0 1px 2px rgba(16, 24, 40, 0.1); cursor: ${({ isDisabled }) => isDisabled ? "not-allowed" : "pointer"}; transition: all 0.2s ease-in-out; position: relative; // Base styling background-color: ${({ isToday, isSelected, isDisabled, hasAvailability }) => { if (isDisabled) return "#f8f9fa"; if (isSelected) return "#3b82f6"; if (isToday) return "#D7F7FB"; if (!hasAvailability) return "#fef2f2"; return "#fff"; }}; color: ${({ isToday, isSelected, isDisabled, hasAvailability }) => { if (isDisabled) return "#6b7280"; if (isSelected) return "#fff"; if (isToday) return "#00abc9"; if (!hasAvailability) return "#dc2626"; return "#2a3547"; }}; border: 2px solid ${({ isSelected, isToday, hasAvailability, isDisabled }) => { if (isSelected) return "#3b82f6"; if (isToday) return "#00abc9"; if (isDisabled) return "#e5e7eb"; if (!hasAvailability) return "#fecaca"; return "transparent"; }}; // Hover effects &:hover { ${({ isDisabled, isSelected }) => !isDisabled && !isSelected && ` background-color: #f8fafc; border-color: #cbd5e1; transform: translateY(-1px); box-shadow: 0 4px 6px rgba(16, 24, 40, 0.1); `} } // Weekend styling ${({ isWeekend, isDisabled, isSelected }) => isWeekend && !isDisabled && !isSelected && ` background-color: #f9fafb; border-color: #f3f4f6; `} @media (max-width: 640px) { min-width: 3rem; padding: 0.375rem; } `; var DayLabel = styled.span` font-size: 0.875rem; font-weight: 600; @media (max-width: 640px) { font-size: 0.75rem; } `; var DayNumber = styled.span` font-size: 1.125rem; font-weight: 700; @media (max-width: 640px) { font-size: 1rem; } `; var AvailabilityBadge = styled.span` position: absolute; top: -0.25rem; right: -0.25rem; background-color: ${({ count }) => count > 0 ? "#10b981" : "#ef4444"}; color: white; font-size: 0.625rem; font-weight: 600; padding: 0.125rem 0.25rem; border-radius: 0.5rem; min-width: 1rem; height: 1rem; display: flex; align-items: center; justify-content: center; line-height: 1; `; var NavButton = styled.button` border: none; background: transparent; cursor: ${({ disabled }) => disabled ? "not-allowed" : "pointer"}; padding: 0.5rem; border-radius: 0.375rem; transition: all 0.2s ease-in-out; color: ${({ disabled }) => disabled ? "#9ca3af" : "#374151"}; &:hover { ${({ disabled }) => !disabled && ` background-color: #f3f4f6; color: #111827; `} } &:focus { outline: 2px solid #3b82f6; outline-offset: 2px; } `; var WeekInfo = styled.div` display: flex; flex-direction: column; align-items: center; gap: 0.25rem; margin: 0 0.5rem; `; var WeekRange = styled.span` font-size: 0.875rem; font-weight: 500; color: #6b7280; `; var MonthYear = styled.span` font-size: 0.75rem; color: #9ca3af; `; // src/components/WeekSlider/hooks.ts import { useState, useEffect, useCallback, useMemo } from "react"; import dayjs2 from "dayjs"; // src/components/WeekSlider/utils.ts import dayjs from "dayjs"; var getStartOfWeek = (date, weekStartsOn = "monday") => { const day = date.day(); if (weekStartsOn === "monday") { return date.subtract(day === 0 ? 6 : day - 1, "day"); } else { return date.subtract(day, "day"); } }; var createWeekSelection = (date, weekStart, weekEnd) => { return { selectedDate: date.format("YYYY-MM-DD"), weekStart: weekStart.format("YYYY-MM-DD"), weekEnd: weekEnd.format("YYYY-MM-DD"), dayOfWeek: date.day(), dayName: date.format("dddd"), displayDate: date.format("ddd, MMM D"), isToday: date.isSame(dayjs(), "day"), isWeekend: date.day() === 0 || date.day() === 6 }; }; // src/components/WeekSlider/hooks.ts function useWeeklySlider(props) { const { selectedDate = null, onDateSelect, onWeekChange, disabledDates = [], availableDates, minDate, maxDate, excludeWeekends = false, excludePastDates = true, highlightToday = true, weekStartsOn = "monday", showDateCount = true, dateAvailability = {}, disabled = false } = props; const today = useMemo(() => dayjs2(), []); const [selectedInternalDate, setSelectedInternalDate] = useState(null); const [startDate, setStartDate] = useState(() => getStartOfWeek(today, weekStartsOn)); const weekDates = useMemo(() => Array.from({ length: 7 }, (_, i) => startDate.add(i, "day")), [startDate]); const weekEnd = useMemo(() => startDate.add(6, "day"), [startDate]); const weekRange = useMemo(() => { const start = startDate.format("MMM D"); const end = weekEnd.format("MMM D"); const year = startDate.format("YYYY"); return `${start} - ${end}, ${year}`; }, [startDate, weekEnd]); const canGoPrevious = useMemo(() => { if (!minDate) return true; const minDayjs = dayjs2(minDate); const prevWeekStart = startDate.subtract(7, "day"); return prevWeekStart.isSame(minDayjs, "day") || prevWeekStart.isAfter(minDayjs, "day"); }, [startDate, minDate]); const canGoNext = useMemo(() => { if (!maxDate) return true; const maxDayjs = dayjs2(maxDate); const nextWeekStart = startDate.add(7, "day"); return nextWeekStart.isSame(maxDayjs, "day") || nextWeekStart.isBefore(maxDayjs, "day"); }, [startDate, maxDate]); const isDateDisabled = useCallback( (date) => { const dateStr = date.format("YYYY-MM-DD"); if (disabledDates.includes(dateStr)) return true; if (availableDates && !availableDates.includes(dateStr)) return true; if (minDate && date.isBefore(dayjs2(minDate), "day")) return true; if (maxDate && date.isAfter(dayjs2(maxDate), "day")) return true; if (excludePastDates && date.isBefore(today, "day")) return true; if (excludeWeekends && (date.day() === 0 || date.day() === 6)) return true; return false; }, [disabledDates, availableDates, minDate, maxDate, excludePastDates, excludeWeekends, today] ); const hasAvailability = useCallback( (date) => { const dateStr = date.format("YYYY-MM-DD"); if (showDateCount && dateAvailability[dateStr] !== void 0) { return dateAvailability[dateStr] > 0; } return !isDateDisabled(date); }, [showDateCount, dateAvailability, isDateDisabled] ); const handlePrev = useCallback(() => { if (disabled || !canGoPrevious) return; const newStart = startDate.subtract(7, "day"); setStartDate(newStart); onWeekChange?.(newStart.format("YYYY-MM-DD"), newStart.add(6, "day").format("YYYY-MM-DD")); }, [disabled, canGoPrevious, startDate, onWeekChange]); const handleNext = useCallback(() => { if (disabled || !canGoNext) return; const newStart = startDate.add(7, "day"); setStartDate(newStart); onWeekChange?.(newStart.format("YYYY-MM-DD"), newStart.add(6, "day").format("YYYY-MM-DD")); }, [disabled, canGoNext, startDate, onWeekChange]); const handleDateClick = useCallback( (date) => { if (disabled || isDateDisabled(date)) return; setSelectedInternalDate(date); const selection = createWeekSelection(date, startDate, weekEnd); onDateSelect?.(selection); }, [disabled, isDateDisabled, startDate, weekEnd, onDateSelect] ); useEffect(() => { if (selectedDate) { const selected = dayjs2(selectedDate); setSelectedInternalDate(selected); setStartDate(getStartOfWeek(selected, weekStartsOn)); } else if (highlightToday) { setSelectedInternalDate(today); setStartDate(getStartOfWeek(today, weekStartsOn)); } }, []); useEffect(() => { onWeekChange?.(startDate.format("YYYY-MM-DD"), weekEnd.format("YYYY-MM-DD")); }, []); useEffect(() => { const handleKeyDown = (event) => { if (disabled) return; switch (event.key) { case "ArrowLeft": event.preventDefault(); handlePrev(); break; case "ArrowRight": event.preventDefault(); handleNext(); break; } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [disabled, handlePrev, handleNext]); return { today, weekDates, weekRange, weekEnd, startDate, selectedInternalDate, isDateDisabled, hasAvailability, handlePrev, handleNext, handleDateClick, canGoPrevious, canGoNext, dateAvailability, showDateCount }; } // src/components/WeekSlider/WeekSlider.tsx import { jsx, jsxs } from "@emotion/react/jsx-runtime"; function WeeklySlider({ selectedDate = null, onDateSelect, onWeekChange, disabledDates = [], availableDates, minDate, maxDate, showWeekRange = false, excludeWeekends = false, excludePastDates = true, highlightToday = true, weekStartsOn = "monday", showDateCount = true, dateAvailability = {}, className, disabled = false }) { const { today, weekDates, weekRange, startDate, selectedInternalDate, isDateDisabled, hasAvailability, handlePrev, handleNext, handleDateClick, canGoPrevious, canGoNext } = useWeeklySlider({ selectedDate, onDateSelect, onWeekChange, disabledDates, availableDates, minDate, maxDate, excludeWeekends, excludePastDates, highlightToday, weekStartsOn, showDateCount, dateAvailability, disabled }); return /* @__PURE__ */ jsxs(Container, { className, disabled, children: [ /* @__PURE__ */ jsx(NavButton, { onClick: handlePrev, disabled: disabled || !canGoPrevious, "aria-label": "Previous Week", title: "Previous Week", children: /* @__PURE__ */ jsx(ChevronLeft, { size: 20 }) }), showWeekRange && /* @__PURE__ */ jsxs(WeekInfo, { children: [ /* @__PURE__ */ jsx(WeekRange, { children: weekRange }), /* @__PURE__ */ jsx(MonthYear, { children: startDate.format("MMMM YYYY") }) ] }), /* @__PURE__ */ jsx(DateGrid, { children: weekDates.map((date) => { const dateStr = date.format("YYYY-MM-DD"); const isSelected = selectedInternalDate ? selectedInternalDate.isSame(date, "day") : false; const isToday = date.isSame(today, "day"); const isDisabled = isDateDisabled(date); const availabilityCount = dateAvailability[dateStr]; return /* @__PURE__ */ jsxs( DateCard, { isToday, isSelected, isDisabled, isWeekend: date.day() === 0 || date.day() === 6, hasAvailability: hasAvailability(date), availabilityCount, onClick: () => handleDateClick(date), title: `${date.format("dddd, MMMM D, YYYY")}${availabilityCount !== void 0 ? ` - ${availabilityCount} slots available` : ""}`, role: "button", tabIndex: isDisabled ? -1 : 0, "aria-label": `Select ${date.format("dddd, MMMM D, YYYY")}`, "aria-pressed": isSelected, "aria-disabled": isDisabled, children: [ /* @__PURE__ */ jsx(DayLabel, { children: date.format("ddd") }), /* @__PURE__ */ jsx(DayNumber, { children: date.format("D") }), showDateCount && availabilityCount !== void 0 && /* @__PURE__ */ jsx(AvailabilityBadge, { count: availabilityCount, children: availabilityCount }) ] }, dateStr ); }) }), /* @__PURE__ */ jsx(NavButton, { onClick: handleNext, disabled: disabled || !canGoNext, "aria-label": "Next Week", title: "Next Week", children: /* @__PURE__ */ jsx(ChevronRight, { size: 20 }) }) ] }); } // src/components/MonthsDropdown.tsx import { useState as useState2 } from "react"; import dayjs3 from "dayjs"; import localeData from "dayjs/plugin/localeData"; import styled2 from "@emotion/styled"; import { jsx as jsx2, jsxs as jsxs2 } from "@emotion/react/jsx-runtime"; dayjs3.extend(localeData); var monthNames = dayjs3.months(); var Select = styled2.select` border: 1px solid #d1d5db; /* border-gray-300 */ border-radius: 0.375rem; /* rounded-md */ padding: 0.375rem 0.75rem; /* px-3 py-1.5 */ font-size: 0.875rem; /* text-sm */ color: #2a3547; outline: none; &:focus { border-color: #2a3547; box-shadow: 0 0 0 1px #2a3547; } `; function MonthsDropdown() { const currentMonth = dayjs3().month(); const currentYear = dayjs3().year(); const [selectedMonth, setSelectedMonth] = useState2(currentMonth); return /* @__PURE__ */ jsx2( Select, { value: selectedMonth, onChange: (e) => setSelectedMonth(Number(e.target.value)), children: monthNames.map((month, index) => /* @__PURE__ */ jsxs2("option", { value: index, children: [ month, " ", currentYear ] }, index)) } ); } // src/components/TimeSlotPicker.tsx import { useState as useState3 } from "react"; import { Sun, Video, Moon } from "lucide-react"; import dayjs4 from "dayjs"; import styled3 from "@emotion/styled"; import { jsx as jsx3, jsxs as jsxs3 } from "@emotion/react/jsx-runtime"; var getTimeSlots = (start, end, step) => { const slots = []; let time = dayjs4(`1970-01-01T${start}`); const endTime = dayjs4(`1970-01-01T${end}`); while (time.isBefore(endTime) || time.isSame(endTime)) { slots.push(time.format("HH:mm")); time = time.add(step, "minute"); } return slots; }; var categorizeSlots = (slots) => { return { morning: slots.filter((t) => parseInt(t.split(":")[0]) < 12), afternoon: slots.filter((t) => { const hr = parseInt(t.split(":")[0]); return hr >= 12 && hr < 16; }), evening: slots.filter((t) => parseInt(t.split(":")[0]) >= 16) }; }; var Container2 = styled3.div` display: flex; flex-direction: column; gap: 1.5rem; width: 100%; padding-left: 0.5rem; padding-right: 0.5rem; @media (min-width: 640px) { padding-left: 0; padding-right: 0; } `; var GroupWrapper = styled3.div` display: flex; align-items: flex-start; gap: 1rem; flex-wrap: wrap; width: 100%; @media (min-width: 640px) { flex-wrap: nowrap; } `; var IconWrapper = styled3.div` margin-top: 0.25rem; flex-shrink: 0; svg { width: 20px; height: 20px; color: #4b5563; /* text-gray-600 */ } `; var SlotGrid = styled3.div` display: grid; gap: 0.75rem; width: 100%; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); `; var TimeButton = styled3.button` border: 1px solid #d1d5db; /* border-gray-300 */ padding: 0.5rem 1rem; border-radius: 0.375rem; font-size: 0.875rem; font-weight: 500; transition: all 0.2s ease-in-out; min-width: 80px; flex-shrink: 0; background-color: ${({ selected }) => selected ? "#00abc9" : "#fff"}; color: ${({ selected }) => selected ? "#fff" : "#1f2937"}; /* text-gray-800 */ cursor: pointer; &:hover { background-color: ${({ selected }) => selected ? "#00abc9" : "#f3f4f6"}; } `; function TimeSlotPicker({ startTime = "00:00", endTime = "23:45", interval = 15 }) { const [selectedTime, setSelectedTime] = useState3(null); const slots = getTimeSlots(startTime, endTime, interval); const { morning, afternoon, evening } = categorizeSlots(slots); const renderGroup = (label, icon, times) => /* @__PURE__ */ jsxs3(GroupWrapper, { children: [ /* @__PURE__ */ jsx3(IconWrapper, { children: icon }), /* @__PURE__ */ jsx3(SlotGrid, { children: times.map((time) => /* @__PURE__ */ jsx3( TimeButton, { selected: selectedTime === time, onClick: () => setSelectedTime(time), children: time }, time )) }) ] }); return /* @__PURE__ */ jsxs3(Container2, { children: [ renderGroup("Morning", /* @__PURE__ */ jsx3(Sun, {}), morning), renderGroup("Afternoon", /* @__PURE__ */ jsx3(Video, {}), afternoon), renderGroup("Evening", /* @__PURE__ */ jsx3(Moon, {}), evening) ] }); } // src/components/Legends.tsx import styled4 from "@emotion/styled"; import { jsx as jsx4, jsxs as jsxs4 } from "@emotion/react/jsx-runtime"; var Container3 = styled4.div` display: flex; gap: 1rem; padding: 0.5rem; border: 1px solid #d1d5db; /* border-gray-300 */ border-radius: 0.375rem; /* rounded-md */ width: fit-content; `; var LegendGroup = styled4.div` display: flex; align-items: center; gap: 0.5rem; `; var ColorBox = styled4.div` width: 1.25rem; height: 1.25rem; border-radius: 0.125rem; flex-shrink: 0; ${({ color, variant }) => variant === "outline" ? `border: 2px solid ${color}; background-color: transparent;` : `background-color: ${color};`} `; var Label = styled4.span` font-size: 0.875rem; /* text-sm */ font-weight: 500; color: #374151; /* text-gray-700 */ `; var Legends = ({ legends }) => { return /* @__PURE__ */ jsx4(Container3, { children: legends.map((legend) => /* @__PURE__ */ jsxs4(LegendGroup, { children: [ /* @__PURE__ */ jsx4(ColorBox, { color: legend.color, variant: legend.variant }), /* @__PURE__ */ jsx4(Label, { children: legend.label }) ] }, legend.label)) }); }; var Legends_default = Legends; export { Legends_default as Legends, MonthsDropdown, TimeSlotPicker, WeeklySlider as WeekSlider };