@customviews-js/customviews 1.0.3 → 1.1.1

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