@customviews-js/customviews 1.1.11 → 1.3.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 (51) hide show
  1. package/dist/custom-views.core.cjs.js +1593 -208
  2. package/dist/custom-views.core.cjs.js.map +1 -1
  3. package/dist/custom-views.core.esm.js +1593 -208
  4. package/dist/custom-views.core.esm.js.map +1 -1
  5. package/dist/custom-views.esm.js +1593 -208
  6. package/dist/custom-views.esm.js.map +1 -1
  7. package/dist/custom-views.js +1593 -208
  8. package/dist/custom-views.js.map +1 -1
  9. package/dist/custom-views.min.js +2 -2
  10. package/dist/custom-views.min.js.map +1 -1
  11. package/dist/types/core/anchor-engine.d.ts +55 -0
  12. package/dist/types/core/anchor-engine.d.ts.map +1 -0
  13. package/dist/types/core/config.d.ts +3 -0
  14. package/dist/types/core/config.d.ts.map +1 -0
  15. package/dist/types/core/core.d.ts +27 -1
  16. package/dist/types/core/core.d.ts.map +1 -1
  17. package/dist/types/core/custom-elements.d.ts +2 -2
  18. package/dist/types/core/custom-elements.d.ts.map +1 -1
  19. package/dist/types/core/focus-manager.d.ts +38 -0
  20. package/dist/types/core/focus-manager.d.ts.map +1 -0
  21. package/dist/types/core/share-button.d.ts +13 -0
  22. package/dist/types/core/share-button.d.ts.map +1 -0
  23. package/dist/types/core/share-manager.d.ts +70 -0
  24. package/dist/types/core/share-manager.d.ts.map +1 -0
  25. package/dist/types/core/tab-manager.d.ts +6 -6
  26. package/dist/types/core/tab-manager.d.ts.map +1 -1
  27. package/dist/types/core/toast-manager.d.ts +12 -0
  28. package/dist/types/core/toast-manager.d.ts.map +1 -0
  29. package/dist/types/core/toggle-manager.d.ts +9 -4
  30. package/dist/types/core/toggle-manager.d.ts.map +1 -1
  31. package/dist/types/core/url-state-manager.d.ts +6 -2
  32. package/dist/types/core/url-state-manager.d.ts.map +1 -1
  33. package/dist/types/core/widget.d.ts +20 -11
  34. package/dist/types/core/widget.d.ts.map +1 -1
  35. package/dist/types/lib/custom-views.d.ts +1 -1
  36. package/dist/types/lib/custom-views.d.ts.map +1 -1
  37. package/dist/types/styles/focus-mode-styles.d.ts +8 -0
  38. package/dist/types/styles/focus-mode-styles.d.ts.map +1 -0
  39. package/dist/types/styles/share-button-styles.d.ts +3 -0
  40. package/dist/types/styles/share-button-styles.d.ts.map +1 -0
  41. package/dist/types/styles/share-mode-styles.d.ts +10 -0
  42. package/dist/types/styles/share-mode-styles.d.ts.map +1 -0
  43. package/dist/types/styles/toast-styles.d.ts +4 -0
  44. package/dist/types/styles/toast-styles.d.ts.map +1 -0
  45. package/dist/types/styles/widget-styles.d.ts +1 -1
  46. package/dist/types/styles/widget-styles.d.ts.map +1 -1
  47. package/dist/types/types/types.d.ts +22 -2
  48. package/dist/types/types/types.d.ts.map +1 -1
  49. package/dist/types/utils/icons.d.ts +2 -0
  50. package/dist/types/utils/icons.d.ts.map +1 -1
  51. package/package.json +2 -2
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * @customviews-js/customviews v1.1.11
2
+ * @customviews-js/customviews v1.3.0
3
3
  * (c) 2025 Chan Ger Teck
4
4
  * Released under the MIT License.
5
5
  */
@@ -154,7 +154,9 @@ class URLStateManager {
154
154
  return url.toString();
155
155
  }
156
156
  /**
157
- * Encode state into URL-safe string (Toggles and Tabs only currently)
157
+ * Encode state into URL-safe string
158
+ *
159
+ * (Covers Toggles, Tabs and Focus currently)
158
160
  */
