emilsoftware-utilities 2.0.0-dev.63 → 2.0.0-dev.65

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,1336 @@
1
+ /*!
2
+ * Emil Software - <es-aree-disponibili>
3
+ *
4
+ * Web Component frameworkless per mostrare le aree abilitate di un utente.
5
+ * Non dipende da Angular, React, Ionic o Bootstrap: basta includere questo file
6
+ * e usare il tag <es-aree-disponibili>.
7
+ *
8
+ * API pubblica:
9
+ * element.groups = [{ name, order, areas: [...] }]
10
+ * element.heading / subtitle / note / eyebrow / emptyText
11
+ * element.hrefMode = 'hash' | 'path' | 'none'
12
+ * element.theme = 'auto' | 'light' | 'dark'
13
+ * eventi: 'es-area-select' (area disponibile), 'es-area-blocked'
14
+ *
15
+ * I colori sono token CSS (--es-*) e, se presenti, ereditano i token
16
+ * Bootstrap 5.3 (--bs-*). Vedi README.md.
17
+ */
18
+ (function () {
19
+ 'use strict';
20
+
21
+ if (typeof window === 'undefined' || typeof customElements === 'undefined') {
22
+ return;
23
+ }
24
+
25
+ const TAG = 'es-aree-disponibili';
26
+ const DEFAULT_UNAVAILABLE =
27
+ "La pagina {page} non esiste nell'app al momento.";
28
+
29
+ const ICONS = {
30
+ 'home-outline':
31
+ '<path d="M3 10.5 12 3l9 7.5"/><path d="M5 9.5V21h14V9.5"/><path d="M9 21v-6h6v6"/>',
32
+ 'people-outline':
33
+ '<circle cx="9" cy="8" r="3"/><path d="M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6"/><path d="M16 5.5a3 3 0 0 1 0 5.8"/><path d="M17.5 14.4c2.1.8 3.5 2.8 3.5 5.6"/>',
34
+ 'albums-outline':
35
+ '<rect x="3" y="5" width="18" height="14" rx="2"/><path d="M7 9h10M7 13h10M7 17h6"/>',
36
+ 'bulb-outline':
37
+ '<path d="M9 18h6"/><path d="M10 21h4"/><path d="M12 3a6 6 0 0 0-4 10.5c.7.7 1 1.5 1 2.5h6c0-1 .3-1.8 1-2.5A6 6 0 0 0 12 3Z"/>',
38
+ 'checkmark-circle-outline':
39
+ '<circle cx="12" cy="12" r="9"/><path d="m8.5 12 2.5 2.5 4.5-5"/>',
40
+ 'checkmark-done-outline':
41
+ '<path d="m2 13 4 4 8-9"/><path d="m10 15 2 2 10-11"/>',
42
+ 'document-outline':
43
+ '<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9Z"/><path d="M14 3v6h6"/><path d="M8 13h8M8 17h5"/>',
44
+ 'eye-outline':
45
+ '<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z"/><circle cx="12" cy="12" r="3"/>',
46
+ 'information-circle-outline':
47
+ '<circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><path d="M12 8h.01"/>',
48
+ 'lock-closed-outline':
49
+ '<rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
50
+ 'play-outline': '<path d="M8 5v14l11-7Z"/>',
51
+ 'book-outline':
52
+ '<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2Z"/>',
53
+ 'camera-outline':
54
+ '<path d="M4 7h3l2-3h6l2 3h3a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2Z"/><circle cx="12" cy="13" r="4"/>',
55
+ 'analytics-outline': '<path d="M4 20V10M10 20V4M16 20v-7M22 20H2"/>',
56
+ 'cash-outline':
57
+ '<rect x="2" y="6" width="20" height="12" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M6 10v4M18 10v4"/>',
58
+ 'chatbubbles-outline':
59
+ '<path d="M21 12a8 8 0 0 1-8 8H7l-4 3v-6.5A8 8 0 1 1 21 12Z"/>',
60
+ 'folder-outline':
61
+ '<path d="M3 7a2 2 0 0 1 2-2h4l2 3h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
62
+ 'megaphone-outline':
63
+ '<path d="M3 11v2a1 1 0 0 0 1 1h2l5 3V7L6 10H4a1 1 0 0 0-1 1Z"/><path d="M14 8a5 5 0 0 1 0 8"/>',
64
+ 'shield-checkmark-outline':
65
+ '<path d="M12 3l7 3v5c0 5-3.5 8.5-7 10-3.5-1.5-7-5-7-10V6Z"/><path d="m9 12 2 2 4-4"/>',
66
+ 'arrow-forward-outline': '<path d="M5 12h14M13 6l6 6-6 6"/>',
67
+ 'compass-outline':
68
+ '<circle cx="12" cy="12" r="9"/><path d="m15.5 8.5-2 5-5 2 2-5 5-2Z"/>',
69
+ 'grid-outline':
70
+ '<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>',
71
+ };
72
+
73
+ const ESCAPES = {
74
+ '&': '&amp;',
75
+ '<': '&lt;',
76
+ '>': '&gt;',
77
+ '"': '&quot;',
78
+ "'": '&#39;',
79
+ };
80
+
81
+ function escapeHtml(value) {
82
+ return String(value == null ? '' : value).replace(
83
+ /[&<>"']/g,
84
+ (character) => ESCAPES[character],
85
+ );
86
+ }
87
+
88
+ function icon(name, className) {
89
+ const markup = ICONS[String(name || '').trim()] || ICONS['grid-outline'];
90
+ return (
91
+ '<svg class="' +
92
+ escapeHtml(className || 'icon') +
93
+ '" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
94
+ 'stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" ' +
95
+ 'aria-hidden="true" focusable="false">' +
96
+ markup +
97
+ '</svg>'
98
+ );
99
+ }
100
+
101
+ function normalizePage(value) {
102
+ const page = String(value == null ? '' : value).trim();
103
+ if (!page) return '';
104
+ return page.startsWith('/') ? page : '/' + page;
105
+ }
106
+
107
+ function normalizeArea(area) {
108
+ const route = String(area && area.route ? area.route : '').trim();
109
+ const page = normalizePage(area && area.page);
110
+ const available =
111
+ typeof (area && area.available) === 'boolean'
112
+ ? area.available
113
+ : Boolean(route);
114
+ return {
115
+ code: String((area && area.code) || page || route || ''),
116
+ label: String(
117
+ (area && (area.label || area.name || area.description)) ||
118
+ (area && area.code) ||
119
+ page ||
120
+ route ||
121
+ '',
122
+ ),
123
+ group: String((area && area.group) || ''),
124
+ icon: String((area && (area.icon || area.icona)) || 'grid-outline'),
125
+ route: available ? route : '',
126
+ page,
127
+ url: String((area && area.url) || ''),
128
+ available,
129
+ reason: String((area && area.reason) || ''),
130
+ order: Number((area && area.order) || 0),
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Accetta sia una lista di gruppi gia pronta sia una lista piatta di aree.
136
+ * In quest'ultimo caso raggruppa per `group`.
137
+ */
138
+ function normalizeGroups(input) {
139
+ if (!Array.isArray(input)) return [];
140
+ const groups = [];
141
+ const hasExplicitGroups = input.some(
142
+ (item) => item && Array.isArray(item.areas),
143
+ );
144
+
145
+ if (hasExplicitGroups) {
146
+ input.forEach((group, index) => {
147
+ if (!group || !Array.isArray(group.areas)) return;
148
+ const areas = group.areas.map(normalizeArea);
149
+ if (areas.length === 0) return;
150
+ groups.push({
151
+ name: String(group.name || group.label || 'Altre aree'),
152
+ order: Number(group.order == null ? index : group.order),
153
+ areas,
154
+ });
155
+ });
156
+ } else {
157
+ const byName = new Map();
158
+ input.map(normalizeArea).forEach((area, index) => {
159
+ const name = area.group || 'Altre aree';
160
+ let group = byName.get(name);
161
+ if (!group) {
162
+ group = { name, order: 999, areas: [] };
163
+ byName.set(name, group);
164
+ }
165
+ area.order = area.order || index;
166
+ group.areas.push(area);
167
+ });
168
+ byName.forEach((group) => groups.push(group));
169
+ }
170
+
171
+ groups.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name));
172
+ groups.forEach((group) =>
173
+ group.areas.sort(
174
+ (a, b) => a.order - b.order || a.label.localeCompare(b.label),
175
+ ),
176
+ );
177
+ return groups;
178
+ }
179
+
180
+ /**
181
+ * Appiattisce sia i grant Accessi sia le strutture gia raggruppate
182
+ * (`mnuAbil`, `{ codGrp, desGrp, menu }`) in una lista uniforme.
183
+ */
184
+ function flattenGrants(input) {
185
+ if (!Array.isArray(input)) return [];
186
+
187
+ const grouped = input.filter(
188
+ (item) =>
189
+ item &&
190
+ (Array.isArray(item.menu) || Array.isArray(item.menus)),
191
+ );
192
+
193
+ if (grouped.length > 0) {
194
+ const flat = [];
195
+ for (const group of grouped) {
196
+ const menus = group.menu || group.menus || [];
197
+ for (const menu of menus) {
198
+ flat.push({
199
+ codiceMenu: menu.codMnu ?? menu.codiceMenu ?? menu.code,
200
+ descrizioneMenu:
201
+ menu.desMnu ?? menu.descrizioneMenu ?? menu.label,
202
+ codiceGruppo:
203
+ group.codGrp ?? group.codiceGruppo ?? menu.codiceGruppo,
204
+ descrizioneGruppo:
205
+ group.desGrp ??
206
+ group.descrizioneGruppo ??
207
+ menu.descrizioneGruppo,
208
+ icona: menu.icon ?? menu.icona,
209
+ pagina: menu.pagina ?? menu.page,
210
+ tipo: menu.tipo ?? 'M',
211
+ });
212
+ }
213
+ }
214
+ return flat;
215
+ }
216
+
217
+ return input;
218
+ }
219
+
220
+ function asNumberMap(value) {
221
+ return value && typeof value === 'object' ? value : {};
222
+ }
223
+
224
+ function buildModel(input, options) {
225
+ const config = options || {};
226
+ const routeMap = config.routeMap || {};
227
+ const menuOrder = asNumberMap(config.menuOrder);
228
+ const groupOrder = asNumberMap(config.groupOrder);
229
+ const availableRoutes = new Set(
230
+ Array.isArray(config.availableRoutes)
231
+ ? config.availableRoutes.map(normalizePage)
232
+ : [],
233
+ );
234
+ const isRouteAvailable =
235
+ typeof config.isRouteAvailable === 'function'
236
+ ? config.isRouteAvailable
237
+ : (path) => availableRoutes.has(normalizePage(path));
238
+
239
+ const seen = new Set();
240
+ const areas = [];
241
+
242
+ flattenGrants(input).forEach((grant, index) => {
243
+ if (!grant || typeof grant !== 'object') return;
244
+ const type = String(grant.tipo || 'M').toUpperCase();
245
+ if (type !== 'M') return;
246
+
247
+ const code = String(
248
+ grant.codiceMenu || grant.codMnu || '',
249
+ )
250
+ .trim()
251
+ .toUpperCase();
252
+ if (!code) return;
253
+
254
+ const page = normalizePage(grant.pagina || grant.page);
255
+ if (!page) return;
256
+
257
+ const mappedRoute = String(routeMap[code] || '').trim();
258
+ const known = Boolean(mappedRoute) || isRouteAvailable(page);
259
+ const route = mappedRoute || (known ? page : '');
260
+ const available = Boolean(route);
261
+
262
+ const key = available ? `route:${route}` : `menu:${code}`;
263
+ if (seen.has(key)) return;
264
+ seen.add(key);
265
+
266
+ const groupName = String(
267
+ grant.descrizioneGruppo ||
268
+ grant.desGrp ||
269
+ grant.codiceGruppo ||
270
+ grant.codGrp ||
271
+ '',
272
+ ).trim();
273
+ const groupKey =
274
+ String(grant.codiceGruppo || grant.codGrp || '').trim() ||
275
+ groupName ||
276
+ 'ALTRE';
277
+
278
+ areas.push({
279
+ code,
280
+ label: String(
281
+ grant.descrizioneMenu ||
282
+ grant.desMnu ||
283
+ grant.label ||
284
+ code,
285
+ ),
286
+ group: groupName,
287
+ groupKey,
288
+ groupOrder: Number(groupOrder[groupKey] ?? 999),
289
+ order: Number(menuOrder[code] ?? index),
290
+ icon: String(grant.icona || grant.icon || 'grid-outline'),
291
+ route,
292
+ page,
293
+ url: '',
294
+ available,
295
+ reason: available
296
+ ? ''
297
+ : typeof config.reasonFor === 'function'
298
+ ? String(config.reasonFor(page) || '')
299
+ : '',
300
+ });
301
+ });
302
+
303
+ const grouped = new Map();
304
+ for (const area of areas) {
305
+ let group = grouped.get(area.groupKey);
306
+ if (!group) {
307
+ group = {
308
+ name: area.group || 'Altre aree',
309
+ order: area.groupOrder,
310
+ areas: [],
311
+ };
312
+ grouped.set(area.groupKey, group);
313
+ }
314
+ group.areas.push(area);
315
+ }
316
+
317
+ const groups = [...grouped.values()].sort(
318
+ (a, b) => a.order - b.order || a.name.localeCompare(b.name),
319
+ );
320
+ for (const group of groups) {
321
+ group.areas.sort(
322
+ (a, b) => a.order - b.order || a.label.localeCompare(b.label),
323
+ );
324
+ }
325
+ return groups;
326
+ }
327
+
328
+ const STYLES = `
329
+ :host {
330
+ --es-font: var(--bs-body-font-family, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif);
331
+ --es-radius: 14px;
332
+ --es-radius-lg: 20px;
333
+ --es-bg: var(--bs-body-bg, #f4f6fb);
334
+ --es-surface: var(--bs-card-bg, #ffffff);
335
+ --es-surface-soft: var(--bs-tertiary-bg, #f8fafc);
336
+ --es-text: var(--bs-body-color, #0f172a);
337
+ --es-muted: var(--bs-secondary-color, #64748b);
338
+ --es-primary: var(--bs-primary, #2563eb);
339
+ --es-on-primary: var(--bs-primary-text-emphasis, #1d4ed8);
340
+ --es-primary-soft: var(--bs-primary-bg-subtle, rgba(37, 99, 235, 0.12));
341
+ --es-border: var(--bs-border-color, rgba(15, 23, 42, 0.08));
342
+ --es-disabled-bg: #eef2f7;
343
+ --es-disabled-text: #8a94a6;
344
+ --es-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
345
+ --es-shadow-hover: 0 16px 36px rgba(15, 23, 42, 0.14);
346
+ --es-success: #15803d;
347
+
348
+ display: block;
349
+ min-height: 100%;
350
+ color-scheme: light;
351
+ background: var(--es-bg);
352
+ color: var(--es-text);
353
+ font-family: var(--es-font);
354
+ font-size: 15px;
355
+ line-height: 1.5;
356
+ -webkit-font-smoothing: antialiased;
357
+ }
358
+
359
+ :host([data-resolved-theme="dark"]) {
360
+ color-scheme: dark;
361
+ --es-bg: var(--bs-dark-bg-subtle, #0b1220);
362
+ --es-surface: #111c2e;
363
+ --es-surface-soft: #0e1728;
364
+ --es-text: #e2e8f0;
365
+ --es-muted: #94a3b8;
366
+ --es-primary: #60a5fa;
367
+ --es-on-primary: #bfdbfe;
368
+ --es-primary-soft: rgba(96, 165, 250, 0.16);
369
+ --es-border: rgba(148, 163, 184, 0.16);
370
+ --es-disabled-bg: #0e1829;
371
+ --es-disabled-text: #7b8aa0;
372
+ --es-shadow: 0 12px 34px rgba(0, 0, 0, 0.35);
373
+ --es-shadow-hover: 0 18px 42px rgba(0, 0, 0, 0.45);
374
+ --es-success: #4ade80;
375
+ }
376
+
377
+ *,
378
+ *::before,
379
+ *::after {
380
+ box-sizing: border-box;
381
+ }
382
+
383
+ .shell {
384
+ width: min(960px, 100%);
385
+ margin: 0 auto;
386
+ padding: clamp(16px, 4vw, 40px) clamp(12px, 3vw, 28px) 48px;
387
+ }
388
+
389
+ .hero {
390
+ display: flex;
391
+ gap: 18px;
392
+ align-items: flex-start;
393
+ padding: clamp(18px, 3vw, 28px);
394
+ border: 1px solid var(--es-border);
395
+ border-radius: var(--es-radius-lg);
396
+ background: var(--es-surface);
397
+ box-shadow: var(--es-shadow);
398
+ }
399
+
400
+ .hero__icon {
401
+ display: grid;
402
+ place-items: center;
403
+ flex: 0 0 auto;
404
+ width: 52px;
405
+ height: 52px;
406
+ border-radius: 14px;
407
+ background: var(--es-primary-soft);
408
+ color: var(--es-primary);
409
+ }
410
+
411
+ .hero__icon svg {
412
+ width: 26px;
413
+ height: 26px;
414
+ }
415
+
416
+ .hero__copy {
417
+ min-width: 0;
418
+ }
419
+
420
+ .hero__eyebrow {
421
+ margin: 0 0 6px;
422
+ color: var(--es-muted);
423
+ font-size: 12px;
424
+ font-weight: 600;
425
+ letter-spacing: 0.08em;
426
+ text-transform: uppercase;
427
+ }
428
+
429
+ .hero h1 {
430
+ margin: 0 0 10px;
431
+ color: var(--es-text);
432
+ font-size: clamp(20px, 2.6vw, 26px);
433
+ font-weight: 700;
434
+ line-height: 1.2;
435
+ }
436
+
437
+ .hero p {
438
+ margin: 0 0 8px;
439
+ color: var(--es-text);
440
+ }
441
+
442
+ .hero p:last-child {
443
+ margin-bottom: 0;
444
+ }
445
+
446
+ .hero__note {
447
+ color: var(--es-muted) !important;
448
+ font-size: 13px;
449
+ }
450
+
451
+ .default-page {
452
+ display: flex;
453
+ align-items: center;
454
+ gap: 14px;
455
+ margin-top: 16px;
456
+ padding: 14px 16px;
457
+ border: 1px solid var(--es-border);
458
+ border-left: 4px solid var(--es-primary);
459
+ border-radius: var(--es-radius);
460
+ background: var(--es-surface);
461
+ box-shadow: var(--es-shadow);
462
+ color: inherit;
463
+ text-decoration: none;
464
+ }
465
+
466
+ .default-page:hover,
467
+ .default-page:focus-visible {
468
+ border-color: var(--es-primary);
469
+ border-left-color: var(--es-primary);
470
+ box-shadow: var(--es-shadow-hover);
471
+ }
472
+
473
+ .default-page:focus-visible {
474
+ outline: 2px solid var(--es-primary);
475
+ outline-offset: 2px;
476
+ }
477
+
478
+ .default-page__icon {
479
+ display: grid;
480
+ place-items: center;
481
+ flex: 0 0 auto;
482
+ width: 44px;
483
+ height: 44px;
484
+ border-radius: 12px;
485
+ background: var(--es-primary-soft);
486
+ color: var(--es-primary);
487
+ }
488
+
489
+ .default-page__icon svg {
490
+ width: 22px;
491
+ height: 22px;
492
+ }
493
+
494
+ .default-page__copy {
495
+ display: flex;
496
+ flex: 1 1 auto;
497
+ flex-direction: column;
498
+ gap: 2px;
499
+ min-width: 0;
500
+ }
501
+
502
+ .default-page__eyebrow {
503
+ color: var(--es-muted);
504
+ font-size: 11px;
505
+ font-weight: 600;
506
+ letter-spacing: 0.08em;
507
+ text-transform: uppercase;
508
+ }
509
+
510
+ .default-page__label {
511
+ overflow: hidden;
512
+ color: var(--es-text);
513
+ font-size: 15px;
514
+ font-weight: 600;
515
+ text-overflow: ellipsis;
516
+ white-space: nowrap;
517
+ }
518
+
519
+ .default-page__url {
520
+ overflow: hidden;
521
+ color: var(--es-muted);
522
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
523
+ font-size: 11px;
524
+ text-overflow: ellipsis;
525
+ white-space: nowrap;
526
+ }
527
+
528
+ .default-page__action {
529
+ flex: 0 0 auto;
530
+ color: var(--es-primary);
531
+ font-size: 13px;
532
+ font-weight: 600;
533
+ }
534
+
535
+ .default-page--blocked {
536
+ border-left-color: var(--es-disabled-text);
537
+ background: var(--es-disabled-bg);
538
+ box-shadow: none;
539
+ cursor: not-allowed;
540
+ opacity: 0.8;
541
+ }
542
+
543
+ .default-page--blocked .default-page__icon,
544
+ .default-page--blocked .default-page__label,
545
+ .default-page--blocked .default-page__action {
546
+ color: var(--es-disabled-text);
547
+ }
548
+
549
+ .default-page__reason {
550
+ color: var(--es-muted);
551
+ font-size: 12px;
552
+ font-style: italic;
553
+ }
554
+
555
+ .groups {
556
+ display: grid;
557
+ gap: 26px;
558
+ margin-top: 22px;
559
+ }
560
+
561
+ .group__title {
562
+ display: flex;
563
+ align-items: center;
564
+ gap: 8px;
565
+ margin: 0 4px 10px;
566
+ color: var(--es-muted);
567
+ font-size: 13px;
568
+ font-weight: 700;
569
+ letter-spacing: 0.06em;
570
+ text-transform: uppercase;
571
+ }
572
+
573
+ .group__count {
574
+ display: inline-grid;
575
+ place-items: center;
576
+ min-width: 22px;
577
+ height: 20px;
578
+ padding: 0 6px;
579
+ border-radius: 999px;
580
+ background: var(--es-primary-soft);
581
+ color: var(--es-primary);
582
+ font-size: 11px;
583
+ letter-spacing: 0;
584
+ }
585
+
586
+ .cards {
587
+ display: grid;
588
+ grid-template-columns: repeat(auto-fill, minmax(min(100%, 300px), 1fr));
589
+ gap: 12px;
590
+ }
591
+
592
+ .card {
593
+ display: flex;
594
+ align-items: center;
595
+ gap: 14px;
596
+ min-width: 0;
597
+ padding: 14px 16px;
598
+ border: 1px solid var(--es-border);
599
+ border-radius: var(--es-radius);
600
+ background: var(--es-surface);
601
+ box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
602
+ color: inherit;
603
+ text-decoration: none;
604
+ transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
605
+ }
606
+
607
+ .card:hover,
608
+ .card:focus-visible {
609
+ border-color: var(--es-primary);
610
+ box-shadow: var(--es-shadow-hover);
611
+ transform: translateY(-1px);
612
+ }
613
+
614
+ .card:focus-visible {
615
+ outline: 2px solid var(--es-primary);
616
+ outline-offset: 2px;
617
+ }
618
+
619
+ .card__icon {
620
+ display: grid;
621
+ place-items: center;
622
+ flex: 0 0 auto;
623
+ width: 44px;
624
+ height: 44px;
625
+ border-radius: 12px;
626
+ background: var(--es-primary-soft);
627
+ color: var(--es-primary);
628
+ }
629
+
630
+ .card__icon svg {
631
+ width: 22px;
632
+ height: 22px;
633
+ }
634
+
635
+ .card__body {
636
+ display: flex;
637
+ flex: 1 1 auto;
638
+ flex-direction: column;
639
+ gap: 2px;
640
+ min-width: 0;
641
+ }
642
+
643
+ .card__label {
644
+ color: var(--es-text);
645
+ font-size: 15px;
646
+ font-weight: 600;
647
+ overflow: hidden;
648
+ text-overflow: ellipsis;
649
+ white-space: nowrap;
650
+ }
651
+
652
+ .card__url {
653
+ overflow: hidden;
654
+ color: var(--es-muted);
655
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
656
+ font-size: 11px;
657
+ text-overflow: ellipsis;
658
+ white-space: nowrap;
659
+ }
660
+
661
+ .card__reason {
662
+ color: var(--es-muted);
663
+ font-size: 12px;
664
+ font-style: italic;
665
+ line-height: 1.35;
666
+ }
667
+
668
+ .card__action {
669
+ flex: 0 0 auto;
670
+ color: var(--es-muted);
671
+ }
672
+
673
+ .card__action svg {
674
+ width: 18px;
675
+ height: 18px;
676
+ }
677
+
678
+ .card--blocked {
679
+ border-style: dashed;
680
+ background: var(--es-disabled-bg);
681
+ box-shadow: none;
682
+ cursor: not-allowed;
683
+ opacity: 0.78;
684
+ }
685
+
686
+ .card--blocked:hover,
687
+ .card--blocked:focus-visible {
688
+ border-color: var(--es-border);
689
+ box-shadow: none;
690
+ transform: none;
691
+ }
692
+
693
+ .card--blocked .card__icon {
694
+ background: transparent;
695
+ color: var(--es-disabled-text);
696
+ }
697
+
698
+ .card--blocked .card__label {
699
+ color: var(--es-disabled-text);
700
+ }
701
+
702
+ .card--blocked .card__reason,
703
+ .card--blocked .card__action {
704
+ color: var(--es-disabled-text);
705
+ }
706
+
707
+ .empty {
708
+ display: grid;
709
+ place-items: center;
710
+ gap: 10px;
711
+ margin-top: 24px;
712
+ padding: 32px 20px;
713
+ border: 1px solid var(--es-border);
714
+ border-radius: 16px;
715
+ background: var(--es-surface);
716
+ color: var(--es-muted);
717
+ text-align: center;
718
+ }
719
+
720
+ .empty svg {
721
+ width: 32px;
722
+ height: 32px;
723
+ }
724
+
725
+ .legend {
726
+ display: flex;
727
+ align-items: center;
728
+ gap: 8px;
729
+ margin: 18px 4px 0;
730
+ color: var(--es-muted);
731
+ font-size: 13px;
732
+ line-height: 1.45;
733
+ }
734
+
735
+ .legend svg {
736
+ flex: 0 0 auto;
737
+ width: 18px;
738
+ height: 18px;
739
+ }
740
+
741
+ @media (max-width: 560px) {
742
+ .hero {
743
+ flex-direction: column;
744
+ }
745
+
746
+ .cards {
747
+ grid-template-columns: 1fr;
748
+ }
749
+ }
750
+
751
+ @media (prefers-reduced-motion: reduce) {
752
+ .card {
753
+ transition: none;
754
+ }
755
+
756
+ .card:hover,
757
+ .card:focus-visible {
758
+ transform: none;
759
+ }
760
+ }
761
+ `;
762
+
763
+ class AreeDisponibiliElement extends HTMLElement {
764
+ static get observedAttributes() {
765
+ return [
766
+ 'theme',
767
+ 'heading',
768
+ 'subtitle',
769
+ 'note',
770
+ 'eyebrow',
771
+ 'empty-text',
772
+ 'unavailable-template',
773
+ 'href-mode',
774
+ 'default-page',
775
+ ];
776
+ }
777
+
778
+ #groups = [];
779
+ #grants = [];
780
+ #useGrants = false;
781
+ #routeMap = {};
782
+ #menuOrder = {};
783
+ #groupOrder = {};
784
+ #availableRoutes = [];
785
+ #isRouteAvailable = null;
786
+ #root;
787
+ #media;
788
+ #observer;
789
+ #onThemeChange = () => this.#applyTheme();
790
+ #onClick = (event) => this.#handleClick(event);
791
+
792
+ constructor() {
793
+ super();
794
+ const shadow = this.attachShadow({ mode: 'open' });
795
+ const style = document.createElement('style');
796
+ style.textContent = STYLES;
797
+ shadow.appendChild(style);
798
+ this.#root = document.createElement('div');
799
+ shadow.appendChild(this.#root);
800
+ this.#root.addEventListener('click', this.#onClick);
801
+ }
802
+
803
+ connectedCallback() {
804
+ this.#watchTheme();
805
+ this.#applyTheme();
806
+ this.render();
807
+ }
808
+
809
+ disconnectedCallback() {
810
+ if (this.#media) {
811
+ this.#media.removeEventListener('change', this.#onThemeChange);
812
+ this.#media = undefined;
813
+ }
814
+ if (this.#observer) {
815
+ this.#observer.disconnect();
816
+ this.#observer = undefined;
817
+ }
818
+ }
819
+
820
+ attributeChangedCallback(name) {
821
+ if (name === 'theme') {
822
+ this.#applyTheme();
823
+ return;
824
+ }
825
+ this.render();
826
+ }
827
+
828
+ get groups() {
829
+ return this.#groups;
830
+ }
831
+
832
+ set groups(value) {
833
+ this.#groups = normalizeGroups(value);
834
+ this.#useGrants = false;
835
+ this.render();
836
+ }
837
+
838
+ /**
839
+ * Grant Accessi (anche raggruppati per `menu`/`menus`). Il componente
840
+ * costruisce da solo gruppi, rotte e stato disponibile/bloccato.
841
+ */
842
+ get grants() {
843
+ return this.#grants;
844
+ }
845
+
846
+ set grants(value) {
847
+ this.#grants = Array.isArray(value) ? value : [];
848
+ this.#useGrants = true;
849
+ this.#rebuildFromGrants();
850
+ }
851
+
852
+ get routeMap() {
853
+ return this.#routeMap;
854
+ }
855
+
856
+ set routeMap(value) {
857
+ this.#routeMap = value && typeof value === 'object' ? value : {};
858
+ this.#refreshIfGrants();
859
+ }
860
+
861
+ get menuOrder() {
862
+ return this.#menuOrder;
863
+ }
864
+
865
+ set menuOrder(value) {
866
+ this.#menuOrder = value && typeof value === 'object' ? value : {};
867
+ this.#refreshIfGrants();
868
+ }
869
+
870
+ get groupOrder() {
871
+ return this.#groupOrder;
872
+ }
873
+
874
+ set groupOrder(value) {
875
+ this.#groupOrder = value && typeof value === 'object' ? value : {};
876
+ this.#refreshIfGrants();
877
+ }
878
+
879
+ get availableRoutes() {
880
+ return this.#availableRoutes;
881
+ }
882
+
883
+ set availableRoutes(value) {
884
+ this.#availableRoutes = Array.isArray(value) ? value : [];
885
+ this.#refreshIfGrants();
886
+ }
887
+
888
+ get isRouteAvailable() {
889
+ return this.#isRouteAvailable;
890
+ }
891
+
892
+ set isRouteAvailable(value) {
893
+ this.#isRouteAvailable = typeof value === 'function' ? value : null;
894
+ this.#refreshIfGrants();
895
+ }
896
+
897
+ get defaultPage() {
898
+ return this.getAttribute('default-page') || '';
899
+ }
900
+
901
+ set defaultPage(value) {
902
+ this.#setOptionalAttribute('default-page', value);
903
+ }
904
+
905
+ #refreshIfGrants() {
906
+ if (!this.#useGrants) return;
907
+ this.#rebuildFromGrants();
908
+ }
909
+
910
+ #rebuildFromGrants() {
911
+ this.#groups = buildModel(this.#grants, {
912
+ routeMap: this.#routeMap,
913
+ menuOrder: this.#menuOrder,
914
+ groupOrder: this.#groupOrder,
915
+ availableRoutes: this.#availableRoutes,
916
+ isRouteAvailable: this.#isRouteAvailable,
917
+ reasonFor: (page) =>
918
+ this.unavailableTemplate.replace('{page}', page),
919
+ });
920
+ this.render();
921
+ }
922
+
923
+ get heading() {
924
+ return this.getAttribute('heading') || 'Aree disponibili';
925
+ }
926
+
927
+ set heading(value) {
928
+ this.#setOptionalAttribute('heading', value);
929
+ }
930
+
931
+ get subtitle() {
932
+ return this.getAttribute('subtitle') || '';
933
+ }
934
+
935
+ set subtitle(value) {
936
+ this.#setOptionalAttribute('subtitle', value);
937
+ }
938
+
939
+ get note() {
940
+ return this.getAttribute('note') || '';
941
+ }
942
+
943
+ set note(value) {
944
+ this.#setOptionalAttribute('note', value);
945
+ }
946
+
947
+ get eyebrow() {
948
+ return this.getAttribute('eyebrow') || 'Accesso personalizzato';
949
+ }
950
+
951
+ set eyebrow(value) {
952
+ this.#setOptionalAttribute('eyebrow', value);
953
+ }
954
+
955
+ get emptyText() {
956
+ return (
957
+ this.getAttribute('empty-text') ||
958
+ "Nessuna area abilitata per il tuo utente. Contatta l'amministratore."
959
+ );
960
+ }
961
+
962
+ set emptyText(value) {
963
+ this.#setOptionalAttribute('empty-text', value);
964
+ }
965
+
966
+ get unavailableTemplate() {
967
+ return this.getAttribute('unavailable-template') || DEFAULT_UNAVAILABLE;
968
+ }
969
+
970
+ set unavailableTemplate(value) {
971
+ this.#setOptionalAttribute('unavailable-template', value);
972
+ }
973
+
974
+ get hrefMode() {
975
+ const value = (this.getAttribute('href-mode') || 'hash').toLowerCase();
976
+ return value === 'path' || value === 'none' ? value : 'hash';
977
+ }
978
+
979
+ set hrefMode(value) {
980
+ this.#setOptionalAttribute('href-mode', value);
981
+ }
982
+
983
+ get theme() {
984
+ return this.getAttribute('theme') || 'light';
985
+ }
986
+
987
+ set theme(value) {
988
+ this.#setOptionalAttribute('theme', value);
989
+ }
990
+
991
+ setGroups(value) {
992
+ this.groups = value;
993
+ return this;
994
+ }
995
+
996
+ refresh() {
997
+ this.render();
998
+ return this;
999
+ }
1000
+
1001
+ #setOptionalAttribute(name, value) {
1002
+ if (value == null || value === '') {
1003
+ this.removeAttribute(name);
1004
+ } else {
1005
+ this.setAttribute(name, String(value));
1006
+ }
1007
+ }
1008
+
1009
+ #watchTheme() {
1010
+ if (this.#media) return;
1011
+ this.#media = window.matchMedia('(prefers-color-scheme: dark)');
1012
+ this.#media.addEventListener('change', this.#onThemeChange);
1013
+ this.#observer = new MutationObserver(this.#onThemeChange);
1014
+ this.#observer.observe(document.documentElement, {
1015
+ attributes: true,
1016
+ attributeFilter: ['class', 'data-bs-theme'],
1017
+ });
1018
+ }
1019
+
1020
+ #resolveTheme() {
1021
+ const explicit = String(this.getAttribute('theme') || 'light').toLowerCase();
1022
+ if (explicit === 'light' || explicit === 'dark') return explicit;
1023
+ if (explicit !== 'auto') return 'light';
1024
+
1025
+ const root = document.documentElement;
1026
+ if (
1027
+ root.getAttribute('data-bs-theme') === 'dark' ||
1028
+ root.classList.contains('dark') ||
1029
+ root.classList.contains('ion-palette-dark')
1030
+ ) {
1031
+ return 'dark';
1032
+ }
1033
+ return this.#media && this.#media.matches ? 'dark' : 'light';
1034
+ }
1035
+
1036
+ #applyTheme() {
1037
+ const resolved = this.#resolveTheme();
1038
+ if (this.getAttribute('data-resolved-theme') !== resolved) {
1039
+ this.setAttribute('data-resolved-theme', resolved);
1040
+ }
1041
+ }
1042
+
1043
+ #handleClick(event) {
1044
+ const card = event.target && event.target.closest
1045
+ ? event.target.closest('[data-area]')
1046
+ : null;
1047
+ if (!card) return;
1048
+
1049
+ const groupIndex = Number(card.getAttribute('data-group'));
1050
+ const areaIndex = Number(card.getAttribute('data-area'));
1051
+ const group = this.#groups[groupIndex];
1052
+ const area = group && group.areas[areaIndex];
1053
+ if (!area) return;
1054
+
1055
+ const detail = {
1056
+ area: { ...area },
1057
+ group: group.name,
1058
+ };
1059
+
1060
+ if (area.available) {
1061
+ this.dispatchEvent(
1062
+ new CustomEvent('es-area-select', {
1063
+ detail,
1064
+ bubbles: true,
1065
+ composed: true,
1066
+ }),
1067
+ );
1068
+ if (this.hrefMode === 'none') event.preventDefault();
1069
+ } else {
1070
+ event.preventDefault();
1071
+ this.dispatchEvent(
1072
+ new CustomEvent('es-area-blocked', {
1073
+ detail: {
1074
+ ...detail,
1075
+ reason: area.reason || this.#defaultReason(area),
1076
+ },
1077
+ bubbles: true,
1078
+ composed: true,
1079
+ }),
1080
+ );
1081
+ }
1082
+ }
1083
+
1084
+ #defaultReason(area) {
1085
+ return this.unavailableTemplate.replace('{page}', area.page || '');
1086
+ }
1087
+
1088
+ #hrefFor(area) {
1089
+ if (!area.available || !area.route) return '';
1090
+ if (this.hrefMode === 'none') return '';
1091
+ if (this.hrefMode === 'path') return area.route;
1092
+ const route = area.route.startsWith('/') ? area.route : '/' + area.route;
1093
+ return '#' + route;
1094
+ }
1095
+
1096
+ render() {
1097
+ if (!this.isConnected) return;
1098
+
1099
+ const groups = this.#groups;
1100
+ const totalAreas = groups.reduce(
1101
+ (total, group) => total + group.areas.length,
1102
+ 0,
1103
+ );
1104
+ const unavailable = groups.reduce(
1105
+ (total, group) =>
1106
+ total + group.areas.filter((area) => !area.available).length,
1107
+ 0,
1108
+ );
1109
+
1110
+ const header =
1111
+ '<header class="hero">' +
1112
+ '<span class="hero__icon">' +
1113
+ icon('compass-outline') +
1114
+ '</span>' +
1115
+ '<div class="hero__copy">' +
1116
+ (this.eyebrow
1117
+ ? '<p class="hero__eyebrow">' + escapeHtml(this.eyebrow) + '</p>'
1118
+ : '') +
1119
+ '<h1>' +
1120
+ escapeHtml(this.heading) +
1121
+ '</h1>' +
1122
+ (this.subtitle ? '<p>' + escapeHtml(this.subtitle) + '</p>' : '') +
1123
+ (this.note ? '<p class="hero__note">' + escapeHtml(this.note) + '</p>' : '') +
1124
+ '</div>' +
1125
+ '</header>';
1126
+
1127
+ const body =
1128
+ totalAreas === 0
1129
+ ? '<section class="empty">' +
1130
+ icon('lock-closed-outline') +
1131
+ '<p>' +
1132
+ escapeHtml(this.emptyText) +
1133
+ '</p></section>'
1134
+ : '<div class="groups">' +
1135
+ groups.map((group, groupIndex) => this.#renderGroup(group, groupIndex)).join('') +
1136
+ '</div>';
1137
+
1138
+ const legend =
1139
+ unavailable > 0
1140
+ ? '<p class="legend">' +
1141
+ icon('information-circle-outline') +
1142
+ '<span>Le aree in grigio non sono ancora disponibili in questa ' +
1143
+ 'applicazione (' +
1144
+ unavailable +
1145
+ ').</span></p>'
1146
+ : '';
1147
+
1148
+ this.#root.innerHTML =
1149
+ '<main class="shell">' +
1150
+ header +
1151
+ this.#renderDefaultPage() +
1152
+ body +
1153
+ legend +
1154
+ '</main>';
1155
+ }
1156
+
1157
+ #renderDefaultPage() {
1158
+ const page = normalizePage(this.defaultPage);
1159
+ if (!page) return '';
1160
+
1161
+ const areas = this.#groups.reduce(
1162
+ (all, group) => all.concat(group.areas),
1163
+ [],
1164
+ );
1165
+ const match = areas.find(
1166
+ (area) => area.route === page || area.page === page,
1167
+ );
1168
+
1169
+ let route = '';
1170
+ if (match) {
1171
+ route = match.available ? match.route : '';
1172
+ } else {
1173
+ const availableRoutes = new Set(
1174
+ this.#availableRoutes.map(normalizePage),
1175
+ );
1176
+ const known = this.#isRouteAvailable
1177
+ ? this.#isRouteAvailable(page)
1178
+ : availableRoutes.has(page);
1179
+ route = known ? page : '';
1180
+ }
1181
+
1182
+ const available = Boolean(route);
1183
+ const href = available
1184
+ ? this.#hrefFor({ available: true, route })
1185
+ : '';
1186
+ const url =
1187
+ (match && match.url) ||
1188
+ (href ? new URL(href, document.baseURI).href : page);
1189
+ const label = match ? match.label : page;
1190
+ const iconName = match ? match.icon : 'home-outline';
1191
+ const reason = available
1192
+ ? ''
1193
+ : (match && match.reason) || this.#defaultReason({ page });
1194
+
1195
+ const content =
1196
+ '<span class="default-page__icon">' +
1197
+ icon(iconName) +
1198
+ '</span>' +
1199
+ '<span class="default-page__copy">' +
1200
+ '<span class="default-page__eyebrow">Pagina di default</span>' +
1201
+ '<span class="default-page__label">' +
1202
+ escapeHtml(label) +
1203
+ '</span>' +
1204
+ (available
1205
+ ? '<span class="default-page__url" title="' +
1206
+ escapeHtml(url) +
1207
+ '">' +
1208
+ escapeHtml(url) +
1209
+ '</span>'
1210
+ : '<span class="default-page__reason">' +
1211
+ escapeHtml(reason) +
1212
+ '</span>') +
1213
+ '</span>';
1214
+
1215
+ if (available) {
1216
+ return (
1217
+ '<a class="default-page" href="' +
1218
+ escapeHtml(href) +
1219
+ '">' +
1220
+ content +
1221
+ '<span class="default-page__action">Apri</span>' +
1222
+ '</a>'
1223
+ );
1224
+ }
1225
+
1226
+ return (
1227
+ '<div class="default-page default-page--blocked" role="link" ' +
1228
+ 'aria-disabled="true" tabindex="0" title="' +
1229
+ escapeHtml(reason) +
1230
+ '">' +
1231
+ content +
1232
+ '<span class="default-page__action">Bloccata</span>' +
1233
+ '</div>'
1234
+ );
1235
+ }
1236
+
1237
+ #renderGroup(group, groupIndex) {
1238
+ const cards = group.areas
1239
+ .map((area, areaIndex) => this.#renderArea(area, groupIndex, areaIndex))
1240
+ .join('');
1241
+ return (
1242
+ '<section class="group">' +
1243
+ '<h2 class="group__title">' +
1244
+ escapeHtml(group.name) +
1245
+ '<span class="group__count">' +
1246
+ group.areas.length +
1247
+ '</span>' +
1248
+ '</h2>' +
1249
+ '<div class="cards">' +
1250
+ cards +
1251
+ '</div>' +
1252
+ '</section>'
1253
+ );
1254
+ }
1255
+
1256
+ #renderArea(area, groupIndex, areaIndex) {
1257
+ const href = this.#hrefFor(area);
1258
+ const reason =
1259
+ area.reason || this.#defaultReason(area);
1260
+ const attributes =
1261
+ 'data-area data-group="' +
1262
+ groupIndex +
1263
+ '" data-area="' +
1264
+ areaIndex +
1265
+ '"';
1266
+ const className = 'card' + (area.available ? '' : ' card--blocked');
1267
+ const url = area.url || href;
1268
+ const content =
1269
+ '<span class="card__icon">' +
1270
+ icon(area.icon) +
1271
+ '</span>' +
1272
+ '<span class="card__body">' +
1273
+ '<span class="card__label" title="' +
1274
+ escapeHtml(area.label) +
1275
+ '">' +
1276
+ escapeHtml(area.label) +
1277
+ '</span>' +
1278
+ (area.available
1279
+ ? url
1280
+ ? '<span class="card__url" title="' +
1281
+ escapeHtml(url) +
1282
+ '">' +
1283
+ escapeHtml(url) +
1284
+ '</span>'
1285
+ : ''
1286
+ : '<span class="card__reason">' +
1287
+ escapeHtml(reason) +
1288
+ '</span>') +
1289
+ '</span>' +
1290
+ '<span class="card__action">' +
1291
+ icon(
1292
+ area.available ? 'arrow-forward-outline' : 'lock-closed-outline',
1293
+ ) +
1294
+ '</span>';
1295
+
1296
+ if (area.available) {
1297
+ return (
1298
+ '<a class="' +
1299
+ className +
1300
+ '" ' +
1301
+ attributes +
1302
+ (href ? ' href="' + escapeHtml(href) + '"' : '') +
1303
+ '>' +
1304
+ content +
1305
+ '</a>'
1306
+ );
1307
+ }
1308
+
1309
+ return (
1310
+ '<div class="' +
1311
+ className +
1312
+ '" ' +
1313
+ attributes +
1314
+ ' role="link" aria-disabled="true" tabindex="0" title="' +
1315
+ escapeHtml(reason) +
1316
+ '">' +
1317
+ content +
1318
+ '</div>'
1319
+ );
1320
+ }
1321
+ }
1322
+
1323
+ if (!customElements.get(TAG)) {
1324
+ customElements.define(TAG, AreeDisponibiliElement);
1325
+ }
1326
+
1327
+ // Esposto per integrazioni che preferiscono costruire elemento e modello
1328
+ // da codice, senza duplicare la logica di raggruppamento nelle applicazioni.
1329
+ window.EmilSoftware = window.EmilSoftware || {};
1330
+ window.EmilSoftware.AreeDisponibiliElement = AreeDisponibiliElement;
1331
+ window.EmilSoftware.AreeDisponibili = {
1332
+ Element: AreeDisponibiliElement,
1333
+ buildModel,
1334
+ version: '1.1.0',
1335
+ };
1336
+ })();