UNPKG

appointment-scheduler

Version:

A reusable appointment scheduler UI component library

632 lines (605 loc) 21.3 kB
"use strict"; var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { Legends: () => Legends_default, MonthsDropdown: () => MonthsDropdown, TimeSlotPicker: () => TimeSlotPicker, WeekSlider: () => WeeklySlider }); module.exports = __toCommonJS(index_exports); // src/components/WeekSlider/WeekSlider.tsx var import_lucide_react = require("lucide-react"); // src/components/WeekSlider/styles.ts var import_styled = __toESM(require("@emotion/styled")); var Container = import_styled.default.div` display: flex; align-items: center; gap: 1rem; opacity: ${({ disabled }) => disabled ? 0.6 : 1}; pointer-events: ${({ disabled }) => disabled ? "none" : "auto"}; `; var DateGrid = import_styled.default.div` display: flex; gap: 0.75rem; @media (max-width: 640px) { gap: 0.5rem; } `; var DateCard = import_styled.default.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 = import_styled.default.span` font-size: 0.875rem; font-weight: 600; @media (max-width: 640px) { font-size: 0.75rem; } `; var DayNumber = import_styled.default.span` font-size: 1.125rem; font-weight: 700; @media (max-width: 640px) { font-size: 1rem; } `; var AvailabilityBadge = import_styled.default.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 = import_styled.default.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 = import_styled.default.div` display: flex; flex-direction: column; align-items: center; gap: 0.25rem; margin: 0 0.5rem; `; var WeekRange = import_styled.default.span` font-size: 0.875rem; font-weight: 500; color: #6b7280; `; var MonthYear = import_styled.default.span` font-size: 0.75rem; color: #9ca3af; `; // src/components/WeekSlider/hooks.ts var import_react = require("react"); var import_dayjs2 = __toESM(require("dayjs")); // src/components/WeekSlider/utils.ts var import_dayjs = __toESM(require("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((0, import_dayjs.default)(), "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 = (0, import_react.useMemo)(() => (0, import_dayjs2.default)(), []); const [selectedInternalDate, setSelectedInternalDate] = (0, import_react.useState)(null); const [startDate, setStartDate] = (0, import_react.useState)(() => getStartOfWeek(today, weekStartsOn)); const weekDates = (0, import_react.useMemo)(() => Array.from({ length: 7 }, (_, i) => startDate.add(i, "day")), [startDate]); const weekEnd = (0, import_react.useMemo)(() => startDate.add(6, "day"), [startDate]); const weekRange = (0, import_react.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 = (0, import_react.useMemo)(() => { if (!minDate) return true; const minDayjs = (0, import_dayjs2.default)(minDate); const prevWeekStart = startDate.subtract(7, "day"); return prevWeekStart.isSame(minDayjs, "day") || prevWeekStart.isAfter(minDayjs, "day"); }, [startDate, minDate]); const canGoNext = (0, import_react.useMemo)(() => { if (!maxDate) return true; const maxDayjs = (0, import_dayjs2.default)(maxDate); const nextWeekStart = startDate.add(7, "day"); return nextWeekStart.isSame(maxDayjs, "day") || nextWeekStart.isBefore(maxDayjs, "day"); }, [startDate, maxDate]); const isDateDisabled = (0, import_react.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((0, import_dayjs2.default)(minDate), "day")) return true; if (maxDate && date.isAfter((0, import_dayjs2.default)(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 = (0, import_react.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 = (0, import_react.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 = (0, import_react.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 = (0, import_react.useCallback)( (date) => { if (disabled || isDateDisabled(date)) return; setSelectedInternalDate(date); const selection = createWeekSelection(date, startDate, weekEnd); onDateSelect?.(selection); }, [disabled, isDateDisabled, startDate, weekEnd, onDateSelect] ); (0, import_react.useEffect)(() => { if (selectedDate) { const selected = (0, import_dayjs2.default)(selectedDate); setSelectedInternalDate(selected); setStartDate(getStartOfWeek(selected, weekStartsOn)); } else if (highlightToday) { setSelectedInternalDate(today); setStartDate(getStartOfWeek(today, weekStartsOn)); } }, []); (0, import_react.useEffect)(() => { onWeekChange?.(startDate.format("YYYY-MM-DD"), weekEnd.format("YYYY-MM-DD")); }, []); (0, import_react.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 var import_jsx_runtime = require("@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__ */ (0, import_jsx_runtime.jsxs)(Container, { className, disabled, children: [ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NavButton, { onClick: handlePrev, disabled: disabled || !canGoPrevious, "aria-label": "Previous Week", title: "Previous Week", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_lucide_react.ChevronLeft, { size: 20 }) }), showWeekRange && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(WeekInfo, { children: [ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(WeekRange, { children: weekRange }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(MonthYear, { children: startDate.format("MMMM YYYY") }) ] }), /* @__PURE__ */ (0, import_jsx_runtime.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__ */ (0, import_jsx_runtime.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__ */ (0, import_jsx_runtime.jsx)(DayLabel, { children: date.format("ddd") }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DayNumber, { children: date.format("D") }), showDateCount && availabilityCount !== void 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AvailabilityBadge, { count: availabilityCount, children: availabilityCount }) ] }, dateStr ); }) }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(NavButton, { onClick: handleNext, disabled: disabled || !canGoNext, "aria-label": "Next Week", title: "Next Week", children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_lucide_react.ChevronRight, { size: 20 }) }) ] }); } // src/components/MonthsDropdown.tsx var import_react2 = require("react"); var import_dayjs3 = __toESM(require("dayjs")); var import_localeData = __toESM(require("dayjs/plugin/localeData")); var import_styled2 = __toESM(require("@emotion/styled")); var import_jsx_runtime2 = require("@emotion/react/jsx-runtime"); import_dayjs3.default.extend(import_localeData.default); var monthNames = import_dayjs3.default.months(); var Select = import_styled2.default.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 = (0, import_dayjs3.default)().month(); const currentYear = (0, import_dayjs3.default)().year(); const [selectedMonth, setSelectedMonth] = (0, import_react2.useState)(currentMonth); return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)( Select, { value: selectedMonth, onChange: (e) => setSelectedMonth(Number(e.target.value)), children: monthNames.map((month, index) => /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)("option", { value: index, children: [ month, " ", currentYear ] }, index)) } ); } // src/components/TimeSlotPicker.tsx var import_react3 = require("react"); var import_lucide_react2 = require("lucide-react"); var import_dayjs4 = __toESM(require("dayjs")); var import_styled3 = __toESM(require("@emotion/styled")); var import_jsx_runtime3 = require("@emotion/react/jsx-runtime"); var getTimeSlots = (start, end, step) => { const slots = []; let time = (0, import_dayjs4.default)(`1970-01-01T${start}`); const endTime = (0, import_dayjs4.default)(`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 = import_styled3.default.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 = import_styled3.default.div` display: flex; align-items: flex-start; gap: 1rem; flex-wrap: wrap; width: 100%; @media (min-width: 640px) { flex-wrap: nowrap; } `; var IconWrapper = import_styled3.default.div` margin-top: 0.25rem; flex-shrink: 0; svg { width: 20px; height: 20px; color: #4b5563; /* text-gray-600 */ } `; var SlotGrid = import_styled3.default.div` display: grid; gap: 0.75rem; width: 100%; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); `; var TimeButton = import_styled3.default.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] = (0, import_react3.useState)(null); const slots = getTimeSlots(startTime, endTime, interval); const { morning, afternoon, evening } = categorizeSlots(slots); const renderGroup = (label, icon, times) => /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(GroupWrapper, { children: [ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(IconWrapper, { children: icon }), /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(SlotGrid, { children: times.map((time) => /* @__PURE__ */ (0, import_jsx_runtime3.jsx)( TimeButton, { selected: selectedTime === time, onClick: () => setSelectedTime(time), children: time }, time )) }) ] }); return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(Container2, { children: [ renderGroup("Morning", /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_lucide_react2.Sun, {}), morning), renderGroup("Afternoon", /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_lucide_react2.Video, {}), afternoon), renderGroup("Evening", /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_lucide_react2.Moon, {}), evening) ] }); } // src/components/Legends.tsx var import_styled4 = __toESM(require("@emotion/styled")); var import_jsx_runtime4 = require("@emotion/react/jsx-runtime"); var Container3 = import_styled4.default.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 = import_styled4.default.div` display: flex; align-items: center; gap: 0.5rem; `; var ColorBox = import_styled4.default.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 = import_styled4.default.span` font-size: 0.875rem; /* text-sm */ font-weight: 500; color: #374151; /* text-gray-700 */ `; var Legends = ({ legends }) => { return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Container3, { children: legends.map((legend) => /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)(LegendGroup, { children: [ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(ColorBox, { color: legend.color, variant: legend.variant }), /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(Label, { children: legend.label }) ] }, legend.label)) }); }; var Legends_default = Legends; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { Legends, MonthsDropdown, TimeSlotPicker, WeekSlider });