@nomideusz/svelte-calendar 0.16.1 → 0.20.1

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
@@ -33,10 +33,13 @@ That's it — 6 views (Day/Week × Planner, Agenda, Mobile), auto-coloring, drag
33
33
 
34
34
  ## Views
35
35
 
36
- Switch between **Planner** (time grid), **Agenda** (list) and the **Month** grid:
36
+ Switch between **Planner** (time grid), **Scroll** (weeks stacked in one
37
+ vertical scroller — drag to move, drop onto a day, scrolls under a drag),
38
+ **Agenda** (list) and the **Month** grid:
37
39
 
38
40
  ```svelte
39
41
  <Calendar {adapter} view="week-planner" /> <!-- default -->
42
+ <Calendar {adapter} view="week-scroll" />
40
43
  <Calendar {adapter} view="day-planner" />
41
44
  <Calendar {adapter} view="week-agenda" />
42
45
  <Calendar {adapter} view="day-agenda" />
@@ -760,7 +763,9 @@ Small helpers used by the built-in views, exported for custom rendering:
760
763
  | `segmentForDay(ev, dayMs)` | The slice of a multi-day event that falls on one day |
761
764
  | `createClock()` | Reactive clock (`tick`, `today`) driving now-lines and relative labels |
762
765
  | `typeset(text)` / `breakLines(text, font, width)` | Knuth-Plass paragraph breaking over the text's own spaces and soft hyphens, measured with pretext. `<p {@attach typeset(text)}>` sets justified block-span lines, re-done on resize; if the browser disagrees with the measure the plain text goes back. Progressive — SSR text stays. |
763
- | `fitLabel([long, short])` / `pickFit` / `fits` / `textHeight` | Text fitting via [pretext](https://github.com/chenglou/pretext) — measure before render, no layout thrash. `<span class="eb-title" {@attach fitLabel([ev.title, ev.short])}>` keeps the longest label that fits. Browser-only. |
766
+ | `fitLabel([long, short])` / `pickFit` / `fits` / `textHeight` / `textWidth` | Text fitting via [pretext](https://github.com/chenglou/pretext) — measure before render, no layout thrash. `<span class="eb-title" {@attach fitLabel([ev.title, ev.short])}>` keeps the longest label that fits. Browser-only. |
767
+ | `createRecurringAdapter(schedule, { movable })` | Projects a repeating schedule. Occurrences are read-only unless `movable` is set — the adapter stores nothing, so a host that opts in must handle `oneventmove` itself: add the date to the rule's `excludeDates` and keep the moved occurrence wherever its one-off events live. |
768
+ | `fitParts(parts, budget)` | Which parts of a dense chip fit, along either axis. Each part is `{ key, text, font }` to measure or `{ key, size }` to state its cost, plus `priority` (`0` never drops, higher goes first) and `extra` for a gap or icon. Anchors always show — they are what earns the ellipsis — and the first part that does not fit ends it, so a chip never keeps a later detail after dropping an earlier one. A budget of `0` (server-rendered, not yet measured) leaves the anchors. |
764
769
 
765
770
  ## Embeddable Widget
766
771
 
@@ -41,8 +41,13 @@ export function createCompositeAdapter(adapters, options = {}) {
41
41
  try {
42
42
  return await adapter.updateEvent(id, patch);
43
43
  }
44
- catch {
45
- // Event not in this adapter, try next
44
+ catch (e) {
45
+ // A refusal is an answer: the adapter that owns this event
46
+ // cannot write it, and asking the others would turn that
47
+ // into a meaningless "not found" the host cannot act on.
48
+ if (e instanceof Error && e.message.includes('read-only'))
49
+ throw e;
50
+ // Otherwise: not this adapter's event, try the next.
46
51
  }
47
52
  }
48
53
  throw new Error(`Event not found in any adapter: ${id}`);
@@ -43,6 +43,12 @@ export interface RecurringEvent {
43
43
  * No events are generated after this date.
44
44
  */
45
45
  until?: string;
46
+ /**
47
+ * Dates this rule skips, `"YYYY-MM-DD"`. The occurrence is simply not
48
+ * projected — this is how a host takes one occurrence out of its rule
49
+ * (moved, cancelled, or replaced by a one-off it stores itself).
50
+ */
51
+ excludeDates?: string[];
46
52
  /**
47
53
  * Maximum number of occurrences, counted from `startDate`.
48
54
  * Alternative to `until` — if both are set the stricter bound wins.
@@ -74,6 +80,16 @@ export interface RecurringAdapterOptions {
74
80
  * Defaults to the built-in vivid palette.
75
81
  */
76
82
  palette?: string[];
83
+ /**
84
+ * Let projected occurrences be dragged (default: `false`).
85
+ *
86
+ * Off, every occurrence carries `data.readOnly` and the views refuse to
87
+ * move it — correct, because this adapter projects and cannot store the
88
+ * result. Turn it on when YOU can: handle `oneventmove`, add that date to
89
+ * the rule's `excludeDates`, and keep the moved occurrence wherever your
90
+ * one-off events live. A read-only occurrence can always be clicked.
91
+ */
92
+ movable?: boolean;
77
93
  }
78
94
  /**
79
95
  * Create a CalendarAdapter that projects recurring events onto concrete
@@ -70,7 +70,7 @@ function createConcreteEvent(rec, date) {
70
70
  tags: rec.tags,
71
71
  location: rec.location,
72
72
  resourceId: rec.resourceId,
73
- data: { ...rec.data, recurringId: rec.id, readOnly: true },
73
+ data: { ...rec.data, recurringId: rec.id },
74
74
  };
75
75
  }
76
76
  // ── Count → until resolution ────────────────────────────
@@ -232,7 +232,7 @@ const PALETTE = VIVID_PALETTE;
232
232
  * Read-only by default — create/update/delete throw.
233
233
  */
234
234
  export function createRecurringAdapter(schedule, options = {}) {
235
- const { mondayStart = true, palette } = options;
235
+ const { mondayStart = true, palette, movable = false } = options;
236
236
  const colors = palette?.length ? palette : PALETTE;
237
237
  // Auto-color: assign from palette by category/title
238
238
  const colorAssignments = new Map();
@@ -282,14 +282,30 @@ export function createRecurringAdapter(schedule, options = {}) {
282
282
  break;
283
283
  }
284
284
  }
285
- return events;
285
+ // Dates a host has taken out of their rule (see `excludeDates`).
286
+ const skipped = new Set();
287
+ for (const rec of schedule) {
288
+ for (const d of rec.excludeDates ?? [])
289
+ skipped.add(`${rec.id}--${d.replace(/-/g, '')}`);
290
+ }
291
+ const kept = skipped.size ? events.filter((e) => !skipped.has(e.id)) : events;
292
+ return movable ? kept : kept.map((e) => ({ ...e, data: { ...e.data, readOnly: true } }));
286
293
  };
287
294
  return {
288
295
  fetchEventsSync,
289
296
  async fetchEvents(range) {
290
297
  return fetchEventsSync(range);
291
298
  },
292
- // Read-only adapter: CRUD methods intentionally omitted.
293
- // Use createMemoryAdapter or createRestAdapter for mutations.
299
+ // This adapter projects; it stores nothing. It still answers for the
300
+ // occurrences it owns, so a composite can tell "not my event" apart
301
+ // from "mine, and it cannot be written" — the second is what lets the
302
+ // HOST take the move (exclude the date, keep the result itself).
303
+ async updateEvent(id) {
304
+ const ruleId = id.split('--')[0];
305
+ if (!schedule.some((rec) => rec.id === ruleId)) {
306
+ throw new Error(`Event not found: ${id}`);
307
+ }
308
+ throw new Error(`read-only: ${id} is a projected occurrence. Add its date to the rule's excludeDates and store the change yourself.`);
309
+ },
294
310
  };