159
161
  static encodeState(state) {
160
162
  try {
@@ -168,6 +170,10 @@ class URLStateManager {
168
170
  if (state.tabs && Object.keys(state.tabs).length > 0) {
169
171
  compact.g = Object.entries(state.tabs);
170
172
  }
173
+ // Add focus if present
174
+ if (state.focus && state.focus.length > 0) {
175
+ compact.f = state.focus;
176
+ }
171
177
  // Convert to JSON and encode
172
178
  const json = JSON.stringify(compact);
173
179
  let encoded;
@@ -189,7 +195,9 @@ class URLStateManager {
189
195
  }
190
196
  }
191
197
  /**
192
- * Decode custom state from URL parameter (Toggles and Tabs only currently)
198
+ * Decode custom state from URL parameter
199
+ *
200
+ * (Covers Toggles, Tabs and Focus currently)
193
201
  */
194
202
  static decodeState(encoded) {
195
203
  try {
@@ -228,6 +236,10 @@ class URLStateManager {
228
236
  }
229
237
  }
230
238
  }
239
+ // Reconstruct Focus
240
+ if (Array.isArray(compact.f)) {
241
+ state.focus = compact.f;
242
+ }
231
243
  return state;
232
244
  }
233
245
  catch (error) {
@@ -412,9 +424,15 @@ function getPinIcon(isPinned = false) {
412
424
  </svg>
413
425
  `.trim();
414
426
  }
427
+ function getShareIcon() {
428
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
429
+ <path fill="currentColor" d="M18 8h-2a1 1 0 0 0 0 2h2v8H6v-8h2a1 1 0 0 0 0-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8a2 2 0 0 0-2-2z"/>
430
+ <path fill="currentColor" d="M11 6.41V12a1 1 0 0 0 2 0V6.41l1.29 1.3a1 1 0 0 0 1.42 0a1 1 0 0 0 0-1.42l-3-3a1 1 0 0 0-1.42 0l-3 3a1 1 0 1 0 1.42 1.42L11 6.41z"/>
431
+ </svg>`;
432
+ }
415
433
 
416
434
  // Constants for selectors
417
- const TABGROUP_SELECTOR = 'cv-tabgroup';
435
+ const TABGROUP_SELECTOR$1 = 'cv-tabgroup';
418
436
  const TAB_SELECTOR = 'cv-tab';
419
437
  const NAV_AUTO_SELECTOR = 'cv-tabgroup[nav="auto"], cv-tabgroup:not([nav])';
420
438
  const NAV_CONTAINER_CLASS = 'cv-tabs-nav';
@@ -430,11 +448,9 @@ class TabManager {
430
448
  return trimmedIds;
431
449
  }
432
450
  /**
433
- * Apply tab selections to all tab groups in the DOM
451
+ * Apply tab selections to a given list of tab group elements
434
452
  */
435
- static applyTabSelections(rootEl, tabs, cfgGroups) {
436
- // Find all cv-tabgroup elements
437
- const tabGroups = rootEl.querySelectorAll(TABGROUP_SELECTOR);
453
+ static applyTabSelections(tabGroups, tabs, cfgGroups) {
438
454
  tabGroups.forEach((groupEl) => {
439
455
  const groupId = groupEl.getAttribute('id');
440
456
  // Determine the active tab for this group
@@ -539,10 +555,18 @@ class TabManager {
539
555
  /**
540
556
  * Build navigation for tab groups (one-time setup)
541
557
  */
542
- static buildNavs(rootEl, cfgGroups, onTabClick, onTabDoubleClick) {
543
- // Find all cv-tabgroup elements with nav="auto" or no nav attribute
544
- const tabGroups = rootEl.querySelectorAll(NAV_AUTO_SELECTOR);
558
+ static buildNavs(tabGroups, cfgGroups, onTabClick, onTabDoubleClick) {
559
+ const rootEl = document.body; // Needed for NAV_HIDE_ROOT_CLASS check
545
560
  tabGroups.forEach((groupEl) => {
561
+ // Prevent re-initialization
562
+ if (groupEl.hasAttribute('data-cv-initialized')) {
563
+ return;
564
+ }
565
+ groupEl.setAttribute('data-cv-initialized', 'true');
566
+ // Filter to only build for groups with nav="auto" or no nav attribute
567
+ if (!groupEl.matches(NAV_AUTO_SELECTOR)) {
568
+ return;
569
+ }
546
570
  const groupId = groupEl.getAttribute('id') || null;
547
571
  // Note: groupId can be null for standalone tabgroups
548
572
  // These won't sync with other groups or persist state
@@ -746,8 +770,7 @@ class TabManager {
746
770
  /**
747
771
  * Update active states for all tab groups based on current state
748
772
  */
749
- static updateAllNavActiveStates(rootEl, tabs, cfgGroups) {
750
- const tabGroups = rootEl.querySelectorAll(TABGROUP_SELECTOR);
773
+ static updateAllNavActiveStates(tabGroups, tabs, cfgGroups) {
751
774
  tabGroups.forEach((groupEl) => {
752
775
  const groupId = groupEl.getAttribute('id');
753
776
  if (!groupId)
@@ -810,7 +833,7 @@ class TabManager {
810
833
  */
811
834
  static getTabgroupsWithId(rootEl, sourceGroupId, tabId) {
812
835
  const syncedGroupEls = [];
813
- const allGroupEls = Array.from(rootEl.querySelectorAll(`${TABGROUP_SELECTOR}[id="${sourceGroupId}"]`));
836
+ const allGroupEls = Array.from(rootEl.querySelectorAll(`${TABGROUP_SELECTOR$1}[id="${sourceGroupId}"]`));
814
837
  allGroupEls.forEach((targetGroupEl) => {
815
838
  // Only sync if target group actually contains this tab
816
839
  if (this.groupHasTab(targetGroupEl, tabId)) {
@@ -822,9 +845,8 @@ class TabManager {
822
845
  /**
823
846
  * Update pin icon visibility for all tab groups based on current state.
824
847
  * Shows pin icon for tabs that are in the persisted state (i.e., have been double-clicked).
825
- */
826
- static updatePinIcons(rootEl, tabs) {
827
- const tabGroups = rootEl.querySelectorAll(TABGROUP_SELECTOR);
848
+ */
849
+ static updatePinIcons(tabGroups, tabs) {
828
850
  tabGroups.forEach((groupEl) => {
829
851
  const groupId = groupEl.getAttribute('id');
830
852
  if (!groupId)
@@ -974,33 +996,31 @@ function renderAssetInto(el, assetId, assetsManager) {
974
996
  }
975
997
  }
976
998
 
977
- // Constants for selectors
978
- const TOGGLE_DATA_SELECTOR = "[data-cv-toggle], [data-customviews-toggle]";
979
- const TOGGLE_ELEMENT_SELECTOR = "cv-toggle";
980
- const TOGGLE_SELECTOR = `${TOGGLE_DATA_SELECTOR}, ${TOGGLE_ELEMENT_SELECTOR}`;
981
999
  /**
982
1000
  * ToggleManager handles discovery, visibility, and asset rendering for toggle elements
983
1001
  */
984
1002
  class ToggleManager {
985
1003
  /**
986
- * Apply toggle visibility to all toggle elements in the DOM
1004
+ * Apply toggle visibility to a given list of toggle elements
987
1005
  */
988
- static applyToggles(rootEl, activeToggles) {
989
- rootEl.querySelectorAll(TOGGLE_SELECTOR).forEach(el => {
1006
+ static applyToggles(elements, activeToggles) {
1007
+ elements.forEach(el => {
990
1008
  const categories = this.getToggleCategories(el);
991
1009
  const shouldShow = categories.some(cat => activeToggles.includes(cat));
992
1010
  this.applyToggleVisibility(el, shouldShow);
993
1011
  });
994
1012
  }
995
1013
  /**
996
- * Render assets into toggle elements that are currently visible
1014
+ * Render assets into a given list of toggle elements that are currently visible
997
1015
  */
998
- static renderAssets(rootEl, activeToggles, assetsManager) {
999
- rootEl.querySelectorAll(TOGGLE_SELECTOR).forEach(el => {
1016
+ static renderAssets(elements, activeToggles, assetsManager) {
1017
+ elements.forEach(el => {
1000
1018
  const categories = this.getToggleCategories(el);
1001
1019
  const toggleId = this.getToggleId(el);
1002
- if (toggleId && categories.some(cat => activeToggles.includes(cat))) {
1020
+ const isRendered = el.dataset.cvRendered === 'true';
1021
+ if (toggleId && !isRendered && categories.some(cat => activeToggles.includes(cat))) {
1003
1022
  renderAssetInto(el, toggleId, assetsManager);
1023
+ el.dataset.cvRendered = 'true';
1004
1024
  }
1005
1025
  });
1006
1026
  }
@@ -1036,6 +1056,21 @@ class ToggleManager {
1036
1056
  el.classList.remove('cv-visible');
1037
1057
  }
1038
1058
  }
1059
+ /**
1060
+ * Scans a given DOM subtree for toggle elements and initializes them.
1061
+ * This includes applying visibility and rendering assets.
1062
+ */
1063
+ static initializeToggles(root, activeToggles, assetsManager) {
1064
+ const elements = [];
1065
+ if (root.matches('[data-cv-toggle], [data-customviews-toggle], cv-toggle')) {
1066
+ elements.push(root);
1067
+ }
1068
+ root.querySelectorAll('[data-cv-toggle], [data-customviews-toggle], cv-toggle').forEach(el => elements.push(el));
1069
+ if (elements.length === 0)
1070
+ return;
1071
+ this.applyToggles(elements, activeToggles);
1072
+ this.renderAssets(elements, activeToggles, assetsManager);
1073
+ }
1039
1074
  }
1040
1075
 
1041
1076
  // src/utils/scroll-manager.ts
@@ -1359,11 +1394,1200 @@ function injectCoreStyles() {
1359
1394
  document.head.appendChild(style);
1360
1395
  }
1361
1396
 
1397
+ const TOAST_STYLE_ID = 'cv-toast-styles';
1398
+ const TOAST_CLASS = 'cv-toast-notification';
1399
+ const TOAST_STYLES = `
1400
+ .cv-toast-notification {
1401
+ position: fixed;
1402
+ top: 20px;
1403
+ left: 50%;
1404
+ transform: translateX(-50%);
1405
+ background-color: #323232;
1406
+ color: white;
1407
+ padding: 12px 24px;
1408
+ border-radius: 4px;
1409
+ z-index: 100000;
1410
+ box-shadow: 0 4px 12px rgba(0,0,0,0.15);
1411
+ opacity: 0;
1412
+ transition: opacity 0.3s ease;
1413
+ pointer-events: none; /* Let clicks pass through if needed, though usually it blocks */
1414
+ font-family: system-ui, -apple-system, sans-serif;
1415
+ font-size: 14px;
1416
+ }
1417
+ `;
1418
+
1419
+ /**
1420
+ * Manages toast notifications for the application.
1421
+ */
1422
+ class ToastManager {
1423
+ static isStyleInjected = false;
1424
+ static toastEl = null;
1425
+ static navTimeout = null;
1426
+ static fadeTimeout = null;
1427
+ static show(message, duration = 2500) {
1428
+ this.injectStyles();
1429
+ // specific reuse logic
1430
+ if (!this.toastEl) {
1431
+ this.toastEl = document.createElement('div');
1432
+ this.toastEl.className = TOAST_CLASS;
1433
+ document.body.appendChild(this.toastEl);
1434
+ }
1435
+ // Reset state
1436
+ this.toastEl.textContent = message;
1437
+ this.toastEl.style.opacity = '0';
1438
+ this.toastEl.style.display = 'block';
1439
+ // Clear any pending dismissal
1440
+ if (this.navTimeout)
1441
+ clearTimeout(this.navTimeout);
1442
+ if (this.fadeTimeout)
1443
+ clearTimeout(this.fadeTimeout);
1444
+ // Trigger reflow & fade in
1445
+ requestAnimationFrame(() => {
1446
+ if (this.toastEl)
1447
+ this.toastEl.style.opacity = '1';
1448
+ });
1449
+ // Schedule fade out
1450
+ this.navTimeout = setTimeout(() => {
1451
+ if (this.toastEl)
1452
+ this.toastEl.style.opacity = '0';
1453
+ this.fadeTimeout = setTimeout(() => {
1454
+ if (this.toastEl)
1455
+ this.toastEl.style.display = 'none';
1456
+ }, 300);
1457
+ }, duration);
1458
+ }
1459
+ static injectStyles() {
1460
+ if (this.isStyleInjected)
1461
+ return;
1462
+ if (document.getElementById(TOAST_STYLE_ID)) {
1463
+ this.isStyleInjected = true;
1464
+ return;
1465
+ }
1466
+ const style = document.createElement('style');
1467
+ style.id = TOAST_STYLE_ID;
1468
+ style.innerHTML = TOAST_STYLES;
1469
+ document.head.appendChild(style);
1470
+ this.isStyleInjected = true;
1471
+ }
1472
+ }
1473
+
1474
+ /**
1475
+ * Engine for generating and resolving robust anchors.
1476
+ *
1477
+ * It implements a simple anchor generation and resolution algorithm that uses a combination of
1478
+ * structural, contextual, and content-based hints to generate a unique anchor for a given DOM element.
1479
+ *
1480
+ * The anchor is generated by first creating an AnchorDescriptor for the element, which contains
1481
+ * information about the element's tag, index, parent ID, and text content. This descriptor is then
1482
+ * serialized into a URL-safe string using a minification algorithm.
1483
+ *
1484
+ * The anchor is then resolved by searching for the element in the DOM using the serialized string.
1485
+ *
1486
+ */
1487
+ class AnchorEngine {
1488
+ /**
1489
+ * Generates a simple hash code for a string.
1490
+ *
1491
+ * It takes each character's Unicode code point and uses it to update the hash value.
1492
+ */
1493
+ static hashCode(str) {
1494
+ let hash = 0;
1495
+ if (str.length === 0)
1496
+ return hash;
1497
+ for (let i = 0; i < str.length; i++) {
1498
+ const char = str.charCodeAt(i);
1499
+ hash = ((hash << 5) - hash) + char;
1500
+ hash = hash & hash; // Convert to 32bit integer
1501
+ }
1502
+ return hash;
1503
+ }
1504
+ /**
1505
+ * Normalizes text content by removing excessive whitespace.
1506
+ *
1507
+ * It trims leading and trailing whitespace and replaces multiple spaces with a single space.
1508
+ */
1509
+ static normalizeText(text) {
1510
+ return text.trim().replace(/\s+/g, ' ');
1511
+ }
1512
+ /**
1513
+ * Creates an AnchorDescriptor for a given DOM element.
1514
+ */
1515
+ static createDescriptor(el) {
1516
+ const tag = el.tagName;
1517
+ const textContent = el.textContent || "";
1518
+ const normalizedText = this.normalizeText(textContent);
1519
+ // Find nearest parent with an ID
1520
+ let parentId;
1521
+ let parent = el.parentElement;
1522
+ while (parent) {
1523
+ if (parent.id) {
1524
+ parentId = parent.id;
1525
+ break;
1526
+ }
1527
+ parent = parent.parentElement;
1528
+ }
1529
+ // Calculate index relative to the container (either the found parent or document.body)
1530
+ const container = parent || document.body;
1531
+ const siblings = Array.from(container.querySelectorAll(tag));
1532
+ // Index is the position of the element in the list of siblings, where siblings are those of the same tag.
1533
+ const index = siblings.indexOf(el);
1534
+ const descriptor = {
1535
+ tag,
1536
+ index: index !== -1 ? index : 0,
1537
+ textSnippet: normalizedText.substring(0, 32),
1538
+ textHash: this.hashCode(normalizedText)
1539
+ };
1540
+ if (parentId) {
1541
+ descriptor.parentId = parentId;
1542
+ }
1543
+ return descriptor;
1544
+ }
1545
+ /**
1546
+ * Serializes a list of AnchorDescriptors into a URL-safe string.
1547
+ */
1548
+ static serialize(descriptors) {
1549
+ // Minify keys for compactness
1550
+ const minified = descriptors.map(d => ({
1551
+ t: d.tag,
1552
+ i: d.index,
1553
+ p: d.parentId,
1554
+ s: d.textSnippet,
1555
+ h: d.textHash
1556
+ }));
1557
+ const json = JSON.stringify(minified);
1558
+ // Base64 encode
1559
+ return btoa(encodeURIComponent(json));
1560
+ }
1561
+ /**
1562
+ * Deserializes a URL-safe string back into a list of AnchorDescriptors.
1563
+ */
1564
+ static deserialize(encoded) {
1565
+ try {
1566
+ const json = decodeURIComponent(atob(encoded));
1567
+ const minified = JSON.parse(json);
1568
+ return minified.map((m) => ({
1569
+ tag: m.t,
1570
+ index: m.i,
1571
+ parentId: m.p,
1572
+ textSnippet: m.s,
1573
+ textHash: m.h
1574
+ }));
1575
+ }
1576
+ catch (e) {
1577
+ console.error("Failed to deserialize anchor:", e);
1578
+ return [];
1579
+ }
1580
+ }
1581
+ /**
1582
+ * Finds the best DOM element match for a descriptor.
1583
+ */
1584
+ static resolve(root, descriptor) {
1585
+ // 1. Scope
1586
+ let scope = root;
1587
+ if (descriptor.parentId) {
1588
+ const foundParent = root.querySelector(`#${descriptor.parentId}`);
1589
+ if (foundParent instanceof HTMLElement) {
1590
+ scope = foundParent;
1591
+ }
1592
+ else {
1593
+ // Fallback: if parent ID not found, search global root - document.body
1594
+ const globalParent = document.getElementById(descriptor.parentId);
1595
+ if (globalParent) {
1596
+ scope = globalParent;
1597
+ }
1598
+ }
1599
+ }
1600
+ // 2. Candidate Search
1601
+ const candidates = Array.from(scope.querySelectorAll(descriptor.tag));
1602
+ // 3. Scoring
1603
+ let bestMatch = null;
1604
+ let highestScore = 0;
1605
+ candidates.forEach((candidate) => {
1606
+ let score = 0;
1607
+ const text = this.normalizeText(candidate.textContent || "");
1608
+ // Exact Text Match (Hash check is faster proxy for full string compare, but let's check hash first)
1609
+ if (this.hashCode(text) === descriptor.textHash) {
1610
+ score += 50;
1611
+ }
1612
+ else if (text.startsWith(descriptor.textSnippet)) {
1613
+ // Fuzzy Text Match (Snippet) - +30 score
1614
+ score += 30;
1615
+ }
1616
+ // Structural Match (Index)
1617
+ // We need to re-calculate index of this candidate to compare with descriptor.index
1618
+ // The descriptor.index is relative to the *found* parentId container.
1619
+ // So we must compare index within the scope we are searching.
1620
+ const siblings = Array.from(scope.querySelectorAll(descriptor.tag));
1621
+ const index = siblings.indexOf(candidate);
1622
+ if (index === descriptor.index) {
1623
+ score += 10;
1624
+ }
1625
+ if (score > highestScore) {
1626
+ highestScore = score;
1627
+ bestMatch = candidate;
1628
+ }
1629
+ });
1630
+ // 4. Winner
1631
+ if (highestScore > 30) {
1632
+ return bestMatch;
1633
+ }
1634
+ return null;
1635
+ }
1636
+ }
1637
+
1638
+ const SHARE_MODE_STYLE_ID = 'cv-share-mode-styles';
1639
+ const FLOATING_ACTION_BAR_ID = 'cv-floating-action-bar';
1640
+ const HOVER_HELPER_ID = 'cv-hover-helper';
1641
+ const HIGHLIGHT_TARGET_CLASS = 'cv-highlight-target';
1642
+ const SELECTED_CLASS = 'cv-share-selected';
1643
+ /**
1644
+ * CSS styles to be injected during Share Mode.
1645
+ */
1646
+ const SHARE_MODE_STYLES = `
1647
+ body.cv-share-mode {
1648
+ cursor: default;
1649
+ }
1650
+
1651
+ /* Highlight outlines */
1652
+ .${HIGHLIGHT_TARGET_CLASS} {
1653
+ outline: 2px dashed #0078D4 !important;
1654
+ outline-offset: 2px;
1655
+ cursor: crosshair;
1656
+ }
1657
+
1658
+ .${SELECTED_CLASS} {
1659
+ outline: 3px solid #005a9e !important;
1660
+ outline-offset: 2px;
1661
+ background-color: rgba(0, 120, 212, 0.05);
1662
+ }
1663
+
1664
+ /* Floating Action Bar */
1665
+ #${FLOATING_ACTION_BAR_ID} {
1666
+ position: fixed;
1667
+ bottom: 20px;
1668
+ left: 50%;
1669
+ transform: translateX(-50%);
1670
+ background-color: #2c2c2c;
1671
+ color: #f1f1f1;
1672
+ border-radius: 8px;
1673
+ padding: 12px 20px;
1674
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
1675
+ display: flex;
1676
+ align-items: center;
1677
+ gap: 16px;
1678
+ z-index: 99999;
1679
+ font-family: system-ui, -apple-system, sans-serif;
1680
+ font-size: 14px;
1681
+ border: 1px solid #4a4a4a;
1682
+ }
1683
+
1684
+ #${FLOATING_ACTION_BAR_ID} .cv-action-button {
1685
+ background-color: #0078D4;
1686
+ color: white;
1687
+ border: none;
1688
+ padding: 8px 14px;
1689
+ border-radius: 5px;
1690
+ cursor: pointer;
1691
+ font-weight: 500;
1692
+ transition: background-color 0.2s;
1693
+ }
1694
+
1695
+ #${FLOATING_ACTION_BAR_ID} .cv-action-button:hover {
1696
+ background-color: #005a9e;
1697
+ }
1698
+
1699
+ #${FLOATING_ACTION_BAR_ID} .cv-action-button.clear {
1700
+ background-color: #5a5a5a;
1701
+ }
1702
+ #${FLOATING_ACTION_BAR_ID} .cv-action-button.clear:hover {
1703
+ background-color: #4a4a4a;
1704
+ }
1705
+ #${FLOATING_ACTION_BAR_ID} .cv-action-button.clear:hover {
1706
+ background-color: #4a4a4a;
1707
+ }
1708
+
1709
+ #${FLOATING_ACTION_BAR_ID} .cv-action-button.preview {
1710
+ background-color: #106ebe;
1711
+ }
1712
+ #${FLOATING_ACTION_BAR_ID} .cv-action-button.preview:hover {
1713
+ background-color: #005a9e;
1714
+ }
1715
+ #${FLOATING_ACTION_BAR_ID} .cv-action-button.exit {
1716
+ background-color: #d13438;
1717
+ }
1718
+ #${FLOATING_ACTION_BAR_ID} .cv-action-button.exit:hover {
1719
+ background-color: #a42628;
1720
+ }
1721
+
1722
+ /* Hover Helper (Smart Label & Level Up) */
1723
+ #${HOVER_HELPER_ID} {
1724
+ position: fixed;
1725
+ z-index: 99999;
1726
+ background-color: #333;
1727
+ color: white;
1728
+ padding: 4px 8px;
1729
+ border-radius: 4px;
1730
+ font-size: 12px;
1731
+ font-family: monospace;
1732
+ display: none;
1733
+ pointer-events: auto; /* Allow clicking buttons inside */
1734
+ align-items: center;
1735
+ gap: 8px;
1736
+ box-shadow: 0 2px 5px rgba(0,0,0,0.2);
1737
+ }
1738
+
1739
+ #${HOVER_HELPER_ID} button {
1740
+ background: #555;
1741
+ border: none;
1742
+ color: white;
1743
+ border-radius: 3px;
1744
+ cursor: pointer;
1745
+ padding: 2px 6px;
1746
+ font-size: 14px;
1747
+ line-height: 1;
1748
+ }
1749
+ #${HOVER_HELPER_ID} button:hover {
1750
+ background: #777;
1751
+ }
1752
+
1753
+ `;
1754
+
1755
+ const CV_CUSTOM_ELEMENTS = 'cv-tabgroup, cv-toggle';
1756
+ const SHAREABLE_SELECTOR = 'div, p, blockquote, pre, li, h1, h2, h3, h4, h5, h6, [data-share], ' + CV_CUSTOM_ELEMENTS;
1757
+ /**
1758
+ * Manages the "Share Mode" for creating custom focus links.
1759
+ * Implementing Robust Granular Sharing with "Innermost Wins" and "Level Up" UI.
1760
+ */
1761
+ class ShareManager {
1762
+ isActive = false;
1763
+ selectedElements = new Set();
1764
+ floatingBarEl = null;
1765
+ helperEl = null;
1766
+ currentHoverTarget = null;
1767
+ excludedTags;
1768
+ excludedIds;
1769
+ boundHandleHover;
1770
+ boundHandleClick;
1771
+ boundHandleKeydown;
1772
+ constructor(options) {
1773
+ this.excludedTags = new Set(options.excludedTags.map(t => t.toUpperCase()));
1774
+ this.excludedIds = new Set(options.excludedIds);
1775
+ this.boundHandleHover = this.handleHover.bind(this);
1776
+ this.boundHandleClick = this.handleClick.bind(this);
1777
+ this.boundHandleKeydown = this.handleKeydown.bind(this);
1778
+ }
1779
+ listeners = [];
1780
+ addStateChangeListener(listener) {
1781
+ this.listeners.push(listener);
1782
+ }
1783
+ removeStateChangeListener(listener) {
1784
+ this.listeners = this.listeners.filter(l => l !== listener);
1785
+ }
1786
+ notifyListeners() {
1787
+ this.listeners.forEach(listener => listener(this.isActive));
1788
+ }
1789
+ toggleShareMode() {
1790
+ this.isActive = !this.isActive;
1791
+ if (this.isActive) {
1792
+ this.activate();
1793
+ }
1794
+ else {
1795
+ this.cleanup();
1796
+ }
1797
+ this.notifyListeners();
1798
+ }
1799
+ /**
1800
+ * Activates the share mode.
1801
+ * Injects styles, creates floating bar, and helper element.
1802
+ * Adds event listeners for hover and click.
1803
+ */
1804
+ activate() {
1805
+ this.injectStyles();
1806
+ this.createFloatingBar();
1807
+ this.helperEl = this.createHelperPopover();
1808
+ // Event Listeners
1809
+ document.addEventListener('mouseover', this.boundHandleHover, true);
1810
+ document.addEventListener('click', this.boundHandleClick, true);
1811
+ document.addEventListener('keydown', this.boundHandleKeydown, true);
1812
+ }
1813
+ injectStyles() {
1814
+ const styleElement = document.createElement('style');
1815
+ styleElement.id = SHARE_MODE_STYLE_ID;
1816
+ styleElement.innerHTML = SHARE_MODE_STYLES;
1817
+ document.head.appendChild(styleElement);
1818
+ }
1819
+ /**
1820
+ * Creates the hover helper element that shows up when hovering over a shareable element.
1821
+ */
1822
+ createHelperPopover() {
1823
+ const div = document.createElement('div');
1824
+ div.id = HOVER_HELPER_ID;
1825
+ div.innerHTML = `
1826
+ <span id="cv-helper-tag">TAG</span>
1827
+ <button id="cv-helper-select-btn" title="Select This Element">✓</button>
1828
+ <button id="cv-helper-up-btn" title="Select Parent">↰</button>
1829
+ `;
1830
+ document.body.appendChild(div);
1831
+ // Select parent button
1832
+ div.querySelector('#cv-helper-up-btn')?.addEventListener('click', (e) => {
1833
+ e.preventDefault();
1834
+ e.stopPropagation();
1835
+ this.handleSelectParent();
1836
+ });
1837
+ // Select element button
1838
+ div.querySelector('#cv-helper-select-btn')?.addEventListener('click', (e) => {
1839
+ e.preventDefault();
1840
+ e.stopPropagation();
1841
+ if (this.currentHoverTarget) {
1842
+ this.toggleSelection(this.currentHoverTarget);
1843
+ }
1844
+ });
1845
+ return div;
1846
+ }
1847
+ /**
1848
+ * Handles mouse hover events.
1849
+ *
1850
+ * This function is called when the user hovers over an element.
1851
+ * It checks if the element is shareable and highlights it.
1852
+ * If a parent element is already selected, it highlights the parent instead,
1853
+ * allowing the helper to remain visible for the selected parent.
1854
+ *
1855
+ * @param e The mouse event triggered by the hover.
1856
+ */
1857
+ handleHover(e) {
1858
+ if (!this.isActive)
1859
+ return;
1860
+ // Check if we are hovering over the helper itself
1861
+ if (this.helperEl && this.helperEl.contains(e.target)) {
1862
+ return;
1863
+ }
1864
+ const target = e.target;
1865
+ // Exclude by Tag or ID
1866
+ const upperTag = target.tagName.toUpperCase();
1867
+ if (this.excludedTags.has(upperTag) || (target.id && this.excludedIds.has(target.id))) {
1868
+ return;
1869
+ }
1870
+ // Check closest excluded (for nested elements in excluded regions)
1871
+ let ancestor = target.parentElement;
1872
+ while (ancestor) {
1873
+ if (this.excludedTags.has(ancestor.tagName.toUpperCase()) || (ancestor.id && this.excludedIds.has(ancestor.id))) {
1874
+ return;
1875
+ }
1876
+ ancestor = ancestor.parentElement;
1877
+ }
1878
+ // Find closest shareable parent element
1879
+ const shareablePart = target.closest(SHAREABLE_SELECTOR);
1880
+ if (!shareablePart) {
1881
+ this.clearHover();
1882
+ return;
1883
+ }
1884
+ // Cast to HTMLElement
1885
+ const finalTarget = shareablePart;
1886
+ // Check if any ancestor is already selected. If so, do NOT highlight this child.
1887
+ // Instead, highlight (or keep highlighted) the SELECTED PARENT so the user can see the helper for it.
1888
+ let parent = finalTarget.parentElement;
1889
+ let selectedAncestor = null;
1890
+ // Loop outwards until we find a selected parent or reach the top
1891
+ while (parent) {
1892
+ if (this.selectedElements.has(parent)) {
1893
+ selectedAncestor = parent;
1894
+ break;
1895
+ }
1896
+ parent = parent.parentElement;
1897
+ }
1898
+ if (selectedAncestor) {
1899
+ // If we are hovering deep inside a selected block, show the helper for that block
1900
+ this.setNewHoverTarget(selectedAncestor);
1901
+ return;
1902
+ }
1903
+ // stop bubbling to parent
1904
+ // when element found for highlight
1905
+ e.stopPropagation();
1906
+ // If we are already on this target, do nothing (and keep it selected/highlighted)
1907
+ if (this.currentHoverTarget === finalTarget)
1908
+ return;
1909
+ // Highlight
1910
+ this.setNewHoverTarget(finalTarget);
1911
+ }
1912
+ setNewHoverTarget(target) {
1913
+ if (this.currentHoverTarget) {
1914
+ this.currentHoverTarget.classList.remove(HIGHLIGHT_TARGET_CLASS);
1915
+ }
1916
+ this.currentHoverTarget = target;
1917
+ this.currentHoverTarget.classList.add(HIGHLIGHT_TARGET_CLASS);
1918
+ this.positionHelper(target);
1919
+ }
1920
+ positionHelper(target) {
1921
+ if (!this.helperEl)
1922
+ return;
1923
+ const rect = target.getBoundingClientRect();
1924
+ const tagLabel = this.helperEl.querySelector('#cv-helper-tag');
1925
+ const upBtn = this.helperEl.querySelector('#cv-helper-up-btn');
1926
+ if (tagLabel)
1927
+ tagLabel.textContent = target.tagName;
1928
+ // Position at top-right of the element
1929
+ // Prevent going off-screen
1930
+ let top = rect.top - 20;
1931
+ if (top < 0)
1932
+ top = rect.top + 10; // Flip down if too close to top
1933
+ let left = rect.right - 80;
1934
+ if (left < 0)
1935
+ left = 10;
1936
+ this.helperEl.style.display = 'flex';
1937
+ this.helperEl.style.top = `${top}px`;
1938
+ this.helperEl.style.left = `${left}px`;
1939
+ // Update Select Button State (Tick or Cross)
1940
+ const selectBtn = this.helperEl.querySelector('#cv-helper-select-btn');
1941
+ if (selectBtn) {
1942
+ if (this.selectedElements.has(target)) {
1943
+ selectBtn.textContent = '✕';
1944
+ selectBtn.title = 'Deselect This Element';
1945
+ selectBtn.style.backgroundColor = '#d13438'; // Reddish
1946
+ }
1947
+ else {
1948
+ selectBtn.textContent = '✓';
1949
+ selectBtn.title = 'Select This Element';
1950
+ selectBtn.style.backgroundColor = ''; // Reset
1951
+ }
1952
+ }
1953
+ // Ancestry Check
1954
+ const parent = target.parentElement;
1955
+ const parentIsShareable = parent && parent.matches(SHAREABLE_SELECTOR);
1956
+ if (parentIsShareable) {
1957
+ upBtn.style.display = 'inline-block';
1958
+ }
1959
+ else {
1960
+ upBtn.style.display = 'none';
1961
+ }
1962
+ }
1963
+ handleSelectParent() {
1964
+ if (this.currentHoverTarget && this.currentHoverTarget.parentElement) {
1965
+ const parent = this.currentHoverTarget.parentElement;
1966
+ if (parent.matches(SHAREABLE_SELECTOR)) {
1967
+ this.setNewHoverTarget(parent);
1968
+ }
1969
+ }
1970
+ }
1971
+ handleClick(e) {
1972
+ if (!this.isActive)
1973
+ return;
1974
+ // If clicking helper
1975
+ if (this.helperEl && this.helperEl.contains(e.target))
1976
+ return;
1977
+ // If clicking floating bar
1978
+ if (this.floatingBarEl && this.floatingBarEl.contains(e.target))
1979
+ return;
1980
+ e.preventDefault();
1981
+ e.stopPropagation();
1982
+ if (this.currentHoverTarget) {
1983
+ this.toggleSelection(this.currentHoverTarget);
1984
+ }
1985
+ }
1986
+ handleKeydown(e) {
1987
+ if (!this.isActive)
1988
+ return;
1989
+ if (e.key === 'Escape') {
1990
+ e.preventDefault();
1991
+ e.stopPropagation();
1992
+ this.toggleShareMode();
1993
+ }
1994
+ }
1995
+ /**
1996
+ * Toggles the selection state of a given HTML element.
1997
+ * Implements selection logic:
1998
+ * - If an ancestor of the element is already selected, the click is ignored.
1999
+ * - If the element being selected is a parent of already selected elements, those children are deselected.
2000
+ * @param el The HTMLElement to toggle selection for.
2001
+ */
2002
+ toggleSelection(el) {
2003
+ if (this.selectedElements.has(el)) {
2004
+ this.selectedElements.delete(el);
2005
+ el.classList.remove(SELECTED_CLASS);
2006
+ }
2007
+ else {
2008
+ // Selection Logic
2009
+ // Scenario A: Selecting a Parent -> Remove children selected while selecting parent
2010
+ // Scenario B: Selecting a Child -> Ignore if ancestor selected (or handle)
2011
+ // B. Check if any ancestor is already selected, return if any (Scenario B)
2012
+ let parent = el.parentElement;
2013
+ while (parent) {
2014
+ if (this.selectedElements.has(parent)) {
2015
+ // Ancestor is selected. Ignore click.
2016
+ return;
2017
+ }
2018
+ parent = parent.parentElement;
2019
+ }
2020
+ // A. Check if any children are selected (Scenario A)
2021
+ // We must iterate over currently selected elements
2022
+ const toRemove = [];
2023
+ this.selectedElements.forEach(selected => {
2024
+ if (el.contains(selected) && el !== selected) {
2025
+ toRemove.push(selected);
2026
+ }
2027
+ });
2028
+ toRemove.forEach(child => {
2029
+ this.selectedElements.delete(child);
2030
+ child.classList.remove(SELECTED_CLASS);
2031
+ });
2032
+ // Add new selection
2033
+ this.selectedElements.add(el);
2034
+ el.classList.add(SELECTED_CLASS);
2035
+ }
2036
+ this.updateFloatingBarCount();
2037
+ }
2038
+ createFloatingBar() {
2039
+ const bar = document.createElement('div');
2040
+ bar.id = FLOATING_ACTION_BAR_ID;
2041
+ bar.innerHTML = `
2042
+ <span id="cv-selected-count">0 items selected</span>
2043
+ <button class="cv-action-button clear">Clear All</button>
2044
+ <button class="cv-action-button preview">Preview</button>
2045
+ <button class="cv-action-button generate">Generate Link</button>
2046
+ <button class="cv-action-button exit">Exit</button>
2047
+ `;
2048
+ document.body.appendChild(bar);
2049
+ this.floatingBarEl = bar;
2050
+ bar.querySelector('.clear')?.addEventListener('click', () => this.clearAll());
2051
+ bar.querySelector('.preview')?.addEventListener('click', () => this.previewLink());
2052
+ bar.querySelector('.generate')?.addEventListener('click', () => this.generateLink());
2053
+ bar.querySelector('.exit')?.addEventListener('click', () => this.toggleShareMode());
2054
+ }
2055
+ updateFloatingBarCount() {
2056
+ if (this.floatingBarEl) {
2057
+ const countElement = this.floatingBarEl.querySelector('#cv-selected-count');
2058
+ if (countElement) {
2059
+ const count = this.selectedElements.size;
2060
+ countElement.textContent = `${count} item${count === 1 ? '' : 's'} selected`;
2061
+ }
2062
+ }
2063
+ }
2064
+ clearAll() {
2065
+ this.selectedElements.forEach(el => el.classList.remove('cv-share-selected'));
2066
+ this.selectedElements.clear();
2067
+ this.updateFloatingBarCount();
2068
+ }
2069
+ getShareUrl() {
2070
+ if (this.selectedElements.size === 0) {
2071
+ return null;
2072
+ }
2073
+ const descriptors = Array.from(this.selectedElements).map(el => AnchorEngine.createDescriptor(el));
2074
+ const serialized = AnchorEngine.serialize(descriptors);
2075
+ const url = new URL(window.location.href);
2076
+ url.searchParams.set('cv-focus', serialized);
2077
+ return url;
2078
+ }
2079
+ async generateLink() {
2080
+ const url = this.getShareUrl();
2081
+ if (!url) {
2082
+ ToastManager.show('Please select at least one item.');
2083
+ return;
2084
+ }
2085
+ try {
2086
+ await navigator.clipboard.writeText(url.toString());
2087
+ ToastManager.show('Link copied to clipboard!');
2088
+ }
2089
+ catch (e) {
2090
+ console.error('Clipboard failed', e);
2091
+ ToastManager.show('Failed to copy link.');
2092
+ }
2093
+ }
2094
+ previewLink() {
2095
+ const url = this.getShareUrl();
2096
+ if (!url) {
2097
+ ToastManager.show('Please select at least one item.');
2098
+ return;
2099
+ }
2100
+ window.open(url.toString(), '_blank');
2101
+ }
2102
+ clearHover() {
2103
+ if (this.currentHoverTarget) {
2104
+ this.currentHoverTarget.classList.remove(HIGHLIGHT_TARGET_CLASS);
2105
+ this.currentHoverTarget = null;
2106
+ }
2107
+ if (this.helperEl) {
2108
+ this.helperEl.style.display = 'none';
2109
+ }
2110
+ }
2111
+ cleanup() {
2112
+ document.body.classList.remove('cv-share-mode');
2113
+ this.clearAll();
2114
+ const style = document.getElementById(SHARE_MODE_STYLE_ID);
2115
+ if (style)
2116
+ document.head.removeChild(style);
2117
+ if (this.floatingBarEl) {
2118
+ document.body.removeChild(this.floatingBarEl);
2119
+ this.floatingBarEl = null;
2120
+ }
2121
+ if (this.helperEl) {
2122
+ document.body.removeChild(this.helperEl);
2123
+ this.helperEl = null;
2124
+ }
2125
+ if (this.currentHoverTarget) {
2126
+ this.currentHoverTarget.classList.remove(HIGHLIGHT_TARGET_CLASS);
2127
+ this.currentHoverTarget = null;
2128
+ }
2129
+ document.removeEventListener('mouseover', this.boundHandleHover, true);
2130
+ document.removeEventListener('click', this.boundHandleClick, true);
2131
+ document.removeEventListener('keydown', this.boundHandleKeydown, true);
2132
+ this.isActive = false;
2133
+ }
2134
+ }
2135
+
2136
+ const SHARE_BUTTON_ID = 'cv-share-button';
2137
+ const SHARE_BUTTON_STYLES = `
2138
+ #${SHARE_BUTTON_ID} {
2139
+ position: fixed;
2140
+ bottom: 20px;
2141
+ right: 100px;
2142
+ width: 50px;
2143
+ height: 50px;
2144
+ border-radius: 50%;
2145
+ background-color: #007bff; /* Primary Blue */
2146
+ color: white;
2147
+ border: none;
2148
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08); /* Drop shadow */
2149
+ z-index: 9998; /* Below modals (9999) but above content */
2150
+ cursor: pointer;
2151
+ display: flex;
2152
+ align-items: center;
2153
+ justify-content: center;
2154
+ transition: opacity 0.3s ease, transform 0.3s ease, background-color 0.2s;
2155
+ opacity: 1;
2156
+ transform: scale(1);
2157
+ padding: 0;
2158
+ margin: 0;
2159
+ }
2160
+
2161
+ #${SHARE_BUTTON_ID}:hover {
2162
+ background-color: #0056b3; /* Darker blue on hover */
2163
+ transform: scale(1.05);
2164
+ }
2165
+
2166
+ #${SHARE_BUTTON_ID}:active {
2167
+ transform: scale(0.95);
2168
+ }
2169
+
2170
+ #${SHARE_BUTTON_ID}.cv-hidden {
2171
+ opacity: 0;
2172
+ pointer-events: none;
2173
+ transform: scale(0.8);
2174
+ }
2175
+
2176
+ @media print {
2177
+ #${SHARE_BUTTON_ID} {
2178
+ display: none !important;
2179
+ }
2180
+ }
2181
+
2182
+ #${SHARE_BUTTON_ID} svg {
2183
+ width: 24px;
2184
+ height: 24px;
2185
+ fill: currentColor;
2186
+ }
2187
+ `;
2188
+
2189
+ class ShareButton {
2190
+ shareManager;
2191
+ button = null;
2192
+ boundHandleClick;
2193
+ constructor(shareManager) {
2194
+ this.shareManager = shareManager;
2195
+ this.boundHandleClick = () => this.shareManager.toggleShareMode();
2196
+ }
2197
+ init() {
2198
+ if (this.button)
2199
+ return;
2200
+ this.injectStyles();
2201
+ this.button = this.createButton();
2202
+ document.body.appendChild(this.button);
2203
+ // Subscribe to share manager state changes
2204
+ this.shareManager.addStateChangeListener((isActive) => {
2205
+ this.setShareModeActive(isActive);
2206
+ });
2207
+ }
2208
+ destroy() {
2209
+ if (this.button) {
2210
+ this.button.remove();
2211
+ this.button = null;
2212
+ }
2213
+ }
2214
+ createButton() {
2215
+ const btn = document.createElement('button');
2216
+ btn.id = SHARE_BUTTON_ID;
2217
+ btn.setAttribute('aria-label', 'Share specific sections');
2218
+ btn.title = 'Select sections to share';
2219
+ // Using the link icon from utils, ensuring it's white via CSS currentColor
2220
+ btn.innerHTML = getShareIcon();
2221
+ btn.addEventListener('click', this.boundHandleClick);
2222
+ return btn;
2223
+ }
2224
+ injectStyles() {
2225
+ if (!document.getElementById('cv-share-button-styles')) {
2226
+ const style = document.createElement('style');
2227
+ style.id = 'cv-share-button-styles';
2228
+ style.textContent = SHARE_BUTTON_STYLES;
2229
+ document.head.appendChild(style);
2230
+ }
2231
+ }
2232
+ setShareModeActive(isActive) {
2233
+ if (!this.button)
2234
+ return;
2235
+ if (isActive) {
2236
+ this.button.classList.add('cv-hidden');
2237
+ }
2238
+ else {
2239
+ this.button.classList.remove('cv-hidden');
2240
+ }
2241
+ }
2242
+ }
2243
+
2244
+ const FOCUS_MODE_STYLE_ID = 'cv-focus-mode-styles';
2245
+ const BODY_FOCUS_CLASS = 'cv-focus-mode';
2246
+ const HIDDEN_CLASS = 'cv-focus-hidden';
2247
+ const FOCUSED_CLASS = 'cv-focused-element';
2248
+ const DIVIDER_CLASS = 'cv-context-divider';
2249
+ const EXIT_BANNER_ID = 'cv-exit-focus-banner';
2250
+ const styles = `
2251
+ body.${BODY_FOCUS_CLASS} {
2252
+ /* e.g. potentially hide scrollbars or adjust layout */
2253
+ }
2254
+
2255
+ .${HIDDEN_CLASS} {
2256
+ display: none !important;
2257
+ }
2258
+
2259
+ .${FOCUSED_CLASS} {
2260
+ /* No visual style for focused elements, just logic class for now. Can add borders for debugging*/
2261
+ }
2262
+
2263
+ .${DIVIDER_CLASS} {
2264
+ padding: 12px;
2265
+ margin: 16px 0;
2266
+ background-color: #f8f8f8;
2267
+ border-top: 1px dashed #ccc;
2268
+ border-bottom: 1px dashed #ccc;
2269
+ color: #555;
2270
+ text-align: center;
2271
+ cursor: pointer;
2272
+ font-family: system-ui, sans-serif;
2273
+ font-size: 13px;
2274
+ transition: background-color 0.2s;
2275
+ }
2276
+ .${DIVIDER_CLASS}:hover {
2277
+ background-color: #e8e8e8;
2278
+ color: #333;
2279
+ }
2280
+
2281
+ #${EXIT_BANNER_ID} {
2282
+ position: sticky;
2283
+ top: 0;
2284
+ left: 0;
2285
+ right: 0;
2286
+ background-color: #0078D4;
2287
+ color: white;
2288
+ padding: 10px 20px;
2289
+ display: flex;
2290
+ align-items: center;
2291
+ justify-content: center;
2292
+ gap: 16px;
2293
+ z-index: 100000;
2294
+ font-family: system-ui, sans-serif;
2295
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
2296
+ }
2297
+
2298
+ #${EXIT_BANNER_ID} button {
2299
+ background: white;
2300
+ color: #0078D4;
2301
+ border: none;
2302
+ padding: 4px 12px;
2303
+ border-radius: 4px;
2304
+ cursor: pointer;
2305
+ font-weight: 600;
2306
+ }
2307
+ #${EXIT_BANNER_ID} button:hover {
2308
+ background: #f0f0f0;
2309
+ }
2310
+ `;
2311
+ const FOCUS_MODE_STYLES = styles;
2312
+
2313
+ /**
2314
+ * Manages the "Focus Mode" (Presentation View).
2315
+ * Parses the URL for robust anchors, resolves them, hides irrelevant content, and inserts context dividers.
2316
+ */
2317
+ const FOCUS_PARAM = 'cv-focus';
2318
+ class FocusManager {
2319
+ rootEl;
2320
+ hiddenElements = new Set();
2321
+ dividers = [];
2322
+ exitBanner = null;
2323
+ excludedTags;
2324
+ excludedIds;
2325
+ constructor(rootEl, options) {
2326
+ this.rootEl = rootEl;
2327
+ this.excludedTags = new Set(options.excludedTags.map(t => t.toUpperCase()));
2328
+ this.excludedIds = new Set(options.excludedIds);
2329
+ }
2330
+ /**
2331
+ * Initializes the Focus Manager. Checks URL for focus parameter.
2332
+ */
2333
+ init() {
2334
+ this.handleUrlChange();
2335
+ }
2336
+ handleUrlChange() {
2337
+ const urlParams = new URLSearchParams(window.location.search);
2338
+ const encodedDescriptors = urlParams.get(FOCUS_PARAM);
2339
+ if (encodedDescriptors) {
2340
+ this.applyFocusMode(encodedDescriptors);
2341
+ }
2342
+ else {
2343
+ // encoding missing, ensure we exit focus mode if active
2344
+ if (document.body.classList.contains(BODY_FOCUS_CLASS)) {
2345
+ this.exitFocusMode();
2346
+ }
2347
+ }
2348
+ }
2349
+ /**
2350
+ * Applies Focus Mode based on encoded descriptors.
2351
+ */
2352
+ applyFocusMode(encodedDescriptors) {
2353
+ const descriptors = AnchorEngine.deserialize(encodedDescriptors);
2354
+ if (!descriptors || descriptors.length === 0)
2355
+ return;
2356
+ // Resolve anchors to DOM elements
2357
+ const targets = [];
2358
+ descriptors.forEach(desc => {
2359
+ const el = AnchorEngine.resolve(this.rootEl, desc);
2360
+ if (el) {
2361
+ targets.push(el);
2362
+ }
2363
+ });
2364
+ if (targets.length === 0) {
2365
+ ToastManager.show("Some shared sections could not be found.");
2366
+ return;
2367
+ }
2368
+ if (targets.length < descriptors.length) {
2369
+ ToastManager.show("Some shared sections could not be found.");
2370
+ }
2371
+ this.injectStyles();
2372
+ document.body.classList.add(BODY_FOCUS_CLASS);
2373
+ this.renderFocusedView(targets);
2374
+ this.showExitBanner();
2375
+ }
2376
+ injectStyles() {
2377
+ if (document.getElementById(FOCUS_MODE_STYLE_ID))
2378
+ return;
2379
+ const style = document.createElement('style');
2380
+ style.id = FOCUS_MODE_STYLE_ID;
2381
+ style.textContent = FOCUS_MODE_STYLES;
2382
+ document.head.appendChild(style);
2383
+ }
2384
+ /**
2385
+ * Hides irrelevant content and adds dividers.
2386
+ */
2387
+ renderFocusedView(targets) {
2388
+ // 1. Mark targets
2389
+ targets.forEach(t => t.classList.add(FOCUSED_CLASS));
2390
+ // 2. We need to hide siblings of targets (and their ancestors up to root generally,
2391
+ // but "siblings between focused zones" suggests we are mostly looking at a flat list or specific nesting).
2392
+ //
2393
+ // "All sibling elements between the focused zones are collapsed and hidden."
2394
+ // "If a user selects a parent element, all of its child elements must be visible."
2395
+ //
2396
+ // Algorithm:
2397
+ // Walk up from each target to finding the common container?
2398
+ // Or just assume targets are somewhat related.
2399
+ //
2400
+ // Let's implement a robust "Hide Siblings" approach.
2401
+ // For every target, we ensure it is visible.
2402
+ // We look at its siblings. If a sibling is NOT a target AND NOT an ancestor of a target, we hide it.
2403
+ // We need to identify all "Keep Visible" elements (targets + ancestors)
2404
+ const keepVisible = new Set();
2405
+ targets.forEach(t => {
2406
+ let curr = t;
2407
+ while (curr && curr !== document.body && curr !== document.documentElement) {
2408
+ keepVisible.add(curr);
2409
+ curr = curr.parentElement;
2410
+ }
2411
+ });
2412
+ // Now iterate through siblings of "Keep Visible" elements?
2413
+ // Actually, we can just walk the tree or iterate siblings of targets/ancestors?
2414
+ //
2415
+ // Improved Algorithm:
2416
+ // 1. Collect all direct siblings of every element in keepVisible set.
2417
+ // 2. If a sibling is NOT in keepVisible, hide it.
2418
+ // To avoid processing the entire DOM, we start from targets and walk up.
2419
+ keepVisible.forEach(el => {
2420
+ if (el === document.body)
2421
+ return; // Don't hide siblings of body (scripts etc) unless we are sure.
2422
+ // Actually usually we want to hide siblings of the content container.
2423
+ const parent = el.parentElement;
2424
+ if (!parent)
2425
+ return;
2426
+ // FIX: "Parent Dominance"
2427
+ // If the parent itself is a target (or we otherwise decided its whole content is meaningful),
2428
+ // then we should NOT hide anything inside it.
2429
+ // We check if 'parent' is one of the explicitly resolved targets.
2430
+ // We can check if it has the FOCUSED_CLASS class, since we added it in step 1.
2431
+ if (parent.classList.contains(FOCUSED_CLASS)) {
2432
+ return;
2433
+ }
2434
+ // Using children because we want element nodes
2435
+ Array.from(parent.children).forEach(child => {
2436
+ if (child instanceof HTMLElement && !keepVisible.has(child)) {
2437
+ this.hideElement(child);
2438
+ }
2439
+ });
2440
+ });
2441
+ // 3. Insert Dividers
2442
+ // We process each container that has hidden elements
2443
+ const processedContainers = new Set();
2444
+ keepVisible.forEach(el => {
2445
+ const parent = el.parentElement;
2446
+ if (parent && !processedContainers.has(parent)) {
2447
+ this.insertDividersForContainer(parent);
2448
+ processedContainers.add(parent);
2449
+ }
2450
+ });
2451
+ }
2452
+ hideElement(el) {
2453
+ if (this.hiddenElements.has(el))
2454
+ return; // Already hidden
2455
+ // Exclude by Tag
2456
+ if (this.excludedTags.has(el.tagName.toUpperCase()))
2457
+ return;
2458
+ // Exclude by ID (if strictly matching)
2459
+ if (el.id && this.excludedIds.has(el.id))
2460
+ return;
2461
+ // Also don't hide things that are aria-hidden
2462
+ if (el.getAttribute('aria-hidden') === 'true')
2463
+ return;
2464
+ // Exclude Toast Notification
2465
+ if (el.classList.contains(TOAST_CLASS))
2466
+ return;
2467
+ // We check if it is already hidden (e.g. by previous focus mode run? No, isActive check handles that)
2468
+ // Just mark it.
2469
+ el.classList.add(HIDDEN_CLASS);
2470
+ this.hiddenElements.add(el);
2471
+ }
2472
+ insertDividersForContainer(container) {
2473
+ const children = Array.from(container.children);
2474
+ let hiddenCount = 0;
2475
+ let hiddenGroupStart = null;
2476
+ children.forEach((child) => {
2477
+ if (child.classList.contains(HIDDEN_CLASS)) {
2478
+ if (hiddenCount === 0)
2479
+ hiddenGroupStart = child;
2480
+ hiddenCount++;
2481
+ }
2482
+ else {
2483
+ // Found a visible element. Was there a hidden group before this?
2484
+ if (hiddenCount > 0 && hiddenGroupStart) {
2485
+ this.createDivider(container, hiddenGroupStart, hiddenCount);
2486
+ hiddenCount = 0;
2487
+ hiddenGroupStart = null;
2488
+ }
2489
+ }
2490
+ });
2491
+ // Trailing hidden group
2492
+ if (hiddenCount > 0 && hiddenGroupStart) {
2493
+ this.createDivider(container, hiddenGroupStart, hiddenCount);
2494
+ }
2495
+ }
2496
+ createDivider(container, insertBeforeEl, count) {
2497
+ const divider = document.createElement('div');
2498
+ divider.className = DIVIDER_CLASS;
2499
+ divider.textContent = `... ${count} section${count > 1 ? 's' : ''} hidden (Click to expand) ...`;
2500
+ divider.onclick = () => this.expandContext(insertBeforeEl, count, divider);
2501
+ container.insertBefore(divider, insertBeforeEl);
2502
+ this.dividers.push(divider);
2503
+ }
2504
+ expandContext(firstHidden, count, divider) {
2505
+ // Divider is inserted BEFORE firstHidden.
2506
+ // So firstHidden is the first element to reveal.
2507
+ let curr = firstHidden;
2508
+ let expanded = 0;
2509
+ while (curr && expanded < count) {
2510
+ if (curr instanceof HTMLElement && curr.classList.contains(HIDDEN_CLASS)) {
2511
+ curr.classList.remove(HIDDEN_CLASS);
2512
+ this.hiddenElements.delete(curr);
2513
+ }
2514
+ curr = curr.nextElementSibling;
2515
+ // Note: If nested dividers or other elements exist, they shouldn't count?
2516
+ // "Children" iteration in insertDividers covered direct children.
2517
+ // sibling iteration also covers direct children.
2518
+ // We assume contiguous hidden siblings.
2519
+ expanded++;
2520
+ }
2521
+ divider.remove();
2522
+ const idx = this.dividers.indexOf(divider);
2523
+ if (idx > -1)
2524
+ this.dividers.splice(idx, 1);
2525
+ // If no more hidden elements, remove the banner
2526
+ if (this.hiddenElements.size === 0) {
2527
+ this.removeExitBanner();
2528
+ }
2529
+ }
2530
+ removeExitBanner() {
2531
+ if (this.exitBanner) {
2532
+ this.exitBanner.remove();
2533
+ this.exitBanner = null;
2534
+ }
2535
+ }
2536
+ /**
2537
+ * Override of renderFocusedView with robust logic
2538
+ */
2539
+ // (We use the class method `renderFocusedView` and internal helpers)
2540
+ showExitBanner() {
2541
+ if (document.getElementById(EXIT_BANNER_ID))
2542
+ return;
2543
+ const banner = document.createElement('div');
2544
+ banner.id = EXIT_BANNER_ID;
2545
+ banner.innerHTML = `
2546
+ <span>You are viewing a focused selection.</span>
2547
+ <button id="cv-exit-focus-btn">Show Full Page</button>
2548
+ `;
2549
+ document.body.prepend(banner); // Top of body
2550
+ banner.querySelector('button')?.addEventListener('click', () => this.exitFocusMode());
2551
+ this.exitBanner = banner;
2552
+ }
2553
+ exitFocusMode() {
2554
+ document.body.classList.remove(BODY_FOCUS_CLASS);
2555
+ // Show all hidden elements
2556
+ this.hiddenElements.forEach(el => el.classList.remove(HIDDEN_CLASS));
2557
+ this.hiddenElements.clear();
2558
+ // Remove dividers
2559
+ this.dividers.forEach(d => d.remove());
2560
+ this.dividers = [];
2561
+ // Remove styling from targets
2562
+ const targets = document.querySelectorAll(`.${FOCUSED_CLASS}`);
2563
+ targets.forEach(t => t.classList.remove(FOCUSED_CLASS));
2564
+ // Remove banner
2565
+ this.removeExitBanner();
2566
+ // Update URL
2567
+ const url = new URL(window.location.href);
2568
+ url.searchParams.delete(FOCUS_PARAM);
2569
+ window.history.pushState({}, '', url.toString());
2570
+ }
2571
+ }
2572
+
2573
+ const DEFAULT_EXCLUDED_TAGS = ['HEADER', 'NAV', 'FOOTER', 'SCRIPT', 'STYLE'];
2574
+ const DEFAULT_EXCLUDED_IDS = ['cv-floating-action-bar', 'cv-hover-helper', 'cv-toast-notification'];
2575
+
2576
+ const TOGGLE_SELECTOR = "[data-cv-toggle], [data-customviews-toggle], cv-toggle";
2577
+ const TABGROUP_SELECTOR = 'cv-tabgroup';
1362
2578
  class CustomViewsCore {
1363
2579
  rootEl;
1364
2580
  assetsManager;
1365
2581
  persistenceManager;
1366
2582
  visibilityManager;
2583
+ observer = null;
2584
+ shareManager;
2585
+ shareButton;
2586
+ focusManager;
2587
+ componentRegistry = {
2588
+ toggles: new Set(),
2589
+ tabGroups: new Set(),
2590
+ };
1367
2591
  config;
1368
2592
  stateChangeListeners = [];
1369
2593
  showUrlEnabled;
@@ -1376,6 +2600,73 @@ class CustomViewsCore {
1376
2600
  this.visibilityManager = new VisibilityManager();
1377
2601
  this.showUrlEnabled = opt.showUrl ?? false;
1378
2602
  this.lastAppliedState = this.cloneState(this.getComputedDefaultState());
2603
+ // Resolve Exclusions
2604
+ const excludedTags = [...DEFAULT_EXCLUDED_TAGS, ...(this.config.shareExclusions?.tags || [])];
2605
+ const excludedIds = [...DEFAULT_EXCLUDED_IDS, ...(this.config.shareExclusions?.ids || [])];
2606
+ const commonOptions = { excludedTags, excludedIds };
2607
+ this.shareManager = new ShareManager(commonOptions);
2608
+ this.shareButton = new ShareButton(this.shareManager);
2609
+ this.focusManager = new FocusManager(this.rootEl, commonOptions);
2610
+ }
2611
+ getShareManager() {
2612
+ return this.shareManager;
2613
+ }
2614
+ /**
2615
+ * Toggles the share mode on or off.
2616
+ */
2617
+ toggleShareMode() {
2618
+ this.shareManager.toggleShareMode();
2619
+ }
2620
+ /**
2621
+ * Scan the given element for toggles and tab groups, register them
2622
+ * Returns true if new components were found
2623
+ */
2624
+ scan(element) {
2625
+ let newComponentsFound = false;
2626
+ // Scan for toggles
2627
+ const toggles = Array.from(element.querySelectorAll(TOGGLE_SELECTOR));
2628
+ if (element.matches(TOGGLE_SELECTOR)) {
2629
+ toggles.unshift(element);
2630
+ }
2631
+ toggles.forEach((toggle) => {
2632
+ if (!this.componentRegistry.toggles.has(toggle)) {
2633
+ this.componentRegistry.toggles.add(toggle);
2634
+ newComponentsFound = true;
2635
+ }
2636
+ });
2637
+ // Scan for tab groups
2638
+ const tabGroups = Array.from(element.querySelectorAll(TABGROUP_SELECTOR));
2639
+ if (element.matches(TABGROUP_SELECTOR)) {
2640
+ tabGroups.unshift(element);
2641
+ }
2642
+ tabGroups.forEach((tabGroup) => {
2643
+ if (!this.componentRegistry.tabGroups.has(tabGroup)) {
2644
+ this.componentRegistry.tabGroups.add(tabGroup);
2645
+ newComponentsFound = true;
2646
+ }
2647
+ });
2648
+ return newComponentsFound;
2649
+ }
2650
+ /**
2651
+ * Unscan the given element for toggles and tab groups, de-register them from registry
2652
+ */
2653
+ unscan(element) {
2654
+ // Unscan for toggles
2655
+ const toggles = Array.from(element.querySelectorAll(TOGGLE_SELECTOR));
2656
+ if (element.matches(TOGGLE_SELECTOR)) {
2657
+ toggles.unshift(element);
2658
+ }
2659
+ toggles.forEach((toggle) => {
2660
+ this.componentRegistry.toggles.delete(toggle);
2661
+ });
2662
+ // Unscan for tab groups
2663
+ const tabGroups = Array.from(element.querySelectorAll(TABGROUP_SELECTOR));
2664
+ if (element.matches(TABGROUP_SELECTOR)) {
2665
+ tabGroups.unshift(element);
2666
+ }
2667
+ tabGroups.forEach((tabGroup) => {
2668
+ this.componentRegistry.tabGroups.delete(tabGroup);
2669
+ });
1379
2670
  }
1380
2671
  getConfig() {
1381
2672
  return this.config;
@@ -1411,7 +2702,7 @@ class CustomViewsCore {
1411
2702
  });
1412
2703
  }
1413
2704
  const computedState = {
1414
- toggles: [...(this.config.allToggles || [])],
2705
+ toggles: this.config.toggles?.map(t => t.id) || [],
1415
2706
  tabs
1416
2707
  };
1417
2708
  return computedState;
@@ -1450,8 +2741,28 @@ class CustomViewsCore {
1450
2741
  // Inject styles, setup listeners and call rendering logic
1451
2742
  async init() {
1452
2743
  injectCoreStyles();
1453
- // Build navigation once (with click and double-click handlers)
1454
- TabManager.buildNavs(this.rootEl, this.config.tabGroups,
2744
+ this.scan(this.rootEl);
2745
+ // Initialize all components found on initial scan
2746
+ this.initializeNewComponents();
2747
+ // Apply stored nav visibility preference on page load
2748
+ const navPref = this.persistenceManager.getPersistedTabNavVisibility();
2749
+ if (navPref !== null) {
2750
+ TabManager.setNavsVisibility(this.rootEl, navPref);
2751
+ }
2752
+ // For session history, clicks on back/forward button
2753
+ window.addEventListener("popstate", () => {
2754
+ this.loadAndCallApplyState();
2755
+ this.focusManager.handleUrlChange();
2756
+ });
2757
+ this.loadAndCallApplyState();
2758
+ this.focusManager.init();
2759
+ this.shareButton.init();
2760
+ this.initObserver();
2761
+ }
2762
+ initializeNewComponents() {
2763
+ // Build navigation for any newly added tab groups.
2764
+ // The `data-cv-initialized` attribute in `buildNavs` prevents re-initialization.
2765
+ TabManager.buildNavs(Array.from(this.componentRegistry.tabGroups), this.config.tabGroups,
1455
2766
  // Single click: update clicked group only (local, no persistence)
1456
2767
  (groupId, tabId, groupEl) => {
1457
2768
  this.setActiveTab(groupId, tabId, groupEl);
@@ -1467,23 +2778,53 @@ class CustomViewsCore {
1467
2778
  const currentToggles = this.getCurrentActiveToggles();
1468
2779
  const newState = {
1469
2780
  toggles: currentToggles,
1470
- tabs: currentTabs
2781
+ tabs: currentTabs,
1471
2782
  };
1472
2783
  // 2. Apply state with scroll anchor information
1473
2784
  this.applyState(newState, {
1474
2785
  scrollAnchor: { element: anchorElement, top: initialTop }
1475
2786
  });
1476
2787
  });
1477
- // Apply stored nav visibility preference on page load
1478
- const navPref = this.persistenceManager.getPersistedTabNavVisibility();
1479
- if (navPref !== null) {
1480
- TabManager.setNavsVisibility(this.rootEl, navPref);
1481
- }
1482
- // For session history, clicks on back/forward button
1483
- window.addEventListener("popstate", () => {
1484
- this.loadAndCallApplyState();
2788
+ // Future components (e.g., toggles, widgets) can be initialized here
2789
+ }
2790
+ initObserver() {
2791
+ this.observer = new MutationObserver((mutations) => {
2792
+ let newComponentsFound = false;
2793
+ for (const mutation of mutations) {
2794
+ if (mutation.type === 'childList') {
2795
+ mutation.addedNodes.forEach((node) => {
2796
+ if (node instanceof Element) {
2797
+ // Scan the new node for components and add them to the registry
2798
+ if (this.scan(node)) {
2799
+ newComponentsFound = true;
2800
+ }
2801
+ }
2802
+ });
2803
+ mutation.removedNodes.forEach((node) => {
2804
+ if (node instanceof Element) {
2805
+ // Unscan the removed node to cleanup the registry
2806
+ this.unscan(node);
2807
+ }
2808
+ });
2809
+ }
2810
+ }
2811
+ if (newComponentsFound) {
2812
+ // Initialize navs for new components.
2813
+ this.initializeNewComponents();
2814
+ // Re-apply the last known state. renderState will handle disconnecting
2815
+ // the observer to prevent infinite loops.
2816
+ if (this.lastAppliedState) {
2817
+ this.renderState(this.lastAppliedState);
2818
+ }
2819
+ }
1485
2820
  });
1486
- this.loadAndCallApplyState();
2821
+ // Observe only the root element to avoid performance issues on large pages.
2822
+ if (this.rootEl) {
2823
+ this.observer.observe(this.rootEl, {
2824
+ childList: true,
2825
+ subtree: true,
2826
+ });
2827
+ }
1487
2828
  }
1488
2829
  // Priority: URL state > persisted state > config default > computed default
1489
2830
  // Also filters using the visibility manager to persist selection
@@ -1537,23 +2878,34 @@ class CustomViewsCore {
1537
2878
  ScrollManager.handleScrollAnchor(options.scrollAnchor);
1538
2879
  }
1539
2880
  }
1540
- /** Render all toggles for the current state */
2881
+ /**
2882
+ * Renders state on components in ComponentRegistry
2883
+ * Applies the given state.
2884
+ * Disconnects the mutation observer during rendering to prevent loops
2885
+ **/
1541
2886
  renderState(state) {
2887
+ this.observer?.disconnect();
1542
2888
  this.lastAppliedState = this.cloneState(state);
1543
2889
  const toggles = state?.toggles || [];
1544
2890
  const finalToggles = this.visibilityManager.filterVisibleToggles(toggles);
2891
+ const toggleElements = Array.from(this.componentRegistry.toggles);
2892
+ const tabGroupElements = Array.from(this.componentRegistry.tabGroups);
1545
2893
  // Apply toggle visibility
1546
- ToggleManager.applyToggles(this.rootEl, finalToggles);
2894
+ ToggleManager.applyToggles(toggleElements, finalToggles);
1547
2895
  // Render assets into toggles
1548
- ToggleManager.renderAssets(this.rootEl, finalToggles, this.assetsManager);
2896
+ ToggleManager.renderAssets(toggleElements, finalToggles, this.assetsManager);
1549
2897
  // Apply tab selections
1550
- TabManager.applyTabSelections(this.rootEl, state.tabs || {}, this.config.tabGroups);
2898
+ TabManager.applyTabSelections(tabGroupElements, state.tabs || {}, this.config.tabGroups);
1551
2899
  // Update nav active states (without rebuilding)
1552
- TabManager.updateAllNavActiveStates(this.rootEl, state.tabs || {}, this.config.tabGroups);
2900
+ TabManager.updateAllNavActiveStates(tabGroupElements, state.tabs || {}, this.config.tabGroups);
1553
2901
  // Update pin icons to show which tabs are persisted
1554
- TabManager.updatePinIcons(this.rootEl, state.tabs || {});
2902
+ TabManager.updatePinIcons(tabGroupElements, state.tabs || {});
1555
2903
  // Notify state change listeners (like widgets)
1556
2904
  this.notifyStateChangeListeners();
2905
+ this.observer?.observe(document.body, {
2906
+ childList: true,
2907
+ subtree: true,
2908
+ });
1557
2909
  }
1558
2910
  /**
1559
2911
  * Reset to default state
@@ -1694,10 +3046,11 @@ function prependBaseUrl(path, baseUrl) {
1694
3046
  }
1695
3047
 
1696
3048
  /**
1697
- * Custom Elements for Tab Groups and Tabs
3049
+ * Defines the custom elements used by CustomViews.
1698
3050
  */
1699
3051
  /**
1700
- * <cv-tab> element - represents a single tab panel
3052
+ * `<cv-tab>`: A custom element representing a single tab panel within a tab group.
3053
+ * Its content is displayed when the corresponding tab is active.
1701
3054
  */
1702
3055
  class CVTab extends HTMLElement {
1703
3056
  connectedCallback() {
@@ -1705,7 +3058,8 @@ class CVTab extends HTMLElement {
1705
3058
  }
1706
3059
  }
1707
3060
  /**
1708
- * <cv-tabgroup> element - represents a group of tabs
3061
+ * `<cv-tabgroup>`: A custom element that encapsulates a set of tabs (`<cv-tab>`).
3062
+ * It manages the tab navigation and content visibility for the group.
1709
3063
  */
1710
3064
  class CVTabgroup extends HTMLElement {
1711
3065
  connectedCallback() {
@@ -1721,7 +3075,7 @@ class CVTabgroup extends HTMLElement {
1721
3075
  }
1722
3076
  }
1723
3077
  /**
1724
- * <cv-toggle> element - represents a toggleable content block
3078
+ * `<cv-toggle>`: A custom element for creating a toggleable content block.
1725
3079
  */
1726
3080
  class CVToggle extends HTMLElement {
1727
3081
  connectedCallback() {
@@ -1729,8 +3083,8 @@ class CVToggle extends HTMLElement {
1729
3083
  }
1730
3084
  }
1731
3085
  /**
1732
- * <cv-tab-header> element - represents tab header with rich HTML formatting
1733
- * Content is extracted and used in the navigation link
3086
+ * `<cv-tab-header>`: A semantic container for a tab's header content.
3087
+ * The content of this element is used to create the navigation link for the tab.
1734
3088
  */
1735
3089
  class CVTabHeader extends HTMLElement {
1736
3090
  connectedCallback() {
@@ -1738,8 +3092,7 @@ class CVTabHeader extends HTMLElement {
1738
3092
  }
1739
3093
  }
1740
3094
  /**
1741
- * <cv-tab-body> element - represents tab body content
1742
- * Semantic container for tab panel content
3095
+ * `<cv-tab-body>`: A semantic container for the main content of a tab panel.
1743
3096
  */
1744
3097
  class CVTabBody extends HTMLElement {
1745
3098
  connectedCallback() {
@@ -1747,7 +3100,7 @@ class CVTabBody extends HTMLElement {
1747
3100
  }
1748
3101
  }
1749
3102
  /**
1750
- * Register custom elements
3103
+ * Registers all CustomViews custom elements with the CustomElementRegistry.
1751
3104
  */
1752
3105
  function registerCustomElements() {
1753
3106
  // Only register if not already defined
@@ -1805,7 +3158,7 @@ class CustomViews {
1805
3158
  else {
1806
3159
  console.error("No config provided, using minimal default config");
1807
3160
  // Create a minimal default config
1808
- config = { allToggles: [], defaultState: {} };
3161
+ config = { toggles: [], defaultState: {} };
1809
3162
  }
1810
3163
  const coreOptions = {
1811
3164
  assetsManager,
@@ -2062,6 +3415,10 @@ const WIDGET_STYLES = `
2062
3415
  animation: fadeIn 0.2s ease;
2063
3416
  }
2064
3417
 
3418
+ .cv-widget-modal-overlay.cv-hidden {
3419
+ display: none;
3420
+ }
3421
+
2065
3422
  @keyframes fadeIn {
2066
3423
  from { opacity: 0; }
2067
3424
  to { opacity: 1; }
@@ -2918,8 +4275,11 @@ class CustomViewsWidget {
2918
4275
  container;
2919
4276
  widgetIcon = null;
2920
4277
  options;
4278
+ _hasVisibleConfig = false;
4279
+ pageToggleIds = new Set();
4280
+ pageTabIds = new Set();
2921
4281
  // Modal state
2922
- modal = null;
4282
+ stateModal = null;
2923
4283
  constructor(options) {
2924
4284
  this.core = options.core;
2925
4285
  this.container = options.container || document.body;
@@ -2937,12 +4297,41 @@ class CustomViewsWidget {
2937
4297
  welcomeMessage: options.welcomeMessage || 'This site is powered by Custom Views. Use the widget on the side (⚙) to customize your experience. Your preferences will be saved and can be shared via URL.<br><br>Learn more at <a href="https://github.com/customviews-js/customviews" target="_blank">customviews GitHub</a>.',
2938
4298
  showTabGroups: options.showTabGroups ?? true
2939
4299
  };
2940
- // No external state manager to initialize
4300
+ // Determine if there are any configurations to show
4301
+ const config = this.core.getConfig();
4302
+ const allToggles = config?.toggles || [];
4303
+ const visibleToggles = allToggles.filter(toggle => {
4304
+ if (toggle.isLocal) {
4305
+ return !!document.querySelector(`[data-cv-toggle="${toggle.id}"], [data-cv-toggle-group-id="${toggle.id}"]`);
4306
+ }
4307
+ return true;
4308
+ });
4309
+ const allTabGroups = this.core.getTabGroups() || [];
4310
+ const visibleTabGroups = allTabGroups.filter(group => {
4311
+ if (group.isLocal) {
4312
+ return !!document.querySelector(`cv-tabgroup[id="${group.id}"]`);
4313
+ }
4314
+ return true;
4315
+ });
4316
+ if (visibleToggles.length > 0 || (this.options.showTabGroups && visibleTabGroups.length > 0)) {
4317
+ this._hasVisibleConfig = true;
4318
+ }
4319
+ // Scan for page-declared local components and cache them
4320
+ // Do this on initialization to avoid querying DOM repeatedly
4321
+ const pageTogglesAttr = document.querySelector('[data-cv-page-local-toggles]')?.getAttribute('data-cv-page-local-toggles') || '';
4322
+ this.pageToggleIds = new Set(pageTogglesAttr.split(',').map(id => id.trim()).filter(id => id));
4323
+ const pageTabsAttr = document.querySelector('[data-cv-page-local-tabs]')?.getAttribute('data-cv-page-local-tabs') || '';
4324
+ this.pageTabIds = new Set(pageTabsAttr.split(',').map(id => id.trim()).filter(id => id));
2941
4325
  }
2942
4326
  /**
2943
- * Render the widget
4327
+ * Render the widget modal icon
4328
+ *
4329
+ * Does not render if there are no visible toggles or tab groups.
2944
4330
  */
2945
- render() {
4331
+ renderModalIcon() {
4332
+ if (!this._hasVisibleConfig) {
4333
+ return;
4334
+ }
2946
4335
  this.widgetIcon = this.createWidgetIcon();
2947
4336
  this.attachEventListeners();
2948
4337
  // Always append to body since it's a floating icon
@@ -2975,9 +4364,9 @@ class CustomViewsWidget {
2975
4364
  this.widgetIcon = null;
2976
4365
  }
2977
4366
  // Clean up modal
2978
- if (this.modal) {
2979
- this.modal.remove();
2980
- this.modal = null;
4367
+ if (this.stateModal) {
4368
+ this.stateModal.remove();
4369
+ this.stateModal = null;
2981
4370
  }
2982
4371
  }
2983
4372
  attachEventListeners() {
@@ -2990,49 +4379,71 @@ class CustomViewsWidget {
2990
4379
  * Close the modal
2991
4380
  */
2992
4381
  closeModal() {
2993
- if (this.modal) {
2994
- this.modal.remove();
2995
- this.modal = null;
4382
+ if (this.stateModal) {
4383
+ this.stateModal.classList.add('cv-hidden');
2996
4384
  }
2997
4385
  }
2998
4386
  /**
2999
4387
  * Open the custom state creator
3000
4388
  */
3001
4389
  openStateModal() {
3002
- // Get toggles from current configuration and open the modal regardless of count
3003
- const config = this.core.getConfig();
3004
- const toggles = config?.allToggles || [];
3005
- this.createCustomStateModal(toggles);
4390
+ if (!this.stateModal) {
4391
+ this._createStateModal();
4392
+ }
4393
+ this._updateStateModalContent();
4394
+ this.stateModal.classList.remove('cv-hidden');
3006
4395
  }
3007
4396
  /**
3008
- * Create the custom state creator modal
4397
+ * Create the custom state creator modal shell and attach listeners
3009
4398
  */
3010
- createCustomStateModal(toggles) {
3011
- // Close existing modal
3012
- this.closeModal();
3013
- this.modal = document.createElement('div');
3014
- this.modal.className = 'cv-widget-modal-overlay';
4399
+ _createStateModal() {
4400
+ this.stateModal = document.createElement('div');
4401
+ this.stateModal.className = 'cv-widget-modal-overlay cv-hidden';
3015
4402
  this.applyThemeToModal();
3016
- const toggleControlsHtml = toggles.map(toggle => `
4403
+ document.body.appendChild(this.stateModal);
4404
+ this._attachStateModalFrameEventListeners();
4405
+ }
4406
+ /**
4407
+ * Update the content of the state modal
4408
+ */
4409
+ _updateStateModalContent() {
4410
+ if (!this.stateModal)
4411
+ return;
4412
+ const pageToggleIds = this.pageToggleIds;
4413
+ const pageTabIds = this.pageTabIds;
4414
+ // Get toggles from current configuration
4415
+ const config = this.core.getConfig();
4416
+ const allToggles = config?.toggles || [];
4417
+ // Filter toggles to only include global and visible/declared local toggles
4418
+ const visibleToggles = allToggles.filter(toggle => {
4419
+ if (toggle.isLocal) {
4420
+ return pageToggleIds.has(toggle.id) || !!document.querySelector(`[data-cv-toggle="${toggle.id}"], [data-cv-toggle-group-id="${toggle.id}"]`);
4421
+ }
4422
+ return true; // Keep global toggles
4423
+ });
4424
+ const toggleControlsHtml = visibleToggles.map(toggle => `
3017
4425
  <div class="cv-toggle-card">
3018
4426
  <div class="cv-toggle-content">
3019
4427
  <div>
3020
- <p class="cv-toggle-title">${this.formatToggleName(toggle)}</p>
4428
+ <p class="cv-toggle-title">${toggle.label || toggle.id}</p>
3021
4429
  </div>
3022
4430
  <label class="cv-toggle-label">
3023
- <input class="cv-toggle-input" type="checkbox" data-toggle="${toggle}"/>
4431
+ <input class="cv-toggle-input" type="checkbox" data-toggle="${toggle.id}"/>
3024
4432
  <span class="cv-toggle-slider"></span>
3025
4433
  </label>
3026
4434
  </div>
3027
4435
  </div>
3028
4436
  `).join('');
3029
- // Todo: Re-add description if needed (Line 168, add label field to toggles if needed change structure)
3030
- // <p class="cv-toggle-description">Show or hide the ${this.formatToggleName(toggle).toLowerCase()} area </p>
3031
4437
  // Get tab groups
3032
- const tabGroups = this.core.getTabGroups();
4438
+ const allTabGroups = this.core.getTabGroups() || [];
4439
+ const tabGroups = allTabGroups.filter(group => {
4440
+ if (group.isLocal) {
4441
+ return pageTabIds.has(group.id) || !!document.querySelector(`cv-tabgroup[id="${group.id}"]`);
4442
+ }
4443
+ return true; // Keep global tab groups
4444
+ });
3033
4445
  let tabGroupControlsHTML = '';
3034
4446
  if (this.options.showTabGroups && tabGroups && tabGroups.length > 0) {
3035
- // Determine initial nav visibility state
3036
4447
  const initialNavsVisible = TabManager.areNavsVisible(document.body);
3037
4448
  tabGroupControlsHTML = `
3038
4449
  <div class="cv-tabgroup-card cv-tabgroup-header">
@@ -3067,7 +4478,7 @@ class CustomViewsWidget {
3067
4478
  </div>
3068
4479
  `;
3069
4480
  }
3070
- this.modal.innerHTML = `
4481
+ this.stateModal.innerHTML = `
3071
4482
  <div class="cv-widget-modal cv-custom-state-modal">
3072
4483
  <header class="cv-modal-header">
3073
4484
  <div class="cv-modal-header-content">
@@ -3083,7 +4494,7 @@ class CustomViewsWidget {
3083
4494
  <main class="cv-modal-main">
3084
4495
  ${this.options.description ? `<p class="cv-modal-description">${this.options.description}</p>` : ''}
3085
4496
 
3086
- ${toggles.length ? `
4497
+ ${visibleToggles.length ? `
3087
4498
  <div class="cv-content-section">
3088
4499
  <div class="cv-section-heading">Toggles</div>
3089
4500
  <div class="cv-toggles-container">
@@ -3113,65 +4524,80 @@ class CustomViewsWidget {
3113
4524
  <span>Copy Shareable URL</span>
3114
4525
  <span class="cv-share-btn-icon">${getCopyIcon()}</span>
3115
4526
  </button>
4527
+
3116
4528
  </footer>
3117
4529
  </div>
3118
4530
  `;
3119
- document.body.appendChild(this.modal);
3120
- this.attachStateModalEventListeners();
3121
- // Load current state into form if we're already in a custom state
4531
+ this._attachStateModalContentEventListeners();
3122
4532
  this.loadCurrentStateIntoForm();
3123
4533
  }
3124
4534
  /**
3125
- * Attach event listeners for custom state creator
4535
+ * Attach event listeners for the modal frame (delegated events)
3126
4536
  */
3127
- attachStateModalEventListeners() {
3128
- if (!this.modal)
4537
+ _attachStateModalFrameEventListeners() {
4538
+ if (!this.stateModal)
3129
4539
  return;
3130
- // Close button
3131
- const closeBtn = this.modal.querySelector('.cv-modal-close');
3132
- if (closeBtn) {
3133
- closeBtn.addEventListener('click', () => {
4540
+ // Delegated click events
4541
+ this.stateModal.addEventListener('click', (e) => {
4542
+ const target = e.target;
4543
+ // Close button
4544
+ if (target.closest('.cv-modal-close')) {
3134
4545
  this.closeModal();
3135
- });
3136
- }
3137
- // Copy URL button
3138
- const copyUrlBtn = this.modal.querySelector('.cv-share-btn');
3139
- if (copyUrlBtn) {
3140
- copyUrlBtn.addEventListener('click', () => {
4546
+ return;
4547
+ }
4548
+ // Copy URL button
4549
+ if (target.closest('.cv-share-btn')) {
3141
4550
  this.copyShareableURL();
3142
- // Visual feedback: change icon to tick for 3 seconds
3143
- const iconContainer = copyUrlBtn.querySelector('.cv-share-btn-icon');
4551
+ const copyUrlBtn = target.closest('.cv-share-btn');
4552
+ const iconContainer = copyUrlBtn?.querySelector('.cv-share-btn-icon');
3144
4553
  if (iconContainer) {
3145
4554
  const originalIcon = iconContainer.innerHTML;
3146
4555
  iconContainer.innerHTML = getTickIcon();
3147
- // Revert after 3 seconds
3148
4556
  setTimeout(() => {
3149
4557
  iconContainer.innerHTML = originalIcon;
3150
4558
  }, 3000);
3151
4559
  }
3152
- });
3153
- }
3154
- // Reset to default button
3155
- const resetBtn = this.modal.querySelector('.cv-reset-btn');
3156
- if (resetBtn) {
3157
- resetBtn.addEventListener('click', () => {
3158
- // Add spinning animation to icon
3159
- const resetIcon = resetBtn.querySelector('.cv-reset-btn-icon');
4560
+ return;
4561
+ }
4562
+ // Reset to default button
4563
+ if (target.closest('.cv-reset-btn')) {
4564
+ const resetBtn = target.closest('.cv-reset-btn');
4565
+ const resetIcon = resetBtn?.querySelector('.cv-reset-btn-icon');
3160
4566
  if (resetIcon) {
3161
4567
  resetIcon.classList.add('cv-spinning');
3162
4568
  }
3163
4569
  this.core.resetToDefault();
3164
4570
  this.loadCurrentStateIntoForm();
3165
- // Remove spinning animation after it completes
3166
4571
  setTimeout(() => {
3167
4572
  if (resetIcon) {
3168
4573
  resetIcon.classList.remove('cv-spinning');
3169
4574
  }
3170
- }, 600); // 600ms matches the animation duration
3171
- });
3172
- }
4575
+ }, 600);
4576
+ return;
4577
+ }
4578
+ // Overlay click to close
4579
+ if (e.target === this.stateModal) {
4580
+ this.closeModal();
4581
+ }
4582
+ });
4583
+ // Escape key to close
4584
+ const handleEscape = (e) => {
4585
+ if (e.key === 'Escape') {
4586
+ this.closeModal();
4587
+ }
4588
+ };
4589
+ // We can't remove this listener easily if it's anonymous, so we attach it to the document
4590
+ // and it will stay for the lifetime of the widget. This is acceptable.
4591
+ document.addEventListener('keydown', handleEscape);
4592
+ }
4593
+ /**
4594
+ * Attach event listeners for custom state creator's dynamic content
4595
+ */
4596
+ _attachStateModalContentEventListeners() {
4597
+ if (!this.stateModal)
4598
+ return;
3173
4599
  // Listen to toggle switches
3174
- const toggleInputs = this.modal.querySelectorAll('.cv-toggle-input');
4600
+ const toggleInputs = this.stateModal.querySelectorAll('.cv-toggle-input');
3175
4601
  toggleInputs.forEach(toggleInput => {
3176
4602
  toggleInput.addEventListener('change', () => {
3177
4603
  const state = this.getCurrentCustomStateFromModal();
@@ -3179,17 +4605,14 @@ class CustomViewsWidget {
3179
4605
  });
3180
4606
  });
3181
4607
  // Listen to tab group selects
3182
- const tabGroupSelects = this.modal.querySelectorAll('.cv-tabgroup-select');
4608
+ const tabGroupSelects = this.stateModal.querySelectorAll('.cv-tabgroup-select');
3183
4609
  tabGroupSelects.forEach(select => {
3184
4610
  select.addEventListener('change', () => {
3185
4611
  const groupId = select.dataset.groupId;
3186
4612
  const tabId = select.value;
3187
4613
  if (groupId && tabId) {
3188
- // Get current state and update the tab for this group, then apply globally
3189
- // This triggers sync behavior and persistence
3190
4614
  const currentTabs = this.core.getCurrentActiveTabs();
3191
4615
  currentTabs[groupId] = tabId;
3192
- // Apply state globally for persistence and sync
3193
4616
  const currentToggles = this.core.getCurrentActiveToggles();
3194
4617
  const newState = {
3195
4618
  toggles: currentToggles,
@@ -3200,84 +4623,56 @@ class CustomViewsWidget {
3200
4623
  });
3201
4624
  });
3202
4625
  // Listener for show/hide tab navs
3203
- const tabNavToggle = this.modal.querySelector('.cv-nav-pref-input');
3204
- const navIcon = this.modal?.querySelector('#cv-nav-icon');
3205
- const navHeaderCard = this.modal?.querySelector('.cv-tabgroup-card.cv-tabgroup-header');
4626
+ const tabNavToggle = this.stateModal.querySelector('.cv-nav-pref-input');
4627
+ const navIcon = this.stateModal?.querySelector('#cv-nav-icon');
4628
+ const navHeaderCard = this.stateModal?.querySelector('.cv-tabgroup-card.cv-tabgroup-header');
3206
4629
  if (tabNavToggle && navIcon && navHeaderCard) {
3207
- // Helper to update icon based on state
3208
4630
  const updateIcon = (isVisible, isHovering = false) => {
3209
4631
  if (isHovering) {
3210
- // On hover, show the transition icon
3211
4632
  navIcon.innerHTML = getNavDashed();
3212
4633
  }
3213
4634
  else {
3214
- // Normal state, show the status icon (on if visible, off if hidden)
3215
4635
  navIcon.innerHTML = isVisible ? getNavHeadingOnIcon() : getNavHeadingOffIcon();
3216
4636
  }
3217
4637
  };
3218
- // Add hover listeners to entire header card
3219
- navHeaderCard.addEventListener('mouseenter', () => {
3220
- updateIcon(tabNavToggle.checked, true);
3221
- });
3222
- navHeaderCard.addEventListener('mouseleave', () => {
3223
- updateIcon(tabNavToggle.checked, false);
3224
- });
3225
- // Add change listener
4638
+ navHeaderCard.addEventListener('mouseenter', () => updateIcon(tabNavToggle.checked, true));
4639
+ navHeaderCard.addEventListener('mouseleave', () => updateIcon(tabNavToggle.checked, false));
3226
4640
  tabNavToggle.addEventListener('change', () => {
3227
4641
  const visible = tabNavToggle.checked;
3228
- // Update the icon based on new state (not hovering)
3229
4642
  updateIcon(visible, false);
3230
- // Persist preference via core
3231
4643
  this.core.persistTabNavVisibility(visible);
3232
- // Apply to DOM using TabManager via core
3233
4644
  try {
3234
- const rootEl = document.body;
3235
- TabManager.setNavsVisibility(rootEl, visible);
4645
+ TabManager.setNavsVisibility(document.body, visible);
3236
4646
  }
3237
4647
  catch (e) {
3238
- // ignore errors
3239
4648
  console.error('Failed to set tab nav visibility:', e);
3240
4649
  }
3241
4650
  });
3242
4651
  }
3243
- // Overlay click to close
3244
- this.modal.addEventListener('click', (e) => {
3245
- if (e.target === this.modal) {
3246
- this.closeModal();
3247
- }
3248
- });
3249
- // Escape key to close
3250
- const handleEscape = (e) => {
3251
- if (e.key === 'Escape') {
3252
- this.closeModal();
3253
- document.removeEventListener('keydown', handleEscape);
3254
- }
3255
- };
3256
- document.addEventListener('keydown', handleEscape);
3257
4652
  }
3258
4653
  /**
3259
4654
  * Apply theme class to the modal overlay based on options
3260
4655
  */
3261
4656
  applyThemeToModal() {
3262
- if (!this.modal)
4657
+ if (!this.stateModal)
3263
4658
  return;
3264
4659
  if (this.options.theme === 'dark') {
3265
- this.modal.classList.add('cv-widget-theme-dark');
4660
+ this.stateModal.classList.add('cv-widget-theme-dark');
3266
4661
  }
3267
4662
  else {
3268
- this.modal.classList.remove('cv-widget-theme-dark');
4663
+ this.stateModal.classList.remove('cv-widget-theme-dark');
3269
4664
  }
3270
4665
  }
3271
4666
  /**
3272
4667
  * Get current state from form values
3273
4668
  */
3274
4669
  getCurrentCustomStateFromModal() {
3275
- if (!this.modal) {
4670
+ if (!this.stateModal) {
3276
4671
  return {};
3277
4672
  }
3278
4673
  // Collect toggle values
3279
4674
  const toggles = [];
3280
- const toggleInputs = this.modal.querySelectorAll('.cv-toggle-input');
4675
+ const toggleInputs = this.stateModal.querySelectorAll('.cv-toggle-input');
3281
4676
  toggleInputs.forEach(toggleInput => {
3282
4677
  const toggle = toggleInput.dataset.toggle;
3283
4678
  if (toggle && toggleInput.checked) {
@@ -3285,7 +4680,7 @@ class CustomViewsWidget {
3285
4680
  }
3286
4681
  });
3287
4682
  // Collect tab selections
3288
- const tabGroupSelects = this.modal.querySelectorAll('.cv-tabgroup-select');
4683
+ const tabGroupSelects = this.stateModal.querySelectorAll('.cv-tabgroup-select');
3289
4684
  const tabs = {};
3290
4685
  tabGroupSelects.forEach(select => {
3291
4686
  const groupId = select.dataset.groupId;
@@ -3313,25 +4708,25 @@ class CustomViewsWidget {
3313
4708
  * Load current state into form based on currently active toggles
3314
4709
  */
3315
4710
  loadCurrentStateIntoForm() {
3316
- if (!this.modal)
4711
+ if (!this.stateModal)
3317
4712
  return;
3318
4713
  // Get currently active toggles (from custom state or default configuration)
3319
4714
  const activeToggles = this.core.getCurrentActiveToggles();
3320
4715
  // First, uncheck all toggle inputs
3321
- const allToggleInputs = this.modal.querySelectorAll('.cv-toggle-input');
4716
+ const allToggleInputs = this.stateModal.querySelectorAll('.cv-toggle-input');
3322
4717
  allToggleInputs.forEach(toggleInput => {
3323
4718
  toggleInput.checked = false;
3324
4719
  });
3325
4720
  // Then check the ones that should be active
3326
4721
  activeToggles.forEach(toggle => {
3327
- const toggleInput = this.modal?.querySelector(`[data-toggle="${toggle}"]`);
4722
+ const toggleInput = this.stateModal?.querySelector(`[data-toggle="${toggle}"]`);
3328
4723
  if (toggleInput) {
3329
4724
  toggleInput.checked = true;
3330
4725
  }
3331
4726
  });
3332
4727
  // Load tab group selections
3333
4728
  const activeTabs = this.core.getCurrentActiveTabs();
3334
- const tabGroupSelects = this.modal.querySelectorAll('.cv-tabgroup-select');
4729
+ const tabGroupSelects = this.stateModal.querySelectorAll('.cv-tabgroup-select');
3335
4730
  tabGroupSelects.forEach(select => {
3336
4731
  const groupId = select.dataset.groupId;
3337
4732
  if (groupId && activeTabs[groupId]) {
@@ -3346,8 +4741,8 @@ class CustomViewsWidget {
3346
4741
  }
3347
4742
  return TabManager.areNavsVisible(document.body);
3348
4743
  })();
3349
- const tabNavToggle = this.modal.querySelector('.cv-nav-pref-input');
3350
- const navIcon = this.modal?.querySelector('#cv-nav-icon');
4744
+ const tabNavToggle = this.stateModal.querySelector('.cv-nav-pref-input');
4745
+ const navIcon = this.stateModal?.querySelector('#cv-nav-icon');
3351
4746
  if (tabNavToggle) {
3352
4747
  tabNavToggle.checked = navPref;
3353
4748
  // Ensure UI matches actual visibility
@@ -3358,33 +4753,22 @@ class CustomViewsWidget {
3358
4753
  }
3359
4754
  }
3360
4755
  }
3361
- /**
3362
- * Format toggle name for display
3363
- */
3364
- formatToggleName(toggle) {
3365
- return toggle.charAt(0).toUpperCase() + toggle.slice(1);
3366
- }
3367
4756
  /**
3368
4757
  * Check if this is the first visit and show welcome modal
3369
4758
  */
3370
4759
  showWelcomeModalIfFirstVisit() {
4760
+ if (!this._hasVisibleConfig)
4761
+ return;
3371
4762
  const STORAGE_KEY = 'cv-welcome-shown';
3372
4763
  // Check if welcome has been shown before
3373
4764
  const hasSeenWelcome = localStorage.getItem(STORAGE_KEY);
3374
4765
  if (!hasSeenWelcome) {
3375
- // Check if this page has any custom views elements
3376
- const hasCustomViewElements = document.querySelector('cv-tabgroup') !== null ||
3377
- document.querySelector('cv-tab') !== null ||
3378
- document.querySelector('cv-toggle') !== null ||
3379
- document.querySelector('[data-cv-toggle]') !== null;
3380
- if (hasCustomViewElements) {
3381
- // Show welcome modal after a short delay to let the page settle
3382
- setTimeout(() => {
3383
- this.createWelcomeModal();
3384
- }, 500);
3385
- // Mark as shown
3386
- localStorage.setItem(STORAGE_KEY, 'true');
3387
- }
4766
+ // Show welcome modal after a short delay to let the page settle
4767
+ setTimeout(() => {
4768
+ this.createWelcomeModal();
4769
+ }, 500);
4770
+ // Mark as shown
4771
+ localStorage.setItem(STORAGE_KEY, 'true');
3388
4772
  }
3389
4773
  }
3390
4774
  /**
@@ -3392,12 +4776,14 @@ class CustomViewsWidget {
3392
4776
  */
3393
4777
  createWelcomeModal() {
3394
4778
  // Don't show if there's already a modal open
3395
- if (this.modal)
4779
+ if (this.stateModal && !this.stateModal.classList.contains('cv-hidden'))
3396
4780
  return;
3397
- this.modal = document.createElement('div');
3398
- this.modal.className = 'cv-widget-modal-overlay cv-welcome-modal-overlay';
3399
- this.applyThemeToModal();
3400
- this.modal.innerHTML = `
4781
+ const welcomeModal = document.createElement('div');
4782
+ welcomeModal.className = 'cv-widget-modal-overlay cv-welcome-modal-overlay';
4783
+ if (this.options.theme === 'dark') {
4784
+ welcomeModal.classList.add('cv-widget-theme-dark');
4785
+ }
4786
+ welcomeModal.innerHTML = `
3401
4787
  <div class="cv-widget-modal cv-welcome-modal">
3402
4788
  <header class="cv-modal-header">
3403
4789
  <div class="cv-modal-header-content">
@@ -3421,33 +4807,32 @@ class CustomViewsWidget {
3421
4807
  </div>
3422
4808
  </div>
3423
4809
  `;
3424
- document.body.appendChild(this.modal);
3425
- this.attachWelcomeModalEventListeners();
4810
+ document.body.appendChild(welcomeModal);
4811
+ this.attachWelcomeModalEventListeners(welcomeModal);
3426
4812
  }
3427
4813
  /**
3428
4814
  * Attach event listeners for welcome modal
3429
4815
  */
3430
- attachWelcomeModalEventListeners() {
3431
- if (!this.modal)
3432
- return;
4816
+ attachWelcomeModalEventListeners(welcomeModal) {
4817
+ const closeModal = () => {
4818
+ welcomeModal.remove();
4819
+ document.removeEventListener('keydown', handleEscape);
4820
+ };
3433
4821
  // Got it button
3434
- const gotItBtn = this.modal.querySelector('.cv-welcome-got-it');
4822
+ const gotItBtn = welcomeModal.querySelector('.cv-welcome-got-it');
3435
4823
  if (gotItBtn) {
3436
- gotItBtn.addEventListener('click', () => {
3437
- this.closeModal();
3438
- });
4824
+ gotItBtn.addEventListener('click', closeModal);
3439
4825
  }
3440
4826
  // Overlay click to close
3441
- this.modal.addEventListener('click', (e) => {
3442
- if (e.target === this.modal) {
3443
- this.closeModal();
4827
+ welcomeModal.addEventListener('click', (e) => {
4828
+ if (e.target === welcomeModal) {
4829
+ closeModal();
3444
4830
  }
3445
4831
  });
3446
4832
  // Escape key to close
3447
4833
  const handleEscape = (e) => {
3448
4834
  if (e.key === 'Escape') {
3449
- this.closeModal();
3450
- document.removeEventListener('keydown', handleEscape);
4835
+ closeModal();
3451
4836
  }
3452
4837
  };
3453
4838
  document.addEventListener('keydown', handleEscape);
@@ -3517,7 +4902,7 @@ function initializeFromScript() {
3517
4902
  console.warn(`[CustomViews] Config file not found at ${fullConfigPath}. Using defaults.`);
3518
4903
  // Provide minimal default config structure
3519
4904
  configFile = {
3520
- config: { allToggles: [], defaultState: {} },
4905
+ config: { toggles: [], defaultState: {} },
3521
4906
  widget: { enabled: true }
3522
4907
  };
3523
4908
  }
@@ -3555,7 +4940,7 @@ function initializeFromScript() {
3555
4940
  core,
3556
4941
  ...configFile.widget
3557
4942
  });
3558
- widget.render();
4943
+ widget.renderModalIcon();
3559
4944
  // Store widget instance
3560
4945
  window.customViewsInstance.widget = widget;
3561
4946
  console.log('[CustomViews] Widget initialized and rendered');