@customviews-js/customviews 1.0.2 → 1.1.0

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.
Files changed (50) hide show
  1. package/README.md +164 -30
  2. package/dist/{custom-views.cjs.js → custom-views.core.cjs.js} +814 -99
  3. package/dist/custom-views.core.cjs.js.map +1 -0
  4. package/dist/custom-views.core.esm.js +2296 -0
  5. package/dist/custom-views.core.esm.js.map +1 -0
  6. package/dist/custom-views.esm.js +814 -98
  7. package/dist/custom-views.esm.js.map +1 -1
  8. package/dist/{custom-views.umd.js → custom-views.js} +814 -99
  9. package/dist/custom-views.js.map +1 -0
  10. package/dist/custom-views.min.js +7 -0
  11. package/dist/custom-views.min.js.map +1 -0
  12. package/dist/types/{models/AssetsManager.d.ts → core/assets-manager.d.ts} +1 -1
  13. package/dist/types/{models/AssetsManager.d.ts.map → core/assets-manager.d.ts.map} +1 -1
  14. package/dist/types/core/core.d.ts +25 -9
  15. package/dist/types/core/core.d.ts.map +1 -1
  16. package/dist/types/core/custom-elements.d.ts +8 -0
  17. package/dist/types/core/custom-elements.d.ts.map +1 -0
  18. package/dist/types/core/render.d.ts +1 -1
  19. package/dist/types/core/render.d.ts.map +1 -1
  20. package/dist/types/core/tab-manager.d.ts +35 -0
  21. package/dist/types/core/tab-manager.d.ts.map +1 -0
  22. package/dist/types/core/url-state-manager.d.ts.map +1 -1
  23. package/dist/types/core/visibility-manager.d.ts +1 -1
  24. package/dist/types/core/widget.d.ts +2 -0
  25. package/dist/types/core/widget.d.ts.map +1 -1
  26. package/dist/types/entry/browser-entry.d.ts +13 -0
  27. package/dist/types/entry/browser-entry.d.ts.map +1 -0
  28. package/dist/types/index.d.ts +11 -20
  29. package/dist/types/index.d.ts.map +1 -1
  30. package/dist/types/lib/custom-views.d.ts +29 -0
  31. package/dist/types/lib/custom-views.d.ts.map +1 -0
  32. package/dist/types/styles/styles.d.ts +2 -0
  33. package/dist/types/styles/styles.d.ts.map +1 -1
  34. package/dist/types/styles/tab-styles.d.ts +5 -0
  35. package/dist/types/styles/tab-styles.d.ts.map +1 -0
  36. package/dist/types/styles/toggle-styles.d.ts +5 -0
  37. package/dist/types/styles/toggle-styles.d.ts.map +1 -0
  38. package/dist/types/styles/widget-styles.d.ts +1 -1
  39. package/dist/types/styles/widget-styles.d.ts.map +1 -1
  40. package/dist/types/types/types.d.ts +85 -0
  41. package/dist/types/types/types.d.ts.map +1 -1
  42. package/dist/types/utils/url-utils.d.ts +8 -0
  43. package/dist/types/utils/url-utils.d.ts.map +1 -0
  44. package/package.json +13 -9
  45. package/dist/custom-views.cjs.js.map +0 -1
  46. package/dist/custom-views.umd.js.map +0 -1
  47. package/dist/custom-views.umd.min.js +0 -7
  48. package/dist/custom-views.umd.min.js.map +0 -1
  49. package/dist/types/models/Config.d.ts +0 -10
  50. package/dist/types/models/Config.d.ts.map +0 -1
