@besaitech/ng-design-system 0.0.78 → 0.0.80

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.
@@ -79,11 +79,24 @@ declare function occursOn(start: Date, end: Date, day: Date): boolean;
79
79
  * on a grid that opens at 08:00 should still show, flush to the top, not vanish.
80
80
  */
81
81
  declare function placeDay<T>(events: readonly SdsCalendarEvent<T>[], day: Date, startHour: number, endHour: number, defaultMinutes: number): PlacedEvent<T>[];
82
+ /**
83
+ * A dragged minute, snapped to the grid and held inside the visible window.
84
+ *
85
+ * <p>Clamping is not cosmetic: without it a drag that leaves the top of the grid proposes a time
86
+ * before the practice opens, and the host either books it or has to invent the same rule itself.
87
+ */
88
+ declare function snapMinute(minute: number, stepMinutes: number, startHour: number, endHour: number): number;
82
89
  /** The all-day (and multi-day) events touching a given day, in a stable order. */
83
90
  declare function allDayOn<T>(events: readonly SdsCalendarEvent<T>[], day: Date, defaultMinutes: number): SdsCalendarEvent<T>[];
84
91
  /** Every event touching a day, all-day first then by start — what a month cell lists. */
85
92
  declare function eventsOn<T>(events: readonly SdsCalendarEvent<T>[], day: Date, defaultMinutes: number): SdsCalendarEvent<T>[];
86
93
 
94
+ /** Where an event was dropped. The component reports; the host decides whether it sticks. */
95
+ interface SdsCalendarDrop<T = unknown> {
96
+ event: SdsCalendarEvent<T>;
97
+ start: Date;
98
+ end: Date;
99
+ }
87
100
  /** A click on empty time: what a host turns into "new appointment". */
