@nickkupriyanov/slotline-react 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/motion.js CHANGED
@@ -1,86 +1,42 @@
1
1
  "use client";
2
2
  import { motion, AnimatePresence } from 'motion/react';
3
- import { createContext, useRef, useState, useCallback, useEffect, useContext, useMemo, useReducer, useId } from 'react';
4
- import { cx, SLOT_CLASS, format, FLOW_STEPS, stepIndex, detectLocale, resolveTimeZone, resolveMessages, normalizeDateInput, todayKey, monthKeyOf, clampDayKey, flowReducer, createFlowState, firstDayOfMonth, lastDayOfMonth, validateDetails, hasErrors, endOf, buildMonthGrid, rotateToWeekStart, shortWeekdayNames, weekdayNames, compareDayKeys, countSlots, formatFullDay, formatTime, addMonths, visibleErrors, formatMonthYear, formatTimeZoneName, weekdayIndex, addDays } from '@nickkupriyanov/slotline-core';
5
- export { FLOW_STEPS, SLOT_CLASS, addDays, addMonths, buildMonthGrid, compareDayKeys, countSlots, createFlowState, createMockAdapter, cx, dayKeyOf, dayKeyOfInstant, defaultMessages, flowReducer, format, formatFullDay, formatMonthYear, formatTime, formatTimeZoneName, isDayKey, monthKeyOf, resolveMessages, stepIndex, todayKey, validateDetails } from '@nickkupriyanov/slotline-core';
3
+ import { createContext, useRef, useState, useEffect, useContext, useCallback, useSyncExternalStore, useMemo, useId } from 'react';
4
+ import { observeAnimatedHeight, observeMediaQuery, REDUCED_MOTION_QUERY, trapFocus, observeVisibilityChange, DARK_SCHEME_QUERY, canMatchMedia, matchesMediaQuery } from '@nickkupriyanov/slotline-core/dom';
5
+ import { defaultMessages, cx, SLOT_CLASS, format, createBookingController, resolveMessages, monthKeyOf, weekdayIndex, addDays, resolveTimeZone, detectLocale } from '@nickkupriyanov/slotline-core';
6
+ export { BOOKING_ERROR_CODES, BookingError, DEFAULT_BOOKING_CONTEXT, DEFAULT_BOOKING_FIELDS, DEFAULT_CONTEXT_KEY, DEFAULT_DURATION_OPTIONS, DEFAULT_TIME_FORMAT, FALLBACK_LOCALE, FALLBACK_TIME_ZONE, FLOW_STEP_ORDER, SLOT_CLASS, addDays, addMonths, bookingReducer, buildMonthGrid, clampDayKey, compareDayKeys, contextKeyOf, countSlots, createBookingController, createBookingState, createEmptyValues, createIdempotencyKey, createMockAdapter, createTimeAnchor, cx, dayKeyOf, dayKeyOfInstant, defaultMessages, deriveBookingView, detectLocale, endOf, format, formatFullDay, formatMonthYear, formatTime, formatTimeZoneName, formatUtcOffset, guaranteesNotCreated, instantOf, instantOfZonedTime, isAbortError, isBookingError, isBookingErrorCode, isDayKey, isRepeatedLocalTime, isRetrySafe, midnightAfter, monthKeyOf, resolveBookingConfig, resolveBookingFields, resolveMessages, resolveTimeZone, retryWindowClosesAt, soonestBoundary, systemClock, timeAnchorKey, toBookingError, todayKey, unmappedFieldNames, userSteps, validateBookingValues, visibleFieldErrors, wallKeyOf, zoneOffsetMs } from '@nickkupriyanov/slotline-core';
6
7
  import { jsx, jsxs } from 'react/jsx-runtime';
7
8
 
8
- function subscribe(query, listener) {
9
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => {
10
- };
11
- const list = window.matchMedia(query);
12
- const handler = (event) => listener(event.matches);
13
- listener(list.matches);
14
- list.addEventListener("change", handler);
15
- return () => list.removeEventListener("change", handler);
16
- }
17
9
  function useMediaQuery(query) {
18
10
  const [matches, setMatches] = useState(false);
19
- useEffect(() => {
20
- let active = true;
21
- const unsubscribe = subscribe(query, (next) => {
22
- if (active) setMatches(next);
23
- });
24
- return () => {
25
- active = false;
26
- unsubscribe();
27
- };
28
- }, [query]);
11
+ useEffect(() => observeMediaQuery(query, setMatches), [query]);
29
12
  return matches;
30
13
  }
31
14
  function useReducedMotion() {
32
- return useMediaQuery("(prefers-reduced-motion: reduce)");
15
+ return useMediaQuery(REDUCED_MOTION_QUERY);
33
16
  }
34
17
 
35
18
  // src/hooks/useAnimatedHeight.ts
36
19
  function useAnimatedHeight(durationMs = 320) {
37
20
  const contentRef = useRef(null);
38
- const heightRef = useRef(void 0);
39
- const timerRef = useRef(null);
40
- const [height, setHeight] = useState();
41
- const [animating, setAnimating] = useState(false);
21
+ const [state, setState] = useState({
22
+ height: void 0,
23
+ animating: false
24
+ });
42
25
  const reducedMotion = useReducedMotion();
43
- const measure = useCallback(() => {
44
- const element = contentRef.current;
45
- if (!element) return;
46
- const next = element.getBoundingClientRect().height;
47
- if (!(next > 0)) return;
48
- const previous = heightRef.current;
49
- if (previous !== void 0 && Math.abs(previous - next) >= 1) {
50
- setAnimating(true);
51
- if (timerRef.current) clearTimeout(timerRef.current);
52
- timerRef.current = setTimeout(() => setAnimating(false), durationMs);
53
- }
54
- heightRef.current = next;
55
- setHeight((current) => current === next ? current : next);
56
- }, [durationMs]);
57
26
  useEffect(() => {
58
- if (reducedMotion) return;
59
- measure();
60
27
  const element = contentRef.current;
61
- if (!element || typeof ResizeObserver === "undefined") {
62
- window.addEventListener("resize", measure);
63
- return () => window.removeEventListener("resize", measure);
64
- }
65
- const observer = new ResizeObserver(() => measure());
66
- observer.observe(element);
67
- return () => observer.disconnect();
68
- }, [measure, reducedMotion]);
69
- useEffect(
70
- () => () => {
71
- if (timerRef.current) clearTimeout(timerRef.current);
72
- },
73
- []
74
- );
28
+ if (!element || reducedMotion) return;
29
+ return observeAnimatedHeight(element, { durationMs, onChange: setState });
30
+ }, [durationMs, reducedMotion]);
75
31
  if (reducedMotion) {
76
32
  return { contentRef, style: void 0, animating: false };
77
33
  }
78
- const style = height === void 0 ? void 0 : {
79
- height,
80
- overflow: animating ? "hidden" : "visible",
34
+ const style = state.height === void 0 ? void 0 : {
35
+ height: state.height,
36
+ overflow: state.animating ? "hidden" : "visible",
81
37
  transition: `height ${durationMs}ms var(--sl-ease-soft)`
82
38
  };
83
- return { contentRef, style, animating };
39
+ return { contentRef, style, animating: state.animating };
84
40
  }
85
41
  var spring = { type: "spring", stiffness: 420, damping: 34, mass: 0.7 };
86
42
  var MotionButton = motion.button;
@@ -168,7 +124,11 @@ var useFlow = useSlotlineContext;
168
124
  var UIContext = createContext({
169
125
  animations: cssAnimations,
170
126
  autoFocus: false,
171
- trapFocus: false
127
+ trapFocus: false,
128
+ canDismiss: false,
129
+ messages: defaultMessages.en,
130
+ locale: "en",
131
+ theme: "dark"
172
132
  });
173
133
  function useUI() {
174
134
  return useContext(UIContext);
@@ -183,57 +143,37 @@ function useClassNames() {
183
143
  [classNames]
184
144
  );
185
145
  }
