@nomideusz/svelte-calendar 1.4.1 → 1.4.2

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.
@@ -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
 
@@ -843,6 +843,8 @@ $effect(() => {
843
843
  align-items: flex-start;
844
844
  }
845
845
 
846
+ /* The first hour has no room above its line: it sits on it, unclipped */
847
+ .mb-hour:first-child .mb-hour-label { top: 0; }
846
848
  .mb-hour-label--under {
847
849
  visibility: hidden;
848
850
  }
@@ -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);
@@ -305,6 +305,9 @@ const weekIsEmpty = $derived(!events.some((ev) => ev.start.getTime() < weekEndMs
305
305
  // with room for its label (centred on the line) below the sticky top.
306
306
  // An event running across that hour line would be sliced under the sticky
307
307
  // top, so the scroll starts at that event's hour instead.
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;
@@ -339,6 +342,7 @@ $effect(() => {
339
342
  }
340
343
  }
341
344
  el.scrollTop = Math.max(0, (hour - startHour) * HOUR_H - LABEL_ROOM);
345
+ placed = true;
342
346
  });
343
347
  });
344
348
  // ── Pointer coalescing ─────────────────────────────
@@ -442,6 +446,9 @@ const TIME_LINE = 11 * 1.1;
442
446
  const TITLE_LINE = 12 * 1.2;
443
447
  const ROOM_LINE = 10 * 1.2;
444
448
  const ROOM_AIR = 9;