88
101
  interface SdsCalendarSlot {
89
102
  start: Date;
@@ -127,6 +140,7 @@ interface SdsCalendarRange {
127
140
  */
128
141
  declare class SdsEventCalendar<T = unknown> {
129
142
  private readonly destroyRef;
143
+ private readonly host;
130
144
  readonly events: _angular_core.InputSignal<readonly SdsCalendarEvent<T>[]>;
131
145
  /** Two-way: the host can deep-link or restore a view. */
132
146
  readonly view: _angular_core.ModelSignal<SdsEventCalendarView>;
@@ -161,7 +175,26 @@ declare class SdsEventCalendar<T = unknown> {
161
175
  readonly weekLabel: _angular_core.InputSignal<string>;
162
176
  readonly threeDayLabel: _angular_core.InputSignal<string>;
163
177
  readonly dayLabel: _angular_core.InputSignal<string>;
178
+ /**
179
+ * Let an event be dragged to another time or day. <b>Opt-in</b>: a calendar whose host does not
180
+ * persist the move would show an appointment settle somewhere it has not been booked, which is
181
+ * worse than not offering the gesture.
182
+ */
183
+ readonly draggable: _angular_core.InputSignalWithTransform<boolean, unknown>;
184
+ /** Drag across empty time to propose a span. The plain click keeps emitting `slotClick`. */
185
+ readonly selectable: _angular_core.InputSignalWithTransform<boolean, unknown>;
186
+ /** Both gestures snap to this. 15 minutes is the finest grid anybody reads off a week view. */
187
+ readonly snapMinutes: _angular_core.InputSignalWithTransform<number, unknown>;
164
188
  readonly eventClick: _angular_core.OutputEmitterRef<SdsCalendarEvent<T>>;
189
+ /**
190
+ * An event was dragged somewhere else. The component does NOT move it: it reports where it was
191
+ * dropped and redraws from `events` as before, so the diary only changes once the host says it
192
+ * did. A drag that moved the chip locally would show a booking that does not exist whenever the
193
+ * write is refused.
194
+ */
195
+ readonly eventDrop: _angular_core.OutputEmitterRef<SdsCalendarDrop<T>>;
196
+ /** A span was dragged out across empty time — "book something from here to here". */
197
+ readonly rangeSelect: _angular_core.OutputEmitterRef<SdsCalendarSlot>;
165
198
  /** A click on empty time, snapped to the hour that was clicked. */
166
199
  readonly slotClick: _angular_core.OutputEmitterRef<SdsCalendarSlot>;
167
200
  readonly dayClick: _angular_core.OutputEmitterRef<Date>;
@@ -169,6 +202,18 @@ declare class SdsEventCalendar<T = unknown> {
169
202
  readonly rangeChange: _angular_core.OutputEmitterRef<SdsCalendarRange>;
170
203
  /** Ticks the now-indicator. One timer, cleared with the component. */
171
204
  private readonly now;
205
+ /** The gesture in flight, if any. Null between gestures — there is never more than one. */
206
+ private readonly drag;
207
+ /** Set once the pointer has moved far enough to be a drag rather than a click. */
208
+ private readonly dragging;
209
+ /**
210
+ * A finished drag still produces a click, and it arrives AFTER pointerup — so checking
211
+ * "am I dragging" inside the click handler is always false by then. Measured: dropping an event
212
+ * emitted `eventDrop` AND `slotClick`, so a host both moved the appointment and opened a
213
+ * "new appointment" panel for where the finger lifted. This survives until the click it has to
214
+ * swallow, and is cleared either way.
215
+ */
216
+ private suppressClick;
172
217
  constructor();
173
218
  protected readonly days: _angular_core.Signal<Date[]>;
174
219
  protected readonly hours: _angular_core.Signal<number[]>;
@@ -198,10 +243,35 @@ declare class SdsEventCalendar<T = unknown> {
198
243
  protected goToday(): void;
199
244
  /** A click on empty grid becomes a slot of `defaultMinutes`, snapped to the hour clicked. */
200
245
  protected onGridClick(event: MouseEvent, day: Date): void;
246
+ protected canDrag(placed: PlacedEvent<T>): boolean;
247
+ protected isDragging(placed: PlacedEvent<T>): boolean;
248
+ /** The dashed block: where this drag would land on this column, or null. */
249
+ protected ghostFor(dayIndex: number): {
250
+ top: number;
251
+ height: number;
252
+ label: string;
253
+ } | null;
254
+ protected startSelect(event: PointerEvent, dayIndex: number): void;
255
+ protected startMove(event: PointerEvent, placed: PlacedEvent<T>, dayIndex: number): void;
256
+ /**
257
+ * Listeners are attached for the gesture and removed on release, rather than living on the host
258
+ * for the component's whole life. A calendar sitting idle on a dashboard should not be handling
259
+ * every pointer move on the page.
260
+ */
261
+ private begin;
262
+ private finish;
263
+ private snap;
264
+ private minuteAt;
265
+ private atMinute;
266
+ private columnAt;
267
+ private columnFor;
268
+ /** True once, for the click that a just-finished drag is about to produce. */
269
+ private swallowed;
270
+ private hhmm;
201
271
  protected onEventClick(event: MouseEvent, placed: PlacedEvent<T>): void;
202
272
  static ɵfac: _angular_core.ɵɵFactoryDeclaration<SdsEventCalendar<any>, never>;
203
- static ɵcmp: _angular_core.ɵɵComponentDeclaration<SdsEventCalendar<any>, "sds-event-calendar", never, { "events": { "alias": "events"; "required": false; "isSignal": true; }; "view": { "alias": "view"; "required": false; "isSignal": true; }; "date": { "alias": "date"; "required": false; "isSignal": true; }; "firstDayOfWeek": { "alias": "firstDayOfWeek"; "required": false; "isSignal": true; }; "startHour": { "alias": "startHour"; "required": false; "isSignal": true; }; "endHour": { "alias": "endHour"; "required": false; "isSignal": true; }; "hourHeight": { "alias": "hourHeight"; "required": false; "isSignal": true; }; "defaultMinutes": { "alias": "defaultMinutes"; "required": false; "isSignal": true; }; "height": { "alias": "height"; "required": false; "isSignal": true; }; "locale": { "alias": "locale"; "required": false; "isSignal": true; }; "nowIndicator": { "alias": "nowIndicator"; "required": false; "isSignal": true; }; "showToolbar": { "alias": "showToolbar"; "required": false; "isSignal": true; }; "showViewSwitcher": { "alias": "showViewSwitcher"; "required": false; "isSignal": true; }; "maxPerCell": { "alias": "maxPerCell"; "required": false; "isSignal": true; }; "todayLabel": { "alias": "todayLabel"; "required": false; "isSignal": true; }; "previousLabel": { "alias": "previousLabel"; "required": false; "isSignal": true; }; "nextLabel": { "alias": "nextLabel"; "required": false; "isSignal": true; }; "allDayLabel": { "alias": "allDayLabel"; "required": false; "isSignal": true; }; "moreLabel": { "alias": "moreLabel"; "required": false; "isSignal": true; }; "viewSwitcherLabel": { "alias": "viewSwitcherLabel"; "required": false; "isSignal": true; }; "monthLabel": { "alias": "monthLabel"; "required": false; "isSignal": true; }; "weekLabel": { "alias": "weekLabel"; "required": false; "isSignal": true; }; "threeDayLabel": { "alias": "threeDayLabel"; "required": false; "isSignal": true; }; "dayLabel": { "alias": "dayLabel"; "required": false; "isSignal": true; }; }, { "view": "viewChange"; "date": "dateChange"; "eventClick": "eventClick"; "slotClick": "slotClick"; "dayClick": "dayClick"; "rangeChange": "rangeChange"; }, never, never, true, never>;
273
+ static ɵcmp: _angular_core.ɵɵComponentDeclaration<SdsEventCalendar<any>, "sds-event-calendar", never, { "events": { "alias": "events"; "required": false; "isSignal": true; }; "view": { "alias": "view"; "required": false; "isSignal": true; }; "date": { "alias": "date"; "required": false; "isSignal": true; }; "firstDayOfWeek": { "alias": "firstDayOfWeek"; "required": false; "isSignal": true; }; "startHour": { "alias": "startHour"; "required": false; "isSignal": true; }; "endHour": { "alias": "endHour"; "required": false; "isSignal": true; }; "hourHeight": { "alias": "hourHeight"; "required": false; "isSignal": true; }; "defaultMinutes": { "alias": "defaultMinutes"; "required": false; "isSignal": true; }; "height": { "alias": "height"; "required": false; "isSignal": true; }; "locale": { "alias": "locale"; "required": false; "isSignal": true; }; "nowIndicator": { "alias": "nowIndicator"; "required": false; "isSignal": true; }; "showToolbar": { "alias": "showToolbar"; "required": false; "isSignal": true; }; "showViewSwitcher": { "alias": "showViewSwitcher"; "required": false; "isSignal": true; }; "maxPerCell": { "alias": "maxPerCell"; "required": false; "isSignal": true; }; "todayLabel": { "alias": "todayLabel"; "required": false; "isSignal": true; }; "previousLabel": { "alias": "previousLabel"; "required": false; "isSignal": true; }; "nextLabel": { "alias": "nextLabel"; "required": false; "isSignal": true; }; "allDayLabel": { "alias": "allDayLabel"; "required": false; "isSignal": true; }; "moreLabel": { "alias": "moreLabel"; "required": false; "isSignal": true; }; "viewSwitcherLabel": { "alias": "viewSwitcherLabel"; "required": false; "isSignal": true; }; "monthLabel": { "alias": "monthLabel"; "required": false; "isSignal": true; }; "weekLabel": { "alias": "weekLabel"; "required": false; "isSignal": true; }; "threeDayLabel": { "alias": "threeDayLabel"; "required": false; "isSignal": true; }; "dayLabel": { "alias": "dayLabel"; "required": false; "isSignal": true; }; "draggable": { "alias": "draggable"; "required": false; "isSignal": true; }; "selectable": { "alias": "selectable"; "required": false; "isSignal": true; }; "snapMinutes": { "alias": "snapMinutes"; "required": false; "isSignal": true; }; }, { "view": "viewChange"; "date": "dateChange"; "eventClick": "eventClick"; "eventDrop": "eventDrop"; "rangeSelect": "rangeSelect"; "slotClick": "slotClick"; "dayClick": "dayClick"; "rangeChange": "rangeChange"; }, never, never, true, never>;
204
274
  }
205
275
 
206
- export { SdsEventCalendar, allDayOn, eventsOn, minutesOfDay, occursOn, placeDay, startOfWeek, step, toDate, visibleDays, visibleRange };
207
- export type { PlacedEvent, SdsCalendarEvent, SdsCalendarRange, SdsCalendarSlot, SdsEventCalendarView, SdsEventColor };
276
+ export { SdsEventCalendar, allDayOn, eventsOn, minutesOfDay, occursOn, placeDay, snapMinute, startOfWeek, step, toDate, visibleDays, visibleRange };
277
+ export type { PlacedEvent, SdsCalendarDrop, SdsCalendarEvent, SdsCalendarRange, SdsCalendarSlot, SdsEventCalendarView, SdsEventColor };
@@ -1,6 +1,6 @@
1
1
  import { startOfDay, addDays, isSameDay, SdsIcon, SdsSegmented } from '@besaitech/ng-design-system';
2
2
  import * as i0 from '@angular/core';
3
- import { inject, DestroyRef, input, model, numberAttribute, booleanAttribute, output, signal, effect, untracked, computed, ChangeDetectionStrategy, Component } from '@angular/core';
3
+ import { inject, DestroyRef, ElementRef, input, model, numberAttribute, booleanAttribute, output, signal, effect, untracked, computed, ChangeDetectionStrategy, Component } from '@angular/core';
4
4
 
5
5
  const MS_PER_MINUTE = 60_000;
6
6
  function toDate(value) {
@@ -148,6 +148,16 @@ function placeDay(events, day, startHour, endHour, defaultMinutes) {
148
148
  flush();
149
149
  return placed;
150
150
  }
151
+ /**
152
+ * A dragged minute, snapped to the grid and held inside the visible window.
153
+ *
154
+ * <p>Clamping is not cosmetic: without it a drag that leaves the top of the grid proposes a time
155
+ * before the practice opens, and the host either books it or has to invent the same rule itself.
156
+ */
157
+ function snapMinute(minute, stepMinutes, startHour, endHour) {
158
+ const step = Math.max(1, stepMinutes);
159
+ return Math.min(endHour * 60, Math.max(startHour * 60, Math.round(minute / step) * step));
160
+ }
151
161
  /** The all-day (and multi-day) events touching a given day, in a stable order. */
152
162
  function allDayOn(events, day, defaultMinutes) {
153
163
  return events
@@ -230,6 +240,7 @@ const DOT = {
230
240
  */
231
241
  class SdsEventCalendar {
232
242
  destroyRef = inject(DestroyRef);
243
+ host = inject(ElementRef);
233
244
  events = input([], ...(ngDevMode ? [{ debugName: "events" }] : []));
234
245
  /** Two-way: the host can deep-link or restore a view. */
235
246
  view = model('week', ...(ngDevMode ? [{ debugName: "view" }] : []));
@@ -264,7 +275,26 @@ class SdsEventCalendar {
264
275
  weekLabel = input('Week', ...(ngDevMode ? [{ debugName: "weekLabel" }] : []));
265
276
  threeDayLabel = input('3 days', ...(ngDevMode ? [{ debugName: "threeDayLabel" }] : []));
266
277
  dayLabel = input('Day', ...(ngDevMode ? [{ debugName: "dayLabel" }] : []));
278
+ /**
279
+ * Let an event be dragged to another time or day. <b>Opt-in</b>: a calendar whose host does not
280
+ * persist the move would show an appointment settle somewhere it has not been booked, which is
281
+ * worse than not offering the gesture.
282
+ */
283
+ draggable = input(false, ...(ngDevMode ? [{ debugName: "draggable", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
284
+ /** Drag across empty time to propose a span. The plain click keeps emitting `slotClick`. */
285
+ selectable = input(true, ...(ngDevMode ? [{ debugName: "selectable", transform: booleanAttribute }] : [{ transform: booleanAttribute }]));
286
+ /** Both gestures snap to this. 15 minutes is the finest grid anybody reads off a week view. */
287
+ snapMinutes = input(15, ...(ngDevMode ? [{ debugName: "snapMinutes", transform: numberAttribute }] : [{ transform: numberAttribute }]));
267
288
  eventClick = output();
289
+ /**
290
+ * An event was dragged somewhere else. The component does NOT move it: it reports where it was
291
+ * dropped and redraws from `events` as before, so the diary only changes once the host says it
292
+ * did. A drag that moved the chip locally would show a booking that does not exist whenever the
293
+ * write is refused.
294
+ */
295
+ eventDrop = output();
296
+ /** A span was dragged out across empty time — "book something from here to here". */
297
+ rangeSelect = output();
268
298
  /** A click on empty time, snapped to the hour that was clicked. */
269
299
  slotClick = output();
270
300
  dayClick = output();
@@ -272,6 +302,18 @@ class SdsEventCalendar {
272
302
  rangeChange = output();
273
303
  /** Ticks the now-indicator. One timer, cleared with the component. */
274
304
  now = signal(new Date(), ...(ngDevMode ? [{ debugName: "now" }] : []));
305
+ /** The gesture in flight, if any. Null between gestures — there is never more than one. */
306
+ drag = signal(null, ...(ngDevMode ? [{ debugName: "drag" }] : []));
307
+ /** Set once the pointer has moved far enough to be a drag rather than a click. */
308
+ dragging = signal(false, ...(ngDevMode ? [{ debugName: "dragging" }] : []));
309
+ /**
310
+ * A finished drag still produces a click, and it arrives AFTER pointerup — so checking
311
+ * "am I dragging" inside the click handler is always false by then. Measured: dropping an event
312
+ * emitted `eventDrop` AND `slotClick`, so a host both moved the appointment and opened a
313
+ * "new appointment" panel for where the finger lifted. This survives until the click it has to
314
+ * swallow, and is cleared either way.
315
+ */
316
+ suppressClick = false;
275
317
  constructor() {
276
318
  const timer = setInterval(() => this.now.set(new Date()), 60_000);
277
319
  this.destroyRef.onDestroy(() => clearInterval(timer));
@@ -393,6 +435,9 @@ class SdsEventCalendar {
393
435
  }
394
436
  /** A click on empty grid becomes a slot of `defaultMinutes`, snapped to the hour clicked. */
395
437
  onGridClick(event, day) {
438
+ if (this.swallowed()) {
439
+ return;
440
+ }
396
441
  const host = event.currentTarget;
397
442
  const y = event.clientY - host.getBoundingClientRect().top;
398
443
  const minutes = this.startHour() * 60 + (y / this.hourHeight()) * 60;
@@ -403,13 +448,180 @@ class SdsEventCalendar {
403
448
  end: new Date(start.getTime() + this.defaultMinutes() * 60_000),
404
449
  });
405
450
  }
451
+ // ------------------------------------------------------------------ dragging
452
+ canDrag(placed) {
453
+ return this.draggable() && !placed.event.cancelled;
454
+ }
455
+ isDragging(placed) {
456
+ const drag = this.drag();
457
+ return this.dragging() && drag?.kind === 'move' && drag.placed.event.id === placed.event.id;
458
+ }
459
+ /** The dashed block: where this drag would land on this column, or null. */
460
+ ghostFor(dayIndex) {
461
+ const drag = this.drag();
462
+ if (!drag || !this.dragging() || drag.dayIndex !== dayIndex) {
463
+ return null;
464
+ }
465
+ const perMinute = this.hourHeight() / 60;
466
+ const from = this.startHour() * 60;
467
+ const [a, b] = drag.kind === 'move'
468
+ ? [drag.atMin, drag.atMin + drag.durationMin]
469
+ : [Math.min(drag.fromMin, drag.atMin), Math.max(drag.fromMin, drag.atMin)];
470
+ const top = (a - from) * perMinute;
471
+ return {
472
+ top,
473
+ height: Math.max((b - a) * perMinute, 2),
474
+ label: `${this.hhmm(a)} – ${this.hhmm(b)}`,
475
+ };
476
+ }
477
+ startSelect(event, dayIndex) {
478
+ if (!this.selectable() || event.button !== 0) {
479
+ return;
480
+ }
481
+ const column = event.currentTarget;
482
+ const minute = this.snap(this.minuteAt(event.clientY, column));
483
+ this.begin(event, { kind: 'select', dayIndex, fromMin: minute, atMin: minute });
484
+ }
485
+ startMove(event, placed, dayIndex) {
486
+ if (!this.canDrag(placed) || event.button !== 0) {
487
+ return;
488
+ }
489
+ // Stop the column beneath from also starting a range-select under the same finger.
490
+ event.stopPropagation();
491
+ const column = event.currentTarget.closest('[data-sds-day]');
492
+ if (!column) {
493
+ return;
494
+ }
495
+ const startMin = minutesOfDay(placed.start);
496
+ const durationMin = Math.max(this.snapMinutes(), Math.round((placed.end.getTime() - placed.start.getTime()) / 60_000));
497
+ this.begin(event, {
498
+ kind: 'move',
499
+ dayIndex,
500
+ placed,
501
+ durationMin,
502
+ // Where in the block the finger went down, so it does not jump to the top on the first move.
503
+ grabOffsetMin: this.minuteAt(event.clientY, column) - startMin,
504
+ atMin: startMin,
505
+ });
506
+ }
507
+ /**
508
+ * Listeners are attached for the gesture and removed on release, rather than living on the host
509
+ * for the component's whole life. A calendar sitting idle on a dashboard should not be handling
510
+ * every pointer move on the page.
511
+ */
512
+ begin(event, state) {
513
+ this.drag.set(state);
514
+ this.dragging.set(false);
515
+ const startY = event.clientY;
516
+ const startX = event.clientX;
517
+ const move = (moved) => {
518
+ const current = this.drag();
519
+ if (!current) {
520
+ return;
521
+ }
522
+ // A few pixels of slop, so a click with a shaky hand is still a click.
523
+ if (!this.dragging() &&
524
+ Math.abs(moved.clientY - startY) < 4 &&
525
+ Math.abs(moved.clientX - startX) < 4) {
526
+ return;
527
+ }
528
+ this.dragging.set(true);
529
+ const column = this.columnAt(moved.clientX, moved.clientY);
530
+ const dayIndex = column ? Number(column.dataset['sdsDay']) : current.dayIndex;
531
+ const target = column ?? this.columnFor(current.dayIndex);
532
+ if (!target) {
533
+ return;
534
+ }
535
+ const raw = this.minuteAt(moved.clientY, target);
536
+ const atMin = current.kind === 'move' ? this.snap(raw - current.grabOffsetMin) : this.snap(raw);
537
+ this.drag.set({ ...current, dayIndex, atMin });
538
+ };
539
+ const up = () => {
540
+ window.removeEventListener('pointermove', move);
541
+ window.removeEventListener('pointerup', up);
542
+ window.removeEventListener('pointercancel', up);
543
+ const finished = this.drag();
544
+ const moved = this.dragging();
545
+ this.drag.set(null);
546
+ this.dragging.set(false);
547
+ if (finished && moved) {
548
+ this.suppressClick = true;
549
+ // If no click follows (the pointer left the grid, a touch cancel), do not swallow the
550
+ // NEXT genuine one — a flag that outlives its gesture is a dead control.
551
+ setTimeout(() => {
552
+ this.suppressClick = false;
553
+ });
554
+ this.finish(finished);
555
+ }
556
+ };
557
+ window.addEventListener('pointermove', move);
558
+ window.addEventListener('pointerup', up);
559
+ window.addEventListener('pointercancel', up);
560
+ }
561
+ finish(drag) {
562
+ const day = this.days()[drag.dayIndex];
563
+ if (!day) {
564
+ return;
565
+ }
566
+ if (drag.kind === 'move') {
567
+ const start = this.atMinute(day, drag.atMin);
568
+ this.eventDrop.emit({
569
+ event: drag.placed.event,
570
+ start,
571
+ end: new Date(start.getTime() + drag.durationMin * 60_000),
572
+ });
573
+ return;
574
+ }
575
+ const from = Math.min(drag.fromMin, drag.atMin);
576
+ const to = Math.max(drag.fromMin, drag.atMin);
577
+ if (to - from < this.snapMinutes()) {
578
+ // Not a span — the click handler will treat it as a slot click.
579
+ return;
580
+ }
581
+ this.rangeSelect.emit({ start: this.atMinute(day, from), end: this.atMinute(day, to) });
582
+ }
583
+ snap(minute) {
584
+ return snapMinute(minute, this.snapMinutes(), this.startHour(), this.endHour());
585
+ }
586
+ minuteAt(clientY, column) {
587
+ const y = clientY - column.getBoundingClientRect().top;
588
+ return this.startHour() * 60 + (y / this.hourHeight()) * 60;
589
+ }
590
+ atMinute(day, minute) {
591
+ return new Date(startOfDay(day).getTime() + minute * 60_000);
592
+ }
593
+ columnAt(x, y) {
594
+ return (document
595
+ .elementsFromPoint(x, y)
596
+ .find((el) => el instanceof HTMLElement && !!el.dataset['sdsDay']) ??
597
+ null);
598
+ }
599
+ columnFor(dayIndex) {
600
+ return this.host.nativeElement.querySelector(`[data-sds-day="${dayIndex}"]`);
601
+ }
602
+ /** True once, for the click that a just-finished drag is about to produce. */
603
+ swallowed() {
604
+ if (!this.suppressClick) {
605
+ return false;
606
+ }
607
+ this.suppressClick = false;
608
+ return true;
609
+ }
610
+ hhmm(minute) {
611
+ const h = Math.floor(minute / 60);
612
+ const m = Math.round(minute % 60);
613
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
614
+ }
406
615
  onEventClick(event, placed) {
407
616
  // Without this the click falls through to the grid and also opens "new appointment".
408
617
  event.stopPropagation();
618
+ if (this.swallowed()) {
619
+ return;
620
+ }
409
621
  this.eventClick.emit(placed.event);
410
622
  }
411
623
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.25", ngImport: i0, type: SdsEventCalendar, deps: [], target: i0.ɵɵFactoryTarget.Component });
412
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsEventCalendar, isStandalone: true, selector: "sds-event-calendar", inputs: { events: { classPropertyName: "events", publicName: "events", isSignal: true, isRequired: false, transformFunction: null }, view: { classPropertyName: "view", publicName: "view", isSignal: true, isRequired: false, transformFunction: null }, date: { classPropertyName: "date", publicName: "date", isSignal: true, isRequired: false, transformFunction: null }, firstDayOfWeek: { classPropertyName: "firstDayOfWeek", publicName: "firstDayOfWeek", isSignal: true, isRequired: false, transformFunction: null }, startHour: { classPropertyName: "startHour", publicName: "startHour", isSignal: true, isRequired: false, transformFunction: null }, endHour: { classPropertyName: "endHour", publicName: "endHour", isSignal: true, isRequired: false, transformFunction: null }, hourHeight: { classPropertyName: "hourHeight", publicName: "hourHeight", isSignal: true, isRequired: false, transformFunction: null }, defaultMinutes: { classPropertyName: "defaultMinutes", publicName: "defaultMinutes", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null }, locale: { classPropertyName: "locale", publicName: "locale", isSignal: true, isRequired: false, transformFunction: null }, nowIndicator: { classPropertyName: "nowIndicator", publicName: "nowIndicator", isSignal: true, isRequired: false, transformFunction: null }, showToolbar: { classPropertyName: "showToolbar", publicName: "showToolbar", isSignal: true, isRequired: false, transformFunction: null }, showViewSwitcher: { classPropertyName: "showViewSwitcher", publicName: "showViewSwitcher", isSignal: true, isRequired: false, transformFunction: null }, maxPerCell: { classPropertyName: "maxPerCell", publicName: "maxPerCell", isSignal: true, isRequired: false, transformFunction: null }, todayLabel: { classPropertyName: "todayLabel", publicName: "todayLabel", isSignal: true, isRequired: false, transformFunction: null }, previousLabel: { classPropertyName: "previousLabel", publicName: "previousLabel", isSignal: true, isRequired: false, transformFunction: null }, nextLabel: { classPropertyName: "nextLabel", publicName: "nextLabel", isSignal: true, isRequired: false, transformFunction: null }, allDayLabel: { classPropertyName: "allDayLabel", publicName: "allDayLabel", isSignal: true, isRequired: false, transformFunction: null }, moreLabel: { classPropertyName: "moreLabel", publicName: "moreLabel", isSignal: true, isRequired: false, transformFunction: null }, viewSwitcherLabel: { classPropertyName: "viewSwitcherLabel", publicName: "viewSwitcherLabel", isSignal: true, isRequired: false, transformFunction: null }, monthLabel: { classPropertyName: "monthLabel", publicName: "monthLabel", isSignal: true, isRequired: false, transformFunction: null }, weekLabel: { classPropertyName: "weekLabel", publicName: "weekLabel", isSignal: true, isRequired: false, transformFunction: null }, threeDayLabel: { classPropertyName: "threeDayLabel", publicName: "threeDayLabel", isSignal: true, isRequired: false, transformFunction: null }, dayLabel: { classPropertyName: "dayLabel", publicName: "dayLabel", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { view: "viewChange", date: "dateChange", eventClick: "eventClick", slotClick: "slotClick", dayClick: "dayClick", rangeChange: "rangeChange" }, host: { classAttribute: "block" }, ngImport: i0, template: `
624
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.25", type: SdsEventCalendar, isStandalone: true, selector: "sds-event-calendar", inputs: { events: { classPropertyName: "events", publicName: "events", isSignal: true, isRequired: false, transformFunction: null }, view: { classPropertyName: "view", publicName: "view", isSignal: true, isRequired: false, transformFunction: null }, date: { classPropertyName: "date", publicName: "date", isSignal: true, isRequired: false, transformFunction: null }, firstDayOfWeek: { classPropertyName: "firstDayOfWeek", publicName: "firstDayOfWeek", isSignal: true, isRequired: false, transformFunction: null }, startHour: { classPropertyName: "startHour", publicName: "startHour", isSignal: true, isRequired: false, transformFunction: null }, endHour: { classPropertyName: "endHour", publicName: "endHour", isSignal: true, isRequired: false, transformFunction: null }, hourHeight: { classPropertyName: "hourHeight", publicName: "hourHeight", isSignal: true, isRequired: false, transformFunction: null }, defaultMinutes: { classPropertyName: "defaultMinutes", publicName: "defaultMinutes", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null }, locale: { classPropertyName: "locale", publicName: "locale", isSignal: true, isRequired: false, transformFunction: null }, nowIndicator: { classPropertyName: "nowIndicator", publicName: "nowIndicator", isSignal: true, isRequired: false, transformFunction: null }, showToolbar: { classPropertyName: "showToolbar", publicName: "showToolbar", isSignal: true, isRequired: false, transformFunction: null }, showViewSwitcher: { classPropertyName: "showViewSwitcher", publicName: "showViewSwitcher", isSignal: true, isRequired: false, transformFunction: null }, maxPerCell: { classPropertyName: "maxPerCell", publicName: "maxPerCell", isSignal: true, isRequired: false, transformFunction: null }, todayLabel: { classPropertyName: "todayLabel", publicName: "todayLabel", isSignal: true, isRequired: false, transformFunction: null }, previousLabel: { classPropertyName: "previousLabel", publicName: "previousLabel", isSignal: true, isRequired: false, transformFunction: null }, nextLabel: { classPropertyName: "nextLabel", publicName: "nextLabel", isSignal: true, isRequired: false, transformFunction: null }, allDayLabel: { classPropertyName: "allDayLabel", publicName: "allDayLabel", isSignal: true, isRequired: false, transformFunction: null }, moreLabel: { classPropertyName: "moreLabel", publicName: "moreLabel", isSignal: true, isRequired: false, transformFunction: null }, viewSwitcherLabel: { classPropertyName: "viewSwitcherLabel", publicName: "viewSwitcherLabel", isSignal: true, isRequired: false, transformFunction: null }, monthLabel: { classPropertyName: "monthLabel", publicName: "monthLabel", isSignal: true, isRequired: false, transformFunction: null }, weekLabel: { classPropertyName: "weekLabel", publicName: "weekLabel", isSignal: true, isRequired: false, transformFunction: null }, threeDayLabel: { classPropertyName: "threeDayLabel", publicName: "threeDayLabel", isSignal: true, isRequired: false, transformFunction: null }, dayLabel: { classPropertyName: "dayLabel", publicName: "dayLabel", isSignal: true, isRequired: false, transformFunction: null }, draggable: { classPropertyName: "draggable", publicName: "draggable", isSignal: true, isRequired: false, transformFunction: null }, selectable: { classPropertyName: "selectable", publicName: "selectable", isSignal: true, isRequired: false, transformFunction: null }, snapMinutes: { classPropertyName: "snapMinutes", publicName: "snapMinutes", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { view: "viewChange", date: "dateChange", eventClick: "eventClick", eventDrop: "eventDrop", rangeSelect: "rangeSelect", slotClick: "slotClick", dayClick: "dayClick", rangeChange: "rangeChange" }, host: { classAttribute: "block" }, ngImport: i0, template: `
413
625
  <div class="flex flex-col overflow-hidden rounded-card border border-border bg-surface">
414
626
  @if (showToolbar()) {
415
627
  <div class="flex flex-wrap items-center gap-x-3 gap-y-2 border-b border-border px-3 py-2.5">
@@ -588,11 +800,13 @@ class SdsEventCalendar {
588
800
  }
589
801
  </div>
590
802
 
591
- @for (day of days(); track day.getTime()) {
803
+ @for (day of days(); track day.getTime(); let dayIndex = $index) {
592
804
  <div
593
- class="relative border-r border-border last:border-r-0"
805
+ class="relative touch-none border-r border-border last:border-r-0"
806
+ [attr.data-sds-day]="dayIndex"
594
807
  [style.height.px]="bodyHeight()"
595
808
  (click)="onGridClick($event, day)"
809
+ (pointerdown)="startSelect($event, dayIndex)"
596
810
  >
597
811
  @for (h of hours(); track h) {
598
812
  <div
@@ -601,6 +815,25 @@ class SdsEventCalendar {
601
815
  ></div>
602
816
  }
603
817
 
818
+ @if (ghostFor(dayIndex); as ghost) {
819
+ <!--
820
+ What the drag will produce, drawn where it will land. A drag with no preview
821
+ is a guess: you find out what you did once the server has been told.
822
+ -->
823
+ <div
824
+ class="pointer-events-none absolute inset-x-0.5 z-30 rounded border-2 border-dashed border-brand-500 bg-brand-500/15"
825
+ [style.top.px]="ghost.top"
826
+ [style.height.px]="ghost.height"
827
+ aria-hidden="true"
828
+ >
829
+ <span
830
+ class="block px-1 pt-0.5 text-[10px] font-medium text-brand-700 dark:text-brand-200"
831
+ >
832
+ {{ ghost.label }}
833
+ </span>
834
+ </div>
835
+ }
836
+
604
837
  @if (nowIndicator() && isToday(day) && nowTop() !== null) {
605
838
  <div
606
839
  class="pointer-events-none absolute inset-x-0 z-20 flex items-center"
@@ -628,7 +861,10 @@ class SdsEventCalendar {
628
861
  [attr.aria-label]="
629
862
  p.event.title + ', ' + clock(p.start) + ' – ' + clock(p.end)
630
863
  "
864
+ [class.cursor-grab]="canDrag(p)"
865
+ [class.opacity-40]="isDragging(p)"
631
866
  (click)="onEventClick($event, p)"
867
+ (pointerdown)="startMove($event, p, dayIndex)"
632
868
  >
633
869
  <span class="block truncate text-[11px] font-medium leading-tight">
634
870
  {{ p.event.title }}
@@ -839,11 +1075,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
839
1075
  }
840
1076
  </div>
841
1077
 
842
- @for (day of days(); track day.getTime()) {
1078
+ @for (day of days(); track day.getTime(); let dayIndex = $index) {
843
1079
  <div
844
- class="relative border-r border-border last:border-r-0"
1080
+ class="relative touch-none border-r border-border last:border-r-0"
1081
+ [attr.data-sds-day]="dayIndex"
845
1082
  [style.height.px]="bodyHeight()"
846
1083
  (click)="onGridClick($event, day)"
1084
+ (pointerdown)="startSelect($event, dayIndex)"
847
1085
  >
848
1086
  @for (h of hours(); track h) {
849
1087
  <div
@@ -852,6 +1090,25 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
852
1090
  ></div>
853
1091
  }
854
1092
 
1093
+ @if (ghostFor(dayIndex); as ghost) {
1094
+ <!--
1095
+ What the drag will produce, drawn where it will land. A drag with no preview
1096
+ is a guess: you find out what you did once the server has been told.
1097
+ -->
1098
+ <div
1099
+ class="pointer-events-none absolute inset-x-0.5 z-30 rounded border-2 border-dashed border-brand-500 bg-brand-500/15"
1100
+ [style.top.px]="ghost.top"
1101
+ [style.height.px]="ghost.height"
1102
+ aria-hidden="true"
1103
+ >
1104
+ <span
1105
+ class="block px-1 pt-0.5 text-[10px] font-medium text-brand-700 dark:text-brand-200"
1106
+ >
1107
+ {{ ghost.label }}
1108
+ </span>
1109
+ </div>
1110
+ }
1111
+
855
1112
  @if (nowIndicator() && isToday(day) && nowTop() !== null) {
856
1113
  <div
857
1114
  class="pointer-events-none absolute inset-x-0 z-20 flex items-center"
@@ -879,7 +1136,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
879
1136
  [attr.aria-label]="
880
1137
  p.event.title + ', ' + clock(p.start) + ' – ' + clock(p.end)
881
1138
  "
1139
+ [class.cursor-grab]="canDrag(p)"
1140
+ [class.opacity-40]="isDragging(p)"
882
1141
  (click)="onEventClick($event, p)"
1142
+ (pointerdown)="startMove($event, p, dayIndex)"
883
1143
  >
884
1144
  <span class="block truncate text-[11px] font-medium leading-tight">
885
1145
  {{ p.event.title }}
@@ -904,7 +1164,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
904
1164
  </div>
905
1165
  `,
906
1166
  }]
907
- }], ctorParameters: () => [], propDecorators: { events: [{ type: i0.Input, args: [{ isSignal: true, alias: "events", required: false }] }], view: [{ type: i0.Input, args: [{ isSignal: true, alias: "view", required: false }] }, { type: i0.Output, args: ["viewChange"] }], date: [{ type: i0.Input, args: [{ isSignal: true, alias: "date", required: false }] }, { type: i0.Output, args: ["dateChange"] }], firstDayOfWeek: [{ type: i0.Input, args: [{ isSignal: true, alias: "firstDayOfWeek", required: false }] }], startHour: [{ type: i0.Input, args: [{ isSignal: true, alias: "startHour", required: false }] }], endHour: [{ type: i0.Input, args: [{ isSignal: true, alias: "endHour", required: false }] }], hourHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "hourHeight", required: false }] }], defaultMinutes: [{ type: i0.Input, args: [{ isSignal: true, alias: "defaultMinutes", required: false }] }], height: [{ type: i0.Input, args: [{ isSignal: true, alias: "height", required: false }] }], locale: [{ type: i0.Input, args: [{ isSignal: true, alias: "locale", required: false }] }], nowIndicator: [{ type: i0.Input, args: [{ isSignal: true, alias: "nowIndicator", required: false }] }], showToolbar: [{ type: i0.Input, args: [{ isSignal: true, alias: "showToolbar", required: false }] }], showViewSwitcher: [{ type: i0.Input, args: [{ isSignal: true, alias: "showViewSwitcher", required: false }] }], maxPerCell: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxPerCell", required: false }] }], todayLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "todayLabel", required: false }] }], previousLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "previousLabel", required: false }] }], nextLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "nextLabel", required: false }] }], allDayLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "allDayLabel", required: false }] }], moreLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "moreLabel", required: false }] }], viewSwitcherLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "viewSwitcherLabel", required: false }] }], monthLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "monthLabel", required: false }] }], weekLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "weekLabel", required: false }] }], threeDayLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "threeDayLabel", required: false }] }], dayLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "dayLabel", required: false }] }], eventClick: [{ type: i0.Output, args: ["eventClick"] }], slotClick: [{ type: i0.Output, args: ["slotClick"] }], dayClick: [{ type: i0.Output, args: ["dayClick"] }], rangeChange: [{ type: i0.Output, args: ["rangeChange"] }] } });
1167
+ }], ctorParameters: () => [], propDecorators: { events: [{ type: i0.Input, args: [{ isSignal: true, alias: "events", required: false }] }], view: [{ type: i0.Input, args: [{ isSignal: true, alias: "view", required: false }] }, { type: i0.Output, args: ["viewChange"] }], date: [{ type: i0.Input, args: [{ isSignal: true, alias: "date", required: false }] }, { type: i0.Output, args: ["dateChange"] }], firstDayOfWeek: [{ type: i0.Input, args: [{ isSignal: true, alias: "firstDayOfWeek", required: false }] }], startHour: [{ type: i0.Input, args: [{ isSignal: true, alias: "startHour", required: false }] }], endHour: [{ type: i0.Input, args: [{ isSignal: true, alias: "endHour", required: false }] }], hourHeight: [{ type: i0.Input, args: [{ isSignal: true, alias: "hourHeight", required: false }] }], defaultMinutes: [{ type: i0.Input, args: [{ isSignal: true, alias: "defaultMinutes", required: false }] }], height: [{ type: i0.Input, args: [{ isSignal: true, alias: "height", required: false }] }], locale: [{ type: i0.Input, args: [{ isSignal: true, alias: "locale", required: false }] }], nowIndicator: [{ type: i0.Input, args: [{ isSignal: true, alias: "nowIndicator", required: false }] }], showToolbar: [{ type: i0.Input, args: [{ isSignal: true, alias: "showToolbar", required: false }] }], showViewSwitcher: [{ type: i0.Input, args: [{ isSignal: true, alias: "showViewSwitcher", required: false }] }], maxPerCell: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxPerCell", required: false }] }], todayLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "todayLabel", required: false }] }], previousLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "previousLabel", required: false }] }], nextLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "nextLabel", required: false }] }], allDayLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "allDayLabel", required: false }] }], moreLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "moreLabel", required: false }] }], viewSwitcherLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "viewSwitcherLabel", required: false }] }], monthLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "monthLabel", required: false }] }], weekLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "weekLabel", required: false }] }], threeDayLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "threeDayLabel", required: false }] }], dayLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "dayLabel", required: false }] }], draggable: [{ type: i0.Input, args: [{ isSignal: true, alias: "draggable", required: false }] }], selectable: [{ type: i0.Input, args: [{ isSignal: true, alias: "selectable", required: false }] }], snapMinutes: [{ type: i0.Input, args: [{ isSignal: true, alias: "snapMinutes", required: false }] }], eventClick: [{ type: i0.Output, args: ["eventClick"] }], eventDrop: [{ type: i0.Output, args: ["eventDrop"] }], rangeSelect: [{ type: i0.Output, args: ["rangeSelect"] }], slotClick: [{ type: i0.Output, args: ["slotClick"] }], dayClick: [{ type: i0.Output, args: ["dayClick"] }], rangeChange: [{ type: i0.Output, args: ["rangeChange"] }] } });
908
1168
 
909
1169
  /*
910
1170
  * @besaitech/ng-design-system/calendar — the event calendar, kept OUT of the main entry point.
@@ -920,5 +1180,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.25", ngImpo
920
1180
  * Generated bundle index. Do not edit.
921
1181
  */
922
1182
 
923
- export { SdsEventCalendar, allDayOn, eventsOn, minutesOfDay, occursOn, placeDay, startOfWeek, step, toDate, visibleDays, visibleRange };
1183
+ export { SdsEventCalendar, allDayOn, eventsOn, minutesOfDay, occursOn, placeDay, snapMinute, startOfWeek, step, toDate, visibleDays, visibleRange };
924
1184
  //# sourceMappingURL=besaitech-ng-design-system-calendar.mjs.map