186
- var FOCUSABLE = [
187
- "a[href]",
188
- "button:not([disabled])",
189
- 'input:not([disabled]):not([type="hidden"])',
190
- "select:not([disabled])",
191
- "textarea:not([disabled])",
192
- '[tabindex]:not([tabindex="-1"])'
193
- ].join(",");
194
- function focusableWithin(container) {
195
- return Array.from(container.querySelectorAll(FOCUSABLE)).filter(
196
- (element) => element.offsetParent !== null || element === document.activeElement || element.tabIndex >= 0
197
- );
198
- }
199
146
  function useFocusTrap(containerRef, { active, onEscape }) {
200
147
  useEffect(() => {
201
- if (!active) return;
202
148
  const container = containerRef.current;
203
- if (!container) return;
204
- const onKeyDown = (event) => {
205
- if (event.key === "Escape" && onEscape) {
206
- event.stopPropagation();
207
- onEscape();
208
- return;
209
- }
210
- if (event.key !== "Tab") return;
211
- const focusable = focusableWithin(container);
212
- if (focusable.length === 0) {
213
- event.preventDefault();
214
- container.focus();
215
- return;
216
- }
217
- const first = focusable[0];
218
- const last = focusable[focusable.length - 1];
219
- const activeElement = document.activeElement;
220
- if (!activeElement || !container.contains(activeElement)) {
221
- event.preventDefault();
222
- (event.shiftKey ? last : first).focus();
223
- return;
224
- }
225
- if (!event.shiftKey && activeElement === last) {
226
- event.preventDefault();
227
- first.focus();
228
- } else if (event.shiftKey && activeElement === first) {
229
- event.preventDefault();
230
- last.focus();
231
- }
232
- };
233
- container.addEventListener("keydown", onKeyDown);
234
- return () => container.removeEventListener("keydown", onKeyDown);
149
+ if (!active || !container) return;
150
+ return trapFocus(container, onEscape ? { onEscape } : {});
235
151
  }, [active, containerRef, onEscape]);
236
152
  }
