@svar-ui/svelte-calendar 2.6.0 → 2.7.0

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.
Files changed (60) hide show
  1. package/dist/components/Calendar.svelte +13 -4
  2. package/dist/components/Calendar.svelte.d.ts +50 -19
  3. package/dist/components/ContextMenu.svelte +13 -5
  4. package/dist/components/Editor.svelte +29 -34
  5. package/dist/components/EventDatesForm.svelte +121 -0
  6. package/dist/components/EventDatesForm.svelte.d.ts +15 -0
  7. package/dist/components/Layout.svelte +18 -5
  8. package/dist/components/Layout.svelte.d.ts +2 -0
  9. package/dist/components/Navigation.svelte +20 -12
  10. package/dist/components/Navigation.svelte.d.ts +1 -0
  11. package/dist/components/Render/BarSection.svelte +3 -0
  12. package/dist/components/Render/EventProjection.svelte +36 -0
  13. package/dist/components/Render/EventProjection.svelte.d.ts +9 -0
  14. package/dist/components/Render/GridSection.svelte +5 -2
  15. package/dist/components/Render/GridSection.svelte.d.ts +2 -1
  16. package/dist/components/Render/ScrollableSection.svelte +22 -3
  17. package/dist/components/Render/ScrollableSection.svelte.d.ts +1 -0
  18. package/dist/components/Render/SectionContent.svelte +1 -0
  19. package/dist/components/Render/Sections.svelte +46 -9
  20. package/dist/components/Render/Sections.svelte.d.ts +1 -0
  21. package/dist/components/Render/YearSection.svelte +8 -1
  22. package/dist/components/Render/resolveEventPosition.d.ts +5 -0
  23. package/dist/components/Render/resolveEventPosition.js +35 -0
  24. package/dist/components/pro_RecurringForm.svelte +346 -0
  25. package/dist/components/pro_RecurringForm.svelte.d.ts +15 -0
  26. package/dist/components/pro_rrule-form.d.ts +23 -0
  27. package/dist/components/pro_rrule-form.js +121 -0
  28. package/dist/components/{Render/useEventOverlay.svelte.d.ts → useEventOverlay.svelte.d.ts} +1 -1
  29. package/dist/components/{Render/useEventOverlay.svelte.js → useEventOverlay.svelte.js} +1 -1
  30. package/dist/context.tx +0 -0
  31. package/dist/defaults.d.ts +18 -0
  32. package/dist/defaults.js +15 -0
  33. package/dist/directives/clickevent.d.ts +1 -0
  34. package/dist/directives/clickevent.js +11 -8
  35. package/dist/directives/drag.js +11 -7
  36. package/dist/directives/dragresize.js +6 -3
  37. package/dist/helpers/pro_export.d.ts +5 -0
  38. package/dist/helpers/pro_export.js +22 -0
  39. package/dist/index.d.ts +5 -4
  40. package/dist/index.js +4 -2
  41. package/dist/pro_export/Calendar.svelte +30 -0
  42. package/dist/pro_export/Calendar.svelte.d.ts +16 -0
  43. package/dist/pro_export/Event.svelte +5 -0
  44. package/dist/pro_export/Event.svelte.d.ts +11 -0
  45. package/dist/pro_export.d.ts +3 -0
  46. package/dist/pro_export.js +15 -0
  47. package/dist/themes/Print.svelte +89 -0
  48. package/dist/themes/Print.svelte.d.ts +13 -0
  49. package/dist/themes/PrintBW.svelte +111 -0
  50. package/dist/themes/PrintBW.svelte.d.ts +13 -0
  51. package/dist/{components/types.d.ts → types.d.ts} +5 -0
  52. package/package.json +17 -17
  53. package/readme.md +2 -2
  54. package/dist/Demo.svelte +0 -7
  55. package/dist/Demo.svelte.d.ts +0 -26
  56. package/dist/components/editorItems.d.ts +0 -15
  57. package/dist/components/editorItems.js +0 -20
  58. package/dist/init.d.ts +0 -1
  59. package/dist/init.js +0 -5
  60. /package/dist/{components/types.js → types.js} +0 -0
