@lavalogic/scoria 0.40.16 → 0.40.18
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,83 @@ 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 slotUnavailableInWindows(windows, slot) {
|
|
104
|
+
const minutes = slot.hour * 60 + slot.minute;
|
|
105
|
+
for (const window of windows) {
|
|
106
|
+
if (minutes >= window.startMinutes && minutes < window.endMinutes) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
function isSlotUnavailable(doorId, slotIndex) {
|
|
113
|
+
if (!availability) {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
const windows = availability.get(doorId);
|
|
117
|
+
if (!windows) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
const slot = timeSlots[slotIndex];
|
|
121
|
+
if (!slot) {
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
return slotUnavailableInWindows(windows, slot);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Contiguous unavailable runs per door, expressed as top/height
|
|
128
|
+
* percentages of the day column. Rendered as a single overlay block per
|
|
129
|
+
* run so the shading pattern is continuous across the whole region
|
|
130
|
+
* rather than restarting (and visibly seaming) at every slot cell.
|
|
131
|
+
*/
|
|
132
|
+
const unavailableRunsByDoor = $derived.by(() => {
|
|
133
|
+
// Local index built inside a $derived; outer reactivity covers reads.
|
|
134
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
135
|
+
const byDoor = new Map();
|
|
136
|
+
const slotCount = timeSlots.length;
|
|
137
|
+
if (!availability || slotCount === 0) {
|
|
138
|
+
return byDoor;
|
|
139
|
+
}
|
|
140
|
+
for (const door of doors) {
|
|
141
|
+
const windows = availability.get(door.id);
|
|
142
|
+
if (!windows) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const runs = [];
|
|
146
|
+
let runStart = null;
|
|
147
|
+
// One extra iteration (i === slotCount) flushes a run that reaches
|
|
148
|
+
// the end of the day.
|
|
149
|
+
for (let i = 0; i <= slotCount; i++) {
|
|
150
|
+
const unavailable = i < slotCount && slotUnavailableInWindows(windows, timeSlots[i]);
|
|
151
|
+
if (unavailable) {
|
|
152
|
+
if (runStart === null) {
|
|
153
|
+
runStart = i;
|
|
154
|
+
}
|
|
155
|
+
} else if (runStart !== null) {
|
|
156
|
+
runs.push({
|
|
157
|
+
topPercent: runStart / slotCount * 100,
|
|
158
|
+
heightPercent: (i - runStart) / slotCount * 100
|
|
159
|
+
});
|
|
160
|
+
runStart = null;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (runs.length > 0) {
|
|
164
|
+
byDoor.set(door.id, runs);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return byDoor;
|
|
168
|
+
});
|
|
169
|
+
function unavailableRunsAt(doorId) {
|
|
170
|
+
return unavailableRunsByDoor.get(doorId) ?? [];
|
|
171
|
+
}
|
|
95
172
|
/**
|
|
96
173
|
* Build a `YYYY-MM-DD` key for date bucketing. Avoids `toISOString`
|
|
97
174
|
* timezone conversions that would smear appointments across days.
|
|
@@ -202,6 +279,10 @@ function onSlotMouseDown(doorId, slotIndex, e) {
|
|
|
202
279
|
if (!ondragcreate || appointmentDragStarted) {
|
|
203
280
|
return;
|
|
204
281
|
}
|
|
282
|
+
// Unavailable slots cannot seed a new appointment.
|
|
283
|
+
if (isSlotUnavailable(doorId, slotIndex)) {
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
205
286
|
isDragging = true;
|
|
206
287
|
dragDoorId = doorId;
|
|
207
288
|
dragStartSlotIndex = slotIndex;
|
|
@@ -221,6 +302,10 @@ function onSlotMouseEnter(doorId, slotIndex, e) {
|
|
|
221
302
|
}
|
|
222
303
|
// Appointment move drag
|
|
223
304
|
if (appointmentDragStarted) {
|
|
305
|
+
// Freeze the drop target rather than land it on an unavailable slot.
|
|
306
|
+
if (isSlotUnavailable(doorId, slotIndex)) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
224
309
|
isDraggingAppointment = true;
|
|
225
310
|
moveTargetDoorId = doorId;
|
|
226
311
|
moveTargetSlotIndex = slotIndex;
|
|
@@ -230,6 +315,10 @@ function onSlotMouseEnter(doorId, slotIndex, e) {
|
|
|
230
315
|
if (!isDragging || doorId !== dragDoorId) {
|
|
231
316
|
return;
|
|
232
317
|
}
|
|
318
|
+
// Keep the selection within the door's available windows.
|
|
319
|
+
if (isSlotUnavailable(doorId, slotIndex)) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
233
322
|
dragEndSlotIndex = slotIndex;
|
|
234
323
|
}
|
|
235
324
|
function onSlotMouseUp() {
|
|
@@ -417,6 +506,38 @@ const viewModes = [
|
|
|
417
506
|
label: "Month"
|
|
418
507
|
}
|
|
419
508
|
];
|
|
509
|
+
// ---- Centre-on-hour scrolling ----
|
|
510
|
+
/** The whole hour whose row carries the `data-centre-row` marker. */
|
|
511
|
+
const centreRowHour = $derived(centreHour == null ? null : Math.floor(centreHour));
|
|
512
|
+
/** The scrollable calendar body; bound so we can centre it on the marker. */
|
|
513
|
+
let calendarBody = $state();
|
|
514
|
+
/**
|
|
515
|
+
* Whether the grid has been centred for the current spell in a
|
|
516
|
+
* day/week view. Plain (non-reactive) so re-centring only happens when
|
|
517
|
+
* the grid (re)appears, never while the user scrolls or data loads.
|
|
518
|
+
*/
|
|
519
|
+
let hasCentred = false;
|
|
520
|
+
$effect(() => {
|
|
521
|
+
// Re-arm whenever we leave the time-grid views so re-entry recentres.
|
|
522
|
+
if (viewMode !== CalendarViewMode.Day && viewMode !== CalendarViewMode.Week) {
|
|
523
|
+
hasCentred = false;
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (hasCentred || centreRowHour == null || !calendarBody) {
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const marker = calendarBody.querySelector("[data-centre-row]");
|
|
530
|
+
if (!marker) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
// Centre the marker row in the viewport via rect maths so nested
|
|
534
|
+
// sticky/relative offset parents don't skew the target.
|
|
535
|
+
const bodyRect = calendarBody.getBoundingClientRect();
|
|
536
|
+
const markerRect = marker.getBoundingClientRect();
|
|
537
|
+
const delta = markerRect.top + markerRect.height / 2 - (bodyRect.top + bodyRect.height / 2);
|
|
538
|
+
calendarBody.scrollTop += delta;
|
|
539
|
+
hasCentred = true;
|
|
540
|
+
});
|
|
420
541
|
</script>
|
|
421
542
|
|
|
422
543
|
<!-- A drag can end anywhere on the page (the pointer is often released outside
|
|
@@ -511,7 +632,10 @@ const viewModes = [
|
|
|
511
632
|
{/if}
|
|
512
633
|
|
|
513
634
|
<!-- Calendar body -->
|
|
514
|
-
<div
|
|
635
|
+
<div
|
|
636
|
+
class="calendar-body"
|
|
637
|
+
bind:this={calendarBody}
|
|
638
|
+
>
|
|
515
639
|
{#if isLoading}
|
|
516
640
|
<LoadingOverlay />
|
|
517
641
|
{/if}
|
|
@@ -590,6 +714,9 @@ const viewModes = [
|
|
|
590
714
|
class="time-label"
|
|
591
715
|
class:hour-start={slot.minute === 0}
|
|
592
716
|
class:row-hover={hoveredSlotIndex === slotIdx}
|
|
717
|
+
data-centre-row={slot.hour === centreRowHour && slot.minute === 0
|
|
718
|
+
? ''
|
|
719
|
+
: undefined}
|
|
593
720
|
>
|
|
594
721
|
{#if slot.minute === 0}
|
|
595
722
|
<span>{slot.label}</span>
|
|
@@ -608,6 +735,7 @@ const viewModes = [
|
|
|
608
735
|
class:drag-selected={isSlotInDragRange(door.id, slotIdx)}
|
|
609
736
|
class:move-target={isSlotInMoveTarget(door.id, slotIdx)}
|
|
610
737
|
class:row-hover={hoveredSlotIndex === slotIdx}
|
|
738
|
+
class:unavailable={isSlotUnavailable(door.id, slotIdx)}
|
|
611
739
|
onmousedown={(e) => {
|
|
612
740
|
onSlotMouseDown(door.id, slotIdx, e);
|
|
613
741
|
}}
|
|
@@ -617,6 +745,13 @@ const viewModes = [
|
|
|
617
745
|
}}
|
|
618
746
|
></div>
|
|
619
747
|
{/each}
|
|
748
|
+
<!-- Unavailable shading: one continuous block per contiguous run -->
|
|
749
|
+
{#each unavailableRunsAt(door.id) as run (run.topPercent)}
|
|
750
|
+
<div
|
|
751
|
+
class="unavailable-overlay"
|
|
752
|
+
style="top: {run.topPercent}%; height: {run.heightPercent}%;"
|
|
753
|
+
></div>
|
|
754
|
+
{/each}
|
|
620
755
|
<!-- Appointment blocks positioned absolutely (rendered after, sit above slots) -->
|
|
621
756
|
{#each appointmentsAt(door.id, day) as apt (apt.id)}
|
|
622
757
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
@@ -858,6 +993,19 @@ const viewModes = [
|
|
|
858
993
|
border-bottom-color: #b4e2f0;
|
|
859
994
|
z-index: 2;
|
|
860
995
|
}
|
|
996
|
+
.slot-cell.unavailable {
|
|
997
|
+
cursor: not-allowed;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
.unavailable-overlay {
|
|
1001
|
+
position: absolute;
|
|
1002
|
+
left: 0;
|
|
1003
|
+
right: 0;
|
|
1004
|
+
z-index: 3;
|
|
1005
|
+
pointer-events: none;
|
|
1006
|
+
background-color: rgba(128, 150, 165, 0.06);
|
|
1007
|
+
background-image: repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(128, 150, 165, 0.14) 6px, rgba(128, 150, 165, 0.14) 12px);
|
|
1008
|
+
}
|
|
861
1009
|
|
|
862
1010
|
.appointment-block {
|
|
863
1011
|
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';
|