@lavalogic/scoria 0.40.16 → 0.40.17

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.
@@ -6,7 +6,7 @@ import Button from "../Button.svelte";
6
6
  import Icon from "../Icon.svelte";
7
7
  import LoadingOverlay from "../LoadingOverlay.svelte";
8
8
  import { CalendarViewMode } from "./AppointmentCalendarProps.js";
9
- let { doors, appointments, selectedDate, viewMode, ondatechange, onviewmodechange, ondragcreate, onappointmentclick, onappointmentmove, startHour = 0, endHour = 24, slotIntervalMinutes = 15, isLoading = false, headerExtra } = $props();
9
+ let { doors, appointments, selectedDate, viewMode, ondatechange, onviewmodechange, ondragcreate, onappointmentclick, onappointmentmove, startHour = 0, endHour = 24, slotIntervalMinutes = 15, availability, centreHour = 12, isLoading = false, headerExtra } = $props();
10
10
  /**
11
11
  * `selectedDate` is always read by the component, never written. The
12
12
  * runtime type may be either a plain `Date` or a `SvelteDate`; both
@@ -92,6 +92,34 @@ const timeSlots = $derived.by(() => {
92
92
  }
93
93
  return slots;
94
94
  });
95
+ // ---- Availability shading ----
96
+ /**
97
+ * Whether the given slot is outside the door's available windows.
98
+ *
99
+ * A door absent from {@link availability} (or no `availability` map at
100
+ * all) is treated as fully available, so nothing is shaded until the
101
+ * caller supplies windows for that door.
102
+ */
103
+ function isSlotUnavailable(doorId, slotIndex) {
104
+ if (!availability) {
105
+ return false;
106
+ }
107
+ const windows = availability.get(doorId);
108
+ if (!windows) {
109
+ return false;
110
+ }
111
+ const slot = timeSlots[slotIndex];
112
+ if (!slot) {
113
+ return false;
114
+ }
115
+ const minutes = slot.hour * 60 + slot.minute;
116
+ for (const window of windows) {
117
+ if (minutes >= window.startMinutes && minutes < window.endMinutes) {
118
+ return false;
119
+ }
120
+ }
121
+ return true;
122
+ }
95
123
  /**
96
124
  * Build a `YYYY-MM-DD` key for date bucketing. Avoids `toISOString`
97
125
  * timezone conversions that would smear appointments across days.
@@ -202,6 +230,10 @@ function onSlotMouseDown(doorId, slotIndex, e) {
202
230
  if (!ondragcreate || appointmentDragStarted) {
203
231
  return;
204
232
  }
233
+ // Unavailable slots cannot seed a new appointment.
234
+ if (isSlotUnavailable(doorId, slotIndex)) {
235
+ return;
236
+ }
205
237
  isDragging = true;
206
238
  dragDoorId = doorId;
207
239
  dragStartSlotIndex = slotIndex;
@@ -221,6 +253,10 @@ function onSlotMouseEnter(doorId, slotIndex, e) {
221
253
  }
222
254
  // Appointment move drag
223
255
  if (appointmentDragStarted) {
256
+ // Freeze the drop target rather than land it on an unavailable slot.
257
+ if (isSlotUnavailable(doorId, slotIndex)) {
258
+ return;
259
+ }
224
260
  isDraggingAppointment = true;
225
261
  moveTargetDoorId = doorId;
226
262
  moveTargetSlotIndex = slotIndex;
@@ -230,6 +266,10 @@ function onSlotMouseEnter(doorId, slotIndex, e) {
230
266
  if (!isDragging || doorId !== dragDoorId) {
231
267
  return;
232
268
  }
269
+ // Keep the selection within the door's available windows.
270
+ if (isSlotUnavailable(doorId, slotIndex)) {
271
+ return;
272
+ }
233
273
  dragEndSlotIndex = slotIndex;
234
274
  }
235
275
  function onSlotMouseUp() {
@@ -417,6 +457,38 @@ const viewModes = [
417
457
  label: "Month"
418
458
  }
419
459
  ];
460
+ // ---- Centre-on-hour scrolling ----
461
+ /** The whole hour whose row carries the `data-centre-row` marker. */
462
+ const centreRowHour = $derived(centreHour == null ? null : Math.floor(centreHour));
463
+ /** The scrollable calendar body; bound so we can centre it on the marker. */
464
+ let calendarBody = $state();
465
+ /**
466
+ * Whether the grid has been centred for the current spell in a
467
+ * day/week view. Plain (non-reactive) so re-centring only happens when
468
+ * the grid (re)appears, never while the user scrolls or data loads.
469
+ */
470
+ let hasCentred = false;
471
+ $effect(() => {
472
+ // Re-arm whenever we leave the time-grid views so re-entry recentres.
473
+ if (viewMode !== CalendarViewMode.Day && viewMode !== CalendarViewMode.Week) {
474
+ hasCentred = false;
475
+ return;
476
+ }
477
+ if (hasCentred || centreRowHour == null || !calendarBody) {
478
+ return;
479
+ }
480
+ const marker = calendarBody.querySelector("[data-centre-row]");
481
+ if (!marker) {
482
+ return;
483
+ }
484
+ // Centre the marker row in the viewport via rect maths so nested
485
+ // sticky/relative offset parents don't skew the target.
486
+ const bodyRect = calendarBody.getBoundingClientRect();
487
+ const markerRect = marker.getBoundingClientRect();
488
+ const delta = markerRect.top + markerRect.height / 2 - (bodyRect.top + bodyRect.height / 2);
489
+ calendarBody.scrollTop += delta;
490
+ hasCentred = true;
491
+ });
420
492
  </script>
