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