@aiaiai-pt/design-system 0.5.7 → 0.5.8

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.
@@ -0,0 +1,293 @@
1
+ <!--
2
+ @component NotificationBell
3
+
4
+ Bell icon button with unread badge, opening a dropdown of notifications.
5
+ Composes Button (ghost, iconOnly), Badge, Menu, and EmptyState.
6
+
7
+ Consumes --notification-* tokens from components.css.
8
+
9
+ @example
10
+ <NotificationBell
11
+ notifications={[
12
+ { id: '1', message: 'Maintenance started', eventType: 'maintenance_started', createdAt: '2026-04-16T10:00:00Z' },
13
+ ]}
14
+ unreadCount={3}
15
+ onmarkread={(id) => markAsRead(id)}
16
+ onmarkallread={() => markAllAsRead()}
17
+ />
18
+ -->
19
+ <script>
20
+ import Button from './Button.svelte';
21
+ import Badge from './Badge.svelte';
22
+ import EmptyState from './EmptyState.svelte';
23
+ import Popover from './Popover.svelte';
24
+
25
+ /**
26
+ * @typedef {{ id: string; message: string; eventType: string; entityType?: string; entityId?: string; createdAt?: string; read?: boolean }} Notification
27
+ */
28
+
29
+ let {
30
+ /** @type {Notification[]} */
31
+ notifications = [],
32
+ /** @type {number} */
33
+ unreadCount = 0,
34
+ /** @type {((id: string) => void) | undefined} */
35
+ onmarkread = undefined,
36
+ /** @type {(() => void) | undefined} */
37
+ onmarkallread = undefined,
38
+ /** @type {((notification: Notification) => void) | undefined} */
39
+ onclick = undefined,
40
+ /** @type {string} */
41
+ class: className = '',
42
+ ...rest
43
+ } = $props();
44
+
45
+ let open = $state(false);
46
+ /** @type {HTMLElement | undefined} */
47
+ let anchorRef = $state(undefined);
48
+
49
+ /** @type {Record<string, string>} */
50
+ const eventIcons = {
51
+ maintenance_started: 'play',
52
+ maintenance_completed: 'check-fat',
53
+ inspection_completed: 'clipboard-text',
54
+ reservation_approved: 'thumbs-up',
55
+ reservation_rejected: 'thumbs-down',
56
+ };
57
+
58
+ /** @type {Record<string, string>} */
59
+ const eventColors = {
60
+ maintenance_started: 'var(--color-info)',
61
+ maintenance_completed: 'var(--color-success)',
62
+ inspection_completed: 'var(--color-success)',
63
+ reservation_approved: 'var(--color-success)',
64
+ reservation_rejected: 'var(--color-destructive)',
65
+ };
66
+
67
+ /**
68
+ * @param {string | undefined} iso
69
+ * @returns {string}
70
+ */
71
+ function formatTime(iso) {
72
+ if (!iso) return '';
73
+ try {
74
+ const d = new Date(iso);
75
+ const now = new Date();
76
+ const diff = now.getTime() - d.getTime();
77
+ const mins = Math.floor(diff / 60000);
78
+ if (mins < 1) return 'Just now';
79
+ if (mins < 60) return `${mins}m ago`;
80
+ const hrs = Math.floor(mins / 60);
81
+ if (hrs < 24) return `${hrs}h ago`;
82
+ const days = Math.floor(hrs / 24);
83
+ return `${days}d ago`;
84
+ } catch {
85
+ return '';
86
+ }
87
+ }
88
+
89
+ function handleItemClick(notification) {
90
+ if (!notification.read && onmarkread) {
91
+ onmarkread(notification.id);
92
+ }
93
+ if (onclick) {
94
+ onclick(notification);
95
+ }
96
+ }
97
+ </script>
98
+
99
+ <div class="notification-bell {className}" {...rest}>
100
+ <div class="notification-bell-trigger">
101
+ <Button
102
+ variant="ghost"
103
+ size="md"
104
+ iconOnly
105
+ aria-label="Notifications"
106
+ bind:ref={anchorRef}
107
+ onclick={() => { open = !open; }}
108
+ >
109
+ {#snippet icon()}
110
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256">
111
+ <path d="M221.8,175.94C216.25,166.38,208,139.33,208,104a80,80,0,1,0-160,0c0,35.34-8.26,62.38-13.81,71.94A16,16,0,0,0,48,200H88.81a40,40,0,0,0,78.38,0H208a16,16,0,0,0,13.8-24.06ZM128,216a24,24,0,0,1-22.62-16h45.24A24,24,0,0,1,128,216ZM48,184c7.7-13.24,16-43.92,16-80a64,64,0,1,1,128,0c0,36.05,8.28,66.73,16,80Z"/>
112
+ </svg>
113
+ {/snippet}
114
+ </Button>
115
+
116
+ {#if unreadCount > 0}
117
+ <div class="notification-bell-badge">
118
+ <Badge variant="error">
119
+ {unreadCount > 99 ? '99+' : unreadCount}
120
+ </Badge>
121
+ </div>
122
+ {/if}
123
+ </div>
124
+
125
+ <Popover bind:open anchor={anchorRef} placement="bottom-end">
126
+ <div class="notification-panel" role="region" aria-label="Notifications">
127
+ <div class="notification-header">
128
+ <span class="notification-title">Notifications</span>
129
+ {#if unreadCount > 0 && onmarkallread}
130
+ <button class="notification-mark-all" onclick={onmarkallread}>
131
+ Mark all read
132
+ </button>
133
+ {/if}
134
+ </div>
135
+
136
+ {#if notifications.length === 0}
137
+ <div class="notification-empty">
138
+ <EmptyState heading="No notifications" body="You're all caught up" />
139
+ </div>
140
+ {:else}
141
+ <div class="notification-list">
142
+ {#each notifications as notification (notification.id)}
143
+ <button
144
+ class="notification-item"
145
+ class:notification-item--unread={!notification.read}
146
+ onclick={() => handleItemClick(notification)}
147
+ >
148
+ <div
149
+ class="notification-item-dot"
150
+ style:background={!notification.read
151
+ ? (eventColors[notification.eventType] || 'var(--color-info)')
152
+ : 'transparent'}
153
+ ></div>
154
+ <div class="notification-item-body">
155
+ <span class="notification-item-message">{notification.message}</span>
156
+ <span class="notification-item-time">{formatTime(notification.createdAt)}</span>
157
+ </div>
158
+ </button>
159
+ {/each}
160
+ </div>
161
+ {/if}
162
+ </div>
163
+ </Popover>
164
+ </div>
165
+
166
+ <style>
167
+ .notification-bell {
168
+ position: relative;
169
+ display: inline-flex;
170
+ }
171
+
172
+ .notification-bell-trigger {
173
+ position: relative;
174
+ }
175
+
176
+ .notification-bell-badge {
177
+ position: absolute;
178
+ top: -2px;
179
+ right: -2px;
180
+ pointer-events: none;
181
+ }
182
+
183
+ .notification-panel {
184
+ width: var(--notification-panel-width, 360px);
185
+ max-height: var(--notification-panel-max-height, 420px);
186
+ display: flex;
187
+ flex-direction: column;
188
+ background: var(--color-surface);
189
+ border-radius: var(--radius-lg);
190
+ overflow: hidden;
191
+ }
192
+
193
+ .notification-header {
194
+ display: flex;
195
+ align-items: center;
196
+ justify-content: space-between;
197
+ padding: var(--space-md) var(--space-lg);
198
+ border-bottom: var(--elevation-border);
199
+ }
200
+
201
+ .notification-title {
202
+ font-family: var(--type-heading-font);
203
+ font-size: var(--type-body-sm-size);
204
+ font-weight: 600;
205
+ color: var(--color-text);
206
+ }
207
+
208
+ .notification-mark-all {
209
+ all: unset;
210
+ cursor: pointer;
211
+ font-family: var(--type-label-font);
212
+ font-size: var(--type-label-size);
213
+ letter-spacing: var(--type-label-tracking);
214
+ color: var(--color-accent);
215
+ transition: opacity var(--duration-instant) var(--easing-default);
216
+ }
217
+
218
+ .notification-mark-all:hover {
219
+ opacity: 0.8;
220
+ }
221
+
222
+ .notification-mark-all:focus-visible {
223
+ outline: var(--focus-ring-width) solid var(--focus-ring-color);
224
+ outline-offset: var(--focus-ring-offset);
225
+ }
226
+
227
+ .notification-empty {
228
+ padding: var(--space-2xl) var(--space-lg);
229
+ }
230
+
231
+ .notification-list {
232
+ overflow-y: auto;
233
+ flex: 1;
234
+ }
235
+
236
+ .notification-item {
237
+ all: unset;
238
+ cursor: pointer;
239
+ display: flex;
240
+ align-items: flex-start;
241
+ gap: var(--space-sm);
242
+ padding: var(--space-sm) var(--space-lg);
243
+ width: 100%;
244
+ box-sizing: border-box;
245
+ transition: background var(--duration-instant) var(--easing-default);
246
+ }
247
+
248
+ .notification-item:hover {
249
+ background: var(--color-surface-secondary);
250
+ }
251
+
252
+ .notification-item:focus-visible {
253
+ outline: var(--focus-ring-width) solid var(--focus-ring-color);
254
+ outline-offset: calc(-1 * var(--focus-ring-width));
255
+ }
256
+
257
+ .notification-item--unread {
258
+ background: var(--color-surface);
259
+ }
260
+
261
+ .notification-item-dot {
262
+ width: 8px;
263
+ height: 8px;
264
+ border-radius: var(--radius-pill);
265
+ flex-shrink: 0;
266
+ margin-top: 6px;
267
+ }
268
+
269
+ .notification-item-body {
270
+ display: flex;
271
+ flex-direction: column;
272
+ gap: var(--space-2xs);
273
+ flex: 1;
274
+ min-width: 0;
275
+ }
276
+
277
+ .notification-item-message {
278
+ font-family: var(--type-body-sm-font);
279
+ font-size: var(--type-body-sm-size);
280
+ color: var(--color-text);
281
+ line-height: 1.4;
282
+ }
283
+
284
+ .notification-item--unread .notification-item-message {
285
+ font-weight: 600;
286
+ }
287
+
288
+ .notification-item-time {
289
+ font-family: var(--type-label-font);
290
+ font-size: var(--type-label-size);
291
+ color: var(--color-text-muted);
292
+ }
293
+ </style>
@@ -0,0 +1,39 @@
1
+ export default NotificationBell;
2
+ type NotificationBell = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<$$ComponentProps>): void;
5
+ };
6
+ /**
7
+ * NotificationBell
8
+ *
9
+ * Bell icon button with unread badge, opening a dropdown of notifications.
10
+ * Composes Button (ghost, iconOnly), Badge, Menu, and EmptyState.
11
+ *
12
+ * Consumes --notification-* tokens from components.css.
13
+ *
14
+ * @example
15
+ * <NotificationBell
16
+ * notifications={[
17
+ * { id: '1', message: 'Maintenance started', eventType: 'maintenance_started', createdAt: '2026-04-16T10:00:00Z' },
18
+ * ]}
19
+ * unreadCount={3}
20
+ * onmarkread={(id) => markAsRead(id)}
21
+ * onmarkallread={() => markAllAsRead()}
22
+ * />
23
+ */
24
+ declare const NotificationBell: import("svelte").Component<{
25
+ notifications?: any[];
26
+ unreadCount?: number;
27
+ onmarkread?: any;
28
+ onmarkallread?: any;
29
+ onclick?: any;
30
+ class?: string;
31
+ } & Record<string, any>, {}, "">;
32
+ type $$ComponentProps = {
33
+ notifications?: any[];
34
+ unreadCount?: number;
35
+ onmarkread?: any;
36
+ onmarkallread?: any;
37
+ onclick?: any;
38
+ class?: string;
39
+ } & Record<string, any>;
@@ -0,0 +1,182 @@
1
+ <!--
2
+ @component ToastManager
3
+
4
+ Lifecycle manager for Toast notifications — positioning, stacking,
5
+ auto-dismiss, and max-visible limit. Wraps the visual Toast component.
6
+
7
+ Mount once in your root layout. Use the exported `toasts` store to
8
+ push notifications from anywhere in the app.
9
+
10
+ Consumes --toast-manager-* tokens from components.css.
11
+
12
+ @example
13
+ <script>
14
+ import { ToastManager, toasts } from '@aiaiai-pt/design-system';
15
+
16
+ toasts.push({ variant: 'success', message: 'Saved!' });
17
+ toasts.push({ variant: 'error', message: 'Failed.', autoDismiss: 8000 });
18
+ </script>
19
+
20
+ <ToastManager />
21
+ -->
22
+ <script>
23
+ import Toast from './Toast.svelte';
24
+
25
+ /**
26
+ * @typedef {'info' | 'success' | 'warning' | 'error'} Variant
27
+ * @typedef {{ id: string; variant: Variant; message: string; actionLabel?: string; onaction?: () => void; autoDismiss?: number }} ToastItem
28
+ */
29
+
30
+ let {
31
+ /** @type {'top-right' | 'top-left' | 'bottom-right' | 'bottom-left'} */
32
+ position = 'top-right',
33
+ /** @type {number} ms before auto-dismiss (0 = no auto-dismiss) */
34
+ autoDismiss = 5000,
35
+ /** @type {number} max visible toasts — oldest removed when exceeded */
36
+ maxVisible = 5,
37
+ /** @type {string} */
38
+ class: className = '',
39
+ } = $props();
40
+
41
+ /** @type {ToastItem[]} */
42
+ let items = $state([]);
43
+
44
+ /** @type {Map<string, ReturnType<typeof setTimeout>>} */
45
+ const timers = new Map();
46
+
47
+ let _idCounter = 0;
48
+
49
+ /**
50
+ * Add a toast. Returns the toast id for manual removal.
51
+ * @param {{ variant?: Variant; message: string; actionLabel?: string; onaction?: () => void; autoDismiss?: number }} toast
52
+ * @returns {string}
53
+ */
54
+ export function push(toast) {
55
+ const id = `toast-${++_idCounter}-${Date.now()}`;
56
+ const item = {
57
+ id,
58
+ variant: toast.variant ?? 'info',
59
+ message: toast.message,
60
+ actionLabel: toast.actionLabel,
61
+ onaction: toast.onaction,
62
+ autoDismiss: toast.autoDismiss,
63
+ };
64
+
65
+ items = [...items, item];
66
+
67
+ // Enforce max visible
68
+ if (items.length > maxVisible) {
69
+ const removed = items[0];
70
+ items = items.slice(1);
71
+ _clearTimer(removed.id);
72
+ }
73
+
74
+ // Schedule auto-dismiss
75
+ const duration = item.autoDismiss ?? autoDismiss;
76
+ if (duration > 0) {
77
+ const timer = setTimeout(() => dismiss(id), duration);
78
+ timers.set(id, timer);
79
+ }
80
+
81
+ return id;
82
+ }
83
+
84
+ /**
85
+ * Remove a toast by id.
86
+ * @param {string} id
87
+ */
88
+ export function dismiss(id) {
89
+ _clearTimer(id);
90
+ items = items.filter((t) => t.id !== id);
91
+ }
92
+
93
+ /** Remove all toasts. */
94
+ export function clear() {
95
+ for (const [id] of timers) clearTimeout(timers.get(id));
96
+ timers.clear();
97
+ items = [];
98
+ }
99
+
100
+ /**
101
+ * @param {string} id
102
+ */
103
+ function _clearTimer(id) {
104
+ const timer = timers.get(id);
105
+ if (timer) {
106
+ clearTimeout(timer);
107
+ timers.delete(id);
108
+ }
109
+ }
110
+
111
+ const positionClasses = {
112
+ 'top-right': 'tm-top tm-right',
113
+ 'top-left': 'tm-top tm-left',
114
+ 'bottom-right': 'tm-bottom tm-right',
115
+ 'bottom-left': 'tm-bottom tm-left',
116
+ };
117
+ </script>
118
+
119
+ {#if items.length > 0}
120
+ <div
121
+ class="toast-manager {positionClasses[position]} {className}"
122
+ aria-live="polite"
123
+ aria-relevant="additions removals"
124
+ >
125
+ {#each items as item (item.id)}
126
+ <div class="toast-slot" data-toast-id={item.id}>
127
+ <Toast
128
+ variant={item.variant}
129
+ actionLabel={item.actionLabel}
130
+ onaction={item.onaction}
131
+ >
132
+ {@html item.message}
133
+ </Toast>
134
+ </div>
135
+ {/each}
136
+ </div>
137
+ {/if}
138
+
139
+ <style>
140
+ .toast-manager {
141
+ position: fixed;
142
+ z-index: var(--toast-manager-z, 60);
143
+ display: flex;
144
+ flex-direction: column;
145
+ gap: var(--toast-manager-gap, var(--space-sm));
146
+ padding: var(--toast-manager-padding, var(--space-lg));
147
+ pointer-events: none;
148
+ }
149
+
150
+ .toast-manager > :global(*) {
151
+ pointer-events: auto;
152
+ }
153
+
154
+ /* Position modifiers */
155
+ .tm-top { top: 0; }
156
+ .tm-bottom { bottom: 0; flex-direction: column-reverse; }
157
+ .tm-right { right: 0; align-items: flex-end; }
158
+ .tm-left { left: 0; align-items: flex-start; }
159
+
160
+ /* Entry animation */
161
+ .toast-slot {
162
+ animation: toast-enter var(--duration-normal) var(--easing-enter);
163
+ }
164
+
165
+ @keyframes toast-enter {
166
+ from {
167
+ opacity: 0;
168
+ transform: translateX(var(--toast-manager-enter-offset, 16px));
169
+ }
170
+ to {
171
+ opacity: 1;
172
+ transform: translateX(0);
173
+ }
174
+ }
175
+
176
+ /* Reduced motion */
177
+ @media (prefers-reduced-motion: reduce) {
178
+ .toast-slot {
179
+ animation: none;
180
+ }
181
+ }
182
+ </style>
@@ -0,0 +1,59 @@
1
+ export default ToastManager;
2
+ type ToastManager = {
3
+ $on?(type: string, callback: (e: any) => void): () => void;
4
+ $set?(props: Partial<$$ComponentProps>): void;
5
+ } & {
6
+ push: (toast: {
7
+ variant?: Variant;
8
+ message: string;
9
+ actionLabel?: string;
10
+ onaction?: () => void;
11
+ autoDismiss?: number;
12
+ }) => string;
13
+ dismiss: (id: string) => void;
14
+ clear: () => void;
15
+ };
16
+ /**
17
+ * ToastManager
18
+ *
19
+ * Lifecycle manager for Toast notifications — positioning, stacking,
20
+ * auto-dismiss, and max-visible limit. Wraps the visual Toast component.
21
+ *
22
+ * Mount once in your root layout. Use the exported `toasts` store to
23
+ * push notifications from anywhere in the app.
24
+ *
25
+ * Consumes --toast-manager-* tokens from components.css.
26
+ *
27
+ * @example
28
+ * <script>
29
+ * import { ToastManager, toasts } from '@aiaiai-pt/design-system';
30
+ *
31
+ * toasts.push({ variant: 'success', message: 'Saved!' });
32
+ * toasts.push({ variant: 'error', message: 'Failed.', autoDismiss: 8000 });
33
+ * </script>
34
+ *
35
+ * <ToastManager />
36
+ */
37
+ declare const ToastManager: import("svelte").Component<{
38
+ position?: string;
39
+ autoDismiss?: number;
40
+ maxVisible?: number;
41
+ class?: string;
42
+ }, {
43
+ push: (toast: {
44
+ variant?: "info" | "success" | "warning" | "error";
45
+ message: string;
46
+ actionLabel?: string;
47
+ onaction?: () => void;
48
+ autoDismiss?: number;
49
+ }) => string;
50
+ dismiss: (id: string) => void;
51
+ clear: () => void;
52
+ }, "">;
53
+ type $$ComponentProps = {
54
+ position?: string;
55
+ autoDismiss?: number;
56
+ maxVisible?: number;
57
+ class?: string;
58
+ };
59
+ type Variant = "info" | "success" | "warning" | "error";
@@ -63,7 +63,9 @@ export { default as TabPanel } from "./TabPanel.svelte";
63
63
  // Feedback
64
64
  export { default as Alert } from "./Alert.svelte";
65
65
  export { default as Toast } from "./Toast.svelte";
66
+ export { default as ToastManager } from "./ToastManager.svelte";
66
67
  export { default as EmptyState } from "./EmptyState.svelte";
68
+ export { default as NotificationBell } from "./NotificationBell.svelte";
67
69
 
68
70
  // Navigation
69
71
  export { default as Sidebar } from "./Sidebar.svelte";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiaiai-pt/design-system",
3
- "version": "0.5.7",
3
+ "version": "0.5.8",
4
4
  "description": "Design system tokens and Svelte components for aiaiai products",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -244,6 +244,16 @@
244
244
  --toast-font-size: var(--type-body-sm-size);
245
245
  --toast-max-width: 360px;
246
246
 
247
+ /* Toast Manager */
248
+ --toast-manager-z: 60;
249
+ --toast-manager-gap: var(--space-sm);
250
+ --toast-manager-padding: var(--space-lg);
251
+ --toast-manager-enter-offset: 16px;
252
+
253
+ /* Notification Bell */
254
+ --notification-panel-width: 360px;
255
+ --notification-panel-max-height: 420px;
256
+
247
257
  /* Empty state */
248
258
  --empty-icon-size: var(--icon-size-2xl);
249
259
  --empty-icon-color: var(--color-text-muted);