153
+ function AttemptStep() {
154
+ const { snapshot, actions } = useFlow();
155
+ const { messages, onCheckStatus } = useUI();
156
+ const cls = useClassNames();
157
+ const attempt = snapshot.view.attempt;
158
+ if (!attempt) return null;
159
+ return /* @__PURE__ */ jsxs("div", { className: cls("empty"), "data-attempt": attempt.status, children: [
160
+ /* @__PURE__ */ jsx("p", { className: "sl-empty-title", children: attempt.title }),
161
+ /* @__PURE__ */ jsx("p", { className: "sl-empty-body", children: attempt.description }),
162
+ attempt.instructions ? /* @__PURE__ */ jsx("p", { className: "sl-empty-body", children: attempt.instructions }) : null,
163
+ /* @__PURE__ */ jsx("div", { className: cls("summary"), "aria-label": attempt.summaryLabel, children: /* @__PURE__ */ jsxs("div", { className: "sl-summary-body", children: [
164
+ /* @__PURE__ */ jsx("p", { className: "sl-summary-date", children: attempt.summary.dateLabel }),
165
+ /* @__PURE__ */ jsx("p", { className: "sl-summary-meta", children: /* @__PURE__ */ jsxs("span", { children: [
166
+ attempt.summary.timeLabel,
167
+ " \xB7 ",
168
+ attempt.summary.durationLabel
169
+ ] }) })
170
+ ] }) }),
171
+ /* @__PURE__ */ jsx("p", { className: "sl-empty-body", children: attempt.retryDescription }),
172
+ attempt.retryAllowed ? /* @__PURE__ */ jsx("button", { type: "button", className: cls("submit"), onClick: actions.retry, children: attempt.retryLabel }) : null,
173
+ attempt.canCheckStatus && onCheckStatus ? /* @__PURE__ */ jsx("button", { type: "button", className: cls("secondaryButton"), onClick: () => onCheckStatus(), children: attempt.checkStatusLabel }) : null,
174
+ /* @__PURE__ */ jsx("span", { className: "sl-visually-hidden", children: messages.attemptSummaryLabel })
175
+ ] });
176
+ }
237
177
  function ChevronLeftIcon(props) {
238
178
  return /* @__PURE__ */ jsx("svg", { viewBox: "0 0 24 24", width: "18", height: "18", "aria-hidden": "true", focusable: "false", ...props, children: /* @__PURE__ */ jsx(
239
179
  "path",
@@ -352,55 +292,44 @@ function chunk(values, size) {
352
292
  return rows;
353
293
  }
354
294
  function CalendarStep() {
355
- const {
356
- state,
357
- messages,
358
- locale,
359
- timeZone,
360
- timeZoneName,
361
- monthLabel,
362
- monthGrid,
363
- weekdayHeaders,
364
- weekdayLongNames,
365
- weekStartsOn,
366
- actions,
367
- availabilityOf,
368
- isDayBookable
369
- } = useFlow();
295
+ const { snapshot, actions } = useFlow();
296
+ const { messages } = useUI();
370
297
  const cls = useClassNames();
298
+ const view = snapshot.view;
299
+ const { month, days } = view;
300
+ const selectedDate = view.selection.date;
371
301
  const gridRef = useRef(null);
372
302
  const pendingFocusRef = useRef(false);
373
303
  const pendingMonthFocusRef = useRef(null);
374
304
  const [focusKey, setFocusKey] = useState(null);
375
- const today = todayKey(timeZone);
376
- const rows = useMemo(() => chunk(monthGrid, 7), [monthGrid]);
305
+ const rows = useMemo(() => chunk(month.grid, 7), [month.grid]);
377
306
  const firstBookable = useMemo(
378
- () => monthGrid.find((day) => monthKeyOf(day) === state.month && isDayBookable(day)),
379
- [monthGrid, state.month, isDayBookable]
307
+ () => month.grid.find((day) => monthKeyOf(day) === month.key && days[day]?.bookable),
308
+ [month.grid, month.key, days]
380
309
  );
381
310
  const focusTarget = useMemo(() => {
382
- if (focusKey && monthGrid.includes(focusKey)) return focusKey;
383
- if (state.selectedDate && monthGrid.includes(state.selectedDate)) return state.selectedDate;
384
- return firstBookable ?? monthGrid[0] ?? `${state.month}-01`;
385
- }, [focusKey, monthGrid, state.selectedDate, firstBookable, state.month]);
311
+ if (focusKey && month.grid.includes(focusKey)) return focusKey;
312
+ if (selectedDate && month.grid.includes(selectedDate)) return selectedDate;
313
+ return firstBookable ?? month.grid[0] ?? `${month.key}-01`;
314
+ }, [focusKey, month.grid, selectedDate, firstBookable, month.key]);
386
315
  useEffect(() => {
387
316
  if (!pendingFocusRef.current || !focusKey) return;
388
317
  pendingFocusRef.current = false;
389
318
  const node = gridRef.current?.querySelector(`[data-day="${focusKey}"]`);
390
319
  node?.focus();
391
- }, [focusKey, monthGrid]);
320
+ }, [focusKey, month.grid]);
392
321
  useEffect(() => {
393
322
  const dayOfMonth = pendingMonthFocusRef.current;
394
323
  if (!dayOfMonth) return;
395
324
  pendingMonthFocusRef.current = null;
396
- const sameDay = monthGrid.find(
397
- (candidate) => monthKeyOf(candidate) === state.month && candidate.slice(8, 10) === dayOfMonth
325
+ const sameDay = month.grid.find(
326
+ (candidate) => monthKeyOf(candidate) === month.key && candidate.slice(8, 10) === dayOfMonth
398
327
  );
399
- const target = sameDay ?? monthGrid.find((candidate) => monthKeyOf(candidate) === state.month);
328
+ const target = sameDay ?? month.grid.find((candidate) => monthKeyOf(candidate) === month.key);
400
329
  if (!target) return;
401
330
  pendingFocusRef.current = true;
402
331
  setFocusKey(target);
403
- }, [state.month, monthGrid]);
332
+ }, [month.key, month.grid]);
404
333
  const moveFocus = useCallback((nextKey) => {
405
334
  pendingFocusRef.current = true;
406
335
  setFocusKey(nextKey);
@@ -409,7 +338,7 @@ function CalendarStep() {
409
338
  (event) => {
410
339
  const current = event.target.dataset?.day;
411
340
  if (!current) return;
412
- const offsetFromWeekStart = (weekdayIndex(current) - weekStartsOn + 7) % 7;
341
+ const offsetFromWeekStart = (weekdayIndex(current) - month.weekStartsOn + 7) % 7;
413
342
  let next;
414
343
  switch (event.key) {
415
344
  case "ArrowLeft":
@@ -444,18 +373,18 @@ function CalendarStep() {
444
373
  return;
445
374
  }
446
375
  event.preventDefault();
447
- const last = monthGrid[monthGrid.length - 1];
448
- const first = monthGrid[0];
376
+ const last = month.grid[month.grid.length - 1];
377
+ const first = month.grid[0];
449
378
  if (last && first && (next < first || next > last)) {
450
379
  actions.goToMonth(monthKeyOf(next));
451
380
  }
452
381
  moveFocus(next);
453
382
  },
454
- [actions, monthGrid, moveFocus, weekStartsOn]
383
+ [actions, month.grid, month.key, month.weekStartsOn]
455
384
  );
456
- return /* @__PURE__ */ jsxs("div", { className: cls("calendar"), "data-status": state.daysStatus, children: [
385
+ return /* @__PURE__ */ jsxs("div", { className: cls("calendar"), "data-status": month.status, children: [
457
386
  /* @__PURE__ */ jsxs("div", { className: cls("calendarHeader"), children: [
458
- /* @__PURE__ */ jsx("h3", { className: cls("monthLabel"), children: monthLabel }),
387
+ /* @__PURE__ */ jsx("h3", { className: cls("monthLabel"), children: month.label }),
459
388
  /* @__PURE__ */ jsxs("div", { className: "sl-calendar-nav", children: [
460
389
  /* @__PURE__ */ jsx(
461
390
  "button",
@@ -479,14 +408,14 @@ function CalendarStep() {
479
408
  )
480
409
  ] })
481
410
  ] }),
482
- /* @__PURE__ */ jsx("div", { className: cls("weekdays"), children: weekdayHeaders.map((label, index) => /* @__PURE__ */ jsxs("span", { className: cls("weekday"), children: [
411
+ /* @__PURE__ */ jsx("div", { className: cls("weekdays"), children: month.weekdayHeaders.map((label, index) => /* @__PURE__ */ jsxs("span", { className: cls("weekday"), children: [
483
412
  /* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: label }),
484
- /* @__PURE__ */ jsx("span", { className: "sl-visually-hidden", children: weekdayLongNames[index] })
485
- ] }, weekdayLongNames[index] ?? index)) }),
486
- state.daysStatus === "error" ? /* @__PURE__ */ jsxs("div", { className: cls("error"), role: "alert", children: [
487
- /* @__PURE__ */ jsx("span", { children: state.daysError }),
413
+ /* @__PURE__ */ jsx("span", { className: "sl-visually-hidden", children: month.weekdayLongNames[index] })
414
+ ] }, month.weekdayLongNames[index] ?? index)) }),
415
+ /* @__PURE__ */ jsxs("div", { className: cls("error"), role: "alert", children: [
416
+ /* @__PURE__ */ jsx("span", { children: month.error }),
488
417
  /* @__PURE__ */ jsx("button", { type: "button", className: "sl-link-button", onClick: actions.reloadAvailability, children: messages.retry })
489
- ] }) : null,
418
+ ] }),
490
419
  /* @__PURE__ */ jsx(
491
420
  "div",
492
421
  {
@@ -494,17 +423,11 @@ function CalendarStep() {
494
423
  className: cls("grid"),
495
424
  role: "grid",
496
425
  "aria-label": messages.calendarLabel,
497
- "aria-busy": state.daysStatus === "loading",
426
+ "aria-busy": month.status === "loading",
498
427
  onKeyDown,
499
428
  children: rows.map((row, rowIndex) => /* @__PURE__ */ jsx("div", { className: "sl-grid-row", role: "row", children: row.map((day, cellIndex) => {
500
- const outside = monthKeyOf(day) !== state.month;
501
- const bookable = isDayBookable(day);
502
- const count = countSlots(availabilityOf(day));
503
- const selected = state.selectedDate === day;
504
- const label = [
505
- formatFullDay(day, locale),
506
- count !== void 0 && count > 0 ? format(messages.slotsCount, { count }) : messages.dayUnavailable
507
- ].join(", ");
429
+ const cell = days[day];
430
+ const bookable = cell?.bookable ?? false;
508
431
  return /* @__PURE__ */ jsxs(
509
432
  "button",
510
433
  {
@@ -512,14 +435,14 @@ function CalendarStep() {
512
435
  role: "gridcell",
513
436
  "data-day": day,
514
437
  tabIndex: day === focusTarget ? 0 : -1,
515
- "aria-selected": selected || void 0,
438
+ "aria-selected": cell?.isSelected || void 0,
516
439
  "aria-disabled": !bookable || void 0,
517
- "aria-current": day === today ? "date" : void 0,
518
- "aria-label": label,
440
+ "aria-current": cell?.isToday ? "date" : void 0,
441
+ "aria-label": cell?.label,
519
442
  className: cls(
520
443
  "day",
521
- outside && "is-outside",
522
- selected && "is-selected",
444
+ cell?.isOutside && "is-outside",
445
+ cell?.isSelected && "is-selected",
523
446
  bookable && "is-bookable"
524
447
  ),
525
448
  style: { "--sl-index": rowIndex * 7 + cellIndex },
@@ -543,90 +466,127 @@ function CalendarStep() {
543
466
  );
544
467
  }) }, row[0]))
545
468
  },
546
- state.month
469
+ month.key
547
470
  ),
548
- /* @__PURE__ */ jsx("p", { className: "sl-timezone", children: format(messages.timeZoneHint, { zone: timeZoneName ?? timeZone, timeZone }) })
471
+ /* @__PURE__ */ jsx("p", { className: "sl-timezone", children: view.time.zoneHint })
549
472
  ] });
550
473
  }
551
474
  function ConfirmedStep() {
552
- const { state, messages, selectedDateLabel, selectedTimeLabel, durationLabel, actions } = useFlow();
475
+ const { snapshot, actions } = useFlow();
553
476
  const cls = useClassNames();
554
- const inviteeEmail = (typeof state.result?.inviteeEmail === "string" ? state.result.inviteeEmail : void 0) ?? state.details.email;
477
+ const confirmation = snapshot.view.confirmation;
478
+ if (!confirmation) return null;
555
479
  return /* @__PURE__ */ jsxs("div", { className: cls("confirmation"), children: [
556
480
  /* @__PURE__ */ jsx("span", { className: cls("confirmationIcon"), "aria-hidden": "true", children: /* @__PURE__ */ jsx(CheckIcon, {}) }),
557
- /* @__PURE__ */ jsx("h3", { className: cls("confirmationTitle"), children: messages.bookedTitle }),
481
+ /* @__PURE__ */ jsx("h3", { className: cls("confirmationTitle"), children: confirmation.title }),
558
482
  /* @__PURE__ */ jsxs("div", { className: cls("confirmationText"), children: [
559
- /* @__PURE__ */ jsx("p", { children: format(messages.bookedSummary, {
560
- date: selectedDateLabel,
561
- time: selectedTimeLabel,
562
- duration: durationLabel
563
- }) }),
564
- /* @__PURE__ */ jsx("p", { children: format(messages.inviteSent, { email: inviteeEmail }) })
483
+ /* @__PURE__ */ jsx("p", { children: confirmation.summary }),
484
+ confirmation.invitation ? /* @__PURE__ */ jsx("p", { children: confirmation.invitation }) : null
565
485
  ] }),
566
- /* @__PURE__ */ jsx("button", { type: "button", className: cls("secondaryButton"), onClick: actions.reset, children: messages.bookAnother })
486
+ /* @__PURE__ */ jsx("button", { type: "button", className: cls("secondaryButton"), onClick: actions.reset, children: confirmation.bookAnotherLabel })
567
487
  ] });
568
488
  }
569
- function Field({
570
- name,
571
- label,
572
- value,
573
- type = "text",
574
- error,
575
- disabled,
576
- autoFocus,
577
- onChange,
578
- onBlur
579
- }) {
489
+ function autoCompleteOf(name) {
490
+ if (name === "name") return "name";
491
+ if (name === "email") return "email";
492
+ if (name === "tel" || name === "phone") return "tel";
493
+ return void 0;
494
+ }
495
+ function inputTypeOf(field) {
496
+ if (field.type === "email") return "email";
497
+ if (field.type === "tel") return "tel";
498
+ if (field.type === "number") return "number";
499
+ return "text";
500
+ }
501
+ function textOf(value) {
502
+ if (value === void 0 || value === null || typeof value === "boolean") return "";
503
+ return String(value);
504
+ }
505
+ function Field({ field, value, error, disabled, autoFocus, onChange, onBlur }) {
580
506
  const cls = useClassNames();
581
- const errorId = useId();
507
+ const generatedId = useId();
508
+ const errorId = `${generatedId}-error`;
509
+ const hintId = `${generatedId}-hint`;
510
+ const shared = {
511
+ id: generatedId,
512
+ name: field.name,
513
+ "aria-label": field.label,
514
+ "aria-invalid": error ? true : void 0,
515
+ "aria-describedby": error ? errorId : field.hint ? hintId : void 0,
516
+ disabled
517
+ };
582
518
  return /* @__PURE__ */ jsxs("div", { className: cls("field"), "data-invalid": error ? "true" : void 0, children: [
583
- /* @__PURE__ */ jsx(
519
+ field.type === "textarea" ? /* @__PURE__ */ jsx(
520
+ "textarea",
521
+ {
522
+ ...shared,
523
+ className: cls("input"),
524
+ placeholder: field.placeholder ?? field.label,
525
+ value: textOf(value),
526
+ onChange: (event) => onChange(event.target.value),
527
+ onBlur
528
+ }
529
+ ) : field.type === "select" ? /* @__PURE__ */ jsxs(
530
+ "select",
531
+ {
532
+ ...shared,
533
+ className: cls("input"),
534
+ value: textOf(value),
535
+ onChange: (event) => onChange(event.target.value),
536
+ onBlur,
537
+ children: [
538
+ /* @__PURE__ */ jsx("option", { value: "", children: field.placeholder ?? "" }),
539
+ (field.options ?? []).map((option) => /* @__PURE__ */ jsx("option", { value: option.value, children: option.label }, option.value))
540
+ ]
541
+ }
542
+ ) : field.type === "checkbox" ? /* @__PURE__ */ jsxs("label", { className: "sl-checkbox", children: [
543
+ /* @__PURE__ */ jsx(
544
+ "input",
545
+ {
546
+ ...shared,
547
+ type: "checkbox",
548
+ checked: value === true,
549
+ onChange: (event) => onChange(event.target.checked),
550
+ onBlur
551
+ }
552
+ ),
553
+ /* @__PURE__ */ jsx("span", { children: field.label })
554
+ ] }) : /* @__PURE__ */ jsx(
584
555
  "input",
585
556
  {
586
- id: name,
587
- name,
588
- type,
589
- inputMode: type === "email" ? "email" : void 0,
590
- autoComplete: name === "email" ? "email" : "name",
557
+ ...shared,
558
+ type: inputTypeOf(field),
559
+ inputMode: field.type === "number" ? "numeric" : field.type === "email" ? "email" : void 0,
560
+ autoComplete: autoCompleteOf(field.name),
591
561
  className: cls("input"),
592
- "aria-label": label,
593
- "aria-invalid": error ? true : void 0,
594
- "aria-describedby": error ? errorId : void 0,
595
- placeholder: label,
596
- value,
597
- disabled,
562
+ placeholder: field.placeholder ?? field.label,
563
+ value: textOf(value),
598
564
  autoFocus,
599
565
  onChange: (event) => onChange(event.target.value),
600
566
  onBlur
601
567
  }
602
568
  ),
569
+ field.hint && !error ? /* @__PURE__ */ jsx("p", { className: "sl-field-hint", id: hintId, children: field.hint }) : null,
603
570
  error ? /* @__PURE__ */ jsx("p", { className: "sl-field-error", id: errorId, role: "alert", children: error }) : null
604
571
  ] });
605
572
  }
606
573
  function DetailsStep() {
607
- const {
608
- state,
609
- messages,
610
- selectedDateLabel,
611
- selectedTimeLabel,
612
- durationLabel,
613
- actions,
614
- shownFieldErrors,
615
- detailsValid
616
- } = useFlow();
574
+ const { snapshot, actions } = useFlow();
575
+ const { messages } = useUI();
617
576
  const cls = useClassNames();
618
- const submitting = state.submitStatus === "submitting";
577
+ const view = snapshot.view;
578
+ const form = view.form;
619
579
  return /* @__PURE__ */ jsxs("div", { className: "sl-details-step", children: [
620
580
  /* @__PURE__ */ jsxs("div", { className: cls("summary"), children: [
621
581
  /* @__PURE__ */ jsx("span", { className: "sl-summary-icon", "aria-hidden": "true", children: /* @__PURE__ */ jsx(CalendarIcon, {}) }),
622
582
  /* @__PURE__ */ jsxs("div", { className: "sl-summary-body", children: [
623
- /* @__PURE__ */ jsx("p", { className: "sl-summary-date", children: selectedDateLabel }),
583
+ /* @__PURE__ */ jsx("p", { className: "sl-summary-date", children: view.time.dateLabel }),
624
584
  /* @__PURE__ */ jsxs("p", { className: "sl-summary-meta", children: [
625
585
  /* @__PURE__ */ jsx(ClockIcon, {}),
626
586
  /* @__PURE__ */ jsxs("span", { children: [
627
- selectedTimeLabel,
587
+ view.time.label,
628
588
  " \xB7 ",
629
- durationLabel
589
+ view.duration.label
630
590
  ] })
631
591
  ] })
632
592
  ] })
@@ -641,54 +601,34 @@ function DetailsStep() {
641
601
  void actions.submit();
642
602
  },
643
603
  children: [
644
- /* @__PURE__ */ jsx(
604
+ form.fields.map((field, index) => /* @__PURE__ */ jsx(
645
605
  Field,
646
606
  {
647
- name: "name",
648
- label: messages.nameLabel,
649
- value: state.details.name,
650
- error: shownFieldErrors.name,
651
- disabled: submitting,
652
- autoFocus: true,
653
- onChange: (value) => actions.setDetails({ name: value }),
654
- onBlur: () => actions.touchField("name")
655
- }
656
- ),
657
- /* @__PURE__ */ jsx(
658
- Field,
659
- {
660
- name: "email",
661
- label: messages.emailLabel,
662
- type: "email",
663
- value: state.details.email,
664
- error: shownFieldErrors.email,
665
- disabled: submitting,
666
- onChange: (value) => actions.setDetails({ email: value }),
667
- onBlur: () => actions.touchField("email")
668
- }
669
- ),
670
- state.submitError ? /* @__PURE__ */ jsxs("div", { className: cls("error"), role: "alert", children: [
671
- /* @__PURE__ */ jsx("span", { children: state.submitError }),
672
- /* @__PURE__ */ jsx(
673
- "button",
674
- {
675
- type: "button",
676
- className: "sl-link-button",
677
- onClick: () => void actions.submit(),
678
- children: messages.retry
679
- }
680
- )
607
+ field,
608
+ value: form.values[field.name],
609
+ error: form.visibleErrors[field.name],
610
+ disabled: form.submitting,
611
+ autoFocus: index === 0,
612
+ onChange: (value) => actions.setValues({ [field.name]: value }),
613
+ onBlur: () => actions.touchField(field.name)
614
+ },
615
+ field.name
616
+ )),
617
+ form.formError ? /* @__PURE__ */ jsx("div", { className: cls("error"), role: "alert", children: /* @__PURE__ */ jsx("span", { children: form.formError }) }) : null,
618
+ form.submitError ? /* @__PURE__ */ jsxs("div", { className: cls("error"), role: "alert", children: [
619
+ /* @__PURE__ */ jsx("span", { children: form.submitError }),
620
+ /* @__PURE__ */ jsx("button", { type: "button", className: "sl-link-button", onClick: () => void actions.submit(), children: messages.retry })
681
621
  ] }) : null,
682
622
  /* @__PURE__ */ jsxs(
683
623
  "button",
684
624
  {
685
625
  type: "submit",
686
626
  className: cls("submit"),
687
- disabled: submitting || !detailsValid,
688
- "data-loading": submitting ? "true" : void 0,
627
+ disabled: form.submitting,
628
+ "data-loading": form.submitting ? "true" : void 0,
689
629
  children: [
690
- /* @__PURE__ */ jsx("span", { children: submitting ? messages.confirming : messages.confirm }),
691
- submitting ? /* @__PURE__ */ jsx(SpinnerIcon, { className: "sl-spinner" }) : /* @__PURE__ */ jsx(ArrowRightIcon, { className: "sl-submit-arrow" })
630
+ /* @__PURE__ */ jsx("span", { children: form.submitLabel }),
631
+ form.submitting ? /* @__PURE__ */ jsx(SpinnerIcon, { className: "sl-spinner" }) : /* @__PURE__ */ jsx(ArrowRightIcon, { className: "sl-submit-arrow" })
692
632
  ]
693
633
  }
694
634
  )
@@ -698,12 +638,15 @@ function DetailsStep() {
698
638
  ] });
699
639
  }
700
640
  function DurationFooter() {
701
- const { state, messages, durations, actions } = useFlow();
641
+ const { snapshot, actions } = useFlow();
642
+ const { messages } = useUI();
702
643
  const cls = useClassNames();
644
+ const duration = snapshot.view.duration;
645
+ if (!duration.showSelector) return null;
703
646
  return /* @__PURE__ */ jsxs("footer", { className: cls("footer"), children: [
704
647
  /* @__PURE__ */ jsx("span", { className: "sl-duration-label", "aria-hidden": "true", children: messages.durationLabel }),
705
- /* @__PURE__ */ jsx("div", { className: "sl-duration-group", role: "radiogroup", "aria-label": messages.durationGroupLabel, children: durations.map((minutes) => {
706
- const active = minutes === state.duration;
648
+ /* @__PURE__ */ jsx("div", { className: "sl-duration-group", role: "radiogroup", "aria-label": messages.durationGroupLabel, children: duration.options.map((minutes) => {
649
+ const active = minutes === duration.current;
707
650
  const label = format(messages.duration, { count: minutes });
708
651
  return /* @__PURE__ */ jsx(
709
652
  "button",
@@ -723,26 +666,23 @@ function DurationFooter() {
723
666
  ] });
724
667
  }
725
668
  function Progress() {
726
- const { state, messages } = useFlow();
669
+ const { snapshot } = useFlow();
727
670
  const cls = useClassNames();
728
- const index = stepIndex(state.step);
671
+ const { progress } = snapshot.view;
729
672
  return /* @__PURE__ */ jsx(
730
673
  "div",
731
674
  {
732
675
  className: cls("progress"),
733
676
  role: "progressbar",
734
677
  "aria-valuemin": 1,
735
- "aria-valuemax": FLOW_STEPS.length,
736
- "aria-valuenow": index + 1,
737
- "aria-valuetext": format(messages.progressLabel, {
738
- step: index + 1,
739
- total: FLOW_STEPS.length
740
- }),
741
- children: FLOW_STEPS.map((step, position) => /* @__PURE__ */ jsx(
678
+ "aria-valuemax": progress.total,
679
+ "aria-valuenow": progress.current,
680
+ "aria-valuetext": progress.label,
681
+ children: progress.steps.map((step, position) => /* @__PURE__ */ jsx(
742
682
  "span",
743
683
  {
744
684
  className: "sl-progress-pill",
745
- "data-state": position <= index ? "done" : "todo",
685
+ "data-state": position < progress.current ? "done" : "todo",
746
686
  "aria-hidden": "true"
747
687
  },
748
688
  step
@@ -752,17 +692,19 @@ function Progress() {
752
692
  }
753
693
  var SKELETON_COUNT = 9;
754
694
  function SlotsStep() {
755
- const { state, messages, locale, timeZone, selectedDateLabel, actions } = useFlow();
695
+ const { snapshot, actions } = useFlow();
696
+ const { messages } = useUI();
756
697
  const cls = useClassNames();
757
- const { Item: Item3 } = useAnimations();
758
- const loading = state.slotsStatus === "loading" || state.slotsStatus === "idle";
698
+ const Animations = useUI().animations;
699
+ const view = snapshot.view;
700
+ const { slots, time } = view;
759
701
  return /* @__PURE__ */ jsxs("div", { className: "sl-slots-step", children: [
760
702
  /* @__PURE__ */ jsxs("div", { className: cls("slotsHeader"), children: [
761
703
  /* @__PURE__ */ jsx("span", { className: "sl-slots-icon", "aria-hidden": "true", children: /* @__PURE__ */ jsx(CalendarIcon, {}) }),
762
- /* @__PURE__ */ jsx("h3", { className: "sl-slots-title", children: selectedDateLabel }),
763
- state.slotsStatus === "ready" ? /* @__PURE__ */ jsx("span", { className: cls("slotsCount"), children: format(messages.slotsCount, { count: state.slots.length }) }) : null
704
+ /* @__PURE__ */ jsx("h3", { className: "sl-slots-title", children: time.dateLabel }),
705
+ slots.countLabel ? /* @__PURE__ */ jsx("span", { className: cls("slotsCount"), children: slots.countLabel }) : null
764
706
  ] }),
765
- loading ? /* @__PURE__ */ jsx("div", { className: cls("slots"), "aria-hidden": "true", children: Array.from({ length: SKELETON_COUNT }, (_, index) => /* @__PURE__ */ jsx(
707
+ slots.loading ? /* @__PURE__ */ jsx("div", { className: cls("slots"), "aria-hidden": "true", children: Array.from({ length: SKELETON_COUNT }, (_, index) => /* @__PURE__ */ jsx(
766
708
  "div",
767
709
  {
768
710
  className: cls("skeleton"),
@@ -770,38 +712,38 @@ function SlotsStep() {
770
712
  },
771
713
  index
772
714
  )) }) : null,
773
- state.slotsStatus === "error" ? /* @__PURE__ */ jsxs("div", { className: cls("error"), role: "alert", children: [
774
- /* @__PURE__ */ jsx("span", { children: state.slotsError }),
715
+ slots.status === "error" ? /* @__PURE__ */ jsxs("div", { className: cls("error"), role: "alert", children: [
716
+ /* @__PURE__ */ jsx("span", { children: slots.error }),
775
717
  /* @__PURE__ */ jsx("button", { type: "button", className: "sl-link-button", onClick: actions.reloadSlots, children: messages.retry })
776
718
  ] }) : null,
777
- state.slotsStatus === "ready" && state.slots.length === 0 ? /* @__PURE__ */ jsxs("div", { className: cls("empty"), children: [
719
+ slots.empty ? /* @__PURE__ */ jsxs("div", { className: cls("empty"), children: [
778
720
  /* @__PURE__ */ jsx("p", { className: "sl-empty-title", children: messages.noSlotsTitle }),
779
721
  /* @__PURE__ */ jsx("p", { className: "sl-empty-body", children: messages.noSlotsBody }),
780
722
  /* @__PURE__ */ jsx("button", { type: "button", className: cls("secondaryButton"), onClick: actions.back, children: messages.pickAnotherDay })
781
723
  ] }) : null,
782
- state.slotsStatus === "ready" && state.slots.length > 0 ? /* @__PURE__ */ jsx("div", { className: cls("slots"), role: "group", "aria-label": messages.slotsLabel, children: state.slots.map((slot, index) => {
783
- const selected = state.selectedSlot?.start === slot.start;
784
- return /* @__PURE__ */ jsx(
785
- Item3,
786
- {
787
- index,
788
- className: cls("slot", selected && "is-selected"),
789
- "aria-current": selected ? "true" : void 0,
790
- onClick: () => actions.selectSlot(slot),
791
- children: formatTime(slot.start, timeZone, locale)
792
- },
793
- slot.start
794
- );
795
- }) }) : null
724
+ slots.status === "ready" && slots.count > 0 ? /* @__PURE__ */ jsx("div", { className: cls("slots"), role: "group", "aria-label": slots.label, children: slots.items.map((slot, index) => /* @__PURE__ */ jsx(
725
+ Animations.Item,
726
+ {
727
+ index,
728
+ className: cls("slot", slot.selected && "is-selected"),
729
+ "aria-current": slot.selected ? "true" : void 0,
730
+ "aria-label": slot.utcOffsetLabel ? `${slot.label} ${slot.utcOffsetLabel}` : void 0,
731
+ onClick: () => actions.selectSlot(slot),
732
+ children: slot.label
733
+ },
734
+ slot.id
735
+ )) }) : null
796
736
  ] });
797
737
  }
798
738
  function StepBody() {
799
- const { state } = useFlow();
800
- switch (state.step) {
739
+ const { snapshot } = useFlow();
740
+ switch (snapshot.view.step) {
801
741
  case "time":
802
742
  return /* @__PURE__ */ jsx(SlotsStep, {});
803
743
  case "details":
804
744
  return /* @__PURE__ */ jsx(DetailsStep, {});
745
+ case "attempt":
746
+ return /* @__PURE__ */ jsx(AttemptStep, {});
805
747
  case "confirmed":
806
748
  return /* @__PURE__ */ jsx(ConfirmedStep, {});
807
749
  case "date":
@@ -810,15 +752,15 @@ function StepBody() {
810
752
  }
811
753
  }
812
754
  function SlotlineView() {
813
- const flow = useFlow();
814
- const { state, messages, title, durationLabel, actions, canGoBack } = flow;
815
- const submitting = state.submitStatus === "submitting";
755
+ const { snapshot, actions } = useFlow();
816
756
  const ui = useUI();
817
757
  const cls = useClassNames();
818
758
  const Animations = ui.animations;
759
+ const view = snapshot.view;
819
760
  const cardRef = useRef(null);
820
761
  const panelRef = useRef(null);
821
762
  const mountedRef = useRef(false);
763
+ const canGoBack = view.actions.canGoBack || (view.step === "date" || view.step === "confirmed") && ui.canDismiss;
822
764
  useFocusTrap(cardRef, {
823
765
  active: ui.trapFocus,
824
766
  ...canGoBack ? { onEscape: actions.back } : {}
@@ -831,22 +773,14 @@ function SlotlineView() {
831
773
  }
832
774
  const active = typeof document === "undefined" ? null : document.activeElement;
833
775
  if (!active || active === document.body) panelRef.current?.focus();
834
- }, [state.step, ui.autoFocus]);
835
- const announcement = state.step === "confirmed" ? `${messages.bookedTitle}. ${format(messages.bookedSummary, {
836
- date: flow.selectedDateLabel,
837
- time: flow.selectedTimeLabel,
838
- duration: durationLabel
839
- })}` : format(messages.progressLabel, {
840
- step: stepIndex(state.step) + 1,
841
- total: FLOW_STEPS.length
842
- });
776
+ }, [view.step, ui.autoFocus]);
843
777
  return /* @__PURE__ */ jsx(
844
778
  "div",
845
779
  {
846
780
  className: cls("root", ui.className),
847
- "data-sl-theme": flow.theme,
848
- "data-sl-step": state.step,
849
- lang: flow.locale,
781
+ "data-sl-theme": ui.theme,
782
+ "data-sl-step": view.step,
783
+ lang: ui.locale,
850
784
  children: /* @__PURE__ */ jsxs("div", { className: cls("card"), ref: cardRef, children: [
851
785
  /* @__PURE__ */ jsxs("header", { className: cls("header"), children: [
852
786
  canGoBack ? /* @__PURE__ */ jsx(
@@ -855,373 +789,183 @@ function SlotlineView() {
855
789
  type: "button",
856
790
  className: "sl-icon-button",
857
791
  onClick: actions.back,
858
- "aria-label": messages.back,
859
- disabled: submitting,
792
+ "aria-label": ui.messages.back,
793
+ disabled: view.form.submitting,
860
794
  children: /* @__PURE__ */ jsx(ChevronLeftIcon, {})
861
795
  }
862
796
  ) : null,
863
797
  /* @__PURE__ */ jsxs("div", { className: "sl-header-main", children: [
864
- /* @__PURE__ */ jsx("h2", { className: cls("title"), children: title }),
865
- /* @__PURE__ */ jsx("span", { className: cls("badge"), children: durationLabel })
798
+ /* @__PURE__ */ jsx("h2", { className: cls("title"), children: view.title }),
799
+ /* @__PURE__ */ jsx("span", { className: cls("badge"), children: view.duration.label })
866
800
  ] }),
867
801
  /* @__PURE__ */ jsx(Progress, {})
868
802
  ] }),
869
- /* @__PURE__ */ jsx("div", { className: cls("panel"), ref: panelRef, tabIndex: -1, children: /* @__PURE__ */ jsx(Animations.Height, { children: /* @__PURE__ */ jsx(Animations.StepHost, { stepKey: state.step, direction: state.direction, children: /* @__PURE__ */ jsx(StepBody, {}) }) }) }),
870
- state.step === "date" ? /* @__PURE__ */ jsx(DurationFooter, {}) : null,
871
- /* @__PURE__ */ jsx("div", { className: "sl-visually-hidden", role: "status", "aria-live": "polite", children: announcement })
803
+ /* @__PURE__ */ jsx("div", { className: cls("panel"), ref: panelRef, tabIndex: -1, children: /* @__PURE__ */ jsx(Animations.Height, { children: /* @__PURE__ */ jsx(Animations.StepHost, { stepKey: view.step, direction: view.direction, children: /* @__PURE__ */ jsx(StepBody, {}) }) }) }),
804
+ view.step === "date" ? /* @__PURE__ */ jsx(DurationFooter, {}) : null,
805
+ /* @__PURE__ */ jsx("div", { className: "sl-visually-hidden", role: "status", "aria-live": "polite", children: view.live.message })
872
806
  ] })
873
807
  }
874
808
  );
875
809
  }
876
- function useControllableState({
877
- value,
878
- defaultValue,
879
- onChange
880
- }) {
881
- const [internal, setInternal] = useState(defaultValue);
882
- const isControlled = value !== void 0;
883
- const current = isControlled ? value : internal;
884
- const onChangeRef = useRef(onChange);
885
- onChangeRef.current = onChange;
886
- const setValue = useCallback(
887
- (next) => {
888
- if (!isControlled) setInternal(next);
889
- onChangeRef.current?.(next);
890
- },
891
- [isControlled]
892
- );
893
- return [current, setValue];
810
+ function hostTheme() {
811
+ if (!canMatchMedia()) return "dark";
812
+ return matchesMediaQuery(DARK_SCHEME_QUERY) ? "dark" : "light";
894
813
  }
895
814
  function useResolvedTheme(theme) {
896
- const [systemTheme, setSystemTheme] = useState(() => {
897
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") return "dark";
898
- return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
899
- });
815
+ const [resolved, setResolved] = useState(hostTheme);
900
816
  useEffect(() => {
901
817
  if (theme !== "system") return;
902
- if (typeof window === "undefined" || typeof window.matchMedia !== "function") return;
903
- const list = window.matchMedia("(prefers-color-scheme: dark)");
904
- const handler = (event) => setSystemTheme(event.matches ? "dark" : "light");
905
- list.addEventListener("change", handler);
906
- return () => list.removeEventListener("change", handler);
818
+ return observeMediaQuery(
819
+ DARK_SCHEME_QUERY,
820
+ (matches) => setResolved(matches ? "dark" : "light")
821
+ );
907
822
  }, [theme]);
908
- if (theme === "system") return systemTheme;
909
- return theme;
823
+ return theme === "system" ? resolved : theme;
910
824
  }
911
825
 
912
826
  // src/useBookingFlow.ts
913
- function toError(value) {
914
- return value instanceof Error ? value : new Error(String(value));
915
- }
916
- function messageOf(value, fallback) {
917
- const error = toError(value);
918
- return error.message && error.message !== "undefined" ? error.message : fallback;
827
+ var cachedBrowserDefaults = null;
828
+ function readBrowserDefaults() {
829
+ cachedBrowserDefaults ??= { locale: detectLocale(), timeZone: resolveTimeZone() };
830
+ return cachedBrowserDefaults;
919
831
  }
920
- function useBookingFlow(props) {
921
- const {
922
- adapter,
923
- durations = [15, 30, 60],
924
- duration: durationProp,
925
- defaultDuration,
926
- onDurationChange,
927
- timeZone: timeZoneProp,
928
- locale: localeProp,
929
- messages: messagesProp,
930
- theme = "dark",
931
- minDate: minDateProp,
932
- maxDate: maxDateProp,
933
- weekStartsOn = 0,
934
- onBooked,
935
- onError,
936
- onDismiss
937
- } = props;
938
- const locale = localeProp ?? detectLocale();
939
- const timeZone = timeZoneProp ?? resolveTimeZone();
940
- const resolvedTheme = useResolvedTheme(theme);
941
- const messages = useMemo(() => resolveMessages(locale, messagesProp), [locale, messagesProp]);
942
- const durationOptions = useMemo(
943
- () => durations.length > 0 ? durations : [15, 30, 60],
944
- [durations]
945
- );
946
- const [duration, setDurationValue] = useControllableState({
947
- value: durationProp,
948
- defaultValue: defaultDuration ?? durationOptions[0] ?? 15,
949
- onChange: onDurationChange
950
- });
951
- const minDate = useMemo(() => normalizeDateInput(minDateProp, timeZone), [minDateProp, timeZone]);
952
- const maxDate = useMemo(() => normalizeDateInput(maxDateProp, timeZone), [maxDateProp, timeZone]);
953
- const today = todayKey(timeZone);
954
- const earliestDate = minDate ?? today;
955
- const initialMonth = monthKeyOf(clampDayKey(earliestDate, minDate, maxDate));
956
- const [state, dispatch] = useReducer(
957
- flowReducer,
958
- void 0,
959
- () => createFlowState({ duration, month: initialMonth })
960
- );
961
- const adapterRef = useRef(adapter);
962
- adapterRef.current = adapter;
963
- const stateRef = useRef(state);
964
- stateRef.current = state;
965
- const messagesRef = useRef(messages);
966
- messagesRef.current = messages;
967
- const onErrorRef = useRef(onError);
968
- onErrorRef.current = onError;
969
- const onBookedRef = useRef(onBooked);
970
- onBookedRef.current = onBooked;
971
- const submitLockRef = useRef(false);
972
- const daysTokenRef = useRef(0);
973
- const slotsTokenRef = useRef(0);
974
- const [availabilityNonce, setAvailabilityNonce] = useReducer((n) => n + 1, 0);
975
- const [slotsNonce, setSlotsNonce] = useReducer((n) => n + 1, 0);
976
- useEffect(() => {
977
- dispatch({ type: "setDuration", duration });
978
- }, [duration]);
979
- useEffect(() => {
980
- const controller = new AbortController();
981
- const token = daysTokenRef.current += 1;
982
- const from = firstDayOfMonth(state.month);
983
- const to = lastDayOfMonth(state.month);
984
- dispatch({ type: "daysLoading", token });
985
- void (async () => {
986
- try {
987
- const days = await adapterRef.current.getAvailability({
988
- from,
989
- to,
990
- duration,
991
- timeZone,
992
- signal: controller.signal
993
- });
994
- if (controller.signal.aborted) return;
995
- dispatch({ type: "daysLoaded", token, days: Array.isArray(days) ? days : [] });
996
- } catch (error) {
997
- if (controller.signal.aborted) return;
998
- dispatch({
999
- type: "daysFailed",
1000
- token,
1001
- error: messageOf(error, messagesRef.current.genericError)
1002
- });
1003
- onErrorRef.current?.(toError(error));
1004
- }
1005
- })();
1006
- return () => controller.abort();
1007
- }, [state.month, duration, timeZone, availabilityNonce]);
1008
- const daysRef = useRef(state.days);
1009
- daysRef.current = state.days;
832
+ function useBrowserDefaults() {
833
+ const [defaults, setDefaults] = useState(null);
1010
834
  useEffect(() => {
1011
- const date = state.selectedDate;
1012
- if (!date) return;
1013
- const token = slotsTokenRef.current += 1;
1014
- dispatch({ type: "slotsLoading", token });
1015
- const inline = daysRef.current[date]?.slots;
1016
- if (inline && inline.length > 0) {
1017
- dispatch({ type: "slotsLoaded", token, slots: inline });
1018
- return;
1019
- }
1020
- const controller = new AbortController();
1021
- void (async () => {
1022
- try {
1023
- const slots = await adapterRef.current.getSlots({
1024
- date,
1025
- duration,
1026
- timeZone,
1027
- signal: controller.signal
1028
- });
1029
- if (controller.signal.aborted) return;
1030
- dispatch({ type: "slotsLoaded", token, slots: Array.isArray(slots) ? slots : [] });
1031
- } catch (error) {
1032
- if (controller.signal.aborted) return;
1033
- dispatch({
1034
- type: "slotsFailed",
1035
- token,
1036
- error: messageOf(error, messagesRef.current.genericError)
1037
- });
1038
- onErrorRef.current?.(toError(error));
1039
- }
1040
- })();
1041
- return () => controller.abort();
1042
- }, [state.selectedDate, state.selectionNonce, duration, timeZone, slotsNonce]);
1043
- const selectDate = useCallback((dayKey) => {
1044
- dispatch({ type: "selectDate", date: dayKey });
1045
- }, []);
1046
- const selectSlot = useCallback((slot) => {
1047
- dispatch({ type: "selectSlot", slot });
1048
- }, []);
1049
- const setDuration = useCallback(
1050
- (minutes) => {
1051
- setDurationValue(minutes);
1052
- },
1053
- [setDurationValue]
1054
- );
1055
- const setDetails = useCallback(
1056
- (details) => {
1057
- dispatch({ type: "setDetails", details });
1058
- },
1059
- []
1060
- );
1061
- const goToMonth = useCallback((monthKey) => {
1062
- dispatch({ type: "setMonth", month: monthKey });
1063
- }, []);
1064
- const touchField = useCallback((field) => {
1065
- dispatch({ type: "touch", field });
1066
- }, []);
1067
- const reset = useCallback(() => {
1068
- dispatch({ type: "reset" });
1069
- }, []);
1070
- const back = useCallback(() => {
1071
- const current = stateRef.current;
1072
- if (current.submitStatus === "submitting") return;
1073
- if (current.step === "date" || current.step === "confirmed") {
1074
- onDismiss?.();
1075
- return;
1076
- }
1077
- dispatch({ type: "back" });
1078
- }, [onDismiss]);
1079
- const reloadAvailability = useCallback(() => {
1080
- setAvailabilityNonce();
835
+ setDefaults(readBrowserDefaults());
1081
836
  }, []);
1082
- const reloadSlots = useCallback(() => {
1083
- setSlotsNonce();
1084
- }, []);
1085
- const submit = useCallback(async () => {
1086
- if (submitLockRef.current) return;
1087
- const snapshot = stateRef.current;
1088
- const currentMessages = messagesRef.current;
1089
- dispatch({ type: "touch", field: "name" });
1090
- dispatch({ type: "touch", field: "email" });
1091
- const errors = validateDetails(snapshot.details, currentMessages);
1092
- if (hasErrors(errors) || !snapshot.selectedSlot || !snapshot.selectedDate) return;
1093
- const request = {
1094
- slot: { ...snapshot.selectedSlot, end: endOf(snapshot.selectedSlot, snapshot.duration) },
1095
- date: snapshot.selectedDate,
1096
- duration: snapshot.duration,
1097
- timeZone,
1098
- details: {
1099
- name: snapshot.details.name.trim(),
1100
- email: snapshot.details.email.trim(),
1101
- ...snapshot.details.notes ? { notes: snapshot.details.notes } : {}
1102
- }
1103
- };
1104
- const scenario = snapshot.scenarioId;
1105
- const stillCurrent = () => {
1106
- const current = stateRef.current;
1107
- return (
1108
- // `scenarioId` also covers "reset, then book the very same slot again":
1109
- // the date and the slot can match a second time, the scenario cannot.
1110
- current.scenarioId === scenario && current.selectedDate === request.date && current.selectedSlot?.start === request.slot.start
1111
- );
1112
- };
1113
- submitLockRef.current = true;
1114
- dispatch({ type: "submitStart" });
1115
- try {
1116
- const result = await adapterRef.current.createBooking(request);
1117
- onBookedRef.current?.(result ?? {}, request);
1118
- if (stillCurrent()) dispatch({ type: "submitSucceeded", scenario, result: result ?? {} });
1119
- } catch (error) {
1120
- onErrorRef.current?.(toError(error));
1121
- if (stillCurrent()) {
1122
- dispatch({
1123
- type: "submitFailed",
1124
- scenario,
1125
- error: messageOf(error, currentMessages.genericError)
1126
- });
1127
- }
1128
- } finally {
1129
- submitLockRef.current = false;
1130
- }
1131
- }, [timeZone]);
1132
- const monthGrid = useMemo(
1133
- () => buildMonthGrid(state.month, weekStartsOn),
1134
- [state.month, weekStartsOn]
1135
- );
1136
- const weekdayHeaders = useMemo(
1137
- () => rotateToWeekStart(shortWeekdayNames(locale, messages.weekdays), weekStartsOn),
1138
- [locale, messages.weekdays, weekStartsOn]
1139
- );
1140
- const weekdayLong = useMemo(
1141
- () => rotateToWeekStart(weekdayNames(locale), weekStartsOn),
1142
- [locale, weekStartsOn]
1143
- );
1144
- const fieldErrors = useMemo(
1145
- () => validateDetails(state.details, messages),
1146
- [state.details, messages]
1147
- );
1148
- const availabilityOf = useCallback((dayKey) => state.days[dayKey], [state.days]);
1149
- const isDayBookable = useCallback(
1150
- (dayKey) => {
1151
- if (compareDayKeys(dayKey, earliestDate) < 0) return false;
1152
- if (maxDate && compareDayKeys(dayKey, maxDate) > 0) return false;
1153
- const availability = state.days[dayKey];
1154
- if (!availability) return false;
1155
- const count = countSlots(availability);
1156
- return count === void 0 ? true : count > 0;
1157
- },
1158
- [state.days, earliestDate, maxDate]
1159
- );
1160
- const selectedDateLabel = state.selectedDate ? formatFullDay(state.selectedDate, locale) : "";
1161
- const selectedTimeLabel = state.selectedSlot ? formatTime(state.selectedSlot.start, timeZone, locale) : "";
837
+ return defaults;
838
+ }
839
+ function optionsOf(props, browser) {
1162
840
  return {
1163
- state,
1164
- messages,
1165
- locale,
1166
- timeZone,
1167
- timeZoneName: state.step === "date" ? formatTimeZoneName(timeZone, locale) : void 0,
1168
- theme: resolvedTheme,
1169
- title: state.step === "confirmed" ? messages.bookedHeader : props.title ?? messages.title,
1170
- durations: durationOptions,
1171
- weekStartsOn,
1172
- ...minDate ? { minDate } : {},
1173
- ...maxDate ? { maxDate } : {},
1174
- monthGrid,
1175
- weekdayHeaders,
1176
- weekdayLongNames: weekdayLong,
1177
- monthLabel: formatMonthYear(state.month, locale),
1178
- selectedDateLabel,
1179
- selectedTimeLabel,
1180
- durationLabel: format(messages.duration, { count: duration }),
1181
- availabilityOf,
1182
- isDayBookable,
1183
- // The confirmation is terminal: there is nothing to go back to, so the back
1184
- // affordance only appears there when the consumer supplied `onDismiss`.
1185
- canGoBack: state.step !== "date" && state.step !== "confirmed" || Boolean(onDismiss),
1186
- fieldErrors,
1187
- shownFieldErrors: visibleErrors(fieldErrors, state.touched),
1188
- detailsValid: !hasErrors(fieldErrors),
1189
- actions: {
1190
- selectDate,
1191
- selectSlot,
1192
- setDuration,
1193
- setDetails,
1194
- touchField,
1195
- back,
1196
- submit,
1197
- reset,
1198
- goToMonth,
1199
- previousMonth: () => goToMonth(addMonths(stateRef.current.month, -1)),
1200
- nextMonth: () => goToMonth(addMonths(stateRef.current.month, 1)),
1201
- reloadAvailability,
1202
- reloadSlots
1203
- }
841
+ adapter: adapterOf(props),
842
+ config: configOf(props, browser),
843
+ inputs: inputsOf(props, browser),
844
+ ...props.attempt == null ? {} : { initialAttempt: props.attempt }
1204
845
  };
1205
846
  }
1206
- function SlotlineProvider(props) {
1207
- const { children } = props;
1208
- const flow = useBookingFlow(props);
1209
- const ui = useMemo(
847
+ function adapterOf(props) {
848
+ return props.adapter;
849
+ }
850
+ function configOf(props, browser) {
851
+ const locale = props.locale ?? browser?.locale;
852
+ return {
853
+ ...locale === void 0 ? {} : { locale },
854
+ ...props.messages === void 0 ? {} : { messages: props.messages },
855
+ ...props.weekStartsOn === void 0 ? {} : { weekStartsOn: props.weekStartsOn },
856
+ ...props.durations === void 0 ? {} : { durationOptions: props.durations },
857
+ ...props.minDate === void 0 ? {} : { minDate: props.minDate },
858
+ ...props.maxDate === void 0 ? {} : { maxDate: props.maxDate },
859
+ ...props.title === void 0 ? {} : { title: props.title },
860
+ ...props.context === void 0 ? {} : { context: props.context }
861
+ };
862
+ }
863
+ function inputsOf(props, browser) {
864
+ const inputs = {};
865
+ if (props.timeZone !== void 0) inputs.timeZone = props.timeZone;
866
+ else {
867
+ const timeZone = props.defaultTimeZone ?? browser?.timeZone;
868
+ if (timeZone !== void 0) inputs.defaultTimeZone = timeZone;
869
+ }
870
+ if (props.duration !== void 0) inputs.duration = props.duration;
871
+ if (props.defaultDuration !== void 0) inputs.defaultDuration = props.defaultDuration;
872
+ if (props.timeFormat !== void 0) inputs.timeFormat = props.timeFormat;
873
+ else if (props.defaultTimeFormat !== void 0)
874
+ inputs.defaultTimeFormat = props.defaultTimeFormat;
875
+ return inputs;
876
+ }
877
+ function useBookingController(props) {
878
+ const browser = useBrowserDefaults();
879
+ const propsRef = useRef(props);
880
+ propsRef.current = props;
881
+ const browserRef = useRef(browser);
882
+ browserRef.current = browser;
883
+ const controllerRef = useRef(null);
884
+ if (controllerRef.current === null) {
885
+ controllerRef.current = createBookingController({
886
+ ...optionsOf(props, browser),
887
+ handlers: {
888
+ // Every handler reads the latest props, so a re-render never leaves a
889
+ // stale callback behind.
890
+ onBooked: (result, request) => (
891
+ // The flow carries the payload as plain values; the adapter's own type is
892
+ // what the application declared for its form.
893
+ propsRef.current.onBooked?.(result, request)
894
+ ),
895
+ onError: (error, context) => propsRef.current.onError?.(error, context),
896
+ onDismiss: () => propsRef.current.onDismiss?.(),
897
+ onDurationChange: (duration, metadata) => propsRef.current.onDurationChange?.(duration, metadata),
898
+ onTimeFormatChange: (timeFormat, metadata) => propsRef.current.onTimeFormatChange?.(timeFormat, metadata),
899
+ onContextChange: (context, metadata) => propsRef.current.onContextChange?.(context, metadata)
900
+ }
901
+ });
902
+ }
903
+ const controller = controllerRef.current;
904
+ useEffect(() => {
905
+ controller.updateAdapter(adapterOf(propsRef.current));
906
+ controller.updateConfig(configOf(propsRef.current, browserRef.current));
907
+ controller.updateInputs(inputsOf(propsRef.current, browserRef.current));
908
+ });
909
+ useEffect(() => {
910
+ controller.start();
911
+ return () => controller.stop();
912
+ }, [controller]);
913
+ useEffect(() => observeVisibilityChange(() => controller.refreshTime()), [controller]);
914
+ return controller;
915
+ }
916
+ function useSubscribedFlow(controller) {
917
+ const snapshot = useSyncExternalStore(
918
+ controller.subscribe,
919
+ controller.getSnapshot,
920
+ controller.getSnapshot
921
+ );
922
+ return useMemo(() => ({ snapshot, actions: controller.actions }), [snapshot, controller]);
923
+ }
924
+ function useBookingFlow(props) {
925
+ return useSubscribedFlow(useBookingController(props));
926
+ }
927
+ function useSlotlineUI(props) {
928
+ const browser = useBrowserDefaults();
929
+ const locale = props.locale ?? browser?.locale ?? "en";
930
+ const messages = useMemo(() => resolveMessages(locale, props.messages), [locale, props.messages]);
931
+ const theme = useResolvedTheme(props.theme ?? "dark");
932
+ return useMemo(
1210
933
  () => ({
1211
934
  animations: props.animations ?? cssAnimations,
1212
935
  classNames: props.classNames,
936
+ className: props.className,
1213
937
  autoFocus: props.autoFocus ?? false,
1214
938
  trapFocus: props.trapFocus ?? false,
1215
- className: props.className
939
+ canDismiss: Boolean(props.onDismiss),
940
+ onCheckStatus: props.onCheckStatus,
941
+ messages,
942
+ locale,
943
+ theme
1216
944
  }),
1217
945
  [
1218
946
  props.animations,
1219
947
  props.classNames,
948
+ props.className,
1220
949
  props.autoFocus,
1221
950
  props.trapFocus,
1222
- props.className
951
+ props.onDismiss,
952
+ props.onCheckStatus,
953
+ messages,
954
+ locale,
955
+ theme
1223
956
  ]
1224
957
  );
958
+ }
959
+ function SlotlineProvider(props) {
960
+ const { children } = props;
961
+ const controller = useBookingController(props);
962
+ const ui = useSlotlineUI(props);
963
+ const snapshot = useSyncExternalStore(
964
+ controller.subscribe,
965
+ controller.getSnapshot,
966
+ controller.getSnapshot
967
+ );
968
+ const flow = useMemo(() => ({ snapshot, actions: controller.actions }), [snapshot, controller]);
1225
969
  return /* @__PURE__ */ jsx(FlowContext.Provider, { value: flow, children: /* @__PURE__ */ jsx(UIContext.Provider, { value: ui, children }) });
1226
970
  }
1227
971
  function createSlotline(animations) {
@@ -1236,6 +980,6 @@ createSlotline(cssAnimations);
1236
980
  // src/motion.ts
1237
981
  var Slotline2 = createSlotline(motionAnimations);
1238
982
 
1239
- export { FlowContext, Slotline2 as Slotline, SlotlineProvider, SlotlineView, UIContext, createSlotline, cssAnimations, motionAnimations, useAnimations, useBookingFlow, useClassNames, useSlotlineContext, useUI };
983
+ export { Slotline2 as Slotline, SlotlineProvider, SlotlineView, createSlotline, cssAnimations, motionAnimations, useAnimations, useBookingFlow, useClassNames, useSlotlineContext, useUI };
1240
984
  //# sourceMappingURL=motion.js.map
1241
985
  //# sourceMappingURL=motion.js.map