@r2digisolutions/components 0.14.3 → 0.14.5

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.
@@ -51,6 +51,21 @@
51
51
  );
52
52
  }
53
53
 
54
+ function bindTrigger() {
55
+ const el = getTriggerEl();
56
+ if (!el || el === rootEl) return;
57
+ el.setAttribute('aria-haspopup', 'dialog');
58
+ el.setAttribute('aria-controls', panelId);
59
+ el.setAttribute('aria-expanded', open ? 'true' : 'false');
60
+ if (strategy !== 'fixed') {
61
+ el.removeAttribute('popovertarget');
62
+ el.removeAttribute('popovertargetaction');
63
+ return;
64
+ }
65
+ el.setAttribute('popovertarget', panelId);
66
+ el.setAttribute('popovertargetaction', 'toggle');
67
+ }
68
+
54
69
  function positionPanel() {
55
70
  if (!panelEl || strategy !== 'fixed') return;
56
71
  if (!panelEl.matches(':popover-open')) return;
@@ -151,6 +166,8 @@
151
166
  $effect(() => {
152
167
  open;
153
168
  strategy;
169
+ rootEl;
170
+ bindTrigger();
154
171
  queueMicrotask(() => {
155
172
  syncNative();
156
173
  if (open) {
@@ -160,6 +177,15 @@
160
177
  });
161
178
  });
162
179
 
180
+ $effect(() => {
181
+ if (strategy === 'fixed') return;
182
+ const el = getTriggerEl();
183
+ if (!el) return;
184
+ const onClick = () => setOpen(!open);
185
+ el.addEventListener('click', onClick);
186
+ return () => el.removeEventListener('click', onClick);
187
+ });
188
+
163
189
  $effect(() => {
164
190
  if (!open || strategy !== 'fixed') return;
165
191
  const onWin = () => positionPanel();
@@ -204,7 +230,7 @@
204
230
  ontoggle={onToggle}
205
231
  style={panelStyle}
206
232
  class={[
207
- 'popover-panel m-0 max-w-[min(20rem,calc(100vw-1rem))] rounded-xl border border-border bg-surface-elevated p-3 shadow-xl outline-none',
233
+ 'popover-panel m-0 rounded-xl border-border bg-surface-elevated p-3 shadow-xl max-w-[min(20rem,calc(100vw-1rem))] border outline-none',
208
234
  panelClass
209
235
  ]}
210
236
  >
@@ -215,7 +241,7 @@
215
241
  bind:this={panelEl}
216
242
  role="dialog"
217
243
  class={[
218
- 'absolute z-50 min-w-48 rounded-xl border border-border bg-surface-elevated p-3 shadow-xl',
244
+ 'min-w-48 rounded-xl border-border bg-surface-elevated p-3 shadow-xl absolute z-50 border',
219
245
  absolutePos[placement],
220
246
  absoluteAlign[align][placement],
221
247
  panelClass
@@ -9,12 +9,10 @@
9
9
  let open = $state(false);
10
10
  </script>
11
11
 
12
- <div class="flex min-h-40 items-center justify-center">
12
+ <div class="min-h-40 flex items-center justify-center">
13
13
  <Popover bind:open placement={props.placement ?? 'bottom'}>
14
14
  {#snippet trigger()}
15
- <Button size="sm" variant="secondary" onclick={() => (open = !open)}>
16
- Open popover
17
- </Button>
15
+ <Button size="sm" variant="secondary" data-popover-trigger>Open popover</Button>
18
16
  {/snippet}
19
17
  <div class="space-y-1">
20
18
  <p class="text-sm font-medium text-primary">Quick actions</p>
@@ -71,13 +71,7 @@
71
71
  y?: number;
72
72
  }
73
73
 
74
- export type DesktopWallpaper =
75
- | 'aurora'
76
- | 'slate'
77
- | 'mint'
78
- | 'dusk'
79
- | 'paper'
80
- | 'custom';
74
+ export type DesktopWallpaper = 'aurora' | 'slate' | 'mint' | 'dusk' | 'paper' | 'custom';
81
75
 
82
76
  export type DesktopShellHeight = 'sm' | 'md' | 'lg' | 'xl' | 'full';
83
77
 
@@ -138,10 +132,8 @@
138
132
  'bg-[radial-gradient(ellipse_at_20%_0%,rgb(56_189_248_/_0.28),transparent_50%),radial-gradient(ellipse_at_80%_10%,rgb(167_139_250_/_0.22),transparent_45%),linear-gradient(160deg,var(--surface),var(--surface-overlay))]',
139
133
  slate:
140
134
  'bg-[radial-gradient(circle_at_30%_20%,rgb(100_116_139_/_0.3),transparent_50%),linear-gradient(165deg,#0f172a,#1e293b_55%,#334155)]',
141
- mint:
142
- 'bg-[radial-gradient(ellipse_at_top,rgb(52_211_153_/_0.22),transparent_50%),linear-gradient(160deg,var(--surface),rgb(236_253_245))]',
143
- dusk:
144
- 'bg-[radial-gradient(ellipse_at_70%_0%,rgb(251_146_60_/_0.28),transparent_45%),radial-gradient(ellipse_at_10%_80%,rgb(99_102_241_/_0.2),transparent_40%),linear-gradient(165deg,#1e1b4b,#312e81_40%,#7c2d12)]',
135
+ mint: 'bg-[radial-gradient(ellipse_at_top,rgb(52_211_153_/_0.22),transparent_50%),linear-gradient(160deg,var(--surface),rgb(236_253_245))]',
136
+ dusk: 'bg-[radial-gradient(ellipse_at_70%_0%,rgb(251_146_60_/_0.28),transparent_45%),radial-gradient(ellipse_at_10%_80%,rgb(99_102_241_/_0.2),transparent_40%),linear-gradient(165deg,#1e1b4b,#312e81_40%,#7c2d12)]',
145
137
  paper:
146
138
  'bg-[linear-gradient(180deg,var(--surface-elevated),var(--surface)),repeating-linear-gradient(0deg,transparent,transparent_23px,rgb(0_0_0_/_0.03)_24px)]'
147
139
  };
@@ -192,7 +184,14 @@
192
184
 
193
185
  const DEFAULT_WIDGETS: DesktopWidget[] = [
194
186
  { id: 'w-weather', kind: 'weather', title: 'Weather', x: 16, y: 16 },
195
- { id: 'w-sticky', kind: 'sticky', title: 'Notes', body: 'Ship desktop polish\n· Icons ✓\n· Menubar ✓', x: 16, y: 140 },
187
+ {
188
+ id: 'w-sticky',
189
+ kind: 'sticky',
190
+ title: 'Notes',
191
+ body: 'Ship desktop polish\n· Icons ✓\n· Menubar ✓',
192
+ x: 16,
193
+ y: 140
194
+ },
196
195
  { id: 'w-stats', kind: 'stats', title: 'Pulse', x: 16, y: 280 }
197
196
  ];
198
197
 
@@ -245,9 +244,7 @@
245
244
  let contextTarget = $state<ContextTarget>({ type: 'desktop' });
246
245
 
247
246
  const openWindows = $derived(
248
- windows
249
- .filter((w) => !w.closed)
250
- .sort((a, b) => (a.rect.z ?? 0) - (b.rect.z ?? 0))
247
+ windows.filter((w) => !w.closed).sort((a, b) => (a.rect.z ?? 0) - (b.rect.z ?? 0))
251
248
  );
252
249
  const taskbarWindows = $derived(windows.filter((w) => !w.closed));
253
250
  const visibleTaskbar = $derived(taskbarWindows.slice(0, TASKBAR_VISIBLE));
@@ -413,9 +410,7 @@
413
410
  const z = nextZ();
414
411
  emit(
415
412
  windows.map((w) =>
416
- w.id === id
417
- ? { ...w, closed: false, rect: { ...w.rect, z, minimized: false } }
418
- : w
413
+ w.id === id ? { ...w, closed: false, rect: { ...w.rect, z, minimized: false } } : w
419
414
  )
420
415
  );
421
416
  activeId = id;
@@ -424,9 +419,7 @@
424
419
 
425
420
  function updateRect(id: string, rect: WindowRect) {
426
421
  emit(
427
- windows.map((w) =>
428
- w.id === id ? { ...w, rect: { ...rect, z: rect.z ?? w.rect.z } } : w
429
- )
422
+ windows.map((w) => (w.id === id ? { ...w, rect: { ...rect, z: rect.z ?? w.rect.z } } : w))
430
423
  );
431
424
  }
432
425
 
@@ -681,7 +674,20 @@
681
674
 
682
675
  function resolveIcon(id?: DesktopIconId, kind?: string): DesktopIconId {
683
676
  if (id) return id;
684
- if (kind && ['files', 'mail', 'music', 'notes', 'settings', 'stats', 'calendar', 'terminal', 'team'].includes(kind)) {
677
+ if (
678
+ kind &&
679
+ [
680
+ 'files',
681
+ 'mail',
682
+ 'music',
683
+ 'notes',
684
+ 'settings',
685
+ 'stats',
686
+ 'calendar',
687
+ 'terminal',
688
+ 'team'
689
+ ].includes(kind)
690
+ ) {
685
691
  return kind as DesktopIconId;
686
692
  }
687
693
  return 'app';
@@ -698,7 +704,7 @@
698
704
 
699
705
  <div
700
706
  class={[
701
- 'relative flex w-full flex-col overflow-hidden rounded-2xl border border-border bg-surface shadow-sm',
707
+ 'rounded-2xl border-border bg-surface shadow-sm relative flex w-full flex-col overflow-hidden border',
702
708
  HEIGHT[height],
703
709
  className
704
710
  ]}
@@ -714,11 +720,11 @@
714
720
 
715
721
  {#if showMenubar}
716
722
  <div
717
- class="relative z-40 flex shrink-0 items-center gap-2 border-b border-border bg-surface-elevated/95 px-2.5 py-1.5 backdrop-blur"
723
+ class="gap-2 border-border bg-surface-elevated/95 px-2.5 py-1.5 backdrop-blur relative z-40 flex shrink-0 items-center border-b"
718
724
  >
719
- <BrandMark mark={brandMark} name={brand} size="sm" showName class="hidden sm:inline-flex" />
725
+ <BrandMark mark={brandMark} name={brand} size="sm" showName class="sm:inline-flex hidden" />
720
726
  <BrandMark mark={brandMark} name={brand} size="sm" class="sm:hidden" />
721
- <div class="h-4 w-px shrink-0 bg-border" aria-hidden="true"></div>
727
+ <div class="h-4 bg-border w-px shrink-0" aria-hidden="true"></div>
722
728
  <Menubar items={menubarItems} class="min-w-0 flex-1" onselect={onMenubarSelect} />
723
729
  {#if showPresence}
724
730
  <SessionPresenceBadge size="sm" showLabel={false} />
@@ -731,7 +737,7 @@
731
737
  <div
732
738
  bind:this={desktopEl}
733
739
  class={[
734
- 'relative min-h-0 flex-1 overflow-hidden',
740
+ 'min-h-0 relative flex-1 overflow-hidden',
735
741
  wallpaper !== 'custom' && WALLPAPER_CLASS[wallpaper]
736
742
  ]}
737
743
  style={wallpaperBg}
@@ -741,7 +747,7 @@
741
747
  }}
742
748
  >
743
749
  {#if showDesktopIcons && launcherApps.length}
744
- <div class="absolute left-3 top-3 z-[1] flex w-[4.75rem] flex-col gap-0.5">
750
+ <div class="left-3 top-3 gap-0.5 absolute z-[1] flex w-[4.75rem] flex-col">
745
751
  {#each launcherApps.slice(0, 8) as app (app.id)}
746
752
  <!-- svelte-ignore a11y_no_static_element_interactions -->
747
753
  <div
@@ -752,7 +758,7 @@
752
758
  >
753
759
  <button
754
760
  type="button"
755
- class="group flex w-full flex-col items-center gap-1 rounded-xl px-1 py-1.5 text-center transition-colors hover:bg-black/10 dark:hover:bg-white/10"
761
+ class="group gap-1 rounded-xl px-1 py-1.5 hover:bg-black/10 dark:hover:bg-white/10 flex w-full flex-col items-center text-center transition-colors"
756
762
  onclick={(e) => {
757
763
  e.stopPropagation();
758
764
  openApp(app);
@@ -764,7 +770,7 @@
764
770
  size="lg"
765
771
  />
766
772
  <span
767
- class="line-clamp-2 w-full text-center text-[10px] font-medium leading-tight text-primary"
773
+ class="font-medium leading-tight text-primary line-clamp-2 w-full text-center text-[10px]"
768
774
  >
769
775
  {app.title}
770
776
  </span>
@@ -775,11 +781,11 @@
775
781
  {/if}
776
782
 
777
783
  {#if showWidgets}
778
- <div class="pointer-events-none absolute right-3 top-3 z-[1] flex w-44 flex-col gap-2">
784
+ <div class="right-3 top-3 w-44 gap-2 pointer-events-none absolute z-[1] flex flex-col">
779
785
  {#each widgets as widget (widget.id)}
780
786
  <!-- svelte-ignore a11y_no_static_element_interactions -->
781
787
  <div
782
- class="pointer-events-auto rounded-xl border border-border/70 bg-surface-elevated/90 p-3 shadow-sm backdrop-blur"
788
+ class="rounded-xl border-border/70 bg-surface-elevated/90 p-3 shadow-sm backdrop-blur pointer-events-auto border"
783
789
  onpointerdown={(e) => e.stopPropagation()}
784
790
  oncontextmenu={(e) => {
785
791
  e.stopPropagation();
@@ -787,33 +793,42 @@
787
793
  }}
788
794
  >
789
795
  {#if widget.kind === 'weather'}
790
- <div class="flex items-start gap-2">
796
+ <div class="gap-2 flex items-start">
791
797
  <span
792
- class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-sky-500/15 text-sky-700 dark:text-sky-300"
798
+ class="h-9 w-9 rounded-lg bg-sky-500/15 text-sky-700 dark:text-sky-300 flex shrink-0 items-center justify-center"
793
799
  >
794
800
  <CloudSun class="h-[18px] w-[18px]" strokeWidth={2} />
795
801
  </span>
796
802
  <div class="min-w-0">
797
803
  <p class="text-xs font-medium text-muted">{widget.title ?? 'Weather'}</p>
798
- <p class="text-lg font-semibold tabular-nums text-primary">22°</p>
799
- <p class="text-[11px] text-secondary">Partly cloudy</p>
804
+ <p class="text-lg font-semibold text-primary tabular-nums">22°</p>
805
+ <p class="text-secondary text-[11px]">Partly cloudy</p>
800
806
  </div>
801
807
  </div>
802
808
  {:else if widget.kind === 'sticky'}
803
- <p class="text-[11px] font-semibold uppercase tracking-wide text-amber-700 dark:text-amber-300">
809
+ <p
810
+ class="font-semibold tracking-wide text-amber-700 dark:text-amber-300 text-[11px] uppercase"
811
+ >
804
812
  {widget.title ?? 'Sticky'}
805
813
  </p>
806
- <p class="mt-1 whitespace-pre-line text-xs leading-relaxed text-primary">
814
+ <p class="mt-1 text-xs leading-relaxed text-primary whitespace-pre-line">
807
815
  {widget.body ?? 'Add a note…'}
808
816
  </p>
809
817
  {:else if widget.kind === 'stats'}
810
- <p class="mb-2 text-[11px] font-medium text-muted">{widget.title ?? 'Stats'}</p>
818
+ <p class="mb-2 font-medium text-muted text-[11px]">{widget.title ?? 'Stats'}</p>
811
819
  <div class="space-y-1">
812
- <StatCard label="Active" value="128" delta="+4%" trend="up" variant="ghost" class="!p-2" />
820
+ <StatCard
821
+ label="Active"
822
+ value="128"
823
+ delta="+4%"
824
+ trend="up"
825
+ variant="ghost"
826
+ class="!p-2"
827
+ />
813
828
  </div>
814
829
  {:else}
815
- <p class="text-sm font-semibold tabular-nums text-primary">{clock}</p>
816
- <p class="text-[11px] text-muted">{clockDate}</p>
830
+ <p class="text-sm font-semibold text-primary tabular-nums">{clock}</p>
831
+ <p class="text-muted text-[11px]">{clockDate}</p>
817
832
  {/if}
818
833
  </div>
819
834
  {/each}
@@ -856,10 +871,10 @@
856
871
  {#if startOpen}
857
872
  <!-- svelte-ignore a11y_no_static_element_interactions -->
858
873
  <div
859
- class="absolute bottom-3 left-3 z-[1000] flex w-[19rem] flex-col overflow-hidden rounded-2xl border border-border bg-surface-elevated/95 shadow-xl backdrop-blur"
874
+ class="bottom-3 left-3 rounded-2xl border-border bg-surface-elevated/95 shadow-xl backdrop-blur absolute z-[1000] flex w-[19rem] flex-col overflow-hidden border"
860
875
  onpointerdown={(e) => e.stopPropagation()}
861
876
  >
862
- <div class="flex items-center gap-2 border-b border-border px-3 py-2.5">
877
+ <div class="gap-2 border-border px-3 py-2.5 flex items-center border-b">
863
878
  <BrandMark mark={brandMark} name={brand} size="sm" showName />
864
879
  </div>
865
880
 
@@ -868,7 +883,7 @@
868
883
 
869
884
  <div class="max-h-56 space-y-0.5 overflow-y-auto">
870
885
  {#if !filteredApps.length}
871
- <p class="px-2 py-4 text-center text-xs text-muted">No apps found</p>
886
+ <p class="px-2 py-4 text-xs text-muted text-center">No apps found</p>
872
887
  {:else}
873
888
  {#each filteredApps as app (app.id)}
874
889
  <Tile
@@ -894,7 +909,7 @@
894
909
  </div>
895
910
  </div>
896
911
 
897
- <div class="flex items-center gap-1 border-t border-border p-1.5">
912
+ <div class="gap-1 border-border p-1.5 flex items-center border-t">
898
913
  <Tooltip content="Cascade">
899
914
  <IconButton label="Cascade windows" size="sm" onclick={cascadeWindows}>
900
915
  <Layers class="h-4 w-4" strokeWidth={2} />
@@ -913,7 +928,7 @@
913
928
  <div class="min-w-0 flex-1"></div>
914
929
  <button
915
930
  type="button"
916
- class="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-[11px] text-muted hover:bg-surface-overlay hover:text-secondary"
931
+ class="gap-1.5 rounded-lg px-2 py-1 text-muted hover:bg-surface-overlay hover:text-secondary inline-flex items-center text-[11px]"
917
932
  onclick={() => {
918
933
  startOpen = false;
919
934
  commandOpen = true;
@@ -929,7 +944,7 @@
929
944
 
930
945
  {#if showTaskbar}
931
946
  <div
932
- class="relative z-40 flex shrink-0 items-center gap-2 border-t border-border bg-surface-elevated/95 px-2 py-2 backdrop-blur sm:px-3"
947
+ class="gap-2 border-border bg-surface-elevated/95 px-2 py-2 backdrop-blur sm:px-3 relative z-40 flex shrink-0 items-center border-t"
933
948
  >
934
949
  {#if showStart}
935
950
  <Tooltip content="Start" side="top">
@@ -972,9 +987,9 @@
972
987
  {@render taskbarLeading()}
973
988
  {/if}
974
989
 
975
- <div class="flex min-w-0 flex-1 items-center justify-center gap-1 overflow-x-auto px-1">
990
+ <div class="min-w-0 gap-1 px-1 flex flex-1 items-center justify-center overflow-x-auto">
976
991
  <div
977
- class="inline-flex items-center gap-0.5 rounded-2xl border border-border/70 bg-surface/70 p-1 shadow-sm"
992
+ class="gap-0.5 rounded-2xl border-border/70 bg-surface/70 p-1 shadow-sm inline-flex items-center border"
978
993
  >
979
994
  {#each visibleTaskbar as win (win.id)}
980
995
  <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -988,7 +1003,7 @@
988
1003
  <button
989
1004
  type="button"
990
1005
  class={[
991
- 'relative flex h-9 w-9 items-center justify-center rounded-xl transition-colors',
1006
+ 'h-9 w-9 rounded-xl relative flex items-center justify-center transition-colors',
992
1007
  activeId === win.id && !win.rect.minimized
993
1008
  ? 'bg-brand-500/15 text-brand-700 dark:text-brand-300'
994
1009
  : 'text-secondary hover:bg-surface-overlay hover:text-primary',
@@ -1006,7 +1021,7 @@
1006
1021
  />
1007
1022
  {#if activeId === win.id && !win.rect.minimized}
1008
1023
  <span
1009
- class="absolute inset-x-2 bottom-0.5 h-0.5 rounded-full bg-brand-500"
1024
+ class="inset-x-2 bottom-0.5 h-0.5 bg-brand-500 absolute rounded-full"
1010
1025
  aria-hidden="true"
1011
1026
  ></span>
1012
1027
  {/if}
@@ -1026,7 +1041,7 @@
1026
1041
  >
1027
1042
  {#snippet trigger()}
1028
1043
  <span
1029
- class="flex h-9 w-9 items-center justify-center rounded-xl text-secondary hover:bg-surface-overlay"
1044
+ class="h-9 w-9 rounded-xl text-secondary hover:bg-surface-overlay flex items-center justify-center"
1030
1045
  aria-label={`More windows (${overflowTaskbar.length})`}
1031
1046
  >
1032
1047
  <MoreHorizontal class="h-4 w-4" strokeWidth={2} />
@@ -1037,7 +1052,7 @@
1037
1052
  </div>
1038
1053
  </div>
1039
1054
 
1040
- <div class="flex shrink-0 items-center gap-1.5">
1055
+ <div class="gap-1.5 flex shrink-0 items-center">
1041
1056
  {#if showWindowCount}
1042
1057
  <Badge size="sm" variant="secondary" class="tabular-nums">
1043
1058
  {taskbarWindows.length - minimizedCount}/{taskbarWindows.length}
@@ -1074,21 +1089,20 @@
1074
1089
  <button
1075
1090
  type="button"
1076
1091
  data-popover-trigger
1077
- class="flex min-w-[4.5rem] flex-col items-end rounded-xl px-2.5 py-1 text-right transition-colors hover:bg-surface-overlay"
1092
+ class="rounded-xl px-2.5 py-1 hover:bg-surface-overlay flex min-w-[4.5rem] flex-col items-end text-right transition-colors"
1078
1093
  aria-expanded={clockOpen}
1079
1094
  aria-haspopup="dialog"
1080
1095
  onclick={() => {
1081
1096
  startOpen = false;
1082
- clockOpen = !clockOpen;
1083
1097
  }}
1084
1098
  >
1085
- <span class="text-xs font-semibold tabular-nums text-primary">{clock}</span>
1086
- <span class="text-[10px] text-muted">{clockDate}</span>
1099
+ <span class="text-xs font-semibold text-primary tabular-nums">{clock}</span>
1100
+ <span class="text-muted text-[10px]">{clockDate}</span>
1087
1101
  </button>
1088
1102
  {/snippet}
1089
1103
  <div class="space-y-3">
1090
- <div class="border-b border-border pb-2">
1091
- <p class="text-2xl font-semibold tabular-nums tracking-tight text-primary">
1104
+ <div class="border-border pb-2 border-b">
1105
+ <p class="text-2xl font-semibold tracking-tight text-primary tabular-nums">
1092
1106
  {clock}
1093
1107
  </p>
1094
1108
  <p class="text-sm text-secondary">{clockDate}</p>
@@ -14,11 +14,26 @@
14
14
  type?: 'info' | 'success' | 'warning' | 'error';
15
15
  }
16
16
 
17
+ export interface NotificationCenterLabels {
18
+ title?: string;
19
+ /** Badge next to the title. `{n}` is replaced with unread count. */
20
+ newCount?: string;
21
+ markAllRead?: string;
22
+ clearAll?: string;
23
+ empty?: string;
24
+ /** Trigger aria-label when there are no unread items */
25
+ trigger?: string;
26
+ /** Appended to trigger aria-label when unread. `{n}` is unread count. */
27
+ unreadSuffix?: string;
28
+ }
29
+
17
30
  interface NotificationCenterProps {
18
31
  id?: string;
19
32
  items?: NotificationItem[];
20
33
  open?: boolean;
21
34
  class?: string;
35
+ labels?: NotificationCenterLabels;
36
+ showClear?: boolean;
22
37
  onitemclick?: (item: NotificationItem) => void;
23
38
  onmarkallread?: () => void;
24
39
  onclear?: () => void;
@@ -29,6 +44,8 @@
29
44
  items = $bindable([]),
30
45
  open = $bindable(false),
31
46
  class: className = '',
47
+ showClear = true,
48
+ labels = {},
32
49
  onitemclick,
33
50
  onmarkallread,
34
51
  onclear
@@ -42,6 +59,22 @@
42
59
  const popoverId = $derived(`${baseId}-popover`);
43
60
  const unreadCount = $derived(items.filter((n) => !n.read).length);
44
61
 
62
+ const DEFAULT_LABELS: Required<NotificationCenterLabels> = {
63
+ title: 'Notifications',
64
+ newCount: '{n} new',
65
+ markAllRead: 'Mark all read',
66
+ clearAll: 'Clear all',
67
+ empty: 'No notifications',
68
+ trigger: 'Notifications',
69
+ unreadSuffix: ', {n} unread'
70
+ };
71
+
72
+ const copy = $derived({ ...DEFAULT_LABELS, ...labels });
73
+
74
+ function withCount(template: string, n: number) {
75
+ return template.replaceAll('{n}', String(n));
76
+ }
77
+
45
78
  function positionPopover() {
46
79
  if (!triggerEl || !popoverEl) return;
47
80
  if (!popoverEl.matches(':popover-open')) return;
@@ -136,7 +169,7 @@
136
169
  type="button"
137
170
  popovertarget={popoverId}
138
171
  popovertargetaction="toggle"
139
- aria-label={`Notifications${unreadCount > 0 ? `, ${unreadCount} unread` : ''}`}
172
+ aria-label="{copy.trigger}{unreadCount > 0 ? withCount(copy.unreadSuffix, unreadCount) : ''}"
140
173
  aria-expanded={open}
141
174
  aria-controls={popoverId}
142
175
  class="relative flex h-9 w-9 items-center justify-center rounded-lg text-secondary outline-none transition-colors hover:bg-surface-overlay hover:text-primary focus-visible:ring-2 focus-visible:ring-brand-500/30"
@@ -173,16 +206,16 @@
173
206
  style={popoverStyle}
174
207
  class="notif-panel inset-auto m-0 flex flex-col overflow-hidden rounded-xl border border-border bg-surface-elevated shadow-2xl outline-none"
175
208
  role="dialog"
176
- aria-label="Notifications"
209
+ aria-label={copy.title}
177
210
  >
178
211
  <div class="flex shrink-0 items-center justify-between border-b border-border px-4 py-3">
179
212
  <div class="flex items-center gap-2">
180
- <h3 class="text-sm font-semibold text-primary">Notifications</h3>
213
+ <h3 class="text-sm font-semibold text-primary">{copy.title}</h3>
181
214
  {#if unreadCount > 0}
182
215
  <span
183
216
  class="rounded-full bg-brand-100 px-2 py-0.5 text-xs font-medium text-brand-600 dark:bg-brand-900 dark:text-brand-400"
184
217
  >
185
- {unreadCount} new
218
+ {withCount(copy.newCount, unreadCount)}
186
219
  </span>
187
220
  {/if}
188
221
  </div>
@@ -193,16 +226,16 @@
193
226
  onclick={markAllRead}
194
227
  class="rounded-lg px-2 py-1 text-xs font-medium text-brand-600 transition-colors hover:bg-surface-overlay dark:text-brand-400"
195
228
  >
196
- Mark all read
229
+ {copy.markAllRead}
197
230
  </button>
198
231
  {/if}
199
- {#if items.length > 0}
232
+ {#if showClear && items.length > 0}
200
233
  <button
201
234
  type="button"
202
235
  onclick={clear}
203
236
  class="rounded-lg px-2 py-1 text-xs font-medium text-muted transition-colors hover:bg-surface-overlay hover:text-primary"
204
237
  >
205
- Clear all
238
+ {copy.clearAll}
206
239
  </button>
207
240
  {/if}
208
241
  </div>
@@ -225,7 +258,7 @@
225
258
  d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
226
259
  />
227
260
  </svg>
228
- <p class="text-sm text-muted">No notifications</p>
261
+ <p class="text-sm text-muted">{copy.empty}</p>
229
262
  </div>
230
263
  {:else}
231
264
  <div class="divide-y divide-border">
@@ -9,11 +9,25 @@ export interface NotificationItem {
9
9
  actor?: string;
10
10
  type?: 'info' | 'success' | 'warning' | 'error';
11
11
  }
12
+ export interface NotificationCenterLabels {
13
+ title?: string;
14
+ /** Badge next to the title. `{n}` is replaced with unread count. */
15
+ newCount?: string;
16
+ markAllRead?: string;
17
+ clearAll?: string;
18
+ empty?: string;
19
+ /** Trigger aria-label when there are no unread items */
20
+ trigger?: string;
21
+ /** Appended to trigger aria-label when unread. `{n}` is unread count. */
22
+ unreadSuffix?: string;
23
+ }
12
24
  interface NotificationCenterProps {
13
25
  id?: string;
14
26
  items?: NotificationItem[];
15
27
  open?: boolean;
16
28
  class?: string;
29
+ labels?: NotificationCenterLabels;
30
+ showClear?: boolean;
17
31
  onitemclick?: (item: NotificationItem) => void;
18
32
  onmarkallread?: () => void;
19
33
  onclear?: () => void;
package/dist/index.d.ts CHANGED
@@ -678,7 +678,7 @@ export type { LoginFormVariant, LoginFormAlign } from './components/organisms/Lo
678
678
  export { default as Menubar } from './components/organisms/Menubar/Menubar.svelte';
679
679
  export type { MenubarItem, MenubarSubItem } from './components/organisms/Menubar/Menubar.svelte';
680
680
  export { default as NotificationCenter } from './components/organisms/NotificationCenter/NotificationCenter.svelte';
681
- export type { NotificationItem } from './components/organisms/NotificationCenter/NotificationCenter.svelte';
681
+ export type { NotificationItem, NotificationCenterLabels } from './components/organisms/NotificationCenter/NotificationCenter.svelte';
682
682
  export { default as Kanban } from './components/organisms/Kanban/Kanban.svelte';
683
683
  export type { KanbanColumn, KanbanCard, KanbanAssignee, KanbanPriority } from './components/organisms/Kanban/Kanban.svelte';
684
684
  export { default as CookieConsent } from './components/organisms/CookieConsent/CookieConsent.svelte';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@r2digisolutions/components",
3
- "version": "0.14.3",
3
+ "version": "0.14.5",
4
4
  "private": false,
5
5
  "description": "R2DigiSolutions Svelte 5 component library — Atomic Design, Tailwind 4, Light/Dark mode",
6
6
  "license": "MIT",