appointment-scheduler
Version:
A reusable appointment scheduler UI component library
632 lines (605 loc) • 21.3 kB
JavaScript
;
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
});