@@ -0,0 +1,2296 @@
1
+ /*!
2
+ * @customviews-js/customviews v1.1.0
3
+ * (c) 2025 Chan Ger Teck
4
+ * Released under the MIT License.
5
+ */
6
+ /** --- Basic renderers --- */
7
+ function renderImage(el, asset) {
8
+ if (!asset.src)
9
+ return;
10
+ el.innerHTML = '';
11
+ const img = document.createElement('img');
12
+ img.src = asset.src;
13
+ img.alt = asset.alt || '';
14
+ // Apply custom styling if provided
15
+ if (asset.className) {
16
+ img.className = asset.className;
17
+ }
18
+ if (asset.style) {
19
+ img.setAttribute('style', asset.style);
20
+ }
21
+ // Default styles (can be overridden by asset.style)
22
+ img.style.maxWidth = img.style.maxWidth || '100%';
23
+ img.style.height = img.style.height || 'auto';
24
+ img.style.display = img.style.display || 'block';
25
+ el.appendChild(img);
26
+ }
27
+ function renderText(el, asset) {
28
+ if (asset.content != null) {
29
+ el.textContent = asset.content;
30
+ }
31
+ // Apply custom styling if provided
32
+ if (asset.className) {
33
+ el.className = asset.className;
34
+ }
35
+ if (asset.style) {
36
+ el.setAttribute('style', asset.style);
37
+ }
38
+ }
39
+ function renderHtml(el, asset) {
40
+ if (asset.content != null) {
41
+ el.innerHTML = asset.content;
42
+ }
43
+ // Apply custom styling if provided
44
+ if (asset.className) {
45
+ el.className = asset.className;
46
+ }
47
+ if (asset.style) {
48
+ el.setAttribute('style', asset.style);
49
+ }
50
+ }
51
+ /** --- Unified asset renderer --- */
52
+ function detectAssetType(asset) {
53
+ // If src exists, it's an image
54
+ if (asset.src)
55
+ return 'image';
56
+ // If content contains HTML tags, it's HTML
57
+ if (asset.content && /<[^>]+>/.test(asset.content)) {
58
+ return 'html';
59
+ }
60
+ return 'text';
61
+ }
62
+ function renderAssetInto(el, assetId, assetsManager) {
63
+ const asset = assetsManager.get(assetId);
64
+ if (!asset)
65
+ return;
66
+ const type = asset.type || detectAssetType(asset);
67
+ switch (type) {
68
+ case 'image':
69
+ renderImage(el, asset);
70
+ break;
71
+ case 'text':
72
+ renderText(el, asset);
73
+ break;
74
+ case 'html':
75
+ renderHtml(el, asset);
76
+ break;
77
+ default:
78
+ el.innerHTML = asset.content || String(asset);
79
+ console.warn('[CustomViews] Unknown asset type:', type);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Manages persistence of custom views state using browser localStorage
85
+ */
86
+ class PersistenceManager {
87
+ // Storage keys for localStorage
88
+ static STORAGE_KEYS = {
89
+ STATE: 'customviews-state'
90
+ };
91
+ /**
92
+ * Check if localStorage is available in the current environment
93
+ */
94
+ isStorageAvailable() {
95
+ return typeof window !== 'undefined' && window.localStorage !== undefined;
96
+ }
97
+ persistState(state) {
98
+ if (!this.isStorageAvailable())
99
+ return;
100
+ try {
101
+ localStorage.setItem(PersistenceManager.STORAGE_KEYS.STATE, JSON.stringify(state));
102
+ }
103
+ catch (error) {
104
+ console.warn('Failed to persist state:', error);
105
+ }
106
+ }
107
+ getPersistedState() {
108
+ if (!this.isStorageAvailable())
109
+ return null;
110
+ try {
111
+ const raw = localStorage.getItem(PersistenceManager.STORAGE_KEYS.STATE);
112
+ return raw ? JSON.parse(raw) : null;
113
+ }
114
+ catch (error) {
115
+ console.warn('Failed to parse persisted state:', error);
116
+ return null;
117
+ }
118
+ }
119
+ /**
120
+ * Clear persisted state
121
+ */
122
+ clearAll() {
123
+ if (!this.isStorageAvailable())
124
+ return;
125
+ localStorage.removeItem(PersistenceManager.STORAGE_KEYS.STATE);
126
+ }
127
+ /**
128
+ * Check if any persistence data exists
129
+ */
130
+ hasPersistedData() {
131
+ if (!this.isStorageAvailable()) {
132
+ return false;
133
+ }
134
+ return !!this.getPersistedState();
135
+ }
136
+ }
137
+
138
+ /**
139
+ * URL State Manager for CustomViews
140
+ * Handles encoding/decoding of states in URL parameters
141
+ */
142
+ class URLStateManager {
143
+ /**
144
+ * Parse current URL parameters into state object
145
+ */
146
+ static parseURL() {
147
+ const urlParams = new URLSearchParams(window.location.search);
148
+ // Get view state
149
+ const viewParam = urlParams.get('view');
150
+ let decoded = null;
151
+ if (viewParam) {
152
+ try {
153
+ decoded = this.decodeState(viewParam);
154
+ }
155
+ catch (error) {
156
+ console.warn('Failed to decode view state from URL:', error);
157
+ }
158
+ }
159
+ return decoded;
160
+ }
161
+ /**
162
+ * Update URL with current state without triggering navigation
163
+ */
164
+ static updateURL(state) {
165
+ if (typeof window === 'undefined' || !window.history)
166
+ return;
167
+ const url = new URL(window.location.href);
168
+ // Clear existing parameters
169
+ url.searchParams.delete('view');
170
+ // Set view state
171
+ if (state) {
172
+ const encoded = this.encodeState(state);
173
+ if (encoded) {
174
+ url.searchParams.set('view', encoded);
175
+ }
176
+ }
177
+ // Use a relative URL to satisfy stricter environments (e.g., jsdom tests)
178
+ const relative = url.pathname + (url.search || '') + (url.hash || '');
179
+ window.history.replaceState({}, '', relative);
180
+ }
181
+ /**
182
+ * Clear all state parameters from URL
183
+ */
184
+ static clearURL() {
185
+ this.updateURL(null);
186
+ }
187
+ /**
188
+ * Generate shareable URL for current state
189
+ */
190
+ static generateShareableURL(state) {
191
+ const url = new URL(window.location.href);
192
+ // Clear existing parameters
193
+ url.searchParams.delete('view');
194
+ // Set new parameters
195
+ if (state) {
196
+ const encoded = this.encodeState(state);
197
+ if (encoded) {
198
+ url.searchParams.set('view', encoded);
199
+ }
200
+ }
201
+ return url.toString();
202
+ }
203
+ /**
204
+ * Encode state into URL-safe string
205
+ */
206
+ static encodeState(state) {
207
+ try {
208
+ // Create a compact representation
209
+ const compact = {
210
+ t: state.toggles
211
+ };
212
+ // Add tab groups if present
213
+ if (state.tabs && Object.keys(state.tabs).length > 0) {
214
+ compact.g = Object.entries(state.tabs);
215
+ }
216
+ // Convert to JSON and encode
217
+ const json = JSON.stringify(compact);
218
+ let encoded;
219
+ if (typeof btoa === 'function') {
220
+ encoded = btoa(json);
221
+ }
222
+ else {
223
+ // Node/test fallback
224
+ // @ts-ignore
225
+ encoded = Buffer.from(json, 'utf-8').toString('base64');
226
+ }
227
+ // Make URL-safe
228
+ const urlSafeString = encoded.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
229
+ return urlSafeString;
230
+ }
231
+ catch (error) {
232
+ console.warn('Failed to encode state:', error);
233
+ return null;
234
+ }
235
+ }
236
+ /**
237
+ * Decode custom state from URL parameter
238
+ */
239
+ static decodeState(encoded) {
240
+ try {
241
+ // Restore base64 padding and characters
242
+ let base64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
243
+ // Add padding if needed
244
+ while (base64.length % 4) {
245
+ base64 += '=';
246
+ }
247
+ // Decode and parse
248
+ let json;
249
+ if (typeof atob === 'function') {
250
+ json = atob(base64);
251
+ }
252
+ else {
253
+ // Node/test fallback
254
+ // @ts-ignore
255
+ json = Buffer.from(base64, 'base64').toString('utf-8');
256
+ }
257
+ const compact = JSON.parse(json);
258
+ // Validate structure
259
+ if (!compact || typeof compact !== 'object') {
260
+ throw new Error('Invalid compact state structure');
261
+ }
262
+ const state = {
263
+ toggles: Array.isArray(compact.t) ? compact.t : []
264
+ };
265
+ // Reconstruct tabs from compact format
266
+ if (Array.isArray(compact.g)) {
267
+ state.tabs = {};
268
+ for (const [groupId, tabId] of compact.g) {
269
+ if (typeof groupId === 'string' && typeof tabId === 'string') {
270
+ state.tabs[groupId] = tabId;
271
+ }
272
+ }
273
+ }
274
+ return state;
275
+ }
276
+ catch (error) {
277
+ console.warn('Failed to decode view state:', error);
278
+ return null;
279
+ }
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Keeps track of which toggles are hidden and which are visible in memory.
285
+ *
286
+ * This class keeps track of hidden toggles without reading the DOM or URL.
287
+ */
288
+ class VisibilityManager {
289
+ hiddenToggles = new Set();
290
+ /** Marks a toggle as visible or hidden.
291
+ * Returns true if changed.
292
+ * Also updates internal set of hidden toggles.
293
+ */
294
+ setToggleVisibility(toggleId, visible) {
295
+ const wasHidden = this.hiddenToggles.has(toggleId);
296
+ const shouldHide = !visible;
297
+ if (shouldHide && !wasHidden) {
298
+ this.hiddenToggles.add(toggleId);
299
+ return true;
300
+ }
301
+ if (!shouldHide && wasHidden) {
302
+ this.hiddenToggles.delete(toggleId);
303
+ return true;
304
+ }
305
+ return false;
306
+ }
307
+ /** Hide all toggles in the provided set. */
308
+ hideAll(allToggleIds) {
309
+ for (const id of allToggleIds) {
310
+ this.setToggleVisibility(id, false);
311
+ }
312
+ }
313
+ /** Show all toggles in the provided set. */
314
+ showAll(allToggleIds) {
315
+ for (const id of allToggleIds) {
316
+ this.setToggleVisibility(id, true);
317
+ }
318
+ }
319
+ /** Get the globally hidden toggle ids (explicitly hidden via API). */
320
+ getHiddenToggles() {
321
+ return Array.from(this.hiddenToggles);
322
+ }
323
+ /** Filter a list of toggles to only those visible per the hidden set. */
324
+ filterVisibleToggles(toggleIds) {
325
+ return toggleIds.filter(t => !this.hiddenToggles.has(t));
326
+ }
327
+ /**
328
+ * Apply simple class-based visibility to a toggle element.
329
+ * The element is assumed to have data-cv-toggle or data-customviews-toggle.
330
+ */
331
+ applyElementVisibility(el, visible) {
332
+ if (visible) {
333
+ el.classList.remove('cv-hidden');
334
+ el.classList.add('cv-visible');
335
+ }
336
+ else {
337
+ el.classList.add('cv-hidden');
338
+ el.classList.remove('cv-visible');
339
+ }
340
+ }
341
+ }
342
+
343
+ // Constants for selectors
344
+ const TABGROUP_SELECTOR = 'cv-tabgroup';
345
+ const TAB_SELECTOR = 'cv-tab';
346
+ const NAV_AUTO_SELECTOR = 'cv-tabgroup[nav="auto"], cv-tabgroup:not([nav])';
347
+ const NAV_CONTAINER_CLASS = 'cv-tabs-nav';
348
+ /**
349
+ * TabManager handles discovery, visibility, and navigation for tab groups and tabs
350
+ */
351
+ class TabManager {
352
+ /**
353
+ * Apply tab selections to all tab groups in the DOM
354
+ */
355
+ static applySelections(rootEl, tabs, cfgGroups) {
356
+ // Find all cv-tabgroup elements
357
+ const tabGroups = rootEl.querySelectorAll(TABGROUP_SELECTOR);
358
+ tabGroups.forEach((groupEl) => {
359
+ const groupId = groupEl.getAttribute('id');
360
+ if (!groupId)
361
+ return;
362
+ // Determine the active tab for this group
363
+ const activeTabId = this.resolveActiveTab(groupId, tabs, cfgGroups, groupEl);
364
+ // Apply visibility to child cv-tab elements
365
+ const tabElements = groupEl.querySelectorAll(TAB_SELECTOR);
366
+ tabElements.forEach((tabEl) => {
367
+ const tabId = tabEl.getAttribute('id');
368
+ if (!tabId)
369
+ return;
370
+ const isActive = tabId === activeTabId;
371
+ this.applyTabVisibility(tabEl, isActive);
372
+ });
373
+ });
374
+ }
375
+ /**
376
+ * Resolve the active tab for a group based on state, config, and DOM
377
+ */
378
+ static resolveActiveTab(groupId, tabs, cfgGroups, groupEl) {
379
+ // 1. Check state
380
+ if (tabs[groupId]) {
381
+ return tabs[groupId];
382
+ }
383
+ // 2. Check config default
384
+ if (cfgGroups) {
385
+ const groupCfg = cfgGroups.find(g => g.id === groupId);
386
+ if (groupCfg) {
387
+ if (groupCfg.default) {
388
+ return groupCfg.default;
389
+ }
390
+ // Fallback to first tab in config
391
+ const firstConfigTab = groupCfg.tabs[0];
392
+ if (firstConfigTab) {
393
+ return firstConfigTab.id;
394
+ }
395
+ }
396
+ }
397
+ // 3. Fallback to first cv-tab child in DOM
398
+ const firstTab = groupEl.querySelector(TAB_SELECTOR);
399
+ if (firstTab) {
400
+ return firstTab.getAttribute('id');
401
+ }
402
+ return null;
403
+ }
404
+ /**
405
+ * Apply visibility classes to a tab element
406
+ */
407
+ static applyTabVisibility(tabEl, isActive) {
408
+ if (isActive) {
409
+ tabEl.classList.remove('cv-hidden');
410
+ tabEl.classList.add('cv-visible');
411
+ }
412
+ else {
413
+ tabEl.classList.add('cv-hidden');
414
+ tabEl.classList.remove('cv-visible');
415
+ }
416
+ }
417
+ /**
418
+ * Build navigation for tab groups with nav="auto" (one-time setup)
419
+ */
420
+ static buildNavs(rootEl, cfgGroups, onTabClick) {
421
+ // Find all cv-tabgroup elements with nav="auto" or no nav attribute
422
+ const tabGroups = rootEl.querySelectorAll(NAV_AUTO_SELECTOR);
423
+ tabGroups.forEach((groupEl) => {
424
+ const groupId = groupEl.getAttribute('id');
425
+ if (!groupId)
426
+ return;
427
+ // Check if nav already exists - if so, skip building
428
+ let navContainer = groupEl.querySelector(`.${NAV_CONTAINER_CLASS}`);
429
+ if (navContainer)
430
+ return; // Already built
431
+ // Get all child tabs
432
+ const tabElements = Array.from(groupEl.querySelectorAll(TAB_SELECTOR));
433
+ if (tabElements.length === 0)
434
+ return;
435
+ // Create nav container
436
+ navContainer = document.createElement('ul');
437
+ navContainer.className = `${NAV_CONTAINER_CLASS} nav-tabs`;
438
+ navContainer.setAttribute('role', 'tablist');
439
+ groupEl.insertBefore(navContainer, groupEl.firstChild);
440
+ // Build nav items
441
+ tabElements.forEach((tabEl) => {
442
+ const tabId = tabEl.getAttribute('id');
443
+ if (!tabId)
444
+ return;
445
+ const header = tabEl.getAttribute('header') || this.getTabLabel(tabId, groupId, cfgGroups) || tabId;
446
+ const listItem = document.createElement('li');
447
+ listItem.className = 'nav-item';
448
+ const navLink = document.createElement('a');
449
+ navLink.className = 'nav-link';
450
+ navLink.textContent = header;
451
+ navLink.href = '#';
452
+ navLink.setAttribute('data-tab-id', tabId);
453
+ navLink.setAttribute('data-group-id', groupId);
454
+ navLink.setAttribute('role', 'tab');
455
+ // Check if this tab is currently active
456
+ const isActive = tabEl.classList.contains('cv-visible');
457
+ if (isActive) {
458
+ navLink.classList.add('active');
459
+ navLink.setAttribute('aria-selected', 'true');
460
+ }
461
+ else {
462
+ navLink.setAttribute('aria-selected', 'false');
463
+ }
464
+ // Add click handler
465
+ if (onTabClick) {
466
+ navLink.addEventListener('click', (e) => {
467
+ e.preventDefault();
468
+ onTabClick(groupId, tabId);
469
+ });
470
+ }
471
+ listItem.appendChild(navLink);
472
+ navContainer.appendChild(listItem);
473
+ });
474
+ // Add bottom border line at the end of the tab group
475
+ const bottomBorder = document.createElement('div');
476
+ bottomBorder.className = 'cv-tabgroup-bottom-border';
477
+ groupEl.appendChild(bottomBorder);
478
+ });
479
+ }
480
+ /**
481
+ * Get tab label from config
482
+ */
483
+ static getTabLabel(tabId, groupId, cfgGroups) {
484
+ if (!cfgGroups)
485
+ return null;
486
+ const groupCfg = cfgGroups.find(g => g.id === groupId);
487
+ if (!groupCfg)
488
+ return null;
489
+ const tabCfg = groupCfg.tabs.find(t => t.id === tabId);
490
+ return tabCfg?.label || null;
491
+ }
492
+ /**
493
+ * Update active state in navs after selection change (single group)
494
+ */
495
+ static updateNavActiveState(rootEl, groupId, activeTabId) {
496
+ const tabGroups = rootEl.querySelectorAll(`${TABGROUP_SELECTOR}[id="${groupId}"]`);
497
+ tabGroups.forEach((groupEl) => {
498
+ const navLinks = groupEl.querySelectorAll('.nav-link');
499
+ navLinks.forEach((link) => {
500
+ const tabId = link.getAttribute('data-tab-id');
501
+ if (tabId === activeTabId) {
502
+ link.classList.add('active');
503
+ link.setAttribute('aria-selected', 'true');
504
+ }
505
+ else {
506
+ link.classList.remove('active');
507
+ link.setAttribute('aria-selected', 'false');
508
+ }
509
+ });
510
+ });
511
+ }
512
+ /**
513
+ * Update active states for all tab groups based on current state
514
+ */
515
+ static updateAllNavActiveStates(rootEl, tabs, cfgGroups) {
516
+ const tabGroups = rootEl.querySelectorAll(TABGROUP_SELECTOR);
517
+ tabGroups.forEach((groupEl) => {
518
+ const groupId = groupEl.getAttribute('id');
519
+ if (!groupId)
520
+ return;
521
+ // Determine the active tab for this group
522
+ const activeTabId = this.resolveActiveTab(groupId, tabs, cfgGroups, groupEl);
523
+ if (!activeTabId)
524
+ return;
525
+ // Update nav links for this group
526
+ const navLinks = groupEl.querySelectorAll('.nav-link');
527
+ navLinks.forEach((link) => {
528
+ const tabId = link.getAttribute('data-tab-id');
529
+ if (tabId === activeTabId) {
530
+ link.classList.add('active');
531
+ link.setAttribute('aria-selected', 'true');
532
+ }
533
+ else {
534
+ link.classList.remove('active');
535
+ link.setAttribute('aria-selected', 'false');
536
+ }
537
+ });
538
+ });
539
+ }
540
+ }
541
+
542
+ /**
543
+ * Styles for toggle visibility and animations
544
+ */
545
+ const TOGGLE_STYLES = `
546
+ /* Core toggle visibility transitions */
547
+ [data-cv-toggle], [data-customviews-toggle] {
548
+ transition: opacity 150ms ease,
549
+ transform 150ms ease,
550
+ max-height 200ms ease,
551
+ margin 150ms ease;
552
+ will-change: opacity, transform, max-height, margin;
553
+ }
554
+
555
+ .cv-visible {
556
+ opacity: 1 !important;
557
+ transform: translateY(0) !important;
558
+ max-height: var(--cv-max-height, 9999px) !important;
559
+ }
560
+
561
+ .cv-hidden {
562
+ opacity: 0 !important;
563
+ transform: translateY(-4px) !important;
564
+ pointer-events: none !important;
565
+ padding-top: 0 !important;
566
+ padding-bottom: 0 !important;
567
+ border-top-width: 0 !important;
568
+ border-bottom-width: 0 !important;
569
+ max-height: 0 !important;
570
+ margin-top: 0 !important;
571
+ margin-bottom: 0 !important;
572
+ overflow: hidden !important;
573
+ }
574
+ `;
575
+
576
+ /**
577
+ * Styles for tab groups and tab navigation
578
+ */
579
+ const TAB_STYLES = `
580
+ /* Tab navigation styles - Bootstrap-style tabs matching MarkBind */
581
+ .cv-tabs-nav {
582
+ display: flex;
583
+ flex-wrap: wrap;
584
+ padding-left: 0;
585
+ margin-top: 0.5rem;
586
+ margin-bottom: 1rem;
587
+ list-style: none;
588
+ border-bottom: 1px solid #dee2e6;
589
+ }
590
+
591
+ .cv-tabs-nav .nav-item {
592
+ margin-bottom: -1px;
593
+ list-style: none;
594
+ display: inline-block;
595
+ }
596
+
597
+ .cv-tabs-nav .nav-link {
598
+ display: block;
599
+ padding: 0.5rem 1rem;
600
+ color: #495057;
601
+ text-decoration: none;
602
+ background-color: transparent;
603
+ border: 1px solid transparent;
604
+ border-top-left-radius: 0.25rem;
605
+ border-top-right-radius: 0.25rem;
606
+ transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
607
+ cursor: pointer;
608
+ }
609
+
610
+ .cv-tabs-nav .nav-link:hover,
611
+ .cv-tabs-nav .nav-link:focus {
612
+ border-color: #e9ecef #e9ecef #dee2e6;
613
+ isolation: isolate;
614
+ }
615
+
616
+ .cv-tabs-nav .nav-link.active {
617
+ color: #495057;
618
+ background-color: #fff;
619
+ border-color: #dee2e6 #dee2e6 #fff;
620
+ }
621
+
622
+ .cv-tabs-nav .nav-link:focus {
623
+ outline: 0;
624
+ box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
625
+ }
626
+
627
+ /* Legacy button-based nav (deprecated, kept for compatibility) */
628
+ .cv-tabs-nav-item {
629
+ background: none;
630
+ border: none;
631
+ border-bottom: 2px solid transparent;
632
+ padding: 0.5rem 1rem;
633
+ cursor: pointer;
634
+ font-size: 1rem;
635
+ color: #6c757d;
636
+ transition: color 150ms ease, border-color 150ms ease;
637
+ }
638
+
639
+ .cv-tabs-nav-item:hover {
640
+ color: #495057;
641
+ border-bottom-color: #dee2e6;
642
+ }
643
+
644
+ .cv-tabs-nav-item.active {
645
+ color: #007bff;
646
+ border-bottom-color: #007bff;
647
+ font-weight: 500;
648
+ }
649
+
650
+ .cv-tabs-nav-item:focus {
651
+ outline: 2px solid #007bff;
652
+ outline-offset: 2px;
653
+ }
654
+
655
+ /* Tab panel base styles */
656
+ cv-tab {
657
+ display: block;
658
+ }
659
+
660
+ /* Override visibility for tab panels - use display instead of collapse animation */
661
+ cv-tab.cv-hidden {
662
+ display: none !important;
663
+ }
664
+
665
+ cv-tab.cv-visible {
666
+ display: block !important;
667
+ }
668
+
669
+ cv-tabgroup {
670
+ display: block;
671
+ margin-bottom: 1.5rem;
672
+ }
673
+
674
+ /* Bottom border line for tab groups */
675
+ .cv-tabgroup-bottom-border {
676
+ border-bottom: 1px solid #dee2e6;
677
+ margin-top: 1rem;
678
+ }
679
+
680
+ /* Tab content wrapper */
681
+ .cv-tab-content {
682
+ padding: 1rem 0;
683
+ }
684
+ `;
685
+
686
+ /**
687
+ * Combined core styles for toggles and tabs
688
+ */
689
+ const CORE_STYLES = `
690
+ ${TOGGLE_STYLES}
691
+
692
+ ${TAB_STYLES}
693
+ `;
694
+ /**
695
+ * Add styles for hiding and showing toggles animations and transitions to the document head
696
+ */
697
+ function injectCoreStyles() {
698
+ if (typeof document === 'undefined')
699
+ return;
700
+ if (document.querySelector('#cv-core-styles'))
701
+ return;
702
+ const style = document.createElement('style');
703
+ style.id = 'cv-core-styles';
704
+ style.textContent = CORE_STYLES;
705
+ document.head.appendChild(style);
706
+ }
707
+
708
+ class CustomViewsCore {
709
+ rootEl;
710
+ assetsManager;
711
+ persistenceManager;
712
+ visibilityManager;
713
+ config;
714
+ stateChangeListeners = [];
715
+ showUrlEnabled;
716
+ lastAppliedState = null;
717
+ constructor(opt) {
718
+ this.assetsManager = opt.assetsManager;
719
+ this.config = opt.config;
720
+ this.rootEl = opt.rootEl || document.body;
721
+ this.persistenceManager = new PersistenceManager();
722
+ this.visibilityManager = new VisibilityManager();
723
+ this.showUrlEnabled = opt.showUrl ?? true;
724
+ this.lastAppliedState = this.cloneState(this.config?.defaultState);
725
+ }
726
+ getConfig() {
727
+ return this.config;
728
+ }
729
+ /**
730
+ * Get tab groups from config
731
+ */
732
+ getTabGroups() {
733
+ return this.config.tabGroups;
734
+ }
735
+ /**
736
+ * Get currently active tabs (from URL > persisted (localStorage) > defaults)
737
+ */
738
+ getCurrentActiveTabs() {
739
+ if (this.lastAppliedState?.tabs) {
740
+ return { ...this.lastAppliedState.tabs };
741
+ }
742
+ const persistedState = this.persistenceManager.getPersistedState();
743
+ if (persistedState?.tabs) {
744
+ return { ...persistedState.tabs };
745
+ }
746
+ return this.config?.defaultState?.tabs ? { ...this.config.defaultState.tabs } : {};
747
+ }
748
+ /**
749
+ * Set active tab for a group and apply state
750
+ */
751
+ setActiveTab(groupId, tabId) {
752
+ // Get current state
753
+ const currentToggles = this.getCurrentActiveToggles();
754
+ const currentTabs = this.getCurrentActiveTabs();
755
+ // Merge new tab selection
756
+ const newTabs = { ...currentTabs, [groupId]: tabId };
757
+ // Create new state
758
+ const newState = {
759
+ toggles: currentToggles,
760
+ tabs: newTabs
761
+ };
762
+ // Apply the state
763
+ this.applyState(newState);
764
+ // Emit custom event
765
+ const event = new CustomEvent('customviews:tab-change', {
766
+ detail: { groupId, tabId },
767
+ bubbles: true
768
+ });
769
+ document.dispatchEvent(event);
770
+ }
771
+ // Inject styles, setup listeners and call rendering logic
772
+ async init() {
773
+ injectCoreStyles();
774
+ // Build navigation once (with click handlers)
775
+ TabManager.buildNavs(this.rootEl, this.config.tabGroups, (groupId, tabId) => {
776
+ this.setActiveTab(groupId, tabId);
777
+ });
778
+ // For session history, clicks on back/forward button
779
+ window.addEventListener("popstate", () => {
780
+ this.loadAndCallApplyState();
781
+ });
782
+ this.loadAndCallApplyState();
783
+ }
784
+ // Priority: URL state > persisted state > default
785
+ // Also filters using the visibility manager to persist selection
786
+ // across back/forward button clicks
787
+ async loadAndCallApplyState() {
788
+ // 1. URL State
789
+ const urlState = URLStateManager.parseURL();
790
+ if (urlState) {
791
+ this.applyState(urlState);
792
+ return;
793
+ }
794
+ // 2. Persisted State
795
+ const persistedState = this.persistenceManager.getPersistedState();
796
+ if (persistedState) {
797
+ this.applyState(persistedState);
798
+ return;
799
+ }
800
+ // 3. Local Config Fallback
801
+ this.renderState(this.config.defaultState);
802
+ }
803
+ /**
804
+ * Apply a custom state, saves to localStorage and updates the URL
805
+ */
806
+ applyState(state) {
807
+ const snapshot = this.cloneState(state);
808
+ this.renderState(snapshot);
809
+ this.persistenceManager.persistState(snapshot);
810
+ if (this.showUrlEnabled) {
811
+ URLStateManager.updateURL(snapshot);
812
+ }
813
+ else {
814
+ URLStateManager.clearURL();
815
+ }
816
+ }
817
+ /** Render all toggles for the current state */
818
+ renderState(state) {
819
+ this.lastAppliedState = this.cloneState(state);
820
+ const toggles = state.toggles || [];
821
+ const finalToggles = this.visibilityManager.filterVisibleToggles(toggles);
822
+ // Toggles hide or show relevant toggles
823
+ this.rootEl.querySelectorAll("[data-cv-toggle], [data-customviews-toggle]").forEach(el => {
824
+ const category = el.dataset.cvToggle || el.dataset.customviewsToggle;
825
+ const shouldShow = !!category && finalToggles.includes(category);
826
+ this.visibilityManager.applyElementVisibility(el, shouldShow);
827
+ });
828
+ // Render toggles
829
+ for (const category of finalToggles) {
830
+ this.rootEl.querySelectorAll(`[data-cv-toggle="${category}"], [data-customviews-toggle="${category}"]`).forEach(el => {
831
+ // if it has an id, then we should render the asset into it
832
+ // Support both (data-cv-id) and (data-customviews-id) attributes
833
+ const toggleId = el.dataset.cvId || el.dataset.customviewsId;
834
+ if (toggleId) {
835
+ renderAssetInto(el, toggleId, this.assetsManager);
836
+ }
837
+ });
838
+ }
839
+ // Apply tab selections
840
+ TabManager.applySelections(this.rootEl, state.tabs || {}, this.config.tabGroups);
841
+ // Update nav active states (without rebuilding)
842
+ TabManager.updateAllNavActiveStates(this.rootEl, state.tabs || {}, this.config.tabGroups);
843
+ // Notify state change listeners (like widgets)
844
+ this.notifyStateChangeListeners();
845
+ }
846
+ /**
847
+ * Reset to default state
848
+ */
849
+ resetToDefault() {
850
+ this.persistenceManager.clearAll();
851
+ if (this.config) {
852
+ this.renderState(this.config.defaultState);
853
+ }
854
+ else {
855
+ console.warn("No configuration loaded, cannot reset to default state");
856
+ }
857
+ // Clear URL
858
+ URLStateManager.clearURL();
859
+ }
860
+ /**
861
+ * Get the currently active toggles regardless of whether they come from custom state or default configuration
862
+ */
863
+ getCurrentActiveToggles() {
864
+ if (this.lastAppliedState) {
865
+ return this.lastAppliedState.toggles || [];
866
+ }
867
+ if (this.config) {
868
+ return this.config.defaultState.toggles || [];
869
+ }
870
+ return [];
871
+ }
872
+ /**
873
+ * Clear all persistence and reset to default
874
+ */
875
+ clearPersistence() {
876
+ this.persistenceManager.clearAll();
877
+ if (this.config) {
878
+ this.renderState(this.config.defaultState);
879
+ }
880
+ else {
881
+ console.warn("No configuration loaded, cannot reset to default state");
882
+ }
883
+ URLStateManager.clearURL();
884
+ }
885
+ setOption(flag, value) {
886
+ switch (flag) {
887
+ case 'showUrl': {
888
+ const nextValue = Boolean(value);
889
+ if (this.showUrlEnabled === nextValue) {
890
+ return;
891
+ }
892
+ this.showUrlEnabled = nextValue;
893
+ if (nextValue) {
894
+ const stateForUrl = this.getTrackedStateSnapshot();
895
+ URLStateManager.updateURL(stateForUrl);
896
+ }
897
+ else {
898
+ URLStateManager.clearURL();
899
+ }
900
+ break;
901
+ }
902
+ default:
903
+ console.warn(`[CustomViews] Unknown option '${flag}' passed to setOption`);
904
+ }
905
+ }
906
+ // === STATE CHANGE LISTENER METHODS ===
907
+ /**
908
+ * Add a listener that will be called whenever the state changes
909
+ */
910
+ addStateChangeListener(listener) {
911
+ this.stateChangeListeners.push(listener);
912
+ }
913
+ /**
914
+ * Remove a state change listener
915
+ */
916
+ removeStateChangeListener(listener) {
917
+ const index = this.stateChangeListeners.indexOf(listener);
918
+ if (index > -1) {
919
+ this.stateChangeListeners.splice(index, 1);
920
+ }
921
+ }
922
+ /**
923
+ * Notify all state change listeners
924
+ */
925
+ notifyStateChangeListeners() {
926
+ this.stateChangeListeners.forEach(listener => {
927
+ try {
928
+ listener();
929
+ }
930
+ catch (error) {
931
+ console.warn('Error in state change listener:', error);
932
+ }
933
+ });
934
+ }
935
+ cloneState(state) {
936
+ const toggles = state?.toggles ? [...state.toggles] : [];
937
+ const tabs = state?.tabs ? { ...state.tabs } : undefined;
938
+ return tabs ? { toggles, tabs } : { toggles };
939
+ }
940
+ getTrackedStateSnapshot() {
941
+ if (this.lastAppliedState) {
942
+ return this.cloneState(this.lastAppliedState);
943
+ }
944
+ if (this.config) {
945
+ return this.cloneState(this.config.defaultState);
946
+ }
947
+ return { toggles: [] };
948
+ }
949
+ }
950
+
951
+ class AssetsManager {
952
+ assets;
953
+ baseURL;
954
+ constructor(assets, baseURL = '') {
955
+ this.assets = assets;
956
+ this.baseURL = baseURL;
957
+ if (!this.validate()) {
958
+ console.warn('Invalid assets:', this.assets);
959
+ }
960
+ }
961
+ // Check each asset has content or src
962
+ validate() {
963
+ return Object.values(this.assets).every(a => a.src || a.content);
964
+ }
965
+ get(assetId) {
966
+ const asset = this.assets[assetId];
967
+ if (!asset)
968
+ return undefined;
969
+ // If there's a baseURL and the asset has a src property, prepend the baseURL
970
+ if (this.baseURL && asset.src) {
971
+ // Create a shallow copy to avoid mutating the original asset
972
+ return {
973
+ ...asset,
974
+ src: this.prependBaseURL(asset.src)
975
+ };
976
+ }
977
+ return asset;
978
+ }
979
+ prependBaseURL(path) {
980
+ // Don't prepend if the path is already absolute (starts with http:// or https://)
981
+ if (path.startsWith('http://') || path.startsWith('https://')) {
982
+ return path;
983
+ }
984
+ // Ensure baseURL doesn't end with / and path starts with /
985
+ const cleanBaseURL = this.baseURL.endsWith('/') ? this.baseURL.slice(0, -1) : this.baseURL;
986
+ const cleanPath = path.startsWith('/') ? path : '/' + path;
987
+ return cleanBaseURL + cleanPath;
988
+ }
989
+ loadFromJSON(json) {
990
+ this.assets = json;
991
+ }
992
+ loadAdditionalAssets(additionalAssets) {
993
+ this.assets = { ...this.assets, ...additionalAssets };
994
+ }
995
+ }
996
+
997
+ /**
998
+ * Helper function to prepend baseUrl to a path
999
+ * @param path The path to prepend the baseUrl to
1000
+ * @param baseUrl The base URL to prepend
1001
+ * @returns The full URL with baseUrl prepended if applicable
1002
+ */
1003
+ function prependBaseUrl(path, baseUrl) {
1004
+ if (!baseUrl)
1005
+ return path;
1006
+ // Don't prepend if the path is already absolute (starts with http:// or https://)
1007
+ if (path.startsWith('http://') || path.startsWith('https://')) {
1008
+ return path;
1009
+ }
1010
+ // Ensure baseUrl doesn't end with / and path starts with /
1011
+ const cleanbaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
1012
+ const cleanPath = path.startsWith('/') ? path : '/' + path;
1013
+ return cleanbaseUrl + cleanPath;
1014
+ }
1015
+
1016
+ /**
1017
+ * Main CustomViews class for initializing and managing custom views
1018
+ */
1019
+ class CustomViews {
1020
+ /**
1021
+ * Entry Point to use CustomViews
1022
+ * @param opts Initialization options including config object and assets path
1023
+ * @returns Promise resolving to the CustomViewsCore instance or null if initialization fails
1024
+ */
1025
+ static async init(opts) {
1026
+ // Load assets JSON if provided
1027
+ let assetsManager;
1028
+ const baseURL = opts.baseURL || '';
1029
+ if (opts.assetsJsonPath) {
1030
+ const assetsPath = prependBaseUrl(opts.assetsJsonPath, baseURL);
1031
+ const assetsJson = await (await fetch(assetsPath)).json();
1032
+ assetsManager = new AssetsManager(assetsJson, baseURL);
1033
+ }
1034
+ else {
1035
+ assetsManager = new AssetsManager({}, baseURL);
1036
+ }
1037
+ // Use provided config or create a minimal default one
1038
+ let config;
1039
+ if (opts.config) {
1040
+ config = opts.config;
1041
+ }
1042
+ else {
1043
+ console.error("No config provided, using minimal default config");
1044
+ // Create a minimal default config
1045
+ config = { allToggles: [], defaultState: { toggles: [] } };
1046
+ }
1047
+ const coreOptions = {
1048
+ assetsManager,
1049
+ config: config,
1050
+ rootEl: opts.rootEl,
1051
+ };
1052
+ if (opts.showUrl !== undefined) {
1053
+ coreOptions.showUrl = opts.showUrl;
1054
+ }
1055
+ const core = new CustomViewsCore(coreOptions);
1056
+ core.init();
1057
+ return core;
1058
+ }
1059
+ }
1060
+
1061
+ /**
1062
+ * Widget styles for CustomViews
1063
+ * Extracted from widget.ts for better maintainability
1064
+ *
1065
+ * Note: Styles are kept as a TypeScript string for compatibility with the build system.
1066
+ * This approach ensures the styles are properly bundled and don't require separate CSS file handling.
1067
+ */
1068
+ const WIDGET_STYLES = `
1069
+ /* Rounded rectangle widget icon styles */
1070
+ .cv-widget-icon {
1071
+ position: fixed;
1072
+ /* Slightly transparent by default so the widget is subtle at the page edge */
1073
+ background: rgba(255, 255, 255, 0.92);
1074
+ color: rgba(0, 0, 0, 0.9);
1075
+ opacity: 0.6;
1076
+ display: flex;
1077
+ align-items: center;
1078
+ justify-content: center;
1079
+ font-size: 18px;
1080
+ font-weight: bold;
1081
+ cursor: pointer;
1082
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1083
+ z-index: 9998;
1084
+ transition: all 0.3s ease;
1085
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
1086
+ }
1087
+
1088
+ .cv-widget-icon:hover {
1089
+ /* Become fully opaque on hover to improve readability */
1090
+ background: rgba(255, 255, 255, 1);
1091
+ color: rgba(0, 0, 0, 1);
1092
+ opacity: 1;
1093
+ }
1094
+
1095
+ /* Top-right: rounded end on left, sticks out leftward on hover */
1096
+ .cv-widget-top-right {
1097
+ top: 20px;
1098
+ right: 0;
1099
+ border-radius: 18px 0 0 18px;
1100
+ padding-left: 8px;
1101
+ justify-content: flex-start;
1102
+ }
1103
+
1104
+ /* Top-left: rounded end on right, sticks out rightward on hover */
1105
+ .cv-widget-top-left {
1106
+ top: 20px;
1107
+ left: 0;
1108
+ border-radius: 0 18px 18px 0;
1109
+ padding-right: 8px;
1110
+ justify-content: flex-end;
1111
+ }
1112
+
1113
+ /* Bottom-right: rounded end on left, sticks out leftward on hover */
1114
+ .cv-widget-bottom-right {
1115
+ bottom: 20px;
1116
+ right: 0;
1117
+ border-radius: 18px 0 0 18px;
1118
+ padding-left: 8px;
1119
+ justify-content: flex-start;
1120
+ }
1121
+
1122
+ /* Bottom-left: rounded end on right, sticks out rightward on hover */
1123
+ .cv-widget-bottom-left {
1124
+ bottom: 20px;
1125
+ left: 0;
1126
+ border-radius: 0 18px 18px 0;
1127
+ padding-right: 8px;
1128
+ justify-content: flex-end;
1129
+ }
1130
+
1131
+ /* Middle-left: rounded end on right, sticks out rightward on hover */
1132
+ .cv-widget-middle-left {
1133
+ top: 50%;
1134
+ left: 0;
1135
+ transform: translateY(-50%);
1136
+ border-radius: 0 18px 18px 0;
1137
+ padding-right: 8px;
1138
+ justify-content: flex-end;
1139
+ }
1140
+
1141
+ /* Middle-right: rounded end on left, sticks out leftward on hover */
1142
+ .cv-widget-middle-right {
1143
+ top: 50%;
1144
+ right: 0;
1145
+ transform: translateY(-50%);
1146
+ border-radius: 18px 0 0 18px;
1147
+ padding-left: 8px;
1148
+ justify-content: flex-start;
1149
+ }
1150
+
1151
+ .cv-widget-top-right,
1152
+ .cv-widget-middle-right,
1153
+ .cv-widget-bottom-right,
1154
+ .cv-widget-top-left,
1155
+ .cv-widget-middle-left,
1156
+ .cv-widget-bottom-left {
1157
+ height: 36px;
1158
+ width: 36px;
1159
+ }
1160
+
1161
+ .cv-widget-middle-right:hover,
1162
+ .cv-widget-top-right:hover,
1163
+ .cv-widget-bottom-right:hover,
1164
+ .cv-widget-top-left:hover,
1165
+ .cv-widget-middle-left:hover,
1166
+ .cv-widget-bottom-left:hover {
1167
+ width: 55px;
1168
+ }
1169
+
1170
+ /* Modal content styles */
1171
+ .cv-widget-section {
1172
+ margin-bottom: 16px;
1173
+ }
1174
+
1175
+ .cv-widget-section:last-child {
1176
+ margin-bottom: 0;
1177
+ }
1178
+
1179
+ .cv-widget-section label {
1180
+ display: block;
1181
+ margin-bottom: 4px;
1182
+ font-weight: 500;
1183
+ color: #555;
1184
+ }
1185
+
1186
+ .cv-widget-profile-select,
1187
+ .cv-widget-state-select {
1188
+ width: 100%;
1189
+ padding: 8px 12px;
1190
+ border: 1px solid #ddd;
1191
+ border-radius: 4px;
1192
+ background: white;
1193
+ font-size: 14px;
1194
+ }
1195
+
1196
+ .cv-widget-profile-select:focus,
1197
+ .cv-widget-state-select:focus {
1198
+ outline: none;
1199
+ border-color: #007bff;
1200
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
1201
+ }
1202
+
1203
+ .cv-widget-profile-select:disabled,
1204
+ .cv-widget-state-select:disabled {
1205
+ background: #f8f9fa;
1206
+ color: #6c757d;
1207
+ cursor: not-allowed;
1208
+ }
1209
+
1210
+ .cv-widget-current {
1211
+ margin: 16px 0;
1212
+ padding: 12px;
1213
+ background: #f8f9fa;
1214
+ border-radius: 4px;
1215
+ border-left: 4px solid #007bff;
1216
+ }
1217
+
1218
+ .cv-widget-current label {
1219
+ font-size: 12px;
1220
+ text-transform: uppercase;
1221
+ letter-spacing: 0.5px;
1222
+ color: #666;
1223
+ margin-bottom: 4px;
1224
+ }
1225
+
1226
+ .cv-widget-current-view {
1227
+ font-weight: 500;
1228
+ color: #333;
1229
+ }
1230
+
1231
+ .cv-widget-reset {
1232
+ width: 100%;
1233
+ padding: 8px 16px;
1234
+ background: #dc3545;
1235
+ color: white;
1236
+ border: none;
1237
+ border-radius: 4px;
1238
+ cursor: pointer;
1239
+ font-size: 14px;
1240
+ font-weight: 500;
1241
+ }
1242
+
1243
+ .cv-widget-reset:hover {
1244
+ background: #c82333;
1245
+ }
1246
+
1247
+ .cv-widget-reset:active {
1248
+ background: #bd2130;
1249
+ }
1250
+
1251
+ /* Responsive design for mobile */
1252
+ @media (max-width: 768px) {
1253
+ .cv-widget-top-right,
1254
+ .cv-widget-top-left {
1255
+ top: 10px;
1256
+ }
1257
+
1258
+ .cv-widget-bottom-right,
1259
+ .cv-widget-bottom-left {
1260
+ bottom: 10px;
1261
+ }
1262
+
1263
+ /* All widgets stay flush with screen edges */
1264
+ .cv-widget-top-right,
1265
+ .cv-widget-bottom-right,
1266
+ .cv-widget-middle-right {
1267
+ right: 0;
1268
+ }
1269
+
1270
+ .cv-widget-top-left,
1271
+ .cv-widget-bottom-left,
1272
+ .cv-widget-middle-left {
1273
+ left: 0;
1274
+ }
1275
+
1276
+ /* Slightly smaller on mobile */
1277
+ .cv-widget-icon {
1278
+ width: 60px;
1279
+ height: 32px;
1280
+ }
1281
+
1282
+ .cv-widget-icon:hover {
1283
+ width: 75px;
1284
+ }
1285
+ }
1286
+
1287
+ /* Modal styles */
1288
+ .cv-widget-modal-overlay {
1289
+ position: fixed;
1290
+ top: 0;
1291
+ left: 0;
1292
+ right: 0;
1293
+ bottom: 0;
1294
+ background: rgba(0, 0, 0, 0.5);
1295
+ display: flex;
1296
+ align-items: center;
1297
+ justify-content: center;
1298
+ z-index: 10002;
1299
+ animation: fadeIn 0.2s ease;
1300
+ }
1301
+
1302
+ @keyframes fadeIn {
1303
+ from { opacity: 0; }
1304
+ to { opacity: 1; }
1305
+ }
1306
+
1307
+ .cv-widget-modal {
1308
+ background: white;
1309
+ border-radius: 8px;
1310
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
1311
+ max-width: 400px;
1312
+ width: 90vw;
1313
+ max-height: 80vh;
1314
+ overflow-y: auto;
1315
+ animation: slideIn 0.2s ease;
1316
+ }
1317
+
1318
+ @keyframes slideIn {
1319
+ from {
1320
+ opacity: 0;
1321
+ transform: scale(0.9) translateY(-20px);
1322
+ }
1323
+ to {
1324
+ opacity: 1;
1325
+ transform: scale(1) translateY(0);
1326
+ }
1327
+ }
1328
+
1329
+ .cv-widget-modal-header {
1330
+ display: flex;
1331
+ justify-content: space-between;
1332
+ align-items: center;
1333
+ padding: 16px 20px;
1334
+ border-bottom: 1px solid #e9ecef;
1335
+ background: #f8f9fa;
1336
+ border-radius: 8px 8px 0 0;
1337
+ }
1338
+
1339
+ .cv-widget-modal-header h3 {
1340
+ margin: 0;
1341
+ font-size: 18px;
1342
+ font-weight: 600;
1343
+ color: #333;
1344
+ }
1345
+
1346
+ .cv-widget-modal-close {
1347
+ background: none;
1348
+ border: none;
1349
+ font-size: 24px;
1350
+ cursor: pointer;
1351
+ padding: 0;
1352
+ width: 32px;
1353
+ height: 32px;
1354
+ display: flex;
1355
+ align-items: center;
1356
+ justify-content: center;
1357
+ border-radius: 4px;
1358
+ color: #666;
1359
+ }
1360
+
1361
+ .cv-widget-modal-close:hover {
1362
+ background: #e9ecef;
1363
+ }
1364
+
1365
+ .cv-widget-modal-content {
1366
+ padding: 20px;
1367
+ }
1368
+
1369
+ .cv-widget-modal-actions {
1370
+ margin-top: 20px;
1371
+ padding-top: 16px;
1372
+ border-top: 1px solid #e9ecef;
1373
+ }
1374
+
1375
+ .cv-widget-restore {
1376
+ width: 100%;
1377
+ padding: 10px 16px;
1378
+ background: #28a745;
1379
+ color: white;
1380
+ border: none;
1381
+ border-radius: 4px;
1382
+ cursor: pointer;
1383
+ font-size: 14px;
1384
+ font-weight: 500;
1385
+ }
1386
+
1387
+ .cv-widget-restore:hover {
1388
+ background: #218838;
1389
+ }
1390
+
1391
+ .cv-widget-create-state {
1392
+ width: 100%;
1393
+ padding: 10px 16px;
1394
+ background: #007bff;
1395
+ color: white;
1396
+ border: none;
1397
+ border-radius: 4px;
1398
+ cursor: pointer;
1399
+ font-size: 14px;
1400
+ font-weight: 500;
1401
+ margin-bottom: 10px;
1402
+ }
1403
+
1404
+ .cv-widget-create-state:hover {
1405
+ background: #0056b3;
1406
+ }
1407
+
1408
+ /* Dark theme modal styles */
1409
+ .cv-widget-theme-dark .cv-widget-modal {
1410
+ background: #2d3748;
1411
+ color: #e2e8f0;
1412
+ }
1413
+
1414
+ .cv-widget-theme-dark .cv-widget-modal-header {
1415
+ background: #1a202c;
1416
+ border-color: #4a5568;
1417
+ }
1418
+
1419
+ .cv-widget-theme-dark .cv-widget-modal-header h3 {
1420
+ color: #e2e8f0;
1421
+ }
1422
+
1423
+ .cv-widget-theme-dark .cv-widget-modal-close {
1424
+ color: #a0aec0;
1425
+ }
1426
+
1427
+ .cv-widget-theme-dark .cv-widget-modal-close:hover {
1428
+ background: #4a5568;
1429
+ }
1430
+
1431
+ .cv-widget-theme-dark .cv-widget-modal-actions {
1432
+ border-color: #4a5568;
1433
+ }
1434
+
1435
+ /* Custom state creator styles */
1436
+ .cv-custom-state-modal {
1437
+ max-width: 500px;
1438
+ }
1439
+
1440
+ .cv-custom-state-form h4 {
1441
+ margin: 20px 0 10px 0;
1442
+ font-size: 16px;
1443
+ font-weight: 600;
1444
+ color: #333;
1445
+ border-bottom: 1px solid #e9ecef;
1446
+ padding-bottom: 5px;
1447
+ }
1448
+
1449
+ .cv-custom-state-section {
1450
+ margin-bottom: 16px;
1451
+ }
1452
+
1453
+ .cv-custom-state-section label {
1454
+ display: block;
1455
+ margin-bottom: 4px;
1456
+ font-weight: 500;
1457
+ color: #555;
1458
+ }
1459
+
1460
+ .cv-custom-state-input {
1461
+ width: 100%;
1462
+ padding: 8px 12px;
1463
+ border: 1px solid #ddd;
1464
+ border-radius: 4px;
1465
+ background: white;
1466
+ font-size: 14px;
1467
+ }
1468
+
1469
+ .cv-custom-state-input:focus {
1470
+ outline: none;
1471
+ border-color: #007bff;
1472
+ box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);
1473
+ }
1474
+
1475
+ .cv-custom-toggles {
1476
+ display: grid;
1477
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
1478
+ gap: 10px;
1479
+ }
1480
+
1481
+ .cv-custom-state-toggle {
1482
+ display: flex;
1483
+ align-items: center;
1484
+ }
1485
+
1486
+ .cv-custom-state-toggle label {
1487
+ display: flex;
1488
+ align-items: center;
1489
+ cursor: pointer;
1490
+ font-weight: normal;
1491
+ margin: 0;
1492
+ }
1493
+
1494
+ .cv-custom-toggle-checkbox {
1495
+ margin-right: 8px;
1496
+ width: auto;
1497
+ }
1498
+
1499
+ .cv-tab-groups {
1500
+ margin-top: 20px;
1501
+ }
1502
+
1503
+ .cv-tab-group-control {
1504
+ margin-bottom: 15px;
1505
+ }
1506
+
1507
+ .cv-tab-group-control label {
1508
+ display: block;
1509
+ margin-bottom: 5px;
1510
+ font-weight: 500;
1511
+ font-size: 14px;
1512
+ }
1513
+
1514
+ .cv-tab-group-select {
1515
+ width: 100%;
1516
+ padding: 8px 12px;
1517
+ border: 1px solid #ced4da;
1518
+ border-radius: 4px;
1519
+ font-size: 14px;
1520
+ background-color: white;
1521
+ cursor: pointer;
1522
+ }
1523
+
1524
+ .cv-tab-group-select:focus {
1525
+ outline: none;
1526
+ border-color: #007bff;
1527
+ box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
1528
+ }
1529
+
1530
+ .cv-widget-theme-dark .cv-tab-group-select {
1531
+ background-color: #2d3748;
1532
+ border-color: #4a5568;
1533
+ color: #e2e8f0;
1534
+ }
1535
+
1536
+ .cv-custom-state-actions {
1537
+ display: flex;
1538
+ gap: 10px;
1539
+ margin-top: 20px;
1540
+ padding-top: 16px;
1541
+ border-top: 1px solid #e9ecef;
1542
+ }
1543
+
1544
+ .cv-custom-state-cancel,
1545
+ .cv-custom-state-copy-url {
1546
+ flex: 1;
1547
+ padding: 10px 16px;
1548
+ border: none;
1549
+ border-radius: 4px;
1550
+ cursor: pointer;
1551
+ font-size: 14px;
1552
+ font-weight: 500;
1553
+ }
1554
+
1555
+ .cv-custom-state-reset {
1556
+ flex: 1;
1557
+ padding: 10px 16px;
1558
+ border: none;
1559
+ border-radius: 4px;
1560
+ cursor: pointer;
1561
+ font-size: 14px;
1562
+ font-weight: 500;
1563
+ background: #dc3545;
1564
+ color: white;
1565
+ }
1566
+
1567
+ .cv-custom-state-reset:hover {
1568
+ background: #c82333;
1569
+ }
1570
+
1571
+ .cv-custom-state-cancel {
1572
+ background: #6c757d;
1573
+ color: white;
1574
+ }
1575
+
1576
+ .cv-custom-state-cancel:hover {
1577
+ background: #5a6268;
1578
+ }
1579
+
1580
+ .cv-custom-state-copy-url {
1581
+ background: #28a745;
1582
+ color: white;
1583
+ }
1584
+
1585
+ .cv-custom-state-copy-url:hover {
1586
+ background: #218838;
1587
+ }
1588
+
1589
+ /* Dark theme custom state styles */
1590
+ .cv-widget-theme-dark .cv-custom-state-form h4 {
1591
+ color: #e2e8f0;
1592
+ border-color: #4a5568;
1593
+ }
1594
+
1595
+ .cv-widget-theme-dark .cv-custom-state-section label {
1596
+ color: #a0aec0;
1597
+ }
1598
+
1599
+ .cv-widget-theme-dark .cv-custom-state-input {
1600
+ background: #1a202c;
1601
+ border-color: #4a5568;
1602
+ color: #e2e8f0;
1603
+ }
1604
+
1605
+ .cv-widget-theme-dark .cv-custom-state-actions {
1606
+ border-color: #4a5568;
1607
+ }
1608
+
1609
+ /* Welcome modal styles */
1610
+ .cv-welcome-modal {
1611
+ max-width: 500px;
1612
+ }
1613
+
1614
+ .cv-welcome-content {
1615
+ text-align: center;
1616
+ }
1617
+
1618
+ .cv-welcome-content p {
1619
+ font-size: 15px;
1620
+ line-height: 1.6;
1621
+ color: #555;
1622
+ margin-bottom: 24px;
1623
+ }
1624
+
1625
+ .cv-welcome-widget-preview {
1626
+ display: flex;
1627
+ flex-direction: column;
1628
+ align-items: center;
1629
+ gap: 12px;
1630
+ padding: 20px;
1631
+ background: #f8f9fa;
1632
+ border-radius: 8px;
1633
+ margin-bottom: 24px;
1634
+ }
1635
+
1636
+ .cv-welcome-widget-icon {
1637
+ width: 36px;
1638
+ height: 36px;
1639
+ background: white;
1640
+ color: black;
1641
+ border-radius: 0 18px 18px 0;
1642
+ display: flex;
1643
+ align-items: center;
1644
+ justify-content: center;
1645
+ font-size: 18px;
1646
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
1647
+ }
1648
+
1649
+ .cv-welcome-widget-label {
1650
+ font-size: 14px;
1651
+ color: #666;
1652
+ margin: 0;
1653
+ font-weight: 500;
1654
+ }
1655
+
1656
+ .cv-welcome-got-it {
1657
+ width: 100%;
1658
+ padding: 12px 24px;
1659
+ background: #007bff;
1660
+ color: white;
1661
+ border: none;
1662
+ border-radius: 4px;
1663
+ cursor: pointer;
1664
+ font-size: 16px;
1665
+ font-weight: 600;
1666
+ transition: background 0.2s ease;
1667
+ }
1668
+
1669
+ .cv-welcome-got-it:hover {
1670
+ background: #0056b3;
1671
+ }
1672
+
1673
+ .cv-welcome-got-it:active {
1674
+ background: #004494;
1675
+ }
1676
+
1677
+ /* Dark theme welcome modal styles */
1678
+ .cv-widget-theme-dark .cv-welcome-content p {
1679
+ color: #cbd5e0;
1680
+ }
1681
+
1682
+ .cv-widget-theme-dark .cv-welcome-widget-preview {
1683
+ background: #1a202c;
1684
+ }
1685
+
1686
+ .cv-widget-theme-dark .cv-welcome-widget-label {
1687
+ color: #a0aec0;
1688
+ }
1689
+ `;
1690
+ /**
1691
+ * Inject widget styles into the document head
1692
+ */
1693
+ function injectWidgetStyles() {
1694
+ // Check if styles are already injected
1695
+ if (document.querySelector('#cv-widget-styles'))
1696
+ return;
1697
+ const style = document.createElement('style');
1698
+ style.id = 'cv-widget-styles';
1699
+ style.textContent = WIDGET_STYLES;
1700
+ document.head.appendChild(style);
1701
+ }
1702
+
1703
+ class CustomViewsWidget {
1704
+ core;
1705
+ container;
1706
+ widgetIcon = null;
1707
+ options;
1708
+ // Modal state
1709
+ modal = null;
1710
+ constructor(options) {
1711
+ this.core = options.core;
1712
+ this.container = options.container || document.body;
1713
+ // Set defaults
1714
+ this.options = {
1715
+ core: options.core,
1716
+ container: this.container,
1717
+ position: options.position || 'middle-left',
1718
+ theme: options.theme || 'light',
1719
+ showReset: options.showReset ?? true,
1720
+ title: options.title || 'Custom Views',
1721
+ description: options.description || 'Toggle different content sections to customize your view. Changes are applied instantly and the URL will be updated for sharing.',
1722
+ showWelcome: options.showWelcome ?? false,
1723
+ welcomeTitle: options.welcomeTitle || 'Welcome to Custom Views!',
1724
+ welcomeMessage: options.welcomeMessage || 'This website uses Custom Views to let you personalize your experience. Use the widget on the side (⚙) to show or hide different content sections based on your preferences. Your selections will be saved and can be shared via URL.',
1725
+ showTabGroups: options.showTabGroups ?? true
1726
+ };
1727
+ // No external state manager to initialize
1728
+ }
1729
+ /**
1730
+ * Render the widget
1731
+ */
1732
+ render() {
1733
+ this.widgetIcon = this.createWidgetIcon();
1734
+ this.attachEventListeners();
1735
+ // Always append to body since it's a floating icon
1736
+ document.body.appendChild(this.widgetIcon);
1737
+ // Show welcome modal on first visit if enabled
1738
+ if (this.options.showWelcome) {
1739
+ this.showWelcomeModalIfFirstVisit();
1740
+ }
1741
+ return this.widgetIcon;
1742
+ }
1743
+ /**
1744
+ * Create the simple widget icon
1745
+ */
1746
+ createWidgetIcon() {
1747
+ const icon = document.createElement('div');
1748
+ icon.className = `cv-widget-icon cv-widget-${this.options.position}`;
1749
+ icon.innerHTML = '⚙';
1750
+ icon.title = this.options.title;
1751
+ icon.setAttribute('aria-label', 'Open Custom Views');
1752
+ // Add styles
1753
+ injectWidgetStyles();
1754
+ return icon;
1755
+ }
1756
+ /**
1757
+ * Remove the widget from DOM
1758
+ */
1759
+ destroy() {
1760
+ if (this.widgetIcon) {
1761
+ this.widgetIcon.remove();
1762
+ this.widgetIcon = null;
1763
+ }
1764
+ // Clean up modal
1765
+ if (this.modal) {
1766
+ this.modal.remove();
1767
+ this.modal = null;
1768
+ }
1769
+ }
1770
+ attachEventListeners() {
1771
+ if (!this.widgetIcon)
1772
+ return;
1773
+ // Click to open customization modal directly
1774
+ this.widgetIcon.addEventListener('click', () => this.openStateModal());
1775
+ }
1776
+ /**
1777
+ * Close the modal
1778
+ */
1779
+ closeModal() {
1780
+ if (this.modal) {
1781
+ this.modal.remove();
1782
+ this.modal = null;
1783
+ }
1784
+ }
1785
+ /**
1786
+ * Open the custom state creator
1787
+ */
1788
+ openStateModal() {
1789
+ // Get toggles from current configuration and open the modal regardless of count
1790
+ const config = this.core.getConfig();
1791
+ const toggles = config?.allToggles || [];
1792
+ this.createCustomStateModal(toggles);
1793
+ }
1794
+ /**
1795
+ * Create the custom state creator modal
1796
+ */
1797
+ createCustomStateModal(toggles) {
1798
+ // Close existing modal
1799
+ this.closeModal();
1800
+ this.modal = document.createElement('div');
1801
+ this.modal.className = 'cv-widget-modal-overlay';
1802
+ this.applyThemeToModal();
1803
+ const toggleControls = toggles.length
1804
+ ? toggles.map(toggle => `
1805
+ <div class="cv-custom-state-toggle">
1806
+ <label>
1807
+ <input type="checkbox" class="cv-custom-toggle-checkbox" data-toggle="${toggle}" />
1808
+ ${this.formatToggleName(toggle)}
1809
+ </label>
1810
+ </div>
1811
+ `).join('')
1812
+ : `<p class="cv-no-toggles">No configurable sections available.</p>`;
1813
+ // Get tab groups
1814
+ const tabGroups = this.core.getTabGroups();
1815
+ let tabGroupsHTML = '';
1816
+ if (this.options.showTabGroups && tabGroups && tabGroups.length > 0) {
1817
+ const tabGroupControls = tabGroups.map(group => {
1818
+ const options = group.tabs.map(tab => `<option value="${tab.id}">${tab.label || tab.id}</option>`).join('');
1819
+ return `
1820
+ <div class="cv-tab-group-control">
1821
+ <label for="tab-group-${group.id}">${group.label || group.id}</label>
1822
+ <select id="tab-group-${group.id}" class="cv-tab-group-select" data-group-id="${group.id}">
1823
+ ${options}
1824
+ </select>
1825
+ </div>
1826
+ `;
1827
+ }).join('');
1828
+ tabGroupsHTML = `
1829
+ <h4>Tab Groups</h4>
1830
+ <div class="cv-tab-groups">
1831
+ ${tabGroupControls}
1832
+ </div>
1833
+ `;
1834
+ }
1835
+ this.modal.innerHTML = `
1836
+ <div class="cv-widget-modal cv-custom-state-modal">
1837
+ <div class="cv-widget-modal-header">
1838
+ <h3>Customize View</h3>
1839
+ <button class="cv-widget-modal-close" aria-label="Close modal">X</button>
1840
+ </div>
1841
+ <div class="cv-widget-modal-content">
1842
+ <div class="cv-custom-state-form">
1843
+ <p>${this.options.description}</p>
1844
+
1845
+ <h4>Content Sections</h4>
1846
+ <div class="cv-custom-toggles">
1847
+ ${toggleControls}
1848
+ </div>
1849
+
1850
+ ${tabGroupsHTML}
1851
+
1852
+ <div class="cv-custom-state-actions">
1853
+ ${this.options.showReset ? `<button class="cv-custom-state-reset">Reset to Default</button>` : ''}
1854
+ <button class="cv-custom-state-copy-url">Copy Shareable URL</button>
1855
+ </div>
1856
+ </div>
1857
+ </div>
1858
+ </div>
1859
+ `;
1860
+ document.body.appendChild(this.modal);
1861
+ this.attachStateModalEventListeners();
1862
+ // Load current state into form if we're already in a custom state
1863
+ this.loadCurrentStateIntoForm();
1864
+ }
1865
+ /**
1866
+ * Attach event listeners for custom state creator
1867
+ */
1868
+ attachStateModalEventListeners() {
1869
+ if (!this.modal)
1870
+ return;
1871
+ // Close button
1872
+ const closeBtn = this.modal.querySelector('.cv-widget-modal-close');
1873
+ if (closeBtn) {
1874
+ closeBtn.addEventListener('click', () => {
1875
+ this.closeModal();
1876
+ });
1877
+ }
1878
+ // Copy URL button
1879
+ const copyUrlBtn = this.modal.querySelector('.cv-custom-state-copy-url');
1880
+ if (copyUrlBtn) {
1881
+ copyUrlBtn.addEventListener('click', () => {
1882
+ this.copyShareableURL();
1883
+ });
1884
+ }
1885
+ // Reset to default button
1886
+ const resetBtn = this.modal.querySelector('.cv-custom-state-reset');
1887
+ if (resetBtn) {
1888
+ resetBtn.addEventListener('click', () => {
1889
+ this.core.resetToDefault();
1890
+ this.loadCurrentStateIntoForm();
1891
+ });
1892
+ }
1893
+ // Listen to toggle checkboxes
1894
+ const toggleCheckboxes = this.modal.querySelectorAll('.cv-custom-toggle-checkbox');
1895
+ toggleCheckboxes.forEach(checkbox => {
1896
+ checkbox.addEventListener('change', () => {
1897
+ const state = this.getCurrentCustomStateFromModal();
1898
+ this.core.applyState(state);
1899
+ });
1900
+ });
1901
+ // Listen to tab group selects
1902
+ const tabGroupSelects = this.modal.querySelectorAll('.cv-tab-group-select');
1903
+ tabGroupSelects.forEach(select => {
1904
+ select.addEventListener('change', () => {
1905
+ const groupId = select.dataset.groupId;
1906
+ const tabId = select.value;
1907
+ if (groupId && tabId) {
1908
+ this.core.setActiveTab(groupId, tabId);
1909
+ }
1910
+ });
1911
+ });
1912
+ // Overlay click to close
1913
+ this.modal.addEventListener('click', (e) => {
1914
+ if (e.target === this.modal) {
1915
+ this.closeModal();
1916
+ }
1917
+ });
1918
+ // Escape key to close
1919
+ const handleEscape = (e) => {
1920
+ if (e.key === 'Escape') {
1921
+ this.closeModal();
1922
+ document.removeEventListener('keydown', handleEscape);
1923
+ }
1924
+ };
1925
+ document.addEventListener('keydown', handleEscape);
1926
+ }
1927
+ /**
1928
+ * Apply theme class to the modal overlay based on options
1929
+ */
1930
+ applyThemeToModal() {
1931
+ if (!this.modal)
1932
+ return;
1933
+ if (this.options.theme === 'dark') {
1934
+ this.modal.classList.add('cv-widget-theme-dark');
1935
+ }
1936
+ else {
1937
+ this.modal.classList.remove('cv-widget-theme-dark');
1938
+ }
1939
+ }
1940
+ /**
1941
+ * Get current state from form values
1942
+ */
1943
+ getCurrentCustomStateFromModal() {
1944
+ if (!this.modal) {
1945
+ return { toggles: [] };
1946
+ }
1947
+ // Collect toggle values
1948
+ const toggles = [];
1949
+ const toggleCheckboxes = this.modal.querySelectorAll('.cv-custom-toggle-checkbox');
1950
+ toggleCheckboxes.forEach(checkbox => {
1951
+ const toggle = checkbox.dataset.toggle;
1952
+ if (toggle && checkbox.checked) {
1953
+ toggles.push(toggle);
1954
+ }
1955
+ });
1956
+ // Collect tab selections
1957
+ const tabGroupSelects = this.modal.querySelectorAll('.cv-tab-group-select');
1958
+ const tabs = {};
1959
+ tabGroupSelects.forEach(select => {
1960
+ const groupId = select.dataset.groupId;
1961
+ if (groupId) {
1962
+ tabs[groupId] = select.value;
1963
+ }
1964
+ });
1965
+ return Object.keys(tabs).length > 0 ? { toggles, tabs } : { toggles };
1966
+ }
1967
+ /**
1968
+ * Copy shareable URL to clipboard
1969
+ */
1970
+ copyShareableURL() {
1971
+ const customState = this.getCurrentCustomStateFromModal();
1972
+ const url = URLStateManager.generateShareableURL(customState);
1973
+ navigator.clipboard.writeText(url).then(() => {
1974
+ console.log('Shareable URL copied to clipboard!');
1975
+ }).catch(() => { console.error('Failed to copy URL!'); });
1976
+ }
1977
+ /**
1978
+ * Load current state into form based on currently active toggles
1979
+ */
1980
+ loadCurrentStateIntoForm() {
1981
+ if (!this.modal)
1982
+ return;
1983
+ // Get currently active toggles (from custom state or default configuration)
1984
+ const activeToggles = this.core.getCurrentActiveToggles();
1985
+ // First, uncheck all checkboxes
1986
+ const allCheckboxes = this.modal.querySelectorAll('.cv-custom-toggle-checkbox');
1987
+ allCheckboxes.forEach(checkbox => {
1988
+ checkbox.checked = false;
1989
+ checkbox.disabled = false;
1990
+ checkbox.parentElement?.removeAttribute('aria-hidden');
1991
+ });
1992
+ // Then check the ones that should be active
1993
+ activeToggles.forEach(toggle => {
1994
+ const checkbox = this.modal?.querySelector(`[data-toggle="${toggle}"]`);
1995
+ if (checkbox) {
1996
+ if (!checkbox.disabled) {
1997
+ checkbox.checked = true;
1998
+ }
1999
+ }
2000
+ });
2001
+ // Load tab group selections
2002
+ const activeTabs = this.core.getCurrentActiveTabs();
2003
+ const tabGroupSelects = this.modal.querySelectorAll('.cv-tab-group-select');
2004
+ tabGroupSelects.forEach(select => {
2005
+ const groupId = select.dataset.groupId;
2006
+ if (groupId && activeTabs[groupId]) {
2007
+ select.value = activeTabs[groupId];
2008
+ }
2009
+ });
2010
+ }
2011
+ /**
2012
+ * Format toggle name for display
2013
+ */
2014
+ formatToggleName(toggle) {
2015
+ return toggle.charAt(0).toUpperCase() + toggle.slice(1);
2016
+ }
2017
+ /**
2018
+ * Check if this is the first visit and show welcome modal
2019
+ */
2020
+ showWelcomeModalIfFirstVisit() {
2021
+ const STORAGE_KEY = 'cv-welcome-shown';
2022
+ // Check if welcome has been shown before
2023
+ const hasSeenWelcome = localStorage.getItem(STORAGE_KEY);
2024
+ if (!hasSeenWelcome) {
2025
+ // Show welcome modal after a short delay to let the page settle
2026
+ setTimeout(() => {
2027
+ this.createWelcomeModal();
2028
+ }, 500);
2029
+ // Mark as shown
2030
+ localStorage.setItem(STORAGE_KEY, 'true');
2031
+ }
2032
+ }
2033
+ /**
2034
+ * Create and show the welcome modal
2035
+ */
2036
+ createWelcomeModal() {
2037
+ // Don't show if there's already a modal open
2038
+ if (this.modal)
2039
+ return;
2040
+ this.modal = document.createElement('div');
2041
+ this.modal.className = 'cv-widget-modal-overlay cv-welcome-modal-overlay';
2042
+ this.applyThemeToModal();
2043
+ this.modal.innerHTML = `
2044
+ <div class="cv-widget-modal cv-welcome-modal">
2045
+ <div class="cv-widget-modal-header">
2046
+ <h3>${this.options.welcomeTitle}</h3>
2047
+ <button class="cv-widget-modal-close" aria-label="Close modal">×</button>
2048
+ </div>
2049
+ <div class="cv-widget-modal-content">
2050
+ <div class="cv-welcome-content">
2051
+ <p>${this.options.welcomeMessage}</p>
2052
+
2053
+ <div class="cv-welcome-widget-preview">
2054
+ <div class="cv-welcome-widget-icon">⚙</div>
2055
+ <p class="cv-welcome-widget-label">Look for this widget on the side of the screen</p>
2056
+ </div>
2057
+
2058
+ <button class="cv-welcome-got-it">Got it!</button>
2059
+ </div>
2060
+ </div>
2061
+ </div>
2062
+ `;
2063
+ document.body.appendChild(this.modal);
2064
+ this.attachWelcomeModalEventListeners();
2065
+ }
2066
+ /**
2067
+ * Attach event listeners for welcome modal
2068
+ */
2069
+ attachWelcomeModalEventListeners() {
2070
+ if (!this.modal)
2071
+ return;
2072
+ // Close button
2073
+ const closeBtn = this.modal.querySelector('.cv-widget-modal-close');
2074
+ if (closeBtn) {
2075
+ closeBtn.addEventListener('click', () => {
2076
+ this.closeModal();
2077
+ });
2078
+ }
2079
+ // Got it button
2080
+ const gotItBtn = this.modal.querySelector('.cv-welcome-got-it');
2081
+ if (gotItBtn) {
2082
+ gotItBtn.addEventListener('click', () => {
2083
+ this.closeModal();
2084
+ });
2085
+ }
2086
+ // Overlay click to close
2087
+ this.modal.addEventListener('click', (e) => {
2088
+ if (e.target === this.modal) {
2089
+ this.closeModal();
2090
+ }
2091
+ });
2092
+ // Escape key to close
2093
+ const handleEscape = (e) => {
2094
+ if (e.key === 'Escape') {
2095
+ this.closeModal();
2096
+ document.removeEventListener('keydown', handleEscape);
2097
+ }
2098
+ };
2099
+ document.addEventListener('keydown', handleEscape);
2100
+ }
2101
+ }
2102
+
2103
+ /**
2104
+ * Custom Elements for Tab Groups and Tabs
2105
+ */
2106
+ /**
2107
+ * <cv-tab> element - represents a single tab panel
2108
+ */
2109
+ class CVTab extends HTMLElement {
2110
+ connectedCallback() {
2111
+ // Element is managed by TabManager
2112
+ }
2113
+ }
2114
+ /**
2115
+ * <cv-tabgroup> element - represents a group of tabs
2116
+ */
2117
+ class CVTabgroup extends HTMLElement {
2118
+ connectedCallback() {
2119
+ // Element is managed by TabManager
2120
+ // Emit ready event after a brief delay to ensure children are parsed
2121
+ setTimeout(() => {
2122
+ const event = new CustomEvent('cv:tabgroup-ready', {
2123
+ bubbles: true,
2124
+ detail: { groupId: this.getAttribute('id') }
2125
+ });
2126
+ this.dispatchEvent(event);
2127
+ }, 0);
2128
+ }
2129
+ }
2130
+ /**
2131
+ * Register custom elements
2132
+ */
2133
+ function registerCustomElements() {
2134
+ // Only register if not already defined
2135
+ if (!customElements.get('cv-tab')) {
2136
+ customElements.define('cv-tab', CVTab);
2137
+ }
2138
+ if (!customElements.get('cv-tabgroup')) {
2139
+ customElements.define('cv-tabgroup', CVTabgroup);
2140
+ }
2141
+ }
2142
+
2143
+ /**
2144
+ * Initialize CustomViews from script tag attributes and config file
2145
+ * This function handles the automatic initialization of CustomViews when included via script tag
2146
+ *
2147
+ * Data attributes supported:
2148
+ * - data-base-url: Base URL for the site (e.g., "/customviews" for subdirectory deployments)
2149
+ * - data-config-path: Path to config file (default: "/customviews.config.json")
2150
+ *
2151
+ * The function fetches the config file and uses it directly to initialize CustomViews.
2152
+ * Widget visibility is controlled via the config file (widget.enabled property).
2153
+ */
2154
+ function initializeFromScript() {
2155
+ // Only run in browser environment
2156
+ if (typeof window === 'undefined')
2157
+ return;
2158
+ // Use the typed global `window` (see src/types/global.d.ts)
2159
+ // Idempotency guard: if already initialized, skip setting up listener again.
2160
+ if (window.__customViewsInitialized) {
2161
+ // Informational for developers; harmless in production.
2162
+ console.info('[CustomViews] Auto-init skipped: already initialized.');
2163
+ return;
2164
+ }
2165
+ document.addEventListener('DOMContentLoaded', async function () {
2166
+ // Prevent concurrent initialization runs (race conditions when script is loaded twice)
2167
+ if (window.__customViewsInitInProgress || window.__customViewsInitialized) {
2168
+ return;
2169
+ }
2170
+ window.__customViewsInitInProgress = true;
2171
+ // Register custom elements early
2172
+ registerCustomElements();
2173
+ try {
2174
+ // Find the script tag
2175
+ let scriptTag = document.currentScript;
2176
+ // Fallback if currentScript is not available (executed after page load)
2177
+ if (!scriptTag) {
2178
+ // Try to find the script tag by looking for our script
2179
+ const scripts = document.querySelectorAll('script[src*="custom-views"]');
2180
+ if (scripts.length > 0) {
2181
+ // Find the most specific match (to avoid matching other custom-views scripts)
2182
+ for (let i = 0; i < scripts.length; i++) {
2183
+ const script = scripts[i];
2184
+ const src = script.getAttribute('src') || '';
2185
+ // Look for .min.js or .js at the end
2186
+ if (src.match(/custom-views(\.min)?\.js($|\?)/)) {
2187
+ scriptTag = script;
2188
+ break;
2189
+ }
2190
+ }
2191
+ // If no specific match found, use the first one
2192
+ if (!scriptTag) {
2193
+ scriptTag = scripts[0];
2194
+ }
2195
+ }
2196
+ }
2197
+ // Read data attributes from script tag
2198
+ let baseURL = '';
2199
+ let configPath = '/customviews.config.json';
2200
+ if (scriptTag) {
2201
+ baseURL = scriptTag.getAttribute('data-base-url') || '';
2202
+ configPath = scriptTag.getAttribute('data-config-path') || configPath;
2203
+ }
2204
+ // Fetch config file
2205
+ let configFile;
2206
+ try {
2207
+ const fullConfigPath = prependBaseUrl(configPath, baseURL);
2208
+ console.log(`[CustomViews] Loading config from: ${fullConfigPath}`);
2209
+ const response = await fetch(fullConfigPath);
2210
+ if (!response.ok) {
2211
+ console.warn(`[CustomViews] Config file not found at ${fullConfigPath}. Using defaults.`);
2212
+ // Provide minimal default config structure
2213
+ configFile = {
2214
+ config: {
2215
+ allToggles: [],
2216
+ defaultState: { toggles: [] }
2217
+ },
2218
+ widget: {
2219
+ enabled: true
2220
+ }
2221
+ };
2222
+ }
2223
+ else {
2224
+ configFile = await response.json();
2225
+ console.log('[CustomViews] Config loaded successfully');
2226
+ }
2227
+ }
2228
+ catch (error) {
2229
+ console.error('[CustomViews] Error loading config file:', error);
2230
+ return; // Abort initialization
2231
+ }
2232
+ // Determine effective baseURL (data attribute takes precedence over config)
2233
+ const effectiveBaseURL = baseURL || configFile.baseURL || '';
2234
+ const options = {
2235
+ config: configFile.config,
2236
+ assetsJsonPath: configFile.assetsJsonPath,
2237
+ baseURL: effectiveBaseURL,
2238
+ };
2239
+ if (configFile.showUrl !== undefined) {
2240
+ options.showUrl = configFile.showUrl;
2241
+ }
2242
+ // Initialize CustomViews core
2243
+ const core = await CustomViews.init(options);
2244
+ if (!core) {
2245
+ console.error('[CustomViews] Failed to initialize core.');
2246
+ return; // Abort widget creation
2247
+ }
2248
+ // Store instance
2249
+ window.customViewsInstance = { core };
2250
+ // Initialize widget if enabled in config
2251
+ let widget;
2252
+ if (configFile.widget?.enabled !== false) {
2253
+ widget = new CustomViewsWidget({
2254
+ core,
2255
+ ...configFile.widget
2256
+ });
2257
+ widget.render();
2258
+ // Store widget instance
2259
+ window.customViewsInstance.widget = widget;
2260
+ console.log('[CustomViews] Widget initialized and rendered');
2261
+ }
2262
+ else {
2263
+ console.log('[CustomViews] Widget disabled in config - skipping initialization');
2264
+ }
2265
+ // Dispatch ready event
2266
+ const readyEvent = new CustomEvent('customviews:ready', {
2267
+ detail: {
2268
+ core,
2269
+ widget
2270
+ }
2271
+ });
2272
+ document.dispatchEvent(readyEvent);
2273
+ // Mark initialized and clear in-progress flag
2274
+ window.__customViewsInitialized = true;
2275
+ window.__customViewsInitInProgress = false;
2276
+ }
2277
+ catch (error) {
2278
+ // Clear in-progress flag so a future attempt can retry
2279
+ window.__customViewsInitInProgress = false;
2280
+ console.error('[CustomViews] Auto-initialization error:', error);
2281
+ }
2282
+ });
2283
+ }
2284
+
2285
+ // Import from new modules
2286
+ // Set up globals and auto-initialization
2287
+ if (typeof window !== "undefined") {
2288
+ // Expose to window to enable usage (e.g. const app = new window.CustomViews(...))
2289
+ window.CustomViews = CustomViews;
2290
+ window.CustomViewsWidget = CustomViewsWidget;
2291
+ // Run auto-initialization
2292
+ initializeFromScript();
2293
+ }
2294
+
2295
+ export { AssetsManager, CustomViews, CustomViewsCore, CustomViewsWidget, PersistenceManager, URLStateManager };
2296
+ //# sourceMappingURL=custom-views.core.esm.js.map