295
311
  }
@@ -27,6 +27,7 @@ import { getLabels, fmtWeekRange } from "../core/locale.js";
27
27
  import { auto } from "../theme/presets.js";
28
28
  import { probeHostTheme, observeHostTheme } from "../theme/auto.js";
29
29
  import Planner from "../views/planner/Planner.svelte";
30
+ import PlannerScroll from "../views/planner/PlannerScroll.svelte";
30
31
  import Agenda from "../views/agenda/Agenda.svelte";
31
32
  import Mobile from "../views/mobile/Mobile.svelte";
32
33
  import MonthGrid from "../views/month/MonthGrid.svelte";
@@ -47,6 +48,12 @@ const DEFAULT_VIEWS = [
47
48
  mode: "week",
48
49
  component: Planner
49
50
  },
51
+ {
52
+ id: "week-scroll",
53
+ label: "Scroll",
54
+ mode: "week",
55
+ component: PlannerScroll
56
+ },
50
57
  {
51
58
  id: "day-agenda",
52
59
  label: "Agenda",
@@ -78,7 +85,7 @@ const DEFAULT_VIEWS = [
78
85
  component: MonthGrid
79
86
  }
80
87
  ];
81
- let { adapter, views = DEFAULT_VIEWS, view: activeViewId, theme = auto, autoTheme, mondayStart = true, height: heightProp = 600, borderRadius = 12, dir, locale, labels: labelsProp, readOnly = false, visibleHours, initialDate, snapInterval = 15, minColumnWidth = 110, showModePills = true, showNavigation = true, equalDays = false, showDates = true, hideDays, currentDate, blockedSlots, days, minDuration, maxDuration, disabledDates, compact = false, columns = false, mobile: mobileProp = "auto", event: eventSnippet, empty: emptySnippet, dayHeader: dayHeaderSnippet, header: headerSnippet, navigation: navigationSnippet, oneventclick, oneventcreate, oneventmove, onviewchange, ondatechange, oneventhover, ondayclick, onerror, timezone } = $props();
88
+ let { adapter, views = DEFAULT_VIEWS, view: activeViewId, theme = auto, autoTheme, mondayStart = true, height: heightProp = 600, borderRadius = 12, dir, locale, labels: labelsProp, readOnly = false, visibleHours, initialDate, snapInterval = 15, minColumnWidth = 110, showModePills = true, showNavigation = true, equalDays = false, showDates = true, hideDays, currentDate, blockedSlots, days, minDuration, maxDuration, disabledDates, compact = false, columns = false, mobile: mobileProp = "auto", event: eventSnippet, empty: emptySnippet, dayHeader: dayHeaderSnippet, header: headerSnippet, navigation: navigationSnippet, oneventclick, oneventcreate, onexternaldrop, oneventmove, onviewchange, ondatechange, oneventhover, ondayclick, onerror, timezone } = $props();
82
89
  // In readOnly mode, suppress mutation callbacks. With a timezone, the
