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