421
493
 
422
494
  <!-- A drag can end anywhere on the page (the pointer is often released outside
@@ -511,7 +583,10 @@ const viewModes = [
511
583
  {/if}
512
584
 
513
585
  <!-- Calendar body -->
514
- <div class="calendar-body">
586
+ <div
587
+ class="calendar-body"
588
+ bind:this={calendarBody}
589
+ >
515
590
  {#if isLoading}
516
591
  <LoadingOverlay />
517
592
  {/if}
@@ -590,6 +665,9 @@ const viewModes = [
590
665
  class="time-label"
591
666
  class:hour-start={slot.minute === 0}
592
667
  class:row-hover={hoveredSlotIndex === slotIdx}
668
+ data-centre-row={slot.hour === centreRowHour && slot.minute === 0
669
+ ? ''
670
+ : undefined}
593
671
  >
594
672
  {#if slot.minute === 0}
595
673
  <span>{slot.label}</span>
@@ -608,6 +686,7 @@ const viewModes = [
608
686
  class:drag-selected={isSlotInDragRange(door.id, slotIdx)}
609
687
  class:move-target={isSlotInMoveTarget(door.id, slotIdx)}
610
688
  class:row-hover={hoveredSlotIndex === slotIdx}
689
+ class:unavailable={isSlotUnavailable(door.id, slotIdx)}
611
690
  onmousedown={(e) => {
612
691
  onSlotMouseDown(door.id, slotIdx, e);
613
692
  }}
@@ -858,6 +937,11 @@ const viewModes = [
858
937
  border-bottom-color: #b4e2f0;
859
938
  z-index: 2;
860
939
  }
940
+ .slot-cell.unavailable {
941
+ cursor: not-allowed;
942
+ background-color: #e9ecf1;
943
+ background-image: repeating-linear-gradient(45deg, transparent, transparent 5px, rgba(128, 150, 165, 0.18) 5px, rgba(128, 150, 165, 0.18) 10px);
944
+ }
861
945
 
862
946
  .appointment-block {
863
947
  position: absolute;
@@ -40,6 +40,16 @@ export interface DragSelection {
40
40
  readonly dateFrom: Date;
41
41
  readonly dateTo: Date;
42
42
  }
43
+ /**
44
+ * A span of available time on the currently displayed day, expressed in
45
+ * minutes from midnight (`startMinutes` inclusive, `endMinutes`
46
+ * exclusive). Consumed by {@link AppointmentCalendarProps.availability}
47
+ * to shade out the times a door is not bookable.
48
+ */
49
+ export interface DoorAvailabilityWindow {
50
+ readonly startMinutes: number;
51
+ readonly endMinutes: number;
52
+ }
43
53
  /**
44
54
  * Arguments passed to {@link AppointmentCalendarProps.onappointmentmove}.
45
55
  *
@@ -85,6 +95,25 @@ export interface AppointmentCalendarProps {
85
95
  endHour?: number;
86
96
  /** The slot interval in minutes (default: 15). */
87
97
  slotIntervalMinutes?: number;
98
+ /**
99
+ * Per-door availability for the currently displayed day, keyed by
100
+ * door (segment) id.
101
+ *
102
+ * When a door id is present, any slot whose start falls outside all
103
+ * of its {@link DoorAvailabilityWindow}s is shaded as unavailable and
104
+ * cannot be used to create or receive an appointment. A door absent
105
+ * from the map is treated as fully available (no shading); map an id
106
+ * to an empty array to mark a door wholly unavailable for the day.
107
+ *
108
+ * Omit the prop entirely to disable availability shading.
109
+ */
110
+ availability?: ReadonlyMap<number, ReadonlyArray<DoorAvailabilityWindow>>;
111
+ /**
112
+ * Hour of the day (0–23) to vertically centre when the day/week grid
113
+ * first appears (default: 12, i.e. midday). Set to `null` to keep the
114
+ * grid scrolled to the top.
115
+ */
116
+ centreHour?: number | null;
88
117
  /** Whether the calendar is loading data. */
89
118
  isLoading?: boolean;
90
119
  /** Optional snippet for extra header content (e.g. buttons). */
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { default as AccordionGroup } from './Components/AccordionGroup.svelte';
2
2
  export { default as AppointmentCalendar } from './Components/AppointmentCalendar/AppointmentCalendar.svelte';
3
- export type { AppointmentCalendarProps, AppointmentMoveArgs, CalendarAppointment, CalendarDoor, CalendarViewMode, DragSelection, } from './Components/AppointmentCalendar/AppointmentCalendarProps.js';
3
+ export type { AppointmentCalendarProps, AppointmentMoveArgs, CalendarAppointment, CalendarDoor, CalendarViewMode, DoorAvailabilityWindow, DragSelection, } from './Components/AppointmentCalendar/AppointmentCalendarProps.js';
4
4
  export type { AccordionGroupButtonProps, AccordionGroupOption, } from './Components/AccordionGroupButtonProps.js';
5
5
  export type { AccordionGroupProps } from './Components/AccordionGroupProps.js';
6
6
  export { default as Action } from './Components/Action.svelte';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lavalogic/scoria",
3
3
  "description": "Svelte components used for the FloWMS Web Frontend",
4
- "version": "0.40.16",
4
+ "version": "0.40.17",
5
5
  "publishConfig": {
6
6
  "access": "restricted"
7
7
  },