83
90
  // drag plane is zoned wall-clock — hosts always receive real instants.
84
91
  const unzone = (d) => timezone ? fromZonedTime(d, timezone) : d;
@@ -86,6 +93,10 @@ const effectiveCreate = $derived(readOnly || !oneventcreate ? undefined : (range
86
93
  start: unzone(range.start),
87
94
  end: unzone(range.end)
88
95
  }));
96
+ const effectiveExternalDrop = $derived(readOnly || !onexternaldrop ? undefined : (info) => onexternaldrop({
97
+ start: unzone(info.start),
98
+ dataTransfer: info.dataTransfer
99
+ }));
89
100
  const effectiveMove = $derived(readOnly || !oneventmove ? undefined : (ev, start, end) => oneventmove(ev, unzone(start), unzone(end)));
90
101
  // Clicking an event selects it (highlight via selectedEventId) and then
91
102
  // notifies the host — selection used to be created but never driven.
@@ -701,6 +712,7 @@ const navCtx = $derived({
701
712
  focusDate={viewState.focusDate}
702
713
  oneventclick={handleEventClick}
703
714
  oneventcreate={effectiveCreate}
715
+ onexternaldrop={effectiveExternalDrop}
704
716
  readOnly={readOnly}
705
717
  visibleHours={visibleHours}
706
718
  selectedEventId={selection.selectedId}
@@ -129,6 +129,13 @@ interface Props {
129
129
  start: Date;
130
130
  end: Date;
131
131
  }) => void;
132
+ /** An HTML5 drag from outside the calendar dropped on the planner grid
133
+ * (a class chip, a template): the pointer's time, snapped, as a real
134
+ * instant — plus the drag's dataTransfer for whatever the source put in. */
135
+ onexternaldrop?: (info: {
136
+ start: Date;
137
+ dataTransfer: DataTransfer;
138
+ }) => void;
132
139
  oneventmove?: (event: TimelineEvent, newStart: Date, newEnd: Date) => void;
133
140
  onviewchange?: (viewId: CalendarViewId) => void;
134
141
  /** Called when the focused date changes (navigation, drag-scroll, etc.) */
@@ -3,7 +3,7 @@ export type { DateRange };
3
3
  /**
4
4
  * Built-in view IDs. Custom view IDs are also supported — see CalendarViewId.
5
5
  */
6
- export type BuiltInViewId = 'day-planner' | 'day-agenda' | 'day-mobile' | 'week-planner' | 'week-agenda' | 'week-mobile' | 'month-grid';
6
+ export type BuiltInViewId = 'day-planner' | 'day-agenda' | 'day-mobile' | 'week-planner' | 'week-scroll' | 'week-agenda' | 'week-mobile' | 'month-grid';
7
7
  /**
8
8
  * Any view identifier. Use built-in strings like 'day-planner' or your own
9
9
  * custom IDs like 'day-kanban', 'week-resource', etc.
package/dist/index.d.ts CHANGED
@@ -15,4 +15,5 @@ export { wrapAdapterWithTimezone } from './core/timezone.js';
15
15
  export type { PresetName, AutoThemeOptions } from './theme/index.js';
16
16
  export { createCalendar, createAgenda, createRangeAgenda } from './headless/index.js';
17
17
  export type { HeadlessCalendarOptions, HeadlessCalendar, HeadlessDay, HeadlessWeek, TodayQueue, HeaderContext, NavigationContext, AgendaOptions, HeadlessAgenda, RangeAgendaOptions, RangeAgendaDay, HeadlessRangeAgenda, } from './headless/index.js';
18
- export { fits, lineCount, textHeight, pickFit, fontOf, fitLabel, breakLines, typeset } from './text-fit.js';
18
+ export { fits, lineCount, textHeight, textWidth, pickFit, fontOf, fitLabel, fitParts, breakLines, typeset } from './text-fit.js';
19
+ export type { ChipPart } from './text-fit.js';
package/dist/index.js CHANGED
@@ -18,4 +18,4 @@ export { wrapAdapterWithTimezone } from './core/timezone.js';
18
18
  // ─── Headless API ───────────────────────────────────────
19
19
  export { createCalendar, createAgenda, createRangeAgenda } from './headless/index.js';
20
20
  // ─── Text fitting (pretext) ─────────────────────────────
21
- export { fits, lineCount, textHeight, pickFit, fontOf, fitLabel, breakLines, typeset } from './text-fit.js';
21
+ export { fits, lineCount, textHeight, textWidth, pickFit, fontOf, fitLabel, fitParts, breakLines, typeset } from './text-fit.js';
@@ -4,8 +4,38 @@ export declare function lineCount(text: string, font: string, width: number, opt
4
4
  export declare function fits(text: string, font: string, width: number, lines?: number): boolean;
5
5
  /** Height the text will take at `width`, before it is in the DOM. */
6
6
  export declare function textHeight(text: string, font: string, width: number, lineHeight: number): number;
7
+ /** Natural (unwrapped) width of `text` in `font`, px. */
8
+ export declare function textWidth(text: string, font: string): number;
7
9
  /** Longest candidate (in given order) that fits in `lines`; the last one if none does. */
8
10
  export declare function pickFit(candidates: readonly string[], font: string, width: number, lines?: number): string;
11
+ /** One part of a chip. Give `text` + `font` to measure it, or `size` to state its cost. */
12
+ export interface ChipPart {
13
+ /** Names this part in the result. */
14
+ key: string;
15
+ /** The text exactly as it will render. Empty or absent ⇒ the part never shows. */
16
+ text?: string;
17
+ /** Canvas font string for `text` — `fontOf(el)`. */
18
+ font?: string;
19
+ /** A stated cost instead of a measured one: a line's height, an icon's width. */
20
+ size?: number;
21
+ /** Dropped before lower numbers; `0` is an anchor and never drops. Default 1. */
22
+ priority?: number;
23
+ /** What this part costs beside its own size: a gap, a dot, a separator. */
24
+ extra?: number;
25
+ }
26
+ /**
27
+ * Which parts of a chip fit in `budget` px along one axis — width for a row
28
+ * of parts, height for a stack of them.
29
+ *
30
+ * Anchors (`priority: 0`) always show; they are what earns an ellipsis when
31
+ * even they overflow. The rest are added in priority order and the first one
32
+ * that does not fit ends it: a chip reads in one direction, so keeping a later
33
+ * part after dropping an earlier one reads as arbitrary.
34
+ *
35
+ * A budget of 0 — server-rendered, or before the first measurement — leaves
36
+ * the anchors, so the first paint is the important part and nothing else.
37
+ */
38
+ export declare function fitParts(parts: readonly ChipPart[], budget: number, measure?: (text: string, font: string) => number): Record<string, boolean>;
9
39
  /** The element's computed font as a canvas font string. */
10
40
  export declare function fontOf(el: Element): string;
11
41
  /**
package/dist/text-fit.js CHANGED
@@ -13,10 +13,52 @@ export function fits(text, font, width, lines = 1) {
13
13
  export function textHeight(text, font, width, lineHeight) {
14
14
  return layout(prepare(text, font), width, lineHeight).height;
15
15
  }
16
+ /** Natural (unwrapped) width of `text` in `font`, px. */
17
+ export function textWidth(text, font) {
18
+ return measureNaturalWidth(prepareWithSegments(text, font));
19
+ }
16
20
  /** Longest candidate (in given order) that fits in `lines`; the last one if none does. */
17
21
  export function pickFit(candidates, font, width, lines = 1) {
18
22
  return candidates.find((c) => fits(c, font, width, lines)) ?? candidates.at(-1) ?? '';
19
23
  }
24
+ /**
25
+ * Which parts of a chip fit in `budget` px along one axis — width for a row
26
+ * of parts, height for a stack of them.
27
+ *
28
+ * Anchors (`priority: 0`) always show; they are what earns an ellipsis when
29
+ * even they overflow. The rest are added in priority order and the first one
30
+ * that does not fit ends it: a chip reads in one direction, so keeping a later
31
+ * part after dropping an earlier one reads as arbitrary.
32
+ *
33
+ * A budget of 0 — server-rendered, or before the first measurement — leaves
34
+ * the anchors, so the first paint is the important part and nothing else.
35
+ */
36
+ export function fitParts(parts, budget, measure = textWidth) {
37
+ const shown = {};
38
+ for (const p of parts)
39
+ shown[p.key] = false;
40
+ const present = parts.filter((p) => p.size !== undefined || !!p.text);
41
+ const anchors = present.filter((p) => (p.priority ?? 1) === 0);
42
+ for (const p of anchors)
43
+ shown[p.key] = true;
44
+ if (!(budget > 0))
45
+ return shown;
46
+ const cost = (p) => (p.size ?? measure(p.text, p.font ?? '')) + (p.extra ?? 0);
47
+ let used = 0;
48
+ for (const p of anchors)
49
+ used += cost(p);
50
+ const optional = present
51
+ .filter((p) => (p.priority ?? 1) !== 0)
52
+ .sort((a, b) => (a.priority ?? 1) - (b.priority ?? 1));
53
+ for (const p of optional) {
54
+ const c = cost(p);
55
+ if (used + c > budget)
56
+ break;
57
+ used += c;
58
+ shown[p.key] = true;
59
+ }
60
+ return shown;
61
+ }
20
62
  /** The element's computed font as a canvas font string. */
21
63
  export function fontOf(el) {
22
64
  const s = getComputedStyle(el);
@@ -141,23 +183,36 @@ export function typeset(text) {
141
183
  return;
142
184
  const font = fontOf(el);
143
185
  let width = 0;
144
- const apply = () => {
145
- if (!width)
146
- return;
147
- const lines = breakLines(text, font, width);
186
+ // A line span never wraps: canvas and layout disagree by a pixel now and
187
+ // then, and a line that wrapped inside its span — the browser hyphenating
188
+ // the last word with its own dictionary, "małżeń-" / "stwo" — read as a
189
+ // hole in the paragraph. Set as nowrap, a misfit is plain overflow, which
190
+ // is measurable: re-break a touch narrower, and only then hand the text
191
+ // back to the browser.
192
+ const set = (w) => {
193
+ const lines = breakLines(text, font, w);
148
194
  el.replaceChildren(...lines.map((l, i) => {
149
195
  const s = document.createElement('span');
150
196
  s.style.display = 'block';
197
+ s.style.whiteSpace = 'nowrap';
198
+ s.style.hyphens = 'manual';
151
199
  s.style.textAlign = 'justify';
152
200
  s.style.textAlignLast = i === lines.length - 1 ? 'auto' : 'justify';
153
201
  s.textContent = l;
154
202
  return s;
155
203
  }));
156
204
  for (const s of el.children)
157
- if (s.scrollWidth > s.clientWidth + 1) {
158
- el.textContent = text;
205
+ if (s.scrollWidth > s.clientWidth + 1)
206
+ return false;
207
+ return true;
208
+ };
209
+ const apply = () => {
210
+ if (!width)
211
+ return;
212
+ for (const w of [width, width - 2, width * 0.985, width * 0.97])
213
+ if (set(w))
159
214
  return;
160
- }
215
+ el.textContent = text;
161
216
  };
162
217
  const ro = new ResizeObserver(([e]) => {
163
218
  if (e.contentRect.width === width)