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