@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 +54 -54
- package/dist/calendar/Calendar.svelte +7 -1
- package/dist/views/agenda/AgendaDay.svelte +1 -9
- package/dist/views/agenda/AgendaWeek.svelte +5 -2
- package/dist/views/mobile/MobileDay.svelte +9 -1
- package/dist/views/month/MonthGrid.svelte +10 -5
- package/dist/views/planner/PlannerScroll.svelte +5 -0
- package/dist/views/planner/PlannerWeek.svelte +86 -39
- package/dist/views/shared/opening-top.d.ts +15 -0
- package/dist/views/shared/opening-top.js +41 -0
- package/package.json +1 -1
- package/widget/widget.js +200 -152
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
51
|
-
fitMore = Math.max(0, Math.
|
|
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:
|
|
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
|
-
//
|
|
307
|
-
//
|
|
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
|
-
|
|
318
|
-
targetHour = Math.max(startHour,
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
{
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
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:
|
|
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:
|
|
1786
|
-
height:
|
|
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:
|
|
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.
|
|
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": [
|