@r2digisolutions/components 0.9.1 → 0.9.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.
@@ -28,28 +28,24 @@
28
28
  class: className = ''
29
29
  }: ImageProps = $props();
30
30
 
31
- let isLoaded = $state(false);
32
31
  let hasError = $state(false);
33
- let activeSrc = $state(src);
32
+ let useFallback = $state(false);
33
+
34
+ const displaySrc = $derived(useFallback && fallbackSrc ? fallbackSrc : src);
34
35
 
35
36
  $effect(() => {
36
- activeSrc = src;
37
- isLoaded = false;
37
+ void src;
38
+ useFallback = false;
38
39
  hasError = false;
39
40
  });
40
41
 
41
- function handleLoad() {
42
- isLoaded = true;
43
- hasError = false;
44
- }
45
-
46
- function handleError() {
47
- if (fallbackSrc && activeSrc !== fallbackSrc) {
48
- activeSrc = fallbackSrc;
49
- } else {
50
- hasError = true;
51
- isLoaded = true;
42
+ function onError() {
43
+ if (fallbackSrc && !useFallback) {
44
+ useFallback = true;
45
+ hasError = false;
46
+ return;
52
47
  }
48
+ hasError = true;
53
49
  }
54
50
 
55
51
  const roundedClass = $derived(
@@ -78,51 +74,51 @@
78
74
  : 'object-none'
79
75
  );
80
76
 
81
- const styleStr = $derived([
82
- aspectRatio ? `aspect-ratio:${aspectRatio}` : '',
83
- width ? `width:${typeof width === 'number' ? `${width}px` : width}` : '',
84
- height ? `height:${typeof height === 'number' ? `${height}px` : height}` : ''
85
- ].filter(Boolean).join(';'));
77
+ const styleStr = $derived(
78
+ [
79
+ aspectRatio ? `aspect-ratio:${aspectRatio}` : '',
80
+ width ? `width:${typeof width === 'number' ? `${width}px` : width}` : '',
81
+ height ? `height:${typeof height === 'number' ? `${height}px` : height}` : ''
82
+ ]
83
+ .filter(Boolean)
84
+ .join(';')
85
+ );
86
86
  </script>
87
87
 
88
88
  <div
89
89
  class={['relative overflow-hidden bg-surface-overlay', roundedClass, className]}
90
90
  style={styleStr || undefined}
91
91
  >
92
- {#if !isLoaded && !hasError}
93
- <!-- Skeleton shimmer -->
94
- <div class="absolute inset-0 animate-pulse bg-surface-overlay">
95
- <div class="h-full w-full bg-gradient-to-r from-surface-overlay via-border to-surface-overlay bg-[length:200%_100%] animate-[shimmer_1.5s_ease-in-out_infinite]"></div>
96
- </div>
97
- {/if}
98
-
99
92
  {#if hasError}
100
- <!-- Error placeholder -->
101
- <div class="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-surface-overlay text-muted">
102
- <svg class="h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
103
- <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M13.5 10.5h.008v.008H13.5V10.5zm-7.5 9h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 006 21.75z" />
93
+ <div
94
+ class="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-surface-overlay text-muted"
95
+ >
96
+ <svg
97
+ class="h-8 w-8"
98
+ viewBox="0 0 24 24"
99
+ fill="none"
100
+ stroke="currentColor"
101
+ stroke-width="1.5"
102
+ aria-hidden="true"
103
+ >
104
+ <path
105
+ stroke-linecap="round"
106
+ stroke-linejoin="round"
107
+ d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M13.5 10.5h.008v.008H13.5V10.5zm-7.5 9h15a2.25 2.25 0 002.25-2.25V6.75A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25v10.5A2.25 2.25 0 006 21.75z"
108
+ />
104
109
  </svg>
105
110
  <span class="text-xs">{alt || 'Image not found'}</span>
106
111
  </div>
107
112
  {:else}
108
- <img
109
- src={activeSrc}
110
- {alt}
111
- {loading}
112
- onload={handleLoad}
113
- onerror={handleError}
114
- class={[
115
- 'block h-full w-full transition-opacity duration-300',
116
- objectFitClass,
117
- isLoaded ? 'opacity-100' : 'opacity-0'
118
- ]}
119
- />
113
+ {#key displaySrc}
114
+ <img
115
+ src={displaySrc}
116
+ {alt}
117
+ {loading}
118
+ decoding="async"
119
+ onerror={onError}
120
+ class={['block h-full w-full', objectFitClass]}
121
+ />
122
+ {/key}
120
123
  {/if}
121
124
  </div>
122
-
123
- <style>
124
- @keyframes shimmer {
125
- 0% { background-position: 200% 0; }
126
- 100% { background-position: -200% 0; }
127
- }
128
- </style>
@@ -11,7 +11,7 @@
11
11
  label?: string;
12
12
  placeholder?: string;
13
13
  value?: string;
14
- type?: 'text' | 'email' | 'password' | 'number' | 'search' | 'tel' | 'url' | 'date';
14
+ type?: 'text' | 'email' | 'password' | 'number' | 'search' | 'tel' | 'url' | 'date' | 'time';
15
15
  status?: InputStatus;
16
16
  helperText?: string;
17
17
  disabled?: boolean;
@@ -7,7 +7,7 @@ interface InputProps {
7
7
  label?: string;
8
8
  placeholder?: string;
9
9
  value?: string;
10
- type?: 'text' | 'email' | 'password' | 'number' | 'search' | 'tel' | 'url' | 'date';
10
+ type?: 'text' | 'email' | 'password' | 'number' | 'search' | 'tel' | 'url' | 'date' | 'time';
11
11
  status?: InputStatus;
12
12
  helperText?: string;
13
13
  disabled?: boolean;
@@ -24,8 +24,13 @@
24
24
  }: SectionTitleProps = $props();
25
25
  </script>
26
26
 
27
- <div class={['flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between', className]}>
28
- <div class="min-w-0 space-y-1">
27
+ <div
28
+ class={[
29
+ 'flex w-full flex-col gap-3 sm:flex-row sm:items-start sm:justify-between',
30
+ className
31
+ ]}
32
+ >
33
+ <div class="min-w-0 flex-1 space-y-1">
29
34
  {#if eyebrow}
30
35
  <p class="text-xs font-semibold uppercase tracking-wider text-brand-600 dark:text-brand-400">
31
36
  {eyebrow}
@@ -37,7 +42,7 @@
37
42
  {/if}
38
43
  </div>
39
44
  {#if actions}
40
- <div class="flex shrink-0 items-center gap-2">
45
+ <div class="flex shrink-0 items-center justify-end gap-2 self-end sm:ml-auto sm:self-start">
41
46
  {@render actions()}
42
47
  </div>
43
48
  {/if}
@@ -20,7 +20,7 @@
20
20
 
21
21
  <nav class={['w-full', className]} aria-label="Breadcrumb">
22
22
  <ol class="flex flex-wrap items-center gap-1.5 text-sm">
23
- {#each items as item, index (item.id)}
23
+ {#each items as item, index (item.id ?? `breadcrumb-${index}`)}
24
24
  <li class="inline-flex items-center gap-1.5">
25
25
  {#if index > 0}
26
26
  <svg class="h-3.5 w-3.5 text-secondary" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
@@ -67,7 +67,7 @@
67
67
  {#if header}
68
68
  <div
69
69
  class={[
70
- 'border-b border-border',
70
+ 'w-full border-b border-border',
71
71
  chrome && 'bg-surface/40',
72
72
  paddingClasses[padding]
73
73
  ]}
@@ -77,7 +77,12 @@
77
77
  {/if}
78
78
 
79
79
  {#if children}
80
- <div class={['min-w-0 flex-1', padding !== 'none' ? paddingClasses[padding] : '']}>
80
+ <div
81
+ class={[
82
+ 'flex min-w-0 flex-1 flex-col',
83
+ padding !== 'none' ? paddingClasses[padding] : ''
84
+ ]}
85
+ >
81
86
  {@render children()}
82
87
  </div>
83
88
  {/if}
@@ -25,6 +25,6 @@ interface DateTimePickerProps {
25
25
  value: string;
26
26
  }) => void;
27
27
  }
28
- declare const DateTimePicker: import("svelte").Component<DateTimePickerProps, {}, "date" | "value" | "time" | "open">;
28
+ declare const DateTimePicker: import("svelte").Component<DateTimePickerProps, {}, "date" | "time" | "value" | "open">;
29
29
  type DateTimePicker = ReturnType<typeof DateTimePicker>;
30
30
  export default DateTimePicker;
@@ -18,6 +18,6 @@ interface FormDateTimePickerProps {
18
18
  value: string;
19
19
  }) => void;
20
20
  }
21
- declare const FormDateTimePicker: import("svelte").Component<FormDateTimePickerProps, {}, "date" | "value" | "time">;
21
+ declare const FormDateTimePicker: import("svelte").Component<FormDateTimePickerProps, {}, "date" | "time" | "value">;
22
22
  type FormDateTimePicker = ReturnType<typeof FormDateTimePicker>;
23
23
  export default FormDateTimePicker;
@@ -43,7 +43,7 @@
43
43
  label?: string;
44
44
  placeholder?: string;
45
45
  value?: string;
46
- type?: 'text' | 'email' | 'password' | 'number' | 'search' | 'tel' | 'url';
46
+ type?: 'text' | 'email' | 'password' | 'number' | 'search' | 'tel' | 'url' | 'date' | 'time';
47
47
  status?: FormFieldStatus;
48
48
  helperText?: string;
49
49
  errorMessage?: string;
@@ -32,7 +32,7 @@ interface FormFieldProps {
32
32
  label?: string;
33
33
  placeholder?: string;
34
34
  value?: string;
35
- type?: 'text' | 'email' | 'password' | 'number' | 'search' | 'tel' | 'url';
35
+ type?: 'text' | 'email' | 'password' | 'number' | 'search' | 'tel' | 'url' | 'date' | 'time';
36
36
  status?: FormFieldStatus;
37
37
  helperText?: string;
38
38
  errorMessage?: string;
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { onDestroy } from 'svelte';
2
+ import { onDestroy, type Snippet } from 'svelte';
3
3
  import Button from '../../atoms/Button/Button.svelte';
4
4
  import IconButton from '../../atoms/IconButton/IconButton.svelte';
5
5
  import Badge from '../../atoms/Badge/Badge.svelte';
@@ -7,6 +7,7 @@
7
7
  import SegmentedControl from '../../molecules/SegmentedControl/SegmentedControl.svelte';
8
8
  import SearchInput from '../../molecules/SearchInput/SearchInput.svelte';
9
9
  import Text from '../../atoms/Text/Text.svelte';
10
+ import Stack from '../../atoms/Stack/Stack.svelte';
10
11
  import ChevronLeft from '@lucide/svelte/icons/chevron-left';
11
12
  import ChevronRight from '@lucide/svelte/icons/chevron-right';
12
13
  import Plus from '@lucide/svelte/icons/plus';
@@ -40,6 +41,25 @@
40
41
  description?: string;
41
42
  }
42
43
 
44
+ export interface CalendarAppLabels {
45
+ create?: string;
46
+ myCalendars?: string;
47
+ today?: string;
48
+ search?: string;
49
+ month?: string;
50
+ week?: string;
51
+ day?: string;
52
+ agenda?: string;
53
+ allDay?: string;
54
+ eventsVisible?: string;
55
+ noEvents?: string;
56
+ dayEvents?: string;
57
+ close?: string;
58
+ previous?: string;
59
+ next?: string;
60
+ back?: string;
61
+ }
62
+
43
63
  interface CalendarAppProps {
44
64
  events?: CalendarAppEvent[];
45
65
  calendars?: CalendarSource[];
@@ -51,6 +71,12 @@
51
71
  showSidebar?: boolean;
52
72
  showSearch?: boolean;
53
73
  showCreate?: boolean;
74
+ /** Right detail panel (day agenda / event detail) on lg+ */
75
+ showDetailPanel?: boolean;
76
+ /** i18n-friendly UI strings; English defaults when omitted */
77
+ labels?: CalendarAppLabels;
78
+ /** Locale for toLocaleDateString (default `'en'`) */
79
+ locale?: string;
54
80
  weekStartsOn?: 0 | 1;
55
81
  /** Allow resizing timed events in week / day views */
56
82
  resizable?: boolean;
@@ -61,6 +87,8 @@
61
87
  /** Minimum event duration when resizing (minutes) */
62
88
  resizeMinMinutes?: number;
63
89
  class?: string;
90
+ /** Host actions (Edit / Delete / Open) under event detail */
91
+ eventActions?: Snippet<[CalendarAppEvent]>;
64
92
  onviewchange?: (view: CalendarView) => void;
65
93
  ondatechange?: (date: Date) => void;
66
94
  oneventclick?: (event: CalendarAppEvent) => void;
@@ -77,6 +105,25 @@
77
105
  ) => void;
78
106
  }
79
107
 
108
+ const DEFAULT_LABELS: Required<CalendarAppLabels> = {
109
+ create: 'Create',
110
+ myCalendars: 'My calendars',
111
+ today: 'Today',
112
+ search: 'Search events…',
113
+ month: 'Month',
114
+ week: 'Week',
115
+ day: 'Day',
116
+ agenda: 'Agenda',
117
+ allDay: 'All day',
118
+ eventsVisible: '{n} events visible',
119
+ noEvents: 'No events',
120
+ dayEvents: 'Day events',
121
+ close: 'Close',
122
+ previous: 'Previous',
123
+ next: 'Next',
124
+ back: 'Back'
125
+ };
126
+
80
127
  let {
81
128
  events = $bindable([] as CalendarAppEvent[]),
82
129
  calendars = $bindable([
@@ -91,12 +138,16 @@
91
138
  showSidebar = true,
92
139
  showSearch = true,
93
140
  showCreate = true,
141
+ showDetailPanel = true,
142
+ labels = undefined,
143
+ locale = 'en',
94
144
  weekStartsOn = 1,
95
145
  resizable = true,
96
146
  draggableEvents = true,
97
147
  resizeSnapMinutes = 15,
98
148
  resizeMinMinutes = 15,
99
149
  class: className = '',
150
+ eventActions,
100
151
  onviewchange,
101
152
  ondatechange,
102
153
  oneventclick,
@@ -107,6 +158,8 @@
107
158
  onmove
108
159
  }: CalendarAppProps = $props();
109
160
 
161
+ const ui = $derived({ ...DEFAULT_LABELS, ...labels });
162
+
110
163
  const weekdayLabels = $derived(
111
164
  weekStartsOn === 1
112
165
  ? ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
@@ -194,12 +247,12 @@
194
247
  }
195
248
 
196
249
  const monthTitle = $derived(
197
- date.toLocaleDateString('en', { month: 'long', year: 'numeric' })
250
+ date.toLocaleDateString(locale, { month: 'long', year: 'numeric' })
198
251
  );
199
252
 
200
253
  const headerTitle = $derived.by(() => {
201
254
  if (view === 'day') {
202
- return date.toLocaleDateString('en', {
255
+ return date.toLocaleDateString(locale, {
203
256
  weekday: 'long',
204
257
  month: 'long',
205
258
  day: 'numeric',
@@ -211,14 +264,26 @@
211
264
  const end = addDays(start, 6);
212
265
  const sameMonth = start.getMonth() === end.getMonth();
213
266
  if (sameMonth) {
214
- return `${start.toLocaleDateString('en', { month: 'long' })} ${start.getDate()}–${end.getDate()}, ${end.getFullYear()}`;
267
+ return `${start.toLocaleDateString(locale, { month: 'long' })} ${start.getDate()}–${end.getDate()}, ${end.getFullYear()}`;
215
268
  }
216
- return `${start.toLocaleDateString('en', { month: 'short', day: 'numeric' })} – ${end.toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' })}`;
269
+ return `${start.toLocaleDateString(locale, { month: 'short', day: 'numeric' })} – ${end.toLocaleDateString(locale, { month: 'short', day: 'numeric', year: 'numeric' })}`;
217
270
  }
218
- if (view === 'agenda') return 'Agenda';
271
+ if (view === 'agenda') return ui.agenda;
219
272
  return monthTitle;
220
273
  });
221
274
 
275
+ const focusDayHeading = $derived(
276
+ date.toLocaleDateString(locale, {
277
+ weekday: 'long',
278
+ month: 'long',
279
+ day: 'numeric'
280
+ })
281
+ );
282
+
283
+ const focusDayEvents = $derived(eventsOn(focusKey));
284
+
285
+ const eventsVisibleLabel = $derived(ui.eventsVisible.replace('{n}', String(filteredEvents.length)));
286
+
222
287
  const monthCells = $derived.by(() => {
223
288
  const y = date.getFullYear();
224
289
  const m = date.getMonth();
@@ -255,7 +320,7 @@
255
320
  return keys
256
321
  .map((key) => ({
257
322
  key,
258
- label: parseIso(key).toLocaleDateString('en', {
323
+ label: parseIso(key).toLocaleDateString(locale, {
259
324
  weekday: 'long',
260
325
  month: 'short',
261
326
  day: 'numeric'
@@ -265,9 +330,7 @@
265
330
  .filter((g) => g.events.length > 0);
266
331
  });
267
332
 
268
- const selectedEvent = $derived(
269
- filteredEvents.find((e) => e.id === selectedEventId) ?? null
270
- );
333
+ let selectedEvent = $state<CalendarAppEvent | null>(null);
271
334
 
272
335
  function setView(v: CalendarView) {
273
336
  view = v;
@@ -294,17 +357,34 @@
294
357
  }
295
358
 
296
359
  function selectEvent(e: CalendarAppEvent) {
360
+ selectedEvent = {
361
+ id: e.id,
362
+ title: e.title,
363
+ date: e.date,
364
+ endDate: e.endDate,
365
+ startTime: e.startTime,
366
+ endTime: e.endTime,
367
+ allDay: e.allDay,
368
+ calendarId: e.calendarId,
369
+ color: e.color,
370
+ location: e.location,
371
+ description: e.description
372
+ };
297
373
  selectedEventId = e.id;
298
374
  oneventclick?.(e);
299
375
  }
300
376
 
377
+ function clearSelectedEvent() {
378
+ selectedEvent = null;
379
+ selectedEventId = null;
380
+ }
301
381
  function toggleCal(id: string, visible: boolean) {
302
382
  calendars = calendars.map((c) => (c.id === id ? { ...c, visible } : c));
303
383
  oncalendartoggle?.(id, visible);
304
384
  }
305
385
 
306
386
  function timeLabel(e: CalendarAppEvent) {
307
- if (e.allDay || (!e.startTime && !e.endTime)) return 'All day';
387
+ if (e.allDay || (!e.startTime && !e.endTime)) return ui.allDay;
308
388
  if (e.startTime && e.endTime) return `${e.startTime}–${e.endTime}`;
309
389
  return e.startTime ?? '';
310
390
  }
@@ -560,12 +640,14 @@
560
640
  }
561
641
 
562
642
  function attachDragListeners() {
643
+ if (typeof document === 'undefined') return;
563
644
  document.addEventListener('pointermove', onDocDragMove, true);
564
645
  document.addEventListener('pointerup', onDocDragUp, true);
565
646
  document.addEventListener('pointercancel', onDocDragCancel, true);
566
647
  }
567
648
 
568
649
  function detachDragListeners() {
650
+ if (typeof document === 'undefined') return;
569
651
  document.removeEventListener('pointermove', onDocDragMove, true);
570
652
  document.removeEventListener('pointerup', onDocDragUp, true);
571
653
  document.removeEventListener('pointercancel', onDocDragCancel, true);
@@ -664,66 +746,86 @@
664
746
 
665
747
  <div
666
748
  class={[
667
- 'flex min-h-[36rem] overflow-hidden rounded-2xl border border-border bg-surface shadow-sm',
749
+ 'flex h-full min-h-0 overflow-hidden rounded-2xl border border-border bg-surface shadow-sm',
668
750
  className
669
751
  ]}
670
752
  >
671
753
  {#if showSidebar}
672
- <aside class="hidden w-56 shrink-0 flex-col border-r border-border bg-surface-elevated p-3 md:flex">
673
- {#if showCreate}
674
- <Button
675
- size="sm"
676
- class="mb-4 w-full"
677
- onclick={() => oncreate?.(focusKey)}
754
+ <aside
755
+ class="hidden w-56 shrink-0 flex-col overflow-hidden border-r border-border bg-surface-elevated md:flex"
756
+ >
757
+ <div class="min-h-0 flex-1 overflow-y-auto overscroll-contain p-3">
758
+ {#if showCreate}
759
+ <Button
760
+ size="sm"
761
+ class="mb-4 w-full"
762
+ onclick={() => oncreate?.(focusKey)}
763
+ >
764
+ <Plus class="h-4 w-4" strokeWidth={2} />
765
+ {ui.create}
766
+ </Button>
767
+ {/if}
768
+
769
+ <Text
770
+ as="p"
771
+ size="xs"
772
+ tone="muted"
773
+ class="mb-2 px-1 font-semibold tracking-wider uppercase"
678
774
  >
679
- <Plus class="h-4 w-4" strokeWidth={2} />
680
- Create
681
- </Button>
682
- {/if}
775
+ {ui.myCalendars}
776
+ </Text>
777
+ <ul class="space-y-0.5">
778
+ {#each calendars as cal (cal.id)}
779
+ <li>
780
+ <div
781
+ class="flex cursor-pointer items-center gap-2.5 rounded-lg px-1.5 py-1.5 hover:bg-surface-overlay"
782
+ role="button"
783
+ tabindex="0"
784
+ onclick={() => toggleCal(cal.id, cal.visible === false)}
785
+ onkeydown={(e) => {
786
+ if (e.key === 'Enter' || e.key === ' ') {
787
+ e.preventDefault();
788
+ toggleCal(cal.id, cal.visible === false);
789
+ }
790
+ }}
791
+ >
792
+ <Checkbox
793
+ size="md"
794
+ checked={cal.visible !== false}
795
+ aria-label={cal.label}
796
+ onchange={(v) => toggleCal(cal.id, v)}
797
+ onclick={(e) => e.stopPropagation()}
798
+ />
799
+ <span
800
+ class={['h-2.5 w-2.5 shrink-0 rounded-full', toneClass[cal.color ?? 'brand']]}
801
+ aria-hidden="true"
802
+ ></span>
803
+ <Text as="span" size="sm" tone="primary" class="min-w-0 flex-1 truncate font-medium">
804
+ {cal.label}
805
+ </Text>
806
+ </div>
807
+ </li>
808
+ {/each}
809
+ </ul>
810
+ </div>
683
811
 
684
- <p class="mb-2 px-1 text-[10px] font-semibold tracking-wider text-muted uppercase">
685
- My calendars
686
- </p>
687
- <ul class="space-y-1">
688
- {#each calendars as cal (cal.id)}
689
- <li>
690
- <label
691
- class="flex cursor-pointer items-center gap-2 rounded-lg px-1.5 py-1.5 hover:bg-surface-overlay"
692
- >
693
- <Checkbox
694
- size="sm"
695
- checked={cal.visible !== false}
696
- onchange={(v) => toggleCal(cal.id, v)}
697
- />
698
- <span
699
- class={['h-2.5 w-2.5 rounded-full', toneClass[cal.color ?? 'brand']]}
700
- aria-hidden="true"
701
- ></span>
702
- <span class="truncate text-xs font-medium text-primary">{cal.label}</span>
703
- </label>
704
- </li>
705
- {/each}
706
- </ul>
707
-
708
- <div class="mt-auto border-t border-border pt-3">
709
- <p class="px-1 text-[10px] text-muted">
710
- {filteredEvents.length} event{filteredEvents.length === 1 ? '' : 's'} visible
711
- </p>
812
+ <div class="shrink-0 border-t border-border px-3 py-3">
813
+ <Text size="sm" tone="muted">{eventsVisibleLabel}</Text>
712
814
  </div>
713
815
  </aside>
714
816
  {/if}
715
817
 
716
- <div class="flex min-w-0 flex-1 flex-col">
818
+ <div class="flex min-h-0 min-w-0 flex-1 flex-col">
717
819
  <!-- Header -->
718
820
  <header
719
- class="flex flex-wrap items-center gap-2 border-b border-border bg-surface-elevated px-3 py-2.5 sm:gap-3 sm:px-4"
821
+ class="flex shrink-0 flex-wrap items-center gap-2 border-b border-border bg-surface-elevated px-3 py-2.5 sm:gap-3 sm:px-4"
720
822
  >
721
823
  <div class="flex items-center gap-1">
722
- <Button size="xs" variant="secondary" onclick={goToday}>Today</Button>
723
- <IconButton label="Previous" size="sm" variant="ghost" onclick={() => shift(-1)}>
824
+ <Button size="xs" variant="secondary" onclick={goToday}>{ui.today}</Button>
825
+ <IconButton label={ui.previous} size="sm" variant="ghost" onclick={() => shift(-1)}>
724
826
  <ChevronLeft class="h-4 w-4" />
725
827
  </IconButton>
726
- <IconButton label="Next" size="sm" variant="ghost" onclick={() => shift(1)}>
828
+ <IconButton label={ui.next} size="sm" variant="ghost" onclick={() => shift(1)}>
727
829
  <ChevronRight class="h-4 w-4" />
728
830
  </IconButton>
729
831
  </div>
@@ -734,7 +836,7 @@
734
836
 
735
837
  {#if showSearch}
736
838
  <div class="order-last w-full sm:order-none sm:w-44 lg:w-56">
737
- <SearchInput bind:value={query} size="sm" placeholder="Search events…" />
839
+ <SearchInput bind:value={query} size="sm" placeholder={ui.search} />
738
840
  </div>
739
841
  {/if}
740
842
 
@@ -742,10 +844,10 @@
742
844
  size="sm"
743
845
  bind:value={view}
744
846
  items={[
745
- { id: 'month', label: 'Month' },
746
- { id: 'week', label: 'Week' },
747
- { id: 'day', label: 'Day' },
748
- { id: 'agenda', label: 'Agenda' }
847
+ { id: 'month', label: ui.month },
848
+ { id: 'week', label: ui.week },
849
+ { id: 'day', label: ui.day },
850
+ { id: 'agenda', label: ui.agenda }
749
851
  ]}
750
852
  onchange={(v) => onviewchange?.(v as CalendarView)}
751
853
  />
@@ -753,12 +855,20 @@
753
855
 
754
856
  <div class="flex min-h-0 flex-1">
755
857
  <!-- Main views -->
756
- <div class="min-w-0 flex-1 overflow-auto p-2 sm:p-3">
858
+ <div
859
+ class={[
860
+ 'min-h-0 min-w-0 flex-1 p-2 sm:p-3',
861
+ view === 'month' ? 'overflow-hidden' : 'overflow-auto'
862
+ ]}
863
+ >
757
864
  {#if view === 'month'}
758
- <div class="grid grid-cols-7 gap-px overflow-hidden rounded-xl border border-border bg-border">
865
+ <div
866
+ class="grid h-full min-h-0 grid-cols-7 gap-px overflow-hidden rounded-xl border border-border bg-border"
867
+ style={`grid-template-rows: auto repeat(${Math.ceil(monthCells.length / 7)}, minmax(0, 1fr));`}
868
+ >
759
869
  {#each weekdayLabels as wd}
760
870
  <div
761
- class="bg-surface-overlay/80 px-1 py-1.5 text-center text-[10px] font-semibold tracking-wide text-muted uppercase"
871
+ class="bg-surface-overlay/80 px-1 py-1.5 text-center text-xs font-semibold tracking-wide text-muted uppercase"
762
872
  >
763
873
  {wd}
764
874
  </div>
@@ -767,7 +877,7 @@
767
877
  {@const dayEvents = eventsOn(cell.key)}
768
878
  <div
769
879
  class={[
770
- 'flex min-h-[5.5rem] flex-col gap-0.5 bg-surface p-1 sm:min-h-[6.5rem]',
880
+ 'flex min-h-0 flex-col gap-0.5 overflow-hidden bg-surface p-1',
771
881
  !cell.inMonth && 'bg-surface-overlay/30 text-muted',
772
882
  cell.key === todayKey && 'ring-1 ring-inset ring-brand-500/40'
773
883
  ]}
@@ -780,6 +890,7 @@
780
890
  cell.key === focusKey && cell.key !== todayKey && 'bg-surface-overlay'
781
891
  ]}
782
892
  onclick={() => {
893
+ clearSelectedEvent();
783
894
  setDate(parseIso(cell.key));
784
895
  ondayclick?.(cell.key);
785
896
  }}
@@ -837,7 +948,7 @@
837
948
  }}
838
949
  >
839
950
  <p class="text-[10px] font-semibold tracking-wide text-muted uppercase">
840
- {(col.date ?? parseIso(col.key)).toLocaleDateString('en', { weekday: 'short' })}
951
+ {(col.date ?? parseIso(col.key)).toLocaleDateString(locale, { weekday: 'short' })}
841
952
  </p>
842
953
  <p
843
954
  class={[
@@ -991,7 +1102,7 @@
991
1102
  <!-- Agenda -->
992
1103
  {#if agendaGroups.length === 0}
993
1104
  <div class="flex h-48 items-center justify-center rounded-xl border border-dashed border-border">
994
- <Text size="sm" tone="muted">No upcoming events</Text>
1105
+ <Text size="sm" tone="muted">{ui.noEvents}</Text>
995
1106
  </div>
996
1107
  {:else}
997
1108
  <ul class="space-y-4">
@@ -1005,7 +1116,7 @@
1005
1116
  >
1006
1117
  {group.label}
1007
1118
  {#if group.key === todayKey}
1008
- <Badge size="sm" variant="primary" class="ml-1">Today</Badge>
1119
+ <Badge size="sm" variant="primary" class="ml-1">{ui.today}</Badge>
1009
1120
  {/if}
1010
1121
  </p>
1011
1122
  <ul class="space-y-1.5">
@@ -1046,65 +1157,137 @@
1046
1157
  {/if}
1047
1158
  </div>
1048
1159
 
1049
- <!-- Detail drawer -->
1050
- {#if selectedEvent}
1160
+ <!-- Detail panel: day agenda (default) or selected event -->
1161
+ {#if showDetailPanel}
1051
1162
  <aside
1052
- class="hidden w-64 shrink-0 flex-col border-l border-border bg-surface-elevated p-4 lg:flex"
1163
+ class="hidden min-h-0 w-80 shrink-0 flex-col overflow-hidden border-l border-border bg-surface-elevated lg:flex"
1053
1164
  >
1054
- <div class="mb-3 flex items-start justify-between gap-2">
1055
- <span
1056
- class={['mt-1 h-2.5 w-2.5 rounded-full', toneClass[eventTone(selectedEvent)]]}
1057
- ></span>
1058
- <div class="min-w-0 flex-1">
1059
- <h3 class="text-sm font-semibold text-primary">{selectedEvent.title}</h3>
1060
- <p class="mt-1 text-xs text-muted">
1061
- {parseIso(selectedEvent.date).toLocaleDateString('en', {
1062
- weekday: 'short',
1063
- month: 'short',
1064
- day: 'numeric'
1065
- })}
1066
- {#if selectedEvent.endDate && selectedEvent.endDate !== selectedEvent.date}
1067
- – {parseIso(selectedEvent.endDate).toLocaleDateString('en', {
1068
- month: 'short',
1069
- day: 'numeric'
1070
- })}
1071
- {/if}
1072
- </p>
1165
+ {#if selectedEvent}
1166
+ {@const tone = eventTone(selectedEvent)}
1167
+ {@const cal = selectedEvent.calendarId
1168
+ ? calendars.find((c) => c.id === selectedEvent.calendarId)
1169
+ : undefined}
1170
+ <div class="flex min-h-0 flex-1 flex-col">
1171
+ <div class="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4">
1172
+ <div class="mb-3 flex items-center gap-1">
1173
+ <IconButton
1174
+ label={ui.back}
1175
+ size="sm"
1176
+ variant="ghost"
1177
+ onclick={clearSelectedEvent}
1178
+ >
1179
+ <ChevronLeft class="h-4 w-4" />
1180
+ </IconButton>
1181
+ <Text as="span" size="xs" tone="muted" class="font-medium">
1182
+ {ui.back}
1183
+ </Text>
1184
+ <div class="flex-1"></div>
1185
+ <IconButton
1186
+ label={ui.close}
1187
+ size="sm"
1188
+ variant="ghost"
1189
+ onclick={clearSelectedEvent}
1190
+ >
1191
+ <X class="h-4 w-4" />
1192
+ </IconButton>
1193
+ </div>
1194
+
1195
+ <div class={['mb-4 rounded-xl px-3.5 py-3', toneSoft[tone]]}>
1196
+ <h3 class="text-base font-semibold text-primary">{selectedEvent.title}</h3>
1197
+ <p class="mt-1 text-xs opacity-80">
1198
+ {parseIso(selectedEvent.date).toLocaleDateString(locale, {
1199
+ weekday: 'short',
1200
+ month: 'short',
1201
+ day: 'numeric'
1202
+ })}
1203
+ {#if selectedEvent.endDate && selectedEvent.endDate !== selectedEvent.date}
1204
+ – {parseIso(selectedEvent.endDate).toLocaleDateString(locale, {
1205
+ month: 'short',
1206
+ day: 'numeric'
1207
+ })}
1208
+ {/if}
1209
+ </p>
1210
+ </div>
1211
+
1212
+ <Stack gap="sm">
1213
+ <div class="flex items-center gap-2 text-sm text-secondary">
1214
+ <Clock class="h-4 w-4 shrink-0 text-muted" />
1215
+ <span>{timeLabel(selectedEvent)}</span>
1216
+ </div>
1217
+ {#if selectedEvent.location}
1218
+ <div class="flex items-center gap-2 text-sm text-secondary">
1219
+ <MapPin class="h-4 w-4 shrink-0 text-muted" />
1220
+ <span class="min-w-0 truncate">{selectedEvent.location}</span>
1221
+ </div>
1222
+ {/if}
1223
+ {#if cal}
1224
+ <div class="flex items-center gap-2">
1225
+ <span
1226
+ class={['h-2.5 w-2.5 shrink-0 rounded-full', toneClass[cal.color ?? 'brand']]}
1227
+ aria-hidden="true"
1228
+ ></span>
1229
+ <Badge size="sm" variant="secondary">{cal.label}</Badge>
1230
+ </div>
1231
+ {/if}
1232
+ {#if selectedEvent.description}
1233
+ <Text as="p" size="sm" tone="secondary" class="leading-relaxed">
1234
+ {selectedEvent.description}
1235
+ </Text>
1236
+ {/if}
1237
+ </Stack>
1238
+ </div>
1239
+
1240
+ {#if eventActions}
1241
+ <div class="shrink-0 border-t border-border bg-surface-elevated p-4">
1242
+ {@render eventActions(selectedEvent)}
1243
+ </div>
1244
+ {/if}
1073
1245
  </div>
1074
- <IconButton
1075
- label="Close"
1076
- size="sm"
1077
- variant="ghost"
1078
- onclick={() => (selectedEventId = null)}
1079
- >
1080
- <X class="h-4 w-4" />
1081
- </IconButton>
1082
- </div>
1246
+ {:else}
1247
+ <div class="flex min-h-0 flex-1 flex-col p-4">
1248
+ <div class="mb-3 flex shrink-0 items-start justify-between gap-2">
1249
+ <div class="min-w-0 flex-1">
1250
+ <p class="text-xs font-semibold tracking-wider text-muted uppercase">
1251
+ {ui.dayEvents}
1252
+ </p>
1253
+ <h3 class="mt-0.5 text-sm font-semibold text-primary">{focusDayHeading}</h3>
1254
+ </div>
1255
+ {#if showCreate}
1256
+ <Button size="xs" variant="secondary" onclick={() => oncreate?.(focusKey)}>
1257
+ <Plus class="h-3.5 w-3.5" strokeWidth={2} />
1258
+ {ui.create}
1259
+ </Button>
1260
+ {/if}
1261
+ </div>
1083
1262
 
1084
- <div class="space-y-3 text-xs text-secondary">
1085
- <p class="inline-flex items-center gap-1.5">
1086
- <Clock class="h-3.5 w-3.5 text-muted" />
1087
- {timeLabel(selectedEvent)}
1088
- </p>
1089
- {#if selectedEvent.location}
1090
- <p class="inline-flex items-center gap-1.5">
1091
- <MapPin class="h-3.5 w-3.5 text-muted" />
1092
- {selectedEvent.location}
1093
- </p>
1094
- {/if}
1095
- {#if selectedEvent.calendarId}
1096
- {@const cal = calendars.find((c) => c.id === selectedEvent.calendarId)}
1097
- {#if cal}
1098
- <p class="inline-flex items-center gap-1.5">
1099
- <span class={['h-2 w-2 rounded-full', toneClass[cal.color ?? 'brand']]}></span>
1100
- {cal.label}
1101
- </p>
1263
+ {#if focusDayEvents.length === 0}
1264
+ <div class="flex min-h-0 flex-1 items-center justify-center px-3 py-8">
1265
+ <Text size="sm" tone="muted">{ui.noEvents}</Text>
1266
+ </div>
1267
+ {:else}
1268
+ <ul class="min-h-0 flex-1 space-y-1.5 overflow-auto overscroll-contain">
1269
+ {#each focusDayEvents as ev (ev.id)}
1270
+ <li>
1271
+ <button
1272
+ type="button"
1273
+ class={[
1274
+ 'flex w-full flex-col gap-0.5 rounded-lg px-2.5 py-2 text-left transition hover:opacity-90',
1275
+ toneSoft[eventTone(ev)]
1276
+ ]}
1277
+ onclick={() => selectEvent(ev)}
1278
+ >
1279
+ <span class="text-xs tabular-nums opacity-80">{timeLabel(ev)}</span>
1280
+ <span class="text-sm font-semibold">{ev.title}</span>
1281
+ {#if ev.location}
1282
+ <span class="truncate text-xs opacity-70">{ev.location}</span>
1283
+ {/if}
1284
+ </button>
1285
+ </li>
1286
+ {/each}
1287
+ </ul>
1102
1288
  {/if}
1103
- {/if}
1104
- {#if selectedEvent.description}
1105
- <p class="leading-relaxed text-secondary">{selectedEvent.description}</p>
1106
- {/if}
1107
- </div>
1289
+ </div>
1290
+ {/if}
1108
1291
  </aside>
1109
1292
  {/if}
1110
1293
  </div>
@@ -1,3 +1,4 @@
1
+ import { type Snippet } from 'svelte';
1
2
  export type CalendarView = 'month' | 'week' | 'day' | 'agenda';
2
3
  export type CalendarTone = 'brand' | 'success' | 'warning' | 'error' | 'info' | 'violet' | 'rose';
3
4
  export interface CalendarSource {
@@ -21,6 +22,24 @@ export interface CalendarAppEvent {
21
22
  location?: string;
22
23
  description?: string;
23
24
  }
25
+ export interface CalendarAppLabels {
26
+ create?: string;
27
+ myCalendars?: string;
28
+ today?: string;
29
+ search?: string;
30
+ month?: string;
31
+ week?: string;
32
+ day?: string;
33
+ agenda?: string;
34
+ allDay?: string;
35
+ eventsVisible?: string;
36
+ noEvents?: string;
37
+ dayEvents?: string;
38
+ close?: string;
39
+ previous?: string;
40
+ next?: string;
41
+ back?: string;
42
+ }
24
43
  interface CalendarAppProps {
25
44
  events?: CalendarAppEvent[];
26
45
  calendars?: CalendarSource[];
@@ -32,6 +51,12 @@ interface CalendarAppProps {
32
51
  showSidebar?: boolean;
33
52
  showSearch?: boolean;
34
53
  showCreate?: boolean;
54
+ /** Right detail panel (day agenda / event detail) on lg+ */
55
+ showDetailPanel?: boolean;
56
+ /** i18n-friendly UI strings; English defaults when omitted */
57
+ labels?: CalendarAppLabels;
58
+ /** Locale for toLocaleDateString (default `'en'`) */
59
+ locale?: string;
35
60
  weekStartsOn?: 0 | 1;
36
61
  /** Allow resizing timed events in week / day views */
37
62
  resizable?: boolean;
@@ -42,6 +67,8 @@ interface CalendarAppProps {
42
67
  /** Minimum event duration when resizing (minutes) */
43
68
  resizeMinMinutes?: number;
44
69
  class?: string;
70
+ /** Host actions (Edit / Delete / Open) under event detail */
71
+ eventActions?: Snippet<[CalendarAppEvent]>;
45
72
  onviewchange?: (view: CalendarView) => void;
46
73
  ondatechange?: (date: Date) => void;
47
74
  oneventclick?: (event: CalendarAppEvent) => void;
package/dist/index.d.ts CHANGED
@@ -751,7 +751,7 @@ export type { RoadmapItem, RoadmapStatus } from './components/organisms/Roadmap/
751
751
  export { default as FAQ } from './components/organisms/FAQ/FAQ.svelte';
752
752
  export type { FaqItem } from './components/organisms/FAQ/FAQ.svelte';
753
753
  export { default as CalendarApp } from './components/organisms/CalendarApp/CalendarApp.svelte';
754
- export type { CalendarView, CalendarTone, CalendarSource, CalendarAppEvent } from './components/organisms/CalendarApp/CalendarApp.svelte';
754
+ export type { CalendarView, CalendarTone, CalendarSource, CalendarAppEvent, CalendarAppLabels } from './components/organisms/CalendarApp/CalendarApp.svelte';
755
755
  export { default as PermissionsMatrix } from './components/organisms/PermissionsMatrix/PermissionsMatrix.svelte';
756
756
  export type { PermissionLevel, PermissionRole, PermissionResource, PermissionMap } from './components/organisms/PermissionsMatrix/PermissionsMatrix.svelte';
757
757
  export { default as RolesPage } from './components/organisms/RolesPage/RolesPage.svelte';
@@ -14,8 +14,19 @@
14
14
  */
15
15
  import { UI_DICTIONARIES, formatMessage, getDictionary, resolveLocaleTag } from './i18n.js';
16
16
  const STORAGE_KEY = 'r2-locale';
17
+ function canUseLocalStorage() {
18
+ try {
19
+ return (typeof localStorage !== 'undefined' &&
20
+ typeof localStorage.getItem === 'function' &&
21
+ typeof localStorage.setItem === 'function');
22
+ }
23
+ catch {
24
+ // Node / locked-down environments may throw on access
25
+ return false;
26
+ }
27
+ }
17
28
  function getStoredLocale() {
18
- if (typeof localStorage === 'undefined')
29
+ if (!canUseLocalStorage())
19
30
  return 'en';
20
31
  return localStorage.getItem(STORAGE_KEY) ?? 'en';
21
32
  }
@@ -52,7 +63,7 @@ class I18nStore {
52
63
  /** Change active locale (`es`, `en-US`, …). */
53
64
  set(locale) {
54
65
  this.#locale = locale;
55
- if (typeof localStorage !== 'undefined') {
66
+ if (canUseLocalStorage()) {
56
67
  localStorage.setItem(STORAGE_KEY, locale);
57
68
  }
58
69
  applyDocumentLang(locale);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/components",
3
- "version": "0.9.1",
3
+ "version": "0.9.3",
4
4
  "private": false,
5
5
  "description": "R2DigiSolutions Svelte 5 component library — Atomic Design, Tailwind 4, Light/Dark mode",
6
6
  "license": "MIT",
@@ -111,6 +111,6 @@
111
111
  "atomic-design"
112
112
  ],
113
113
  "dependencies": {
114
- "@xyflow/svelte": "^1.6.3"
114
+ "@xyflow/svelte": "^1.6.5"
115
115
  }
116
116
  }