@@ -0,0 +1,36 @@
1
+ <script lang="ts">const { primitives, dx, dy } = $props();
2
+ const gap = 2;
3
+ function style(primitive) {
4
+ const left = primitive.x * dx + gap;
5
+ const top = primitive.y * dy + gap;
6
+ const width = Math.max(2, primitive.width * dx - gap * 2);
7
+ const height = Math.max(2, primitive.height * dy - gap * 2);
8
+ return `left:${left}px;top:${top}px;width:${width}px;height:${height}px`;
9
+ }
10
+ export {};
11
+ </script>
12
+
13
+ <div class="wx-event-placeholders" aria-hidden="true">
14
+ {#each primitives as primitive (primitive.id)}
15
+ <div class="wx-event-placeholder" style={style(primitive)}></div>
16
+ {/each}
17
+ </div>
18
+
19
+ <style>
20
+ .wx-event-placeholders {
21
+ position: absolute;
22
+ inset: 0;
23
+ z-index: 5;
24
+ pointer-events: none;
25
+ }
26
+
27
+ .wx-event-placeholder {
28
+ position: absolute;
29
+ box-sizing: border-box;
30
+ min-width: 2px;
31
+ min-height: 2px;
32
+ border: 2px solid var(--wx-color-primary);
33
+ border-radius: var(--wx-border-radius);
34
+ background: color-mix(in srgb, var(--wx-color-primary) 20%, var(--wx-background) 30%, transparent);
35
+ }
36
+ </style>
@@ -0,0 +1,9 @@
1
+ import type { Primitive } from "@svar-ui/calendar-store";
2
+ type $$ComponentProps = {
3
+ primitives: Primitive[];
4
+ dx: number;
5
+ dy: number;
6
+ };
7
+ declare const EventProjection: import("svelte").Component<$$ComponentProps, {}, "">;
8
+ type EventProjection = ReturnType<typeof EventProjection>;
9
+ export default EventProjection;
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">import { setID } from "@svar-ui/lib-dom";
2
- const { primitives, cells, dx, dy, cellCss, eventCss, eventContent, view, section, onoverflow } = $props();
2
+ const { primitives, cells, dx, dy, cellCss, eventCss, eventContent, view, section, eventOverflow = "more", onoverflow } = $props();
3
3
  const laneHeight = 22;
4
4
  const gap = 2;
5
5
  const fullLaneHeight = laneHeight + gap * 2;
@@ -27,7 +27,7 @@ const rowLayout = $derived.by(() => {
27
27
  // Only reserve space for "+more" label if lanes don't all fit
28
28
  const allFit = Math.floor(groupHeight / fullLaneHeight) >= info.totalLanes;
29
29
  const maxVisible = allFit ? info.totalLanes : Math.max(0, Math.floor((groupHeight - moreLabelHeight) / fullLaneHeight));
30
- const isExpanded = expandedRows.has(y);
30
+ const isExpanded = eventOverflow === "expand" || expandedRows.has(y);
31
31
  let extraHeight = 0;
32
32
  if (isExpanded && info.totalLanes > maxVisible) {
33
33
  // Extra pixels needed beyond the normal row height
@@ -298,6 +298,9 @@ function toggleRow(y) {
298
298
  box-shadow: none;
299
299
  border-radius: 0;
300
300
  }
301
+ .wx-bar-single-day:global(.wx-dragging) {
302
+ background-color: var(--wx-background);
303
+ }
301
304
  .wx-bar-single-day:hover {
302
305
  background-color: var(--wx-color-secondary-hover);
303
306
  box-shadow: none;
@@ -1,4 +1,4 @@
1
- import type { Primitive, GridCell, CellCss, EventCss } from "@svar-ui/calendar-store";
1
+ import type { Primitive, GridCell, CellCss, EventCss, EventOverflowMode } from "@svar-ui/calendar-store";
2
2
  type $$ComponentProps = {
3
3
  primitives: Primitive[];
4
4
  cells: GridCell[];
@@ -9,6 +9,7 @@ type $$ComponentProps = {
9
9
  eventContent?: any;
10
10
  view: string;
11
11
  section: string;
12
+ eventOverflow?: EventOverflowMode;
12
13
  onoverflow?: (overflow: boolean) => void;
13
14
  };
14
15
  declare const GridSection: import("svelte").Component<$$ComponentProps, {}, "">;
@@ -1,14 +1,16 @@
1
- <script lang="ts">import { getContext, onMount } from "svelte";
1
+ <script lang="ts">import { getContext, onMount, untrack } from "svelte";
2
2
  import { drag } from "../../directives/drag.js";
3
3
  import { clickevent } from "../../directives/clickevent.js";
4
4
  import { clickdate } from "../../directives/clickdate.js";
5
5
  import { Popup } from "@svar-ui/svelte-core";
6
6
  import Headers from "./Headers.svelte";
7
7
  import SectionContent from "./SectionContent.svelte";
8
- import { useEventOverlay } from "./useEventOverlay.svelte.js";
8
+ import EventProjection from "./EventProjection.svelte";
9
+ import { resolveEventPosition } from "./resolveEventPosition.js";
10
+ import { useEventOverlay } from "../useEventOverlay.svelte.js";
9
11
  const api = getContext("calendar-api");
10
12
  const { _view } = api.getReactiveState();
11
- const { data, cellCss, eventCss, eventContent, view, tooltip, eventPopup, readonly = false } = $props();
13
+ const { data, cellCss, eventCss, eventContent, view, tooltip, eventPopup, readonly = false, eventProjection } = $props();
12
14
  const section = $derived(data[0]);
13
15
  const xHeaders = $derived(section?.xHeaders ?? null);
14
16
  const yHeaders = $derived(section?.yHeaders ?? null);
@@ -55,6 +57,16 @@ function getMinContentHeight() {
55
57
  }
56
58
  const minW = $derived(getMinContentWidth());
57
59
  const minH = $derived(getMinContentHeight());
60
+ const projection = $derived.by(() => {
61
+ if (!eventProjection || !eventProjection.htmlEvent) return null;
62
+ const event = resolveEventPosition(eventProjection.htmlEvent, eventProjection.event, section, contentEl, dx, dy, $_view, document);
63
+ if (!event) return null;
64
+ // store calculated props on the original projection object
65
+ untrack(() => {
66
+ Object.assign(eventProjection.event, event);
67
+ });
68
+ return $_view.projectEvent(event).find((item) => item.section === section.name);
69
+ });
58
70
  const overlay = useEventOverlay((id) => api.getEvent(id), () => section?.mode === "boxes" ? "right-start" : "bottom-start");
59
71
  // trackScroll exists in Popup but is missing from its .d.ts
60
72
  const popupExtra = { trackScroll: true };
@@ -124,6 +136,13 @@ const popupExtra = { trackScroll: true };
124
136
  {view}
125
137
  {tooltip}
126
138
  />
139
+ {#if projection}
140
+ <EventProjection
141
+ primitives={projection.primitives}
142
+ {dx}
143
+ {dy}
144
+ />
145
+ {/if}
127
146
  {/if}
128
147
  </div>
129
148
  </div>
@@ -8,6 +8,7 @@ type $$ComponentProps = {
8
8
  tooltip?: any;
9
9
  eventPopup?: any;
10
10
  readonly?: boolean;
11
+ eventProjection?: any;
11
12
  };
12
13
  declare const ScrollableSection: import("svelte").Component<$$ComponentProps, {}, "">;
13
14
  type ScrollableSection = ReturnType<typeof ScrollableSection>;
@@ -25,6 +25,7 @@ const { section, dx, dy, scrollHeight, measured, cellCss, eventCss, eventContent
25
25
  {eventContent}
26
26
  {view}
27
27
  section={section.name}
28
+ eventOverflow={section.ui?.eventOverflow}
28
29
  {onoverflow}
29
30
  />
30
31
  {:else}
@@ -1,14 +1,16 @@
1
- <script lang="ts">import { getContext, onMount, tick } from "svelte";
1
+ <script lang="ts">import { getContext, onMount, tick, untrack } from "svelte";
2
2
  import { drag } from "../../directives/drag.js";
3
3
  import { clickevent } from "../../directives/clickevent.js";
4
4
  import { clickdate } from "../../directives/clickdate.js";
5
5
  import { Popup } from "@svar-ui/svelte-core";
6
6
  import Headers from "./Headers.svelte";
7
7
  import SectionContent from "./SectionContent.svelte";
8
- import { useEventOverlay } from "./useEventOverlay.svelte.js";
8
+ import EventProjection from "./EventProjection.svelte";
9
+ import { resolveEventPosition } from "./resolveEventPosition.js";
10
+ import { useEventOverlay } from "../useEventOverlay.svelte.js";
9
11
  const api = getContext("calendar-api");
10
12
  const { _view } = api.getReactiveState();
11
- const { data, cellCss, eventCss, eventContent, view, tooltip, eventPopup, readonly = false } = $props();
13
+ const { data, cellCss, eventCss, eventContent, view, tooltip, eventPopup, readonly = false, eventProjection } = $props();
12
14
  let ready = $state(false);
13
15
  let sectionEls = $state({});
14
16
  let contentEls = $state({});
@@ -84,6 +86,9 @@ function getBarSectionHeight(section) {
84
86
  const lanes = p.totalLanes ?? 1;
85
87
  if (lanes > maxLanes) maxLanes = lanes;
86
88
  }
89
+ if (!maxLanes && projectionFor(section.name)) {
90
+ maxLanes = 1;
91
+ }
87
92
  return maxLanes * BAR_LANE_HEIGHT;
88
93
  }
89
94
  function dy(name, section) {
@@ -98,7 +103,24 @@ function sectionMinHeight(section) {
98
103
  }
99
104
  return getMinContentHeight(section);
100
105
  }
101
- const visibleSections = $derived(data.filter((s) => s.size !== "content-optional" || s.primitives.length > 0));
106
+ const projections = $derived.by(() => {
107
+ if (!eventProjection || !eventProjection.htmlEvent) return [];
108
+ let event;
109
+ for (let item of visibleSections) {
110
+ event = resolveEventPosition(eventProjection.htmlEvent, eventProjection.event, item, contentEls[item.name], dx(item.name), dy(item.name, item), $_view, document);
111
+ if (event) break;
112
+ }
113
+ if (!event) return [];
114
+ // store calculated props on the original projection object
115
+ untrack(() => {
116
+ Object.assign(eventProjection.event, event);
117
+ });
118
+ return $_view.projectEvent(event);
119
+ });
120
+ function projectionFor(section) {
121
+ return projections.find((item) => item.section === section);
122
+ }
123
+ const visibleSections = $derived(data.filter((s) => s.size !== "content-optional" || s.primitives.length > 0 || !!projectionFor(s.name)));
102
124
  // Sticky prefix: consecutive content-sized sections from the top.
103
125
  // The first non-content section ends the prefix; later content sections
104
126
  // (if any) scroll normally.
@@ -122,9 +144,13 @@ const stickyOffsets = $derived.by(() => {
122
144
  }
123
145
  return offsets;
124
146
  });
125
- let gridOverflow = $state(false);
126
- function onGridOverflow(overflow) {
127
- gridOverflow = overflow;
147
+ let gridOverflow = $state({});
148
+ function onGridOverflow(section, overflow) {
149
+ if (!!gridOverflow[section] === overflow) return;
150
+ gridOverflow = {
151
+ ...gridOverflow,
152
+ [section]: overflow
153
+ };
128
154
  }
129
155
  const hasYHeaders = $derived(visibleSections.some((s) => s.yHeaders !== null && s.yVisible !== false));
130
156
  const xHeaders = $derived(visibleSections.find((s) => s.xHeaders !== null && s.xVisible !== false)?.xHeaders ?? null);
@@ -160,11 +186,13 @@ const popupExtra = { trackScroll: true };
160
186
  {@const secDx = dx(section.name)}
161
187
  {@const secDy = dy(section.name, section)}
162
188
  {@const minH = sectionMinHeight(section)}
189
+ {@const projection = projectionFor(section.name)}
163
190
  <div
164
191
  class="wx-section"
165
192
  class:wx-section-last={idx === visibleSections.length - 1}
166
193
  class:wx-section-sticky={sticky}
167
- class:wx-section-grid={section.mode === "grid" && gridOverflow}
194
+ class:wx-section-grid={section.mode === "grid" &&
195
+ !!gridOverflow[section.name]}
168
196
  class:wx-has-y-headers={hasYHeaders}
169
197
  style:flex={sectionFlex(section, sticky)}
170
198
  style:min-height={minH > 0 ? `${minH}px` : undefined}
@@ -221,8 +249,17 @@ const popupExtra = { trackScroll: true };
221
249
  {eventContent}
222
250
  {view}
223
251
  {tooltip}
224
- onoverflow={section.mode === "grid" ? onGridOverflow : undefined}
252
+ onoverflow={section.mode === "grid"
253
+ ? overflow => onGridOverflow(section.name, overflow)
254
+ : undefined}
225
255
  />
256
+ {#if projection}
257
+ <EventProjection
258
+ primitives={projection.primitives}
259
+ dx={secDx}
260
+ dy={secDy}
261
+ />
262
+ {/if}
226
263
  </div>
227
264
  </div>
228
265
  {/each}
@@ -8,6 +8,7 @@ type $$ComponentProps = {
8
8
  tooltip?: any;
9
9
  eventPopup?: any;
10
10
  readonly?: boolean;
11
+ eventProjection?: any;
11
12
  };
12
13
  declare const Sections: import("svelte").Component<$$ComponentProps, {}, "">;
13
14
  type Sections = ReturnType<typeof Sections>;
@@ -85,7 +85,7 @@ function eventTitle(event) {
85
85
 
86
86
  <div
87
87
  class="wx-year-grid"
88
- style="grid-template-columns: repeat({columns}, 1fr)"
88
+ style="--wx-year-columns: {columns}"
89
89
  >
90
90
  {#each months as month (month.month)}
91
91
  <div class="wx-year-month">
@@ -163,10 +163,17 @@ function eventTitle(event) {
163
163
  <style>
164
164
  .wx-year-grid {
165
165
  display: grid;
166
+ grid-template-columns: repeat(var(--wx-year-columns, 3), 1fr);
166
167
  gap: 16px;
167
168
  padding: 16px;
168
169
  position: relative;
169
170
  }
171
+ :global(.wx-calendar--compact) {
172
+ .wx-year-grid {
173
+ display: flex;
174
+ flex-direction: column;
175
+ }
176
+ }
170
177
  .wx-month-label {
171
178
  font-weight: var(--wx-font-weight-md);
172
179
  margin-bottom: 4px;
@@ -0,0 +1,5 @@
1
+ import type { CalendarEvent, SectionResult, ViewModel } from "@svar-ui/calendar-store";
2
+ import type { CalendarPoint } from "../../types.js";
3
+ export declare function resolveEventPosition(point: CalendarPoint, event: Partial<CalendarEvent>, section: SectionResult, element: HTMLElement, dx: number, dy: number, model: ViewModel, doc: {
4
+ elementFromPoint: (x: number, y: number) => Element | null;
5
+ }): Partial<CalendarEvent> | null;
@@ -0,0 +1,35 @@
1
+ export function resolveEventPosition(point, event, section, element, dx, dy, model, doc) {
2
+ const hit = doc.elementFromPoint(point.clientX, point.clientY);
3
+ if (!element.contains(hit))
4
+ return null;
5
+ if (section.mode === "list" ||
6
+ section.mode === "year" ||
7
+ dx <= 0 ||
8
+ dy <= 0) {
9
+ return null;
10
+ }
11
+ const rect = element.getBoundingClientRect();
12
+ if (point.clientX < rect.left ||
13
+ point.clientX > rect.right ||
14
+ point.clientY < rect.top ||
15
+ point.clientY > rect.bottom) {
16
+ return null;
17
+ }
18
+ const x = Math.max(0, Math.min(99.999, (point.clientX - rect.left + element.scrollLeft) / dx));
19
+ const y = Math.max(0, Math.min(99.999, (point.clientY - rect.top + element.scrollTop) / dy));
20
+ const duration = typeof event.duration === "number" && event.duration > 0
21
+ ? event.duration
22
+ : event.start instanceof Date && event.end instanceof Date
23
+ ? event.end.getTime() - event.start.getTime()
24
+ : 0;
25
+ const positioned = model.toPositionStart(section.name, x, y, event, true);
26
+ if (!(positioned.start instanceof Date))
27
+ return null;
28
+ if (duration > 0) {
29
+ positioned.end = new Date(positioned.start.getTime() + duration);
30
+ }
31
+ else {
32
+ delete positioned.end;
33
+ }
34
+ return positioned;
35
+ }
@@ -0,0 +1,346 @@
1
+ <script>
2
+ import { getContext, setContext } from "svelte";
3
+ import {
4
+ DatePicker,
5
+ TimePicker,
6
+ Select,
7
+ Checkbox,
8
+ Counter,
9
+ } from "@svar-ui/svelte-core";
10
+ import {
11
+ parseRRule,
12
+ buildRRule,
13
+ WEEKDAYS,
14
+ weekdayOf,
15
+ weekdayName,
16
+ nthOf,
17
+ nthLabel,
18
+ } from "./pro_rrule-form";
19
+
20
+ const locale = getContext("wx-i18n");
21
+ const _ = locale ? locale.getGroup("eventCalendar") : v => v;
22
+
23
+ setContext("wx-input-id", "");
24
+
25
+ let { value, error, onchange } = $props();
26
+
27
+ const update = part => onchange({ value: { ...value, ...part } });
28
+
29
+ function sameDay(a, b) {
30
+ return (
31
+ a.getFullYear() === b.getFullYear() &&
32
+ a.getMonth() === b.getMonth() &&
33
+ a.getDate() === b.getDate()
34
+ );
35
+ }
36
+
37
+ // calendar returns the picked day at midnight; keep the current time part
38
+ function pickDate(key, day) {
39
+ if (!day) return;
40
+ const current = value[key];
41
+ const next = new Date(day);
42
+ if (current) {
43
+ next.setHours(current.getHours(), current.getMinutes(), 0, 0);
44
+ }
45
+ if (key === "start" && sameDay(value.start, value.end)) {
46
+ const end = new Date(value.end);
47
+ end.setFullYear(next.getFullYear(), next.getMonth(), next.getDate());
48
+ update({ start: next, end });
49
+ } else {
50
+ update({ [key]: next });
51
+ }
52
+ }
53
+
54
+ // time picker returns hours/minutes only; keep the current date part
55
+ function pickTime(key, time) {
56
+ const current = value[key];
57
+ if (!current) return;
58
+ const next = new Date(current);
59
+ next.setHours(time.getHours(), time.getMinutes(), 0, 0);
60
+ update({ [key]: next });
61
+ }
62
+
63
+ // local editable model; buildRRule(form) may collapse a custom setup
64
+ // into an equivalent preset string (custom / every 1 day -> FREQ=DAILY),
65
+ // so re-seeding from the incoming rrule is skipped while it equals the
66
+ // last string this component emitted - otherwise the autoSave echo
67
+ // would flip the "Custom" select back to the preset
68
+ // svelte-ignore state_referenced_locally
69
+ let form = $state(parseRRule(value?.rrule, value?.start));
70
+ // svelte-ignore state_referenced_locally
71
+ let lastRRule = value?.rrule ?? "";
72
+
73
+ $effect.pre(() => {
74
+ const incoming = value?.rrule ?? "";
75
+ if (incoming !== lastRRule) {
76
+ lastRRule = incoming;
77
+ form = parseRRule(incoming, value?.start);
78
+ }
79
+ });
80
+
81
+ function applyForm(part) {
82
+ Object.assign(form, part);
83
+ const rrule = buildRRule(form, value.start);
84
+ lastRRule = rrule;
85
+ update({ rrule });
86
+ }
87
+
88
+ function setType(type) {
89
+ const part = { type };
90
+ // pre-seed the custom block so it opens in a sensible state
91
+ if (type === "custom" && !form.weekdays.length)
92
+ part.weekdays = [weekdayOf(value.start)];
93
+ applyForm(part);
94
+ }
95
+
96
+ function setEnds(ends) {
97
+ const part = { ends };
98
+ if (ends === "until" && !form.until) {
99
+ const d = new Date(value.start);
100
+ d.setMonth(d.getMonth() + 1);
101
+ part.until = d;
102
+ }
103
+ applyForm(part);
104
+ }
105
+
106
+ function toggleDay(id, checked) {
107
+ // unchecking all days is allowed - BYDAY is then omitted and the
108
+ // rule falls back to the start date's weekday
109
+ applyForm({
110
+ weekdays: checked
111
+ ? [...form.weekdays, id]
112
+ : form.weekdays.filter(d => d !== id),
113
+ });
114
+ }
115
+
116
+ const repeatOptions = [
117
+ { id: "never", label: _("Never") },
118
+ { id: "daily", label: _("Daily") },
119
+ { id: "weekly", label: _("Weekly") },
120
+ { id: "monthly", label: _("Monthly") },
121
+ { id: "yearly", label: _("Yearly") },
122
+ { id: "workdays", label: _("Every workday") },
123
+ { id: "custom", label: _("Custom") },
124
+ ];
125
+ const unitOptions = [
126
+ { id: "DAILY", label: _("day(s)") },
127
+ { id: "WEEKLY", label: _("week(s)") },
128
+ { id: "MONTHLY", label: _("month(s)") },
129
+ { id: "YEARLY", label: _("year(s)") },
130
+ ];
131
+ const endsOptions = [
132
+ { id: "never", label: _("Never") },
133
+ { id: "count", label: _("After") },
134
+ { id: "until", label: _("On date") },
135
+ ];
136
+
137
+ function formatLabel(template, values) {
138
+ return Object.entries(values).reduce(
139
+ (label, [key, value]) => label.replace(`%${key}`, String(value)),
140
+ template
141
+ );
142
+ }
143
+
144
+ // labels follow the current start date; a stored rule is re-derived
145
+ // from the new start only on the next recurrence edit
146
+ const monthModeOptions = $derived([
147
+ {
148
+ id: "day",
149
+ label: formatLabel(_("on day %d"), {
150
+ d: value?.start?.getDate() ?? 1,
151
+ }),
152
+ },
153
+ {
154
+ id: "weekday",
155
+ label: formatLabel(_("on the %n %d"), {
156
+ n: _(nthLabel(nthOf(value?.start))),
157
+ d: _(weekdayName(weekdayOf(value?.start))),
158
+ }),
159
+ },
160
+ ]);
161
+ </script>
162
+
163
+ <div class="wx-recurrence" class:wx-error={!!error}>
164
+ <div class="wx-row">
165
+ <span class="wx-date-label">{_("Start date")}</span>
166
+ <div class="wx-date">
167
+ <DatePicker
168
+ value={value.start}
169
+ buttons={false}
170
+ onchange={ev => pickDate("start", ev.value)}
171
+ />
172
+ </div>
173
+ {#if !value.allDay}
174
+ <div class="wx-time">
175
+ <TimePicker
176
+ value={value.start}
177
+ onchange={ev => pickTime("start", ev.value)}
178
+ />
179
+ </div>
180
+ {/if}
181
+ </div>
182
+
183
+ <div class="wx-row">
184
+ <span class="wx-date-label">{_("End date")}</span>
185
+ <div class="wx-date">
186
+ <DatePicker
187
+ value={value.end}
188
+ buttons={false}
189
+ onchange={ev => pickDate("end", ev.value)}
190
+ />
191
+ </div>
192
+ {#if !value.allDay}
193
+ <div class="wx-time">
194
+ <TimePicker
195
+ value={value.end}
196
+ onchange={ev => pickTime("end", ev.value)}
197
+ />
198
+ </div>
199
+ {/if}
200
+ </div>
201
+
202
+ <div class="wx-row">
203
+ <span class="wx-date-label"></span>
204
+ <Checkbox
205
+ label={_("All day")}
206
+ value={!!value.allDay}
207
+ onchange={ev => update({ allDay: ev.value })}
208
+ />
209
+ </div>
210
+
211
+ <div class="wx-row">
212
+ <span class="wx-label">{_("Repeat")}</span>
213
+ <div class="wx-control">
214
+ <Select
215
+ value={form.type}
216
+ options={repeatOptions}
217
+ onchange={ev => setType(ev.value)}
218
+ />
219
+ </div>
220
+ </div>
221
+
222
+ {#if form.type === "custom"}
223
+ <div class="wx-row">
224
+ <span class="wx-label">{_("Every")}</span>
225
+ <div class="wx-counter">
226
+ <Counter
227
+ value={form.interval}
228
+ min={1}
229
+ max={999}
230
+ onchange={ev => !ev.input && applyForm({ interval: ev.value })}
231
+ />
232
+ </div>
233
+ <div class="wx-control">
234
+ <Select
235
+ value={form.freq}
236
+ options={unitOptions}
237
+ onchange={ev => applyForm({ freq: ev.value })}
238
+ />
239
+ </div>
240
+ </div>
241
+ {#if form.freq === "WEEKLY"}
242
+ <div class="wx-row">
243
+ <span class="wx-label"></span>
244
+ <div class="wx-days">
245
+ {#each WEEKDAYS as day (day.id)}
246
+ <Checkbox
247
+ label={_(day.label)}
248
+ value={form.weekdays.includes(day.id)}
249
+ onchange={ev => toggleDay(day.id, ev.value)}
250
+ />
251
+ {/each}
252
+ </div>
253
+ </div>
254
+ {:else if form.freq === "MONTHLY" || form.freq === "YEARLY"}
255
+ <div class="wx-row">
256
+ <span class="wx-label"></span>
257
+ <div class="wx-control">
258
+ <Select
259
+ value={form.monthMode}
260
+ options={monthModeOptions}
261
+ onchange={ev => applyForm({ monthMode: ev.value })}
262
+ />
263
+ </div>
264
+ </div>
265
+ {/if}
266
+ {/if}
267
+
268
+ {#if form.type !== "never"}
269
+ <div class="wx-row">
270
+ <span class="wx-label">{_("Ends")}</span>
271
+ <div class="wx-control">
272
+ <Select
273
+ value={form.ends}
274
+ options={endsOptions}
275
+ onchange={ev => setEnds(ev.value)}
276
+ />
277
+ </div>
278
+ {#if form.ends === "count"}
279
+ <div class="wx-counter">
280
+ <Counter
281
+ value={form.count}
282
+ min={1}
283
+ max={999}
284
+ onchange={ev => !ev.input && applyForm({ count: ev.value })}
285
+ />
286
+ </div>
287
+ <span class="wx-hint">{_("occurrence(s)")}</span>
288
+ {:else if form.ends === "until"}
289
+ <div class="wx-date">
290
+ <DatePicker
291
+ value={form.until}
292
+ onchange={ev => ev.value && applyForm({ until: ev.value })}
293
+ />
294
+ </div>
295
+ {/if}
296
+ </div>
297
+ {/if}
298
+ </div>
299
+
300
+ <style>
301
+ .wx-recurrence {
302
+ display: flex;
303
+ flex-direction: column;
304
+ gap: var(--wx-padding);
305
+ }
306
+ .wx-row {
307
+ display: flex;
308
+ align-items: center;
309
+ gap: var(--wx-padding);
310
+ }
311
+ .wx-date-label,
312
+ .wx-label {
313
+ flex: none;
314
+ width: 52px;
315
+ }
316
+ .wx-date-label {
317
+ width: 72px;
318
+ }
319
+ .wx-control {
320
+ flex: 1;
321
+ }
322
+ .wx-date {
323
+ flex: 1;
324
+ min-width: 0;
325
+ }
326
+ .wx-time {
327
+ flex: none;
328
+ width: 96px;
329
+ }
330
+ .wx-counter {
331
+ flex: none;
332
+ width: 140px;
333
+ }
334
+ .wx-days {
335
+ flex: 1;
336
+ display: flex;
337
+ flex-wrap: wrap;
338
+ gap: 4px 12px;
339
+ }
340
+ .wx-hint {
341
+ white-space: nowrap;
342
+ }
343
+ .wx-error :global(.wx-input) {
344
+ border-color: var(--wx-color-danger);
345
+ }
346
+ </style>