@nomideusz/svelte-calendar 1.4.1 → 1.4.3

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/README.md CHANGED
@@ -580,55 +580,7 @@ The calendar reads its events through an adapter. Which one:
580
580
  | [`createCompositeAdapter(adapters)`](#composite-adapter) | One calendar shows several sources, e.g. a recurring timetable plus one-off events. |
581
581
  | [Custom `CalendarAdapter`](#custom-adapter) | None of the above fits: implement `fetchEvents`, plus the writes you need. |
582
582
 
583
- ## Recurring Schedules
584
-
585
- For fixed repeating events (class timetables, office hours):
586
-
587
- ```svelte
588
- <script>
589
- import { Calendar, createRecurringAdapter } from '@nomideusz/svelte-calendar';
590
-
591
- const adapter = createRecurringAdapter([
592
- { id: '1', title: 'Yoga', dayOfWeek: 1, startTime: '07:00', endTime: '08:30' },
593
- { id: '2', title: 'Standup', frequency: 'daily', startTime: '09:00', endTime: '09:15',
594
- startDate: '2026-03-01', until: '2026-03-31' },
595
- { id: '3', title: 'Review', frequency: 'monthly', dayOfMonth: 15,
596
- startTime: '10:00', endTime: '11:00' },
597
- { id: '4', title: 'Night shift', dayOfWeek: [5, 6], startTime: '22:00', endTime: '02:00' },
598
- ]);
599
- </script>
600
-
601
- <Calendar {adapter} view="week-planner" />
602
- ```
603
-
604
- <details>
605
- <summary>RecurringEvent fields</summary>
606
-
607
- | Field | Type | Default | Description |
608
- |-------|------|---------|-------------|
609
- | `id` | `string` | *required* | Rule identifier |
610
- | `title` | `string` | *required* | Event title |
611
- | `startTime` | `string` | *required* | Start time `"HH:MM"`, 24-hour |
612
- | `endTime` | `string` | *required* | End time `"HH:MM"`. Earlier than `startTime` → ends the next day (overnight). |
613
- | `frequency` | `'daily' \| 'weekly' \| 'monthly'` | `'weekly'` | Recurrence frequency |
614
- | `interval` | `number` | `1` | Every N periods (`2` + weekly = biweekly). Needs `startDate` when > 1. |
615
- | `dayOfWeek` | `number \| number[]` | — | ISO weekday 1=Mon…7=Sun. Required for weekly. |
616
- | `dayOfMonth` | `number` | `1` | Day of month (1–31) for monthly; clamped to shorter months. |
617
- | `startDate` | `string` | — | First possible occurrence `"YYYY-MM-DD"` |
618
- | `until` | `string` | — | Last possible occurrence `"YYYY-MM-DD"` |
619
- | `count` | `number` | — | Max occurrences from `startDate` (requires it). With `until`, the stricter wins. |
620
- | `excludeDates` | `string[]` | — | `"YYYY-MM-DD"` dates this rule skips |
621
- | `color` | `string` | — | Bar color; else from `palette`, else ink (`--dt-event`) |
622
- | `subtitle` / `tags` / `category` / `location` / `resourceId` | | — | Copied to every occurrence |
623
- | `data` | `Record<string, unknown>` | — | Copied to every occurrence, plus `recurringId` |
624
-
625
- </details>
626
-
627
- Options (`RecurringAdapterOptions`): `mondayStart` (default `true`), `palette` (colors rules without a `color`; off by default), `movable` (default `false`).
628
-
629
- Occurrences get the id `` `${rule.id}--YYYYMMDD` `` and `data.recurringId`. The adapter projects and stores nothing, so by default every occurrence carries `data.readOnly` and can't be dragged (it can still be clicked). With `movable: true` the views allow the drag and the adapter refuses the write with a read-only error, so `oneventmove` fires and the host stores the move: add the date to the rule's `excludeDates` and keep the moved occurrence with your one-off events (for example the memory side of a [composite adapter](#composite-adapter)).
630
-
631
- ## REST Adapter
583
+ ### REST Adapter
632
584
 
633
585
  Connect to any REST API:
634
586
 
@@ -653,7 +605,7 @@ const adapter = createRestAdapter({
653
605
 
654
606
  The adapter calls `GET {baseUrl}/events?start=…&end=…` (ISO strings), `POST /events`, `PATCH /events/:id` and `DELETE /events/:id`, sending JSON with your `headers`. By default a list response must be an array of events and a single response one event; `start`/`end` are parsed into `Date`s. Override with `mapEvents(data)` for lists and `mapEvent(data)` for single events. A non-2xx response or invalid JSON throws.
655
607
 
656
- ## JMAP Adapter
608
+ ### JMAP Adapter
657
609
 
658
610
  Read events from a [JMAP Calendars](https://jmap.io/) server (Fastmail, Stalwart, Cyrus):
659
611
 
@@ -678,7 +630,7 @@ const adapter = createJmapAdapter(client, {
678
630
 
679
631
  Read-only. Expands recurrences server-side and handles all-day events, ISO-duration ends and per-calendar colors (pass `calendars: [{ id, name, color }]`, or a function returning them).
680
632
 
681
- ## Mapped Adapter
633
+ ### Mapped Adapter
682
634
 
683
635
  Wrap any static array of external records (class schedules, appointments, timetables) without writing a custom adapter. Supply a declarative field mapping (or a `mapEvent` function for full control) and the adapter handles parsing, colors, tags and status:
684
636
 
@@ -721,7 +673,7 @@ const adapter = createMappedAdapter(rawData, {
721
673
 
722
674
  Mapped adapters are read-only by default (writes throw a read-only error, so drags reach `oneventmove`). Set `readOnly: false` to allow writes; supply `onMutate: { onCreate, onUpdate, onDelete }` to persist them (each returns the stored source record, which is mapped again), or omit it to keep changes in memory.
723
675
 
724
- ## Composite Adapter
676
+ ### Composite Adapter
725
677
 
726
678
  Merge several adapters into one, e.g. a recurring weekly schedule plus one-off events:
727
679
 
@@ -746,7 +698,7 @@ const adapter = createCompositeAdapter([recurring, memory], {
746
698
  });
747
699
  ```
748
700
 
749
- ## Custom Adapter
701
+ ### Custom Adapter
750
702
 
751
703
  Implement the `CalendarAdapter` interface to connect any data source. Only `fetchEvents` is required:
752
704
 
@@ -778,7 +730,7 @@ const adapter = withInitialEvents(createRestAdapter({ baseUrl: '/api' }), data.e
778
730
 
779
731
  The first load is answered synchronously from the seed; every later load (navigation, refresh) goes to the wrapped adapter.
780
732
 
781
- ### Refusing a write
733
+ #### Refusing a write
782
734
 
783
735
  The Calendar tells two refusals apart when `updateEvent` throws:
784
736
 
@@ -790,6 +742,54 @@ The Calendar tells two refusals apart when `updateEvent` throws:
790
742
 
791
743
  An adapter without `updateEvent` counts as read-only. Plain `Error`s whose message contains `read-only` (lower case) or `not found` (any case) are read the same way, so older adapters keep working. `isReadOnlyError(e)` and `isNotFoundError(e)` test both forms.
792
744
 
745
+ ## Recurring Schedules
746
+
747
+ For fixed repeating events (class timetables, office hours):
748
+
749
+ ```svelte
750
+ <script>
751
+ import { Calendar, createRecurringAdapter } from '@nomideusz/svelte-calendar';
752
+
753
+ const adapter = createRecurringAdapter([
754
+ { id: '1', title: 'Yoga', dayOfWeek: 1, startTime: '07:00', endTime: '08:30' },
755
+ { id: '2', title: 'Standup', frequency: 'daily', startTime: '09:00', endTime: '09:15',
756
+ startDate: '2026-03-01', until: '2026-03-31' },
757
+ { id: '3', title: 'Review', frequency: 'monthly', dayOfMonth: 15,
758
+ startTime: '10:00', endTime: '11:00' },
759
+ { id: '4', title: 'Night shift', dayOfWeek: [5, 6], startTime: '22:00', endTime: '02:00' },
760
+ ]);
761
+ </script>
762
+
763
+ <Calendar {adapter} view="week-planner" />
764
+ ```
765
+
766
+ <details>
767
+ <summary>RecurringEvent fields</summary>
768
+
769
+ | Field | Type | Default | Description |
770
+ |-------|------|---------|-------------|
771
+ | `id` | `string` | *required* | Rule identifier |
772
+ | `title` | `string` | *required* | Event title |
773
+ | `startTime` | `string` | *required* | Start time `"HH:MM"`, 24-hour |
774
+ | `endTime` | `string` | *required* | End time `"HH:MM"`. Earlier than `startTime` → ends the next day (overnight). |
775
+ | `frequency` | `'daily' \| 'weekly' \| 'monthly'` | `'weekly'` | Recurrence frequency |
776
+ | `interval` | `number` | `1` | Every N periods (`2` + weekly = biweekly). Needs `startDate` when > 1. |
777
+ | `dayOfWeek` | `number \| number[]` | — | ISO weekday 1=Mon…7=Sun. Required for weekly. |
778
+ | `dayOfMonth` | `number` | `1` | Day of month (1–31) for monthly; clamped to shorter months. |
779
+ | `startDate` | `string` | — | First possible occurrence `"YYYY-MM-DD"` |
780
+ | `until` | `string` | — | Last possible occurrence `"YYYY-MM-DD"` |
781
+ | `count` | `number` | — | Max occurrences from `startDate` (requires it). With `until`, the stricter wins. |
782
+ | `excludeDates` | `string[]` | — | `"YYYY-MM-DD"` dates this rule skips |
783
+ | `color` | `string` | — | Bar color; else from `palette`, else ink (`--dt-event`) |
784
+ | `subtitle` / `tags` / `category` / `location` / `resourceId` | | — | Copied to every occurrence |
785
+ | `data` | `Record<string, unknown>` | — | Copied to every occurrence, plus `recurringId` |
786
+
787
+ </details>
788
+
789
+ Options (`RecurringAdapterOptions`): `mondayStart` (default `true`), `palette` (colors rules without a `color`; off by default), `movable` (default `false`).
790
+
791
+ Occurrences get the id `` `${rule.id}--YYYYMMDD` `` and `data.recurringId`. The adapter projects and stores nothing, so by default every occurrence carries `data.readOnly` and can't be dragged (it can still be clicked). With `movable: true` the views allow the drag and the adapter refuses the write with a read-only error, so `oneventmove` fires and the host stores the move: add the date to the rule's `excludeDates` and keep the moved occurrence with your one-off events (for example the memory side of a [composite adapter](#composite-adapter)).
792
+
793
793
  ## Headless API
794
794
 
795
795
  For full control over rendering, skip `<Calendar>` and drive everything from reactive state. `createCalendar()` returns computed layouts, navigation actions, drag helpers and the raw engines. Zero DOM: bring your own UI. Call it during component initialisation.
@@ -759,7 +759,8 @@ const navCtx = $derived({
759
759
  <span class="cal-sr" role="status" aria-live="polite" aria-atomic="true">{noteText}</span>
760
760
  {/if}
761
761
 
762
- <div class="cal-body">
762
+ <!-- mobile='auto' doesn't know phone vs desktop until measured: hidden till then (SSR), shown anyway after 1.5s -->
763
+ <div class="cal-body" class:cal-body--unmeasured={mobileProp === 'auto' && containerWidth === 0}>
763
764
  {#if activeView}
764
765
  {@const Comp = activeView.component}
765
766
  <Comp
@@ -1002,6 +1003,11 @@ const navCtx = $derived({
1002
1003
  .cal--auto .cal-body {
1003
1004
  overflow: visible;
1004
1005
  }
1006
+ .cal-body--unmeasured {
1007
+ visibility: hidden;
1008
+ animation: cal-reveal 0s 1.5s forwards;
1009
+ }
1010
+ @keyframes cal-reveal { to { visibility: visible; } }
1005
1011
 
1006
1012
  .cal-empty {
1007
1013
  display: flex;
@@ -526,7 +526,7 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
526
526
  }
527
527
  .ag-body::-webkit-scrollbar-thumb {
528
528
  background: var(--dt-border);
529
- border-radius: 2px;
529
+ border-radius: 0;
530
530
  }
531
531
 
532
532
  /* ═══ All-day strip ═══ */
@@ -892,16 +892,8 @@ const hiddenDoneCount = $derived(showAllDone ? 0 : Math.max(0, dayCat.past.lengt
892
892
  border-radius: 50%;
893
893
  background: var(--ev-color, var(--dt-accent, #1d4fd7));
894
894
  margin-bottom: 8px;
895
- animation: ag-pulse 2.5s ease-in-out infinite;
896
- }
897
- @keyframes ag-pulse {
898
- 0%, 100% { opacity: 1; }
899
- 50% { opacity: 0.4; }
900
895
  }
901
896
  @media (prefers-reduced-motion: reduce) {
902
- .ag-q-now-dot {
903
- animation: none;
904
- }
905
897
  .ag-q-now-fill {
906
898
  transition: none;
907
899
  }
@@ -579,7 +579,7 @@ const weekDays = $derived.by(() => {
579
579
  }
580
580
  .ag-body::-webkit-scrollbar-thumb {
581
581
  background: var(--dt-border);
582
- border-radius: 2px;
582
+ border-radius: 0;
583
583
  }
584
584
 
585
585
  /* ═══ All-day chips ═══ */
@@ -844,8 +844,11 @@ const weekDays = $derived.by(() => {
844
844
  .ag-wday .ag-wday-badge--muted {
845
845
  color: var(--dt-text-2, rgba(15, 15, 15, 0.66));
846
846
  }
847
+ /* Set like the weekday beside it, so the heading reads in one case */
847
848
  .ag-wday-date {
848
- font: 400 12.5px/1 var(--dt-sans, system-ui, sans-serif);
849
+ font: 400 12px/1 var(--dt-sans, system-ui, sans-serif);
850
+ letter-spacing: 0.08em;
851
+ text-transform: uppercase;
849
852
  color: var(--dt-text-2, rgba(15, 15, 15, 0.66));
850
853
  }
851
854
 
@@ -13,6 +13,7 @@ import { HOUR_MS, sod, addDaysMs, diffDays, isAllDay, segmentForDay } from "../.
13
13
  import { fmtHParts, fmtTime } from "../../core/locale.js";
14
14
  import { createSwipe } from "./swipe.js";
15
15
  import { dateLong } from "../shared/format.js";
16
+ import { openingTop } from "../shared/opening-top.js";
16
17
  let { height = null, events = [], style = "", locale, focusDate, oneventclick, oneventcreate, selectedEventId = null, readOnly = false, visibleHours } = $props();
17
18
  // ── Context ────────────────────────────────────────
18
19
  const ctx = useCalendarContext();
@@ -459,13 +460,18 @@ function onResizeCancel() {
459
460
  cleanupResize();
460
461
  }
461
462
  // ── Auto-scroll to now ─────────────────────────────
463
+ // The hour before now, its label clear of the top, no event sliced there
464
+ // (openingTop). The grid's 8px padding sits above hour 0.
462
465
  let gridEl;
463
466
  $effect(() => {
464
467
  void dayMs;
465
468
  const el = gridEl;
466
469
  if (!el) return;
467
470
  untrack(() => {
468
- if (nowOffset >= 0) el.scrollTop = Math.max(0, nowOffset - 120);
471
+ if (nowOffset < 0) return;
472
+ const line = Math.max(0, Math.floor(nowOffset / HOUR_HEIGHT - 1)) * HOUR_HEIGHT;
473
+ const y = openingTop(positionedEvents, line, HOUR_HEIGHT, 8, nowOffset, el.clientHeight - 8);
474
+ el.scrollTop = y && y + 8;
469
475
  });
470
476
  });
471
477
  </script>
@@ -843,6 +849,8 @@ $effect(() => {
843
849
  align-items: flex-start;
844
850
  }
845
851
 
852
+ /* The first hour has no room above its line: it sits on it, unclipped */
853
+ .mb-hour:first-child .mb-hour-label { top: 0; }
846
854
  .mb-hour-label--under {
847
855
  visibility: hidden;
848
856
  }
@@ -2,7 +2,7 @@
2
2
  MonthGrid — classic month overview.
3
3
 
4
4
  Week-aligned grid (4–6 rows, driven by the view-state 'month' range).
5
- Each cell lists up to MAX_CHIPS events plus a "+N more" overflow; clicking
5
+ Each cell lists as many events as fit plus a "+N more" overflow; clicking
6
6
  an event fires oneventclick, clicking a day (or the overflow) fires
7
7
  ondayclick — the natural month → day drill-down.
8
8
  -->
@@ -29,7 +29,7 @@ const todayMs = $derived(clock.today);
29
29
  // cell tap (day drill-down) carry the interaction.
30
30
  const dotsMode = $derived(isMobile);
31
31
  const MAX_CHIPS = 3;
32
- // How many chips a cell has room for, measured, never more than MAX_CHIPS.
32
+ // How many chips a cell has room for, measured (MAX_CHIPS until then).
33
33
  // A cell that overflows must also show its "+N more", so it gets one row
34
34
  // fewer when that row would not fit beside its chips: a hidden event is
35
35
  // always counted, never clipped away. Auto height and dots grow instead.
@@ -47,8 +47,8 @@ function measureFit() {
47
47
  const chipH = chip.offsetHeight;
48
48
  const moreH = bodyEl?.querySelector(".mg-more")?.offsetHeight ?? chipH;
49
49
  if (!chipH) return;
50
- fitAll = Math.min(MAX_CHIPS, Math.floor((room + gap) / (chipH + gap)));
51
- fitMore = Math.max(0, Math.min(MAX_CHIPS, Math.floor((room - moreH) / (chipH + gap))));
50
+ fitAll = Math.max(0, Math.floor((room + gap) / (chipH + gap)));
51
+ fitMore = Math.max(0, Math.floor((room - moreH) / (chipH + gap)));
52
52
  }
53
53
  $effect(() => {
54
54
  if (!bodyEl || autoHeight || dotsMode) {
@@ -456,7 +456,7 @@ function chipTime(e) {
456
456
  gap: 5px;
457
457
  border: none;
458
458
  background: none;
459
- padding: 2px 4px;
459
+ padding: 1px 4px;
460
460
  border-radius: 0;
461
461
  font: 400 12.5px/1.3 var(--dt-sans, system-ui, sans-serif);
462
462
  color: var(--dt-text);
@@ -499,7 +499,12 @@ function chipTime(e) {
499
499
  overflow: hidden;
500
500
  text-overflow: ellipsis;
501
501
  white-space: nowrap;
502
+ /* A Latin title in RTL keeps its own direction: the ellipsis cuts its end */
503
+ unicode-bidi: plaintext;
504
+ text-align: left;
502
505
  }
506
+ /* …but it sits on the calendar's side, by the bar */
507
+ .mg-chip-title:dir(rtl) { text-align: right; }
503
508
  .mg-more {
504
509
  align-self: flex-start;
505
510
  border: none;
@@ -1446,7 +1446,12 @@ function onCellKeydown(e, ms) {
1446
1446
  text-overflow: ellipsis;
1447
1447
  min-width: 0;
1448
1448
  flex: 1 1 auto;
1449
+ /* A Latin title in RTL keeps its own direction: the ellipsis cuts its end */
1450
+ unicode-bidi: plaintext;
1451
+ text-align: left;
1449
1452
  }
1453
+ /* …but it sits on the calendar's side, by the bar */
1454
+ .wg-ev-title:dir(rtl) { text-align: right; }
1450
1455
 
1451
1456
  .wg-ev-loc {
1452
1457
  font: 400 12.5px / 1 var(--dt-sans, system-ui, sans-serif);
@@ -23,6 +23,7 @@ import { HOUR_MS, sod, addDaysMs, diffDays } from "../../core/time.js";
23
23
  import { startOfWeek as sowFn, isAllDay, segmentForDay } from "../../core/time.js";
24
24
  import { fmtHParts, fmtTime, fmtDayNum, fmtDuration, fmtWeekRange, weekdayShort, weekdayLong } from "../../core/locale.js";
25
25
  import { dateLong } from "../shared/format.js";
26
+ import { openingTop } from "../shared/opening-top.js";
26
27
  let { mode = "week", mondayStart = true, locale, height = 520, events = [], style = "", focusDate, oneventclick, oneventcreate, onexternaldrop, selectedEventId = null, readOnly = false, visibleHours } = $props();
27
28
  // ── Context ────────────────────────────────────────
28
29
  const ctx = useCalendarContext();
@@ -302,9 +303,11 @@ const weekIsEmpty = $derived(!events.some((ev) => ev.start.getTime() < weekEndMs
302
303
  // ── Auto-scroll (mount + week change) ──────────────
303
304
  // Week with today → current time minus ~1h of context; otherwise the
304
305
  // visibleHours start or 08:00, whichever is later. Lands on a whole hour,
305
- // with room for its label (centred on the line) below the sticky top.
306
- // An event running across that hour line would be sliced under the sticky
307
- // top, so the scroll starts at that event's hour instead.
306
+ // with room for its label (centred on the line) below the sticky top,
307
+ // moved so no event is sliced under it (openingTop).
308
+ // A server-rendered grid sits at midnight until this runs: it stays
309
+ // hidden until placed (.tw-body--unplaced), and shows anyway after 1.5s.
310
+ let placed = $state(false);
308
311
  $effect(() => {
309
312
  void weekStartMs;
310
313
  void startHour;
@@ -313,32 +316,16 @@ $effect(() => {
313
316
  if (!el) return;
314
317
  untrack(() => {
315
318
  let targetHour;
319
+ let anchorHour;
316
320
  if (weekHasToday) {
317
- const clamped = Math.min(Math.max(nowFracHour, startHour), endHour);
318
- targetHour = Math.max(startHour, clamped - 1);
321
+ anchorHour = Math.min(Math.max(nowFracHour, startHour), endHour);
322
+ targetHour = Math.max(startHour, anchorHour - 1);
319
323
  } else {
320
- targetHour = Math.max(startHour, Math.min(8, endHour - 1));
324
+ targetHour = anchorHour = Math.max(startHour, Math.min(8, endHour - 1));
321
325
  }
322
- let hour = Math.floor(targetHour);
323
- // Moving up can slice an earlier event on another day, so repeat
324
- // until no block crosses the line.
325
- // ponytail: looks back at most 2 h, so a long block can't drag the view to dawn.
326
- const floorY = (hour - 2 - startHour) * HOUR_H;
327
- for (let moved = true; moved;) {
328
- moved = false;
329
- const line = (hour - startHour) * HOUR_H;
330
- for (const day of layoutByDay.values()) {
331
- for (const p of day) {
332
- if (p.top < line && p.top + p.height > line && p.top >= floorY) {
333
- hour = Math.floor(startHour + p.top / HOUR_H);
334
- moved = true;
335
- break;
336
- }
337
- }
338
- if (moved) break;
339
- }
340
- }
341
- el.scrollTop = Math.max(0, (hour - startHour) * HOUR_H - LABEL_ROOM);
326
+ const topH = el.querySelector(".tw-top")?.offsetHeight ?? 0;
327
+ el.scrollTop = openingTop([...layoutByDay.values()].flat(), (Math.floor(targetHour) - startHour) * HOUR_H, HOUR_H, LABEL_ROOM, (anchorHour - startHour) * HOUR_H, el.clientHeight - topH);
328
+ placed = true;
342
329
  });
343
330
  });
344
331
  // ── Pointer coalescing ─────────────────────────────
@@ -442,6 +429,9 @@ const TIME_LINE = 11 * 1.1;
442
429
  const TITLE_LINE = 12 * 1.2;
443
430
  const ROOM_LINE = 10 * 1.2;
444
431
  const ROOM_AIR = 9;
432
+ /** Tall enough for two title lines over the time (.tw-ev--tall), in the
433
+ * CSS's own line boxes: title 15px/1.25, time 14px/1.2. */
434
+ const TWO_LINE_H = BODY_PAD_Y + 2 * 15 * 1.25 + LINE_GAP + 14 * 1.2;
445
435
  function blockParts(ev, height) {
446
436
  return fitParts([
447
437
  {
@@ -661,6 +651,20 @@ function handleColsClick(e) {
661
651
  let focusedColMs = $state(null);
662
652
  const tabbableColMs = $derived((dayCols.find((d) => d.ms === focusedColMs) ?? dayCols.find((d) => d.isToday) ?? dayCols[0])?.ms);
663
653
  const colsCreate = $derived(!!oneventcreate && !readOnly);
654
+ // After Enter creates, the keyboard follows the new block once the host
655
+ // adds it: the first unseen event at that start, within two updates.
656
+ let createFocus = null;
657
+ $effect(() => {
658
+ const list = events;
659
+ untrack(() => {
660
+ if (!createFocus) return;
661
+ const { startMs, known } = createFocus;
662
+ const ev = list.find((e) => !known.has(e.id) && e.start.getTime() === startMs);
663
+ const block = ev && colsEl?.querySelector(`.tw-ev[data-event-id="${CSS.escape(ev.id)}"]`);
664
+ if (block) block.focus();
665
+ if (block || --createFocus.tries <= 0) createFocus = null;
666
+ });
667
+ });
664
668
  function onColKeydown(e, i) {
665
669
  if (e.target !== e.currentTarget) return;
666
670
  const day = dayCols[i];
@@ -675,6 +679,11 @@ function onColKeydown(e, i) {
675
679
  const slot = isBlockedAt(day.ms, hour);
676
680
  if (slot) return ctx.announce(`${slot.label || L.unavailable}, ${blockedRangeLabel(day.ms, slot.start, slot.end)}`);
677
681
  const startMs = atHour(day.ms, hour);
682
+ createFocus = {
683
+ startMs,
684
+ known: new Set(events.map((ev) => ev.id)),
685
+ tries: 2
686
+ };
678
687
  oneventcreate({
679
688
  start: new Date(startMs),
680
689
  end: new Date(startMs + Math.max(60, minDuration ?? 0) * 6e4)
@@ -989,18 +998,16 @@ function onWindowKeydown(e) {
989
998
  {/if}
990
999
 
991
1000
  <!-- Grid body -->
992
- <div class="tw-body" style:height="{gridHeight}px">
1001
+ <div class="tw-body" class:tw-body--unplaced={!placed} style:height="{gridHeight}px">
993
1002
  <!-- Time gutter -->
994
1003
  <div class="tw-gutter" style:width="{GUTTER_W}px" aria-hidden="true">
995
1004
  {#each { length: hourCount } as _, i}
996
- {#if i > 0}
997
- {@const [hNum, hSuf] = fmtHParts(startHour + i, locale)}
998
- <!-- an hour the now plate would half-cover gives way to it -->
999
- <span
1000
- class="tw-gutter-lb"
1001
- class:tw-gutter-lb--under={nowY !== null && weekHasToday && Math.abs(i * HOUR_H - nowY) < 18}
1002
- style:top="{i * HOUR_H}px">{hNum}<small>{hSuf}</small></span>
1003
- {/if}
1005
+ {@const [hNum, hSuf] = fmtHParts(startHour + i, locale)}
1006
+ <!-- an hour the now plate would half-cover gives way to it -->
1007
+ <span
1008
+ class="tw-gutter-lb"
1009
+ class:tw-gutter-lb--under={nowY !== null && weekHasToday && Math.abs(i * HOUR_H - nowY) < 18}
1010
+ style:top="{i * HOUR_H}px">{hNum}<small>{hSuf}</small></span>
1004
1011
  {/each}
1005
1012
  {#if axisGhost}
1006
1013
  <!-- The axis reads the drag to the minute, at both edges. -->
@@ -1095,6 +1102,7 @@ function onWindowKeydown(e) {
1095
1102
  class:tw-ev--past={p.ev.end.getTime() < clock.tick}
1096
1103
  class:tw-ev--short={p.height < 44}
1097
1104
  class:tw-ev--compact={p.height < 34}
1105
+ class:tw-ev--tall={p.height >= TWO_LINE_H}
1098
1106
  class:tw-ev--on-now={weekHasToday && nowY !== null && p.top < nowY && p.top + p.height > nowY}
1099
1107
  style:top="{p.top}px"
1100
1108
  style:height="{p.height}px"
@@ -1220,7 +1228,7 @@ function onWindowKeydown(e) {
1220
1228
  .tw-scroll::-webkit-scrollbar { width: 5px; height: 5px; }
1221
1229
  .tw-scroll::-webkit-scrollbar-thumb {
1222
1230
  background: var(--dt-scrollbar, rgba(15, 15, 15, 0.1));
1223
- border-radius: 4px;
1231
+ border-radius: 0;
1224
1232
  }
1225
1233
  .tw-scroll::-webkit-scrollbar-track { background: transparent; }
1226
1234
 
@@ -1378,9 +1386,13 @@ function onWindowKeydown(e) {
1378
1386
  white-space: nowrap;
1379
1387
  overflow: hidden;
1380
1388
  text-overflow: ellipsis;
1389
+ unicode-bidi: plaintext;
1390
+ text-align: left;
1381
1391
  flex: 1;
1382
1392
  min-width: 0;
1383
1393
  }
1394
+ /* …but it sits on the calendar's side, by the bar */
1395
+ .tw-ad-title:dir(rtl) { text-align: right; }
1384
1396
  .tw-ad-span {
1385
1397
  font: 400 12.5px/1 var(--dt-sans, system-ui, sans-serif);
1386
1398
  font-variant-numeric: tabular-nums;
@@ -1432,6 +1444,11 @@ function onWindowKeydown(e) {
1432
1444
  display: flex;
1433
1445
  position: relative;
1434
1446
  }
1447
+ .tw-body--unplaced {
1448
+ visibility: hidden;
1449
+ animation: tw-reveal 0s 1.5s forwards;
1450
+ }
1451
+ @keyframes tw-reveal { to { visibility: visible; } }
1435
1452
 
1436
1453
  /* ─── Time gutter ────────────────────────────────── */
1437
1454
  .tw-gutter {
@@ -1469,6 +1486,10 @@ function onWindowKeydown(e) {
1469
1486
  align-self: center;
1470
1487
  background: var(--dt-border-day, rgba(15, 15, 15, 0.3));
1471
1488
  }
1489
+ /* The first hour has no room above its line: it hangs below it, the
1490
+ tick still on the line. */
1491
+ .tw-gutter-lb:first-child { transform: none; }
1492
+ .tw-gutter-lb:first-child::after { align-self: flex-start; }
1472
1493
  .tw-gutter-lb--under {
1473
1494
  visibility: hidden;
1474
1495
  }
@@ -1767,6 +1788,30 @@ function onWindowKeydown(e) {
1767
1788
  white-space: nowrap;
1768
1789
  overflow: hidden;
1769
1790
  text-overflow: ellipsis;
1791
+ /* A Latin title in an RTL calendar keeps its own direction, so the
1792
+ ellipsis cuts its end, not its start. */
1793
+ unicode-bidi: plaintext;
1794
+ text-align: left;
1795
+ }
1796
+ /* …but it sits on the calendar's side, by the bar */
1797
+ :is(.tw-ev-title, .tw-ev-loc):dir(rtl) { text-align: right; }
1798
+ /* A block tall enough for two title lines over the time wraps the title
1799
+ to two. The parts flow in a wrapping column, so a room that no longer
1800
+ fits under them moves into a second column the block clips: it gives
1801
+ way whole, before the title is cut, instead of being sliced. */
1802
+ .tw-ev--tall .tw-ev-body {
1803
+ flex-wrap: wrap;
1804
+ column-gap: 16px;
1805
+ }
1806
+ .tw-ev--tall :is(.tw-ev-title, .tw-ev-time, .tw-ev-loc) { width: 100%; }
1807
+ .tw-ev--tall .tw-ev-title {
1808
+ display: -webkit-box;
1809
+ -webkit-box-orient: vertical;
1810
+ -webkit-line-clamp: 2;
1811
+ line-clamp: 2;
1812
+ white-space: normal;
1813
+ overflow-wrap: break-word; /* a word wider than an overlap lane breaks, not clips */
1814
+ hyphens: auto; /* …and hyphenates where the host page declares a lang */
1770
1815
  }
1771
1816
 
1772
1817
  .tw-ev-loc {
@@ -1776,14 +1821,16 @@ function onWindowKeydown(e) {
1776
1821
  white-space: nowrap;
1777
1822
  overflow: hidden;
1778
1823
  text-overflow: ellipsis;
1824
+ unicode-bidi: plaintext;
1825
+ text-align: left;
1779
1826
  }
1780
1827
 
1781
1828
  .tw-ev-live {
1782
1829
  position: absolute;
1783
1830
  top: 4px;
1784
1831
  right: 4px;
1785
- width: 6px;
1786
- height: 6px;
1832
+ width: 7px;
1833
+ height: 7px;
1787
1834
  border-radius: 50%;
1788
1835
  background: var(--ev-color, var(--dt-accent));
1789
1836
  animation: tw-pulse 2s ease-in-out infinite;
@@ -1831,7 +1878,7 @@ function onWindowKeydown(e) {
1831
1878
  transform: translateX(-50%);
1832
1879
  width: 20px;
1833
1880
  height: 3px;
1834
- border-radius: 2px;
1881
+ border-radius: 0;
1835
1882
  background: var(--ev-color);
1836
1883
  opacity: 0;
1837
1884
  transition: opacity 120ms;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Where a time grid opens: the y (grid px, 0 = first visible hour) to put at
3
+ * the top of the scroller so no event is sliced there.
4
+ *
5
+ * Starts at the hour line `line`, moves up an hour at a time while an event
6
+ * crosses it (looking back at most 2 h), then leaves `room` above for the
7
+ * hour label. If an event still crosses the top, the top goes to that event's
8
+ * start while `anchor` (the now line) stays on screen, else to the first hour
9
+ * after it ends, else the room is dropped; only a block too long for all of
10
+ * those is cut.
11
+ */
12
+ export declare function openingTop(blocks: readonly {
13
+ top: number;
14
+ height: number;
15
+ }[], line: number, hourH: number, room: number, anchor: number, viewH: number): number;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Where a time grid opens: the y (grid px, 0 = first visible hour) to put at
3
+ * the top of the scroller so no event is sliced there.
4
+ *
5
+ * Starts at the hour line `line`, moves up an hour at a time while an event
6
+ * crosses it (looking back at most 2 h), then leaves `room` above for the
7
+ * hour label. If an event still crosses the top, the top goes to that event's
8
+ * start while `anchor` (the now line) stays on screen, else to the first hour
9
+ * after it ends, else the room is dropped; only a block too long for all of
10
+ * those is cut.
11
+ */
12
+ export function openingTop(blocks, line, hourH, room, anchor, viewH) {
13
+ const cuts = (y) => blocks.filter((b) => b.top < y && b.top + b.height > y);
14
+ const floor = line - 2 * hourH;
15
+ for (let c = cuts(line).filter((b) => b.top >= floor); c.length; c = cuts(line).filter((b) => b.top >= floor)) {
16
+ line = Math.floor(Math.min(...c.map((b) => b.top)) / hourH) * hourH;
17
+ }
18
+ // The anchor must stay on screen, with half an hour under it.
19
+ const fits = (y) => anchor >= y && anchor <= y + viewH - hourH / 2;
20
+ let y = line;
21
+ for (let guard = 0; guard < 8; guard++) {
22
+ const above = cuts(y - room);
23
+ if (!above.length)
24
+ return Math.max(0, y - room);
25
+ const start = Math.min(...above.map((b) => b.top));
26
+ if (start < y && fits(start)) {
27
+ y = start;
28
+ continue;
29
+ }
30
+ const across = cuts(y);
31
+ if (!across.length)
32
+ return Math.max(0, y);
33
+ const after = Math.ceil(Math.max(...across.map((b) => b.top + b.height)) / hourH) * hourH;
34
+ if (after > y && fits(after)) {
35
+ y = after;
36
+ continue;
37
+ }
38
+ break;
39
+ }
40
+ return Math.max(0, y);
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomideusz/svelte-calendar",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "A themeable Svelte 5 calendar: day, week, scrolling-week and month views, agenda lists, touch-first mobile layouts, drag-and-drop, timezones, a headless API and an embeddable web component.",
5
5
  "type": "module",
6
6
  "sideEffects": [