449
+ /** Tall enough for two title lines over the time (.tw-ev--tall), in the
450
+ * CSS's own line boxes: title 15px/1.25, time 14px/1.2. */
451
+ const TWO_LINE_H = BODY_PAD_Y + 2 * 15 * 1.25 + LINE_GAP + 14 * 1.2;
445
452
  function blockParts(ev, height) {
446
453
  return fitParts([
447
454
  {
@@ -661,6 +668,20 @@ function handleColsClick(e) {
661
668
  let focusedColMs = $state(null);
662
669
  const tabbableColMs = $derived((dayCols.find((d) => d.ms === focusedColMs) ?? dayCols.find((d) => d.isToday) ?? dayCols[0])?.ms);
663
670
  const colsCreate = $derived(!!oneventcreate && !readOnly);
671
+ // After Enter creates, the keyboard follows the new block once the host
672
+ // adds it: the first unseen event at that start, within two updates.
673
+ let createFocus = null;
674
+ $effect(() => {
675
+ const list = events;
676
+ untrack(() => {
677
+ if (!createFocus) return;
678
+ const { startMs, known } = createFocus;
679
+ const ev = list.find((e) => !known.has(e.id) && e.start.getTime() === startMs);
680
+ const block = ev && colsEl?.querySelector(`.tw-ev[data-event-id="${CSS.escape(ev.id)}"]`);
681
+ if (block) block.focus();
682
+ if (block || --createFocus.tries <= 0) createFocus = null;
683
+ });
684
+ });
664
685
  function onColKeydown(e, i) {
665
686
  if (e.target !== e.currentTarget) return;
666
687
  const day = dayCols[i];
@@ -675,6 +696,11 @@ function onColKeydown(e, i) {
675
696
  const slot = isBlockedAt(day.ms, hour);
676
697
  if (slot) return ctx.announce(`${slot.label || L.unavailable}, ${blockedRangeLabel(day.ms, slot.start, slot.end)}`);
677
698
  const startMs = atHour(day.ms, hour);
699
+ createFocus = {
700
+ startMs,
701
+ known: new Set(events.map((ev) => ev.id)),
702
+ tries: 2
703
+ };
678
704
  oneventcreate({
679
705
  start: new Date(startMs),
680
706
  end: new Date(startMs + Math.max(60, minDuration ?? 0) * 6e4)
@@ -989,18 +1015,16 @@ function onWindowKeydown(e) {
989
1015
  {/if}
990
1016
 
991
1017
  <!-- Grid body -->
992
- <div class="tw-body" style:height="{gridHeight}px">
1018
+ <div class="tw-body" class:tw-body--unplaced={!placed} style:height="{gridHeight}px">
993
1019
  <!-- Time gutter -->
994
1020
  <div class="tw-gutter" style:width="{GUTTER_W}px" aria-hidden="true">
995
1021
  {#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}
1022
+ {@const [hNum, hSuf] = fmtHParts(startHour + i, locale)}
1023
+ <!-- an hour the now plate would half-cover gives way to it -->
1024
+ <span
1025
+ class="tw-gutter-lb"
1026
+ class:tw-gutter-lb--under={nowY !== null && weekHasToday && Math.abs(i * HOUR_H - nowY) < 18}
1027
+ style:top="{i * HOUR_H}px">{hNum}<small>{hSuf}</small></span>
1004
1028
  {/each}
1005
1029
  {#if axisGhost}
1006
1030
  <!-- The axis reads the drag to the minute, at both edges. -->
@@ -1095,6 +1119,7 @@ function onWindowKeydown(e) {
1095
1119
  class:tw-ev--past={p.ev.end.getTime() < clock.tick}
1096
1120
  class:tw-ev--short={p.height < 44}
1097
1121
  class:tw-ev--compact={p.height < 34}
1122
+ class:tw-ev--tall={p.height >= TWO_LINE_H}
1098
1123
  class:tw-ev--on-now={weekHasToday && nowY !== null && p.top < nowY && p.top + p.height > nowY}
1099
1124
  style:top="{p.top}px"
1100
1125
  style:height="{p.height}px"
@@ -1378,9 +1403,13 @@ function onWindowKeydown(e) {
1378
1403
  white-space: nowrap;
1379
1404
  overflow: hidden;
1380
1405
  text-overflow: ellipsis;
1406
+ unicode-bidi: plaintext;
1407
+ text-align: left;
1381
1408
  flex: 1;
1382
1409
  min-width: 0;
1383
1410
  }
1411
+ /* …but it sits on the calendar's side, by the bar */
1412
+ .tw-ad-title:dir(rtl) { text-align: right; }
1384
1413
  .tw-ad-span {
1385
1414
  font: 400 12.5px/1 var(--dt-sans, system-ui, sans-serif);
1386
1415
  font-variant-numeric: tabular-nums;
@@ -1432,6 +1461,11 @@ function onWindowKeydown(e) {
1432
1461
  display: flex;
1433
1462
  position: relative;
1434
1463
  }
1464
+ .tw-body--unplaced {
1465
+ visibility: hidden;
1466
+ animation: tw-reveal 0s 1.5s forwards;
1467
+ }
1468
+ @keyframes tw-reveal { to { visibility: visible; } }
1435
1469
 
1436
1470
  /* ─── Time gutter ────────────────────────────────── */
1437
1471
  .tw-gutter {
@@ -1469,6 +1503,10 @@ function onWindowKeydown(e) {
1469
1503
  align-self: center;
1470
1504
  background: var(--dt-border-day, rgba(15, 15, 15, 0.3));
1471
1505
  }
1506
+ /* The first hour has no room above its line: it hangs below it, the
1507
+ tick still on the line. */
1508
+ .tw-gutter-lb:first-child { transform: none; }
1509
+ .tw-gutter-lb:first-child::after { align-self: flex-start; }
1472
1510
  .tw-gutter-lb--under {
1473
1511
  visibility: hidden;
1474
1512
  }
@@ -1767,6 +1805,30 @@ function onWindowKeydown(e) {
1767
1805
  white-space: nowrap;
1768
1806
  overflow: hidden;
1769
1807
  text-overflow: ellipsis;
1808
+ /* A Latin title in an RTL calendar keeps its own direction, so the
1809
+ ellipsis cuts its end, not its start. */
1810
+ unicode-bidi: plaintext;
1811
+ text-align: left;
1812
+ }
1813
+ /* …but it sits on the calendar's side, by the bar */
1814
+ :is(.tw-ev-title, .tw-ev-loc):dir(rtl) { text-align: right; }
1815
+ /* A block tall enough for two title lines over the time wraps the title
1816
+ to two. The parts flow in a wrapping column, so a room that no longer
1817
+ fits under them moves into a second column the block clips: it gives
1818
+ way whole, before the title is cut, instead of being sliced. */
1819
+ .tw-ev--tall .tw-ev-body {
1820
+ flex-wrap: wrap;
1821
+ column-gap: 16px;
1822
+ }
1823
+ .tw-ev--tall :is(.tw-ev-title, .tw-ev-time, .tw-ev-loc) { width: 100%; }
1824
+ .tw-ev--tall .tw-ev-title {
1825
+ display: -webkit-box;
1826
+ -webkit-box-orient: vertical;
1827
+ -webkit-line-clamp: 2;
1828
+ line-clamp: 2;
1829
+ white-space: normal;
1830
+ overflow-wrap: break-word; /* a word wider than an overlap lane breaks, not clips */
1831
+ hyphens: auto; /* …and hyphenates where the host page declares a lang */
1770
1832
  }
1771
1833
 
1772
1834
  .tw-ev-loc {
@@ -1776,6 +1838,8 @@ function onWindowKeydown(e) {
1776
1838
  white-space: nowrap;
1777
1839
  overflow: hidden;
1778
1840
  text-overflow: ellipsis;
1841
+ unicode-bidi: plaintext;
1842
+ text-align: left;
1779
1843
  }
1780
1844
 
1781
1845
  .tw-ev-live {
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.2",
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": [