@aiaiai-pt/design-system 0.5.6 → 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.
- package/components/GeoSearch.svelte +41 -11
- package/components/MapPicker.svelte +68 -3
- package/components/NotificationBell.svelte +293 -0
- package/components/NotificationBell.svelte.d.ts +39 -0
- package/components/ToastManager.svelte +182 -0
- package/components/ToastManager.svelte.d.ts +59 -0
- package/components/index.js +2 -0
- package/package.json +1 -1
- package/tokens/components.css +10 -0
|
@@ -1,25 +1,23 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
@component GeoSearch
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
Bidirectional geocoding input powered by Nominatim (OpenStreetMap).
|
|
5
|
+
Forward: type address → get coordinates.
|
|
6
|
+
Reverse: set `coords` prop → shows resolved address.
|
|
7
7
|
Consumes --combobox-* tokens from components.css.
|
|
8
8
|
|
|
9
|
-
@example
|
|
9
|
+
@example Forward geocoding
|
|
10
10
|
<GeoSearch onlocation={(lon, lat) => console.log(lon, lat)} />
|
|
11
11
|
|
|
12
|
-
@example
|
|
12
|
+
@example Bidirectional (MapPicker sets coords on click → address shows)
|
|
13
13
|
<GeoSearch
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
viewbox={[-9.5, 38.5, -8.8, 39.0]}
|
|
17
|
-
onlocation={(lon, lat) => { mapCenter = [lon, lat]; mapZoom = 15; }}
|
|
14
|
+
bind:coords={pickedCoords}
|
|
15
|
+
onlocation={(lon, lat) => { mapCenter = [lon, lat]; }}
|
|
18
16
|
/>
|
|
19
17
|
|
|
20
|
-
@example
|
|
18
|
+
@example With viewbox bias
|
|
21
19
|
<GeoSearch
|
|
22
|
-
|
|
20
|
+
viewbox={[-9.5, 38.5, -8.8, 39.0]}
|
|
23
21
|
onlocation={(lon, lat) => console.log(lon, lat)}
|
|
24
22
|
/>
|
|
25
23
|
-->
|
|
@@ -45,6 +43,8 @@
|
|
|
45
43
|
viewbox = undefined,
|
|
46
44
|
/** @type {((lon: number, lat: number, item: { label: string, value: string }) => void) | undefined} */
|
|
47
45
|
onlocation = undefined,
|
|
46
|
+
/** @type {[number, number] | undefined} — set externally to reverse-geocode and show address */
|
|
47
|
+
coords = $bindable(undefined),
|
|
48
48
|
/** @type {string} */
|
|
49
49
|
class: className = '',
|
|
50
50
|
...rest
|
|
@@ -55,6 +55,36 @@
|
|
|
55
55
|
let loading = $state(false);
|
|
56
56
|
let value = $state('');
|
|
57
57
|
let debounceTimer = 0;
|
|
58
|
+
let lastReversed = '';
|
|
59
|
+
|
|
60
|
+
// Reverse geocode when coords change (map click or initial load)
|
|
61
|
+
$effect(() => {
|
|
62
|
+
if (!coords) return;
|
|
63
|
+
const key = `${coords[0]},${coords[1]}`;
|
|
64
|
+
if (key === lastReversed) return;
|
|
65
|
+
lastReversed = key;
|
|
66
|
+
reverseGeocode(coords[0], coords[1]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
async function reverseGeocode(lon, lat) {
|
|
70
|
+
loading = true;
|
|
71
|
+
try {
|
|
72
|
+
const base = providerUrl.replace('/search', '/reverse');
|
|
73
|
+
const resp = await fetch(`${base}?lon=${lon}&lat=${lat}&format=json`, {
|
|
74
|
+
headers: { 'Accept': 'application/json' },
|
|
75
|
+
});
|
|
76
|
+
if (!resp.ok) return;
|
|
77
|
+
const result = await resp.json();
|
|
78
|
+
if (result.display_name) {
|
|
79
|
+
value = `${lon},${lat}`;
|
|
80
|
+
items = [{ value: `${lon},${lat}`, label: result.display_name, description: result.type?.replace(/_/g, ' ') ?? '' }];
|
|
81
|
+
}
|
|
82
|
+
} catch {
|
|
83
|
+
// Reverse geocoding failed — leave search bar as-is
|
|
84
|
+
} finally {
|
|
85
|
+
loading = false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
58
88
|
|
|
59
89
|
/** Nominatim rate limit: 1 req/sec. Debounce at 400ms. */
|
|
60
90
|
function handleSearch(query) {
|
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
Interactive map for selecting a point or drawing a polygon.
|
|
5
5
|
OpenLayers with configurable tiles and Draw interaction.
|
|
6
6
|
Draw sketch styled with DS tokens (not OL blue default).
|
|
7
|
+
Built-in GeoSearch (Nominatim) for address lookup + pan.
|
|
7
8
|
Consumes --map-* tokens from components.css.
|
|
8
9
|
|
|
9
|
-
@example Point selection
|
|
10
|
+
@example Point selection with search
|
|
10
11
|
<MapPicker mode="point" onchange={(coords) => console.log(coords)} />
|
|
11
12
|
|
|
13
|
+
@example Without search
|
|
14
|
+
<MapPicker mode="point" search={false} onchange={(coords) => console.log(coords)} />
|
|
15
|
+
|
|
12
16
|
@example Polygon drawing
|
|
13
17
|
<MapPicker mode="polygon" onchange={(coords) => console.log(coords)} />
|
|
14
18
|
-->
|
|
@@ -19,6 +23,7 @@
|
|
|
19
23
|
<script>
|
|
20
24
|
import { fromLonLat, toLonLat } from 'ol/proj.js';
|
|
21
25
|
import { createTileLayer, createMapStyles, watchTheme, renderMapError } from './map-utils.js';
|
|
26
|
+
import GeoSearch from './GeoSearch.svelte';
|
|
22
27
|
|
|
23
28
|
let {
|
|
24
29
|
/** @type {'point' | 'polygon'} */
|
|
@@ -37,6 +42,12 @@
|
|
|
37
42
|
error = undefined,
|
|
38
43
|
/** @type {boolean} */
|
|
39
44
|
disabled = false,
|
|
45
|
+
/** @type {boolean} — show GeoSearch bar above map */
|
|
46
|
+
search = true,
|
|
47
|
+
/** @type {string} — Nominatim-compatible search endpoint */
|
|
48
|
+
searchProviderUrl = undefined,
|
|
49
|
+
/** @type {[number, number, number, number] | undefined} — viewbox bias for search */
|
|
50
|
+
searchViewbox = undefined,
|
|
40
51
|
/** @type {import('./map-utils.js').TileSourceConfig} */
|
|
41
52
|
tileSource = { type: 'osm' },
|
|
42
53
|
/** @type {((coords: [number, number] | number[][]) => void) | undefined} */
|
|
@@ -57,6 +68,45 @@
|
|
|
57
68
|
|
|
58
69
|
/** @type {HTMLElement | undefined} */
|
|
59
70
|
let container = $state();
|
|
71
|
+
/** @type {import('ol/Map.js').default | undefined} */
|
|
72
|
+
let _map = $state();
|
|
73
|
+
/** @type {any} — VectorSource for placing markers via search */
|
|
74
|
+
let _vectorSource;
|
|
75
|
+
/** @type {any} */
|
|
76
|
+
let _Feature;
|
|
77
|
+
/** @type {any} */
|
|
78
|
+
let _Point;
|
|
79
|
+
|
|
80
|
+
// Bidirectional coords for GeoSearch: search → map, map click → reverse geocode
|
|
81
|
+
let searchCoords = $state(/** @type {[number, number] | undefined} */ (undefined));
|
|
82
|
+
let initialReverseDone = false;
|
|
83
|
+
|
|
84
|
+
// Set searchCoords from initial value (triggers reverse geocode in GeoSearch)
|
|
85
|
+
$effect(() => {
|
|
86
|
+
if (!initialReverseDone && value) {
|
|
87
|
+
searchCoords = /** @type {[number, number]} */ ([...value]);
|
|
88
|
+
initialReverseDone = true;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
function handleGeoLocation(lon, lat) {
|
|
93
|
+
if (!_map) return;
|
|
94
|
+
const view = _map.getView();
|
|
95
|
+
if (view) view.animate({ center: fromLonLat([lon, lat]), zoom: 16, duration: 400 });
|
|
96
|
+
|
|
97
|
+
// In point mode, also place a marker and emit the value
|
|
98
|
+
if (mode === 'point' && _vectorSource && _Feature && _Point) {
|
|
99
|
+
_vectorSource.clear();
|
|
100
|
+
_vectorSource.addFeature(new _Feature({ geometry: new _Point(fromLonLat([lon, lat])) }));
|
|
101
|
+
value = [lon, lat];
|
|
102
|
+
onchange?.([lon, lat]);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Called by MapPicker when user clicks to place a point — triggers reverse geocoding */
|
|
107
|
+
function handleMapPointPlaced(lon, lat) {
|
|
108
|
+
searchCoords = [lon, lat];
|
|
109
|
+
}
|
|
60
110
|
|
|
61
111
|
$effect(() => {
|
|
62
112
|
if (!container || disabled) return;
|
|
@@ -95,6 +145,9 @@
|
|
|
95
145
|
if (disposed) return;
|
|
96
146
|
|
|
97
147
|
const vectorSource = new VectorSource();
|
|
148
|
+
_vectorSource = vectorSource;
|
|
149
|
+
_Feature = Feature;
|
|
150
|
+
_Point = Point;
|
|
98
151
|
|
|
99
152
|
if (value && mode === 'point') {
|
|
100
153
|
vectorSource.addFeature(new Feature({ geometry: new Point(fromLonLat(value)) }));
|
|
@@ -133,6 +186,7 @@
|
|
|
133
186
|
const coords = /** @type {import('ol/geom/Point.js').default} */ (geom).getCoordinates();
|
|
134
187
|
const wgs84 = /** @type {[number, number]} */ (toLonLat(coords));
|
|
135
188
|
value = wgs84;
|
|
189
|
+
handleMapPointPlaced(wgs84[0], wgs84[1]);
|
|
136
190
|
onchange?.(wgs84);
|
|
137
191
|
} else {
|
|
138
192
|
const coords = /** @type {import('ol/geom/Polygon.js').default} */ (geom).getCoordinates()[0];
|
|
@@ -145,14 +199,14 @@
|
|
|
145
199
|
|
|
146
200
|
map = new OlMap({
|
|
147
201
|
target: container,
|
|
148
|
-
layers: [tileLayer, vectorLayer],
|
|
149
|
-
view: new View({
|
|
202
|
+
layers: [tileLayer, vectorLayer], view: new View({
|
|
150
203
|
center: initialCenter,
|
|
151
204
|
zoom,
|
|
152
205
|
}),
|
|
153
206
|
});
|
|
154
207
|
|
|
155
208
|
map.addInteraction(drawInteraction);
|
|
209
|
+
_map = map;
|
|
156
210
|
|
|
157
211
|
disposeTheme = watchTheme(() => {
|
|
158
212
|
styles.refresh();
|
|
@@ -173,6 +227,17 @@
|
|
|
173
227
|
<label class="map-picker-label" for={pickerId}>{label}</label>
|
|
174
228
|
{/if}
|
|
175
229
|
|
|
230
|
+
{#if search && !disabled}
|
|
231
|
+
<GeoSearch
|
|
232
|
+
placeholder="Search address or place..."
|
|
233
|
+
providerUrl={searchProviderUrl}
|
|
234
|
+
viewbox={searchViewbox}
|
|
235
|
+
onlocation={handleGeoLocation}
|
|
236
|
+
bind:coords={searchCoords}
|
|
237
|
+
size="sm"
|
|
238
|
+
/>
|
|
239
|
+
{/if}
|
|
240
|
+
|
|
176
241
|
<div
|
|
177
242
|
bind:this={container}
|
|
178
243
|
id={pickerId}
|
|
@@ -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";
|
package/components/index.js
CHANGED
|
@@ -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
package/tokens/components.css
CHANGED
|
@@ -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);
|