@customviews-js/customviews 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +164 -30
  2. package/dist/{custom-views.cjs.js → custom-views.core.cjs.js} +814 -99
  3. package/dist/custom-views.core.cjs.js.map +1 -0
  4. package/dist/custom-views.core.esm.js +2296 -0
  5. package/dist/custom-views.core.esm.js.map +1 -0
  6. package/dist/custom-views.esm.js +814 -98
  7. package/dist/custom-views.esm.js.map +1 -1
  8. package/dist/{custom-views.umd.js → custom-views.js} +814 -99
  9. package/dist/custom-views.js.map +1 -0
  10. package/dist/custom-views.min.js +7 -0
  11. package/dist/custom-views.min.js.map +1 -0
  12. package/dist/types/{models/AssetsManager.d.ts → core/assets-manager.d.ts} +1 -1
  13. package/dist/types/{models/AssetsManager.d.ts.map → core/assets-manager.d.ts.map} +1 -1
  14. package/dist/types/core/core.d.ts +25 -9
  15. package/dist/types/core/core.d.ts.map +1 -1
  16. package/dist/types/core/custom-elements.d.ts +8 -0
  17. package/dist/types/core/custom-elements.d.ts.map +1 -0
  18. package/dist/types/core/render.d.ts +1 -1
  19. package/dist/types/core/render.d.ts.map +1 -1
  20. package/dist/types/core/tab-manager.d.ts +35 -0
  21. package/dist/types/core/tab-manager.d.ts.map +1 -0
  22. package/dist/types/core/url-state-manager.d.ts.map +1 -1
  23. package/dist/types/core/visibility-manager.d.ts +1 -1
  24. package/dist/types/core/widget.d.ts +2 -0
  25. package/dist/types/core/widget.d.ts.map +1 -1
  26. package/dist/types/entry/browser-entry.d.ts +13 -0
  27. package/dist/types/entry/browser-entry.d.ts.map +1 -0
  28. package/dist/types/index.d.ts +11 -20
  29. package/dist/types/index.d.ts.map +1 -1
  30. package/dist/types/lib/custom-views.d.ts +29 -0
  31. package/dist/types/lib/custom-views.d.ts.map +1 -0
  32. package/dist/types/styles/styles.d.ts +2 -0
  33. package/dist/types/styles/styles.d.ts.map +1 -1
  34. package/dist/types/styles/tab-styles.d.ts +5 -0
  35. package/dist/types/styles/tab-styles.d.ts.map +1 -0
  36. package/dist/types/styles/toggle-styles.d.ts +5 -0
  37. package/dist/types/styles/toggle-styles.d.ts.map +1 -0
  38. package/dist/types/styles/widget-styles.d.ts +1 -1
  39. package/dist/types/styles/widget-styles.d.ts.map +1 -1
  40. package/dist/types/types/types.d.ts +85 -0
  41. package/dist/types/types/types.d.ts.map +1 -1
  42. package/dist/types/utils/url-utils.d.ts +8 -0
  43. package/dist/types/utils/url-utils.d.ts.map +1 -0
  44. package/package.json +13 -9
  45. package/dist/custom-views.cjs.js.map +0 -1
  46. package/dist/custom-views.umd.js.map +0 -1
  47. package/dist/custom-views.umd.min.js +0 -7
  48. package/dist/custom-views.umd.min.js.map +0 -1
  49. package/dist/types/models/Config.d.ts +0 -10
  50. package/dist/types/models/Config.d.ts.map +0 -1
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * @customviews-js/customviews v1.0.2
2
+ * @customviews-js/customviews v1.1.0
3
3
  * (c) 2025 Chan Ger Teck
4
4
  * Released under the MIT License.
5
5
  */
@@ -86,18 +86,6 @@
86
86
  }
87
87
  }
88
88
 
89
- /**
90
- * Configuration for the site, has default state and list of toggles
91
- */
92
- class Config {
93
- defaultState;
94
- allToggles;
95
- constructor(defaultState, allToggles) {
96
- this.defaultState = defaultState;
97
- this.allToggles = allToggles;
98
- }
99
- }
100
-
101
89
  /**
102
90
  * Manages persistence of custom views state using browser localStorage
103
91
  */
@@ -227,6 +215,10 @@
227
215
  const compact = {
228
216
  t: state.toggles
229
217
  };
218
+ // Add tab groups if present
219
+ if (state.tabs && Object.keys(state.tabs).length > 0) {
220
+ compact.g = Object.entries(state.tabs);
221
+ }
230
222
  // Convert to JSON and encode
231
223
  const json = JSON.stringify(compact);
232
224
  let encoded;
@@ -273,9 +265,19 @@
273
265
  if (!compact || typeof compact !== 'object') {
274
266
  throw new Error('Invalid compact state structure');
275
267
  }
276
- return {
268
+ const state = {
277
269
  toggles: Array.isArray(compact.t) ? compact.t : []
278
270
  };
271
+ // Reconstruct tabs from compact format
272
+ if (Array.isArray(compact.g)) {
273
+ state.tabs = {};
274
+ for (const [groupId, tabId] of compact.g) {
275
+ if (typeof groupId === 'string' && typeof tabId === 'string') {
276
+ state.tabs[groupId] = tabId;
277
+ }
278
+ }
279
+ }
280
+ return state;
279
281
  }
280
282
  catch (error) {
281
283
  console.warn('Failed to decode view state:', error);
@@ -330,7 +332,7 @@
330
332
  }
331
333
  /**
332
334
  * Apply simple class-based visibility to a toggle element.
333
- * The element is assumed to have data-customviews-toggle.
335
+ * The element is assumed to have data-cv-toggle or data-customviews-toggle.
334
336
  */
335
337
  applyElementVisibility(el, visible) {
336
338
  if (visible) {
@@ -344,8 +346,211 @@
344
346
  }
345
347
  }
346
348
 
347
- const CORE_STYLES = `
348
- [data-customviews-toggle] {
349
+ // Constants for selectors
350
+ const TABGROUP_SELECTOR = 'cv-tabgroup';
351
+ const TAB_SELECTOR = 'cv-tab';
352
+ const NAV_AUTO_SELECTOR = 'cv-tabgroup[nav="auto"], cv-tabgroup:not([nav])';
353
+ const NAV_CONTAINER_CLASS = 'cv-tabs-nav';
354
+ /**
355
+ * TabManager handles discovery, visibility, and navigation for tab groups and tabs
356
+ */
357
+ class TabManager {
358
+ /**
359
+ * Apply tab selections to all tab groups in the DOM
360
+ */
361
+ static applySelections(rootEl, tabs, cfgGroups) {
362
+ // Find all cv-tabgroup elements
363
+ const tabGroups = rootEl.querySelectorAll(TABGROUP_SELECTOR);
364
+ tabGroups.forEach((groupEl) => {
365
+ const groupId = groupEl.getAttribute('id');
366
+ if (!groupId)
367
+ return;
368
+ // Determine the active tab for this group
369
+ const activeTabId = this.resolveActiveTab(groupId, tabs, cfgGroups, groupEl);
370
+ // Apply visibility to child cv-tab elements
371
+ const tabElements = groupEl.querySelectorAll(TAB_SELECTOR);
372
+ tabElements.forEach((tabEl) => {
373
+ const tabId = tabEl.getAttribute('id');
374
+ if (!tabId)
375
+ return;
376
+ const isActive = tabId === activeTabId;
377
+ this.applyTabVisibility(tabEl, isActive);
378
+ });
379
+ });
380
+ }
381
+ /**
382
+ * Resolve the active tab for a group based on state, config, and DOM
383
+ */
384
+ static resolveActiveTab(groupId, tabs, cfgGroups, groupEl) {
385
+ // 1. Check state
386
+ if (tabs[groupId]) {
387
+ return tabs[groupId];
388
+ }
389
+ // 2. Check config default
390
+ if (cfgGroups) {
391
+ const groupCfg = cfgGroups.find(g => g.id === groupId);
392
+ if (groupCfg) {
393
+ if (groupCfg.default) {
394
+ return groupCfg.default;
395
+ }
396
+ // Fallback to first tab in config
397
+ const firstConfigTab = groupCfg.tabs[0];
398
+ if (firstConfigTab) {
399
+ return firstConfigTab.id;
400
+ }
401
+ }
402
+ }
403
+ // 3. Fallback to first cv-tab child in DOM
404
+ const firstTab = groupEl.querySelector(TAB_SELECTOR);
405
+ if (firstTab) {
406
+ return firstTab.getAttribute('id');
407
+ }
408
+ return null;
409
+ }
410
+ /**
411
+ * Apply visibility classes to a tab element
412
+ */
413
+ static applyTabVisibility(tabEl, isActive) {
414
+ if (isActive) {
415
+ tabEl.classList.remove('cv-hidden');
416
+ tabEl.classList.add('cv-visible');
417
+ }
418
+ else {
419
+ tabEl.classList.add('cv-hidden');
420
+ tabEl.classList.remove('cv-visible');
421
+ }
422
+ }
423
+ /**
424
+ * Build navigation for tab groups with nav="auto" (one-time setup)
425
+ */
426
+ static buildNavs(rootEl, cfgGroups, onTabClick) {
427
+ // Find all cv-tabgroup elements with nav="auto" or no nav attribute
428
+ const tabGroups = rootEl.querySelectorAll(NAV_AUTO_SELECTOR);
429
+ tabGroups.forEach((groupEl) => {
430
+ const groupId = groupEl.getAttribute('id');
431
+ if (!groupId)
432
+ return;
433
+ // Check if nav already exists - if so, skip building
434
+ let navContainer = groupEl.querySelector(`.${NAV_CONTAINER_CLASS}`);
435
+ if (navContainer)
436
+ return; // Already built
437
+ // Get all child tabs
438
+ const tabElements = Array.from(groupEl.querySelectorAll(TAB_SELECTOR));
439
+ if (tabElements.length === 0)
440
+ return;
441
+ // Create nav container
442
+ navContainer = document.createElement('ul');
443
+ navContainer.className = `${NAV_CONTAINER_CLASS} nav-tabs`;
444
+ navContainer.setAttribute('role', 'tablist');
445
+ groupEl.insertBefore(navContainer, groupEl.firstChild);
446
+ // Build nav items
447
+ tabElements.forEach((tabEl) => {
448
+ const tabId = tabEl.getAttribute('id');
449
+ if (!tabId)
450
+ return;
451
+ const header = tabEl.getAttribute('header') || this.getTabLabel(tabId, groupId, cfgGroups) || tabId;
452
+ const listItem = document.createElement('li');
453
+ listItem.className = 'nav-item';
454
+ const navLink = document.createElement('a');
455
+ navLink.className = 'nav-link';
456
+ navLink.textContent = header;
457
+ navLink.href = '#';
458
+ navLink.setAttribute('data-tab-id', tabId);
459
+ navLink.setAttribute('data-group-id', groupId);
460
+ navLink.setAttribute('role', 'tab');
461
+ // Check if this tab is currently active
462
+ const isActive = tabEl.classList.contains('cv-visible');
463
+ if (isActive) {
464
+ navLink.classList.add('active');
465
+ navLink.setAttribute('aria-selected', 'true');
466
+ }
467
+ else {
468
+ navLink.setAttribute('aria-selected', 'false');
469
+ }
470
+ // Add click handler
471
+ if (onTabClick) {
472
+ navLink.addEventListener('click', (e) => {
473
+ e.preventDefault();
474
+ onTabClick(groupId, tabId);
475
+ });
476
+ }
477
+ listItem.appendChild(navLink);
478
+ navContainer.appendChild(listItem);
479
+ });
480
+ // Add bottom border line at the end of the tab group
481
+ const bottomBorder = document.createElement('div');
482
+ bottomBorder.className = 'cv-tabgroup-bottom-border';
483
+ groupEl.appendChild(bottomBorder);
484
+ });
485
+ }
486
+ /**
487
+ * Get tab label from config
488
+ */
489
+ static getTabLabel(tabId, groupId, cfgGroups) {
490
+ if (!cfgGroups)
491
+ return null;
492
+ const groupCfg = cfgGroups.find(g => g.id === groupId);
493
+ if (!groupCfg)
494
+ return null;
495
+ const tabCfg = groupCfg.tabs.find(t => t.id === tabId);
496
+ return tabCfg?.label || null;
497
+ }
498
+ /**
499
+ * Update active state in navs after selection change (single group)
500
+ */
501
+ static updateNavActiveState(rootEl, groupId, activeTabId) {
502
+ const tabGroups = rootEl.querySelectorAll(`${TABGROUP_SELECTOR}[id="${groupId}"]`);
503
+ tabGroups.forEach((groupEl) => {
504
+ const navLinks = groupEl.querySelectorAll('.nav-link');
505
+ navLinks.forEach((link) => {
506
+ const tabId = link.getAttribute('data-tab-id');
507
+ if (tabId === activeTabId) {
508
+ link.classList.add('active');
509
+ link.setAttribute('aria-selected', 'true');
510
+ }
511
+ else {
512
+ link.classList.remove('active');
513
+ link.setAttribute('aria-selected', 'false');
514
+ }
515
+ });
516
+ });
517
+ }
518
+ /**
519
+ * Update active states for all tab groups based on current state
520
+ */
521
+ static updateAllNavActiveStates(rootEl, tabs, cfgGroups) {
522
+ const tabGroups = rootEl.querySelectorAll(TABGROUP_SELECTOR);
523
+ tabGroups.forEach((groupEl) => {
524
+ const groupId = groupEl.getAttribute('id');
525
+ if (!groupId)
526
+ return;
527
+ // Determine the active tab for this group
528
+ const activeTabId = this.resolveActiveTab(groupId, tabs, cfgGroups, groupEl);
529
+ if (!activeTabId)
530
+ return;
531
+ // Update nav links for this group
532
+ const navLinks = groupEl.querySelectorAll('.nav-link');
533
+ navLinks.forEach((link) => {
534
+ const tabId = link.getAttribute('data-tab-id');
535
+ if (tabId === activeTabId) {
536
+ link.classList.add('active');
537
+ link.setAttribute('aria-selected', 'true');
538
+ }
539
+ else {
540
+ link.classList.remove('active');
541
+ link.setAttribute('aria-selected', 'false');
542
+ }
543
+ });
544
+ });
545
+ }
546
+ }
547
+
548
+ /**
549
+ * Styles for toggle visibility and animations
550
+ */
551
+ const TOGGLE_STYLES = `
552
+ /* Core toggle visibility transitions */
553
+ [data-cv-toggle], [data-customviews-toggle] {
349
554
  transition: opacity 150ms ease,
350
555
  transform 150ms ease,
351
556
  max-height 200ms ease,
@@ -372,6 +577,125 @@
372
577
  margin-bottom: 0 !important;
373
578
  overflow: hidden !important;
374
579
  }
580
+ `;
581
+
582
+ /**
583
+ * Styles for tab groups and tab navigation
584
+ */
585
+ const TAB_STYLES = `
586
+ /* Tab navigation styles - Bootstrap-style tabs matching MarkBind */
587
+ .cv-tabs-nav {
588
+ display: flex;
589
+ flex-wrap: wrap;
590
+ padding-left: 0;
591
+ margin-top: 0.5rem;
592
+ margin-bottom: 1rem;
593
+ list-style: none;
594
+ border-bottom: 1px solid #dee2e6;
595
+ }
596
+
597
+ .cv-tabs-nav .nav-item {
598
+ margin-bottom: -1px;
599
+ list-style: none;
600
+ display: inline-block;
601
+ }
602
+
603
+ .cv-tabs-nav .nav-link {
604
+ display: block;
605
+ padding: 0.5rem 1rem;
606
+ color: #495057;
607
+ text-decoration: none;
608
+ background-color: transparent;
609
+ border: 1px solid transparent;
610
+ border-top-left-radius: 0.25rem;
611
+ border-top-right-radius: 0.25rem;
612
+ transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out;
613
+ cursor: pointer;
614
+ }
615
+
616
+ .cv-tabs-nav .nav-link:hover,
617
+ .cv-tabs-nav .nav-link:focus {
618
+ border-color: #e9ecef #e9ecef #dee2e6;
619
+ isolation: isolate;
620
+ }
621
+
622
+ .cv-tabs-nav .nav-link.active {
623
+ color: #495057;
624
+ background-color: #fff;
625
+ border-color: #dee2e6 #dee2e6 #fff;
626
+ }
627
+
628
+ .cv-tabs-nav .nav-link:focus {
629
+ outline: 0;
630
+ box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25);
631
+ }
632
+
633
+ /* Legacy button-based nav (deprecated, kept for compatibility) */
634
+ .cv-tabs-nav-item {
635
+ background: none;
636
+ border: none;
637
+ border-bottom: 2px solid transparent;
638
+ padding: 0.5rem 1rem;
639
+ cursor: pointer;
640
+ font-size: 1rem;
641
+ color: #6c757d;
642
+ transition: color 150ms ease, border-color 150ms ease;
643
+ }
644
+
645
+ .cv-tabs-nav-item:hover {
646
+ color: #495057;
647
+ border-bottom-color: #dee2e6;
648
+ }
649
+
650
+ .cv-tabs-nav-item.active {
651
+ color: #007bff;
652
+ border-bottom-color: #007bff;
653
+ font-weight: 500;
654
+ }
655
+
656
+ .cv-tabs-nav-item:focus {
657
+ outline: 2px solid #007bff;
658
+ outline-offset: 2px;
659
+ }
660
+
661
+ /* Tab panel base styles */
662
+ cv-tab {
663
+ display: block;
664
+ }
665
+
666
+ /* Override visibility for tab panels - use display instead of collapse animation */
667
+ cv-tab.cv-hidden {
668
+ display: none !important;
669
+ }
670
+
671
+ cv-tab.cv-visible {
672
+ display: block !important;
673
+ }
674
+
675
+ cv-tabgroup {
676
+ display: block;
677
+ margin-bottom: 1.5rem;
678
+ }
679
+
680
+ /* Bottom border line for tab groups */
681
+ .cv-tabgroup-bottom-border {
682
+ border-bottom: 1px solid #dee2e6;
683
+ margin-top: 1rem;
684
+ }
685
+
686
+ /* Tab content wrapper */
687
+ .cv-tab-content {
688
+ padding: 1rem 0;
689
+ }
690
+ `;
691
+
692
+ /**
693
+ * Combined core styles for toggles and tabs
694
+ */
695
+ const CORE_STYLES = `
696
+ ${TOGGLE_STYLES}
697
+
698
+ ${TAB_STYLES}
375
699
  `;
376
700
  /**
377
701
  * Add styles for hiding and showing toggles animations and transitions to the document head
@@ -392,36 +716,85 @@
392
716
  assetsManager;
393
717
  persistenceManager;
394
718
  visibilityManager;
395
- stateFromUrl = null;
396
- localConfig;
719
+ config;
397
720
  stateChangeListeners = [];
721
+ showUrlEnabled;
722
+ lastAppliedState = null;
398
723
  constructor(opt) {
399
724
  this.assetsManager = opt.assetsManager;
400
- this.localConfig = opt.config;
725
+ this.config = opt.config;
401
726
  this.rootEl = opt.rootEl || document.body;
402
727
  this.persistenceManager = new PersistenceManager();
403
728
  this.visibilityManager = new VisibilityManager();
729
+ this.showUrlEnabled = opt.showUrl ?? true;
730
+ this.lastAppliedState = this.cloneState(this.config?.defaultState);
731
+ }
732
+ getConfig() {
733
+ return this.config;
734
+ }
735
+ /**
736
+ * Get tab groups from config
737
+ */
738
+ getTabGroups() {
739
+ return this.config.tabGroups;
740
+ }
741
+ /**
742
+ * Get currently active tabs (from URL > persisted (localStorage) > defaults)
743
+ */
744
+ getCurrentActiveTabs() {
745
+ if (this.lastAppliedState?.tabs) {
746
+ return { ...this.lastAppliedState.tabs };
747
+ }
748
+ const persistedState = this.persistenceManager.getPersistedState();
749
+ if (persistedState?.tabs) {
750
+ return { ...persistedState.tabs };
751
+ }
752
+ return this.config?.defaultState?.tabs ? { ...this.config.defaultState.tabs } : {};
404
753
  }
405
- getLocalConfig() {
406
- return this.localConfig;
754
+ /**
755
+ * Set active tab for a group and apply state
756
+ */
757
+ setActiveTab(groupId, tabId) {
758
+ // Get current state
759
+ const currentToggles = this.getCurrentActiveToggles();
760
+ const currentTabs = this.getCurrentActiveTabs();
761
+ // Merge new tab selection
762
+ const newTabs = { ...currentTabs, [groupId]: tabId };
763
+ // Create new state
764
+ const newState = {
765
+ toggles: currentToggles,
766
+ tabs: newTabs
767
+ };
768
+ // Apply the state
769
+ this.applyState(newState);
770
+ // Emit custom event
771
+ const event = new CustomEvent('customviews:tab-change', {
772
+ detail: { groupId, tabId },
773
+ bubbles: true
774
+ });
775
+ document.dispatchEvent(event);
407
776
  }
408
777
  // Inject styles, setup listeners and call rendering logic
409
778
  async init() {
410
779
  injectCoreStyles();
780
+ // Build navigation once (with click handlers)
781
+ TabManager.buildNavs(this.rootEl, this.config.tabGroups, (groupId, tabId) => {
782
+ this.setActiveTab(groupId, tabId);
783
+ });
411
784
  // For session history, clicks on back/forward button
412
785
  window.addEventListener("popstate", () => {
413
- this.loadAndRenderState();
786
+ this.loadAndCallApplyState();
414
787
  });
415
- this.loadAndRenderState();
788
+ this.loadAndCallApplyState();
416
789
  }
417
790
  // Priority: URL state > persisted state > default
418
791
  // Also filters using the visibility manager to persist selection
419
792
  // across back/forward button clicks
420
- async loadAndRenderState() {
793
+ async loadAndCallApplyState() {
421
794
  // 1. URL State
422
- this.stateFromUrl = URLStateManager.parseURL();
423
- if (this.stateFromUrl) {
424
- this.applyState(this.stateFromUrl);
795
+ const urlState = URLStateManager.parseURL();
796
+ if (urlState) {
797
+ this.applyState(urlState);
425
798
  return;
426
799
  }
427
800
  // 2. Persisted State
@@ -431,38 +804,48 @@
431
804
  return;
432
805
  }
433
806
  // 3. Local Config Fallback
434
- this.renderState(this.localConfig.defaultState);
807
+ this.renderState(this.config.defaultState);
435
808
  }
436
809
  /**
437
- * Apply a custom state, saves to localStorage and updates the URL
438
- */
810
+ * Apply a custom state, saves to localStorage and updates the URL
811
+ */
439
812
  applyState(state) {
440
- this.renderState(state);
441
- this.persistenceManager.persistState(state);
442
- this.stateFromUrl = state;
443
- URLStateManager.updateURL(state);
813
+ const snapshot = this.cloneState(state);
814
+ this.renderState(snapshot);
815
+ this.persistenceManager.persistState(snapshot);
816
+ if (this.showUrlEnabled) {
817
+ URLStateManager.updateURL(snapshot);
818
+ }
819
+ else {
820
+ URLStateManager.clearURL();
821
+ }
444
822
  }
445
823
  /** Render all toggles for the current state */
446
824
  renderState(state) {
825
+ this.lastAppliedState = this.cloneState(state);
447
826
  const toggles = state.toggles || [];
448
827
  const finalToggles = this.visibilityManager.filterVisibleToggles(toggles);
449
828
  // Toggles hide or show relevant toggles
450
- this.rootEl.querySelectorAll("[data-customviews-toggle]").forEach(el => {
451
- const category = el.dataset.customviewsToggle;
829
+ this.rootEl.querySelectorAll("[data-cv-toggle], [data-customviews-toggle]").forEach(el => {
830
+ const category = el.dataset.cvToggle || el.dataset.customviewsToggle;
452
831
  const shouldShow = !!category && finalToggles.includes(category);
453
832
  this.visibilityManager.applyElementVisibility(el, shouldShow);
454
833
  });
455
834
  // Render toggles
456
835
  for (const category of finalToggles) {
457
- this.rootEl.querySelectorAll(`[data-customviews-toggle="${category}"]`).forEach(el => {
458
- // if it has an id, then we render the asset into it
459
- // if it has no id, then we assume it's a container
460
- const toggleId = el.dataset.customviewsId;
836
+ this.rootEl.querySelectorAll(`[data-cv-toggle="${category}"], [data-customviews-toggle="${category}"]`).forEach(el => {
837
+ // if it has an id, then we should render the asset into it
838
+ // Support both (data-cv-id) and (data-customviews-id) attributes
839
+ const toggleId = el.dataset.cvId || el.dataset.customviewsId;
461
840
  if (toggleId) {
462
841
  renderAssetInto(el, toggleId, this.assetsManager);
463
842
  }
464
843
  });
465
844
  }
845
+ // Apply tab selections
846
+ TabManager.applySelections(this.rootEl, state.tabs || {}, this.config.tabGroups);
847
+ // Update nav active states (without rebuilding)
848
+ TabManager.updateAllNavActiveStates(this.rootEl, state.tabs || {}, this.config.tabGroups);
466
849
  // Notify state change listeners (like widgets)
467
850
  this.notifyStateChangeListeners();
468
851
  }
@@ -470,10 +853,9 @@
470
853
  * Reset to default state
471
854
  */
472
855
  resetToDefault() {
473
- this.stateFromUrl = null;
474
856
  this.persistenceManager.clearAll();
475
- if (this.localConfig) {
476
- this.renderState(this.localConfig.defaultState);
857
+ if (this.config) {
858
+ this.renderState(this.config.defaultState);
477
859
  }
478
860
  else {
479
861
  console.warn("No configuration loaded, cannot reset to default state");
@@ -485,15 +867,12 @@
485
867
  * Get the currently active toggles regardless of whether they come from custom state or default configuration
486
868
  */
487
869
  getCurrentActiveToggles() {
488
- // If we have a custom state, return its toggles
489
- if (this.stateFromUrl) {
490
- return this.stateFromUrl.toggles || [];
870
+ if (this.lastAppliedState) {
871
+ return this.lastAppliedState.toggles || [];
491
872
  }
492
- // Otherwise, if we have local config, return its default state toggles
493
- if (this.localConfig) {
494
- return this.localConfig.defaultState.toggles || [];
873
+ if (this.config) {
874
+ return this.config.defaultState.toggles || [];
495
875
  }
496
- // No configuration or state
497
876
  return [];
498
877
  }
499
878
  /**
@@ -501,15 +880,35 @@
501
880
  */
502
881
  clearPersistence() {
503
882
  this.persistenceManager.clearAll();
504
- this.stateFromUrl = null;
505
- if (this.localConfig) {
506
- this.renderState(this.localConfig.defaultState);
883
+ if (this.config) {
884
+ this.renderState(this.config.defaultState);
507
885
  }
508
886
  else {
509
887
  console.warn("No configuration loaded, cannot reset to default state");
510
888
  }
511
889
  URLStateManager.clearURL();
512
890
  }
891
+ setOption(flag, value) {
892
+ switch (flag) {
893
+ case 'showUrl': {
894
+ const nextValue = Boolean(value);
895
+ if (this.showUrlEnabled === nextValue) {
896
+ return;
897
+ }
898
+ this.showUrlEnabled = nextValue;
899
+ if (nextValue) {
900
+ const stateForUrl = this.getTrackedStateSnapshot();
901
+ URLStateManager.updateURL(stateForUrl);
902
+ }
903
+ else {
904
+ URLStateManager.clearURL();
905
+ }
906
+ break;
907
+ }
908
+ default:
909
+ console.warn(`[CustomViews] Unknown option '${flag}' passed to setOption`);
910
+ }
911
+ }
513
912
  // === STATE CHANGE LISTENER METHODS ===
514
913
  /**
515
914
  * Add a listener that will be called whenever the state changes
@@ -539,6 +938,20 @@
539
938
  }
540
939
  });
541
940
  }
941
+ cloneState(state) {
942
+ const toggles = state?.toggles ? [...state.toggles] : [];
943
+ const tabs = state?.tabs ? { ...state.tabs } : undefined;
944
+ return tabs ? { toggles, tabs } : { toggles };
945
+ }
946
+ getTrackedStateSnapshot() {
947
+ if (this.lastAppliedState) {
948
+ return this.cloneState(this.lastAppliedState);
949
+ }
950
+ if (this.config) {
951
+ return this.cloneState(this.config.defaultState);
952
+ }
953
+ return { toggles: [] };
954
+ }
542
955
  }
543
956
 
544
957
  class AssetsManager {
@@ -587,6 +1000,70 @@
587
1000
  }
588
1001
  }
589
1002
 
1003
+ /**
1004
+ * Helper function to prepend baseUrl to a path
1005
+ * @param path The path to prepend the baseUrl to
1006
+ * @param baseUrl The base URL to prepend
1007
+ * @returns The full URL with baseUrl prepended if applicable
1008
+ */
1009
+ function prependBaseUrl(path, baseUrl) {
1010
+ if (!baseUrl)
1011
+ return path;
1012
+ // Don't prepend if the path is already absolute (starts with http:// or https://)
1013
+ if (path.startsWith('http://') || path.startsWith('https://')) {
1014
+ return path;
1015
+ }
1016
+ // Ensure baseUrl doesn't end with / and path starts with /
1017
+ const cleanbaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
1018
+ const cleanPath = path.startsWith('/') ? path : '/' + path;
1019
+ return cleanbaseUrl + cleanPath;
1020
+ }
1021
+
1022
+ /**
1023
+ * Main CustomViews class for initializing and managing custom views
1024
+ */
1025
+ class CustomViews {
1026
+ /**
1027
+ * Entry Point to use CustomViews
1028
+ * @param opts Initialization options including config object and assets path
1029
+ * @returns Promise resolving to the CustomViewsCore instance or null if initialization fails
1030
+ */
1031
+ static async init(opts) {
1032
+ // Load assets JSON if provided
1033
+ let assetsManager;
1034
+ const baseURL = opts.baseURL || '';
1035
+ if (opts.assetsJsonPath) {
1036
+ const assetsPath = prependBaseUrl(opts.assetsJsonPath, baseURL);
1037
+ const assetsJson = await (await fetch(assetsPath)).json();
1038
+ assetsManager = new AssetsManager(assetsJson, baseURL);
1039
+ }
1040
+ else {
1041
+ assetsManager = new AssetsManager({}, baseURL);
1042
+ }
1043
+ // Use provided config or create a minimal default one
1044
+ let config;
1045
+ if (opts.config) {
1046
+ config = opts.config;
1047
+ }
1048
+ else {
1049
+ console.error("No config provided, using minimal default config");
1050
+ // Create a minimal default config
1051
+ config = { allToggles: [], defaultState: { toggles: [] } };
1052
+ }
1053
+ const coreOptions = {
1054
+ assetsManager,
1055
+ config: config,
1056
+ rootEl: opts.rootEl,
1057
+ };
1058
+ if (opts.showUrl !== undefined) {
1059
+ coreOptions.showUrl = opts.showUrl;
1060
+ }
1061
+ const core = new CustomViewsCore(coreOptions);
1062
+ core.init();
1063
+ return core;
1064
+ }
1065
+ }
1066
+
590
1067
  /**
591
1068
  * Widget styles for CustomViews
592
1069
  * Extracted from widget.ts for better maintainability
@@ -598,8 +1075,10 @@
598
1075
  /* Rounded rectangle widget icon styles */
599
1076
  .cv-widget-icon {
600
1077
  position: fixed;
601
- background: white;
602
- color: black;
1078
+ /* Slightly transparent by default so the widget is subtle at the page edge */
1079
+ background: rgba(255, 255, 255, 0.92);
1080
+ color: rgba(0, 0, 0, 0.9);
1081
+ opacity: 0.6;
603
1082
  display: flex;
604
1083
  align-items: center;
605
1084
  justify-content: center;
@@ -613,8 +1092,10 @@
613
1092
  }
614
1093
 
615
1094
  .cv-widget-icon:hover {
616
- background: white;
617
- color: black;
1095
+ /* Become fully opaque on hover to improve readability */
1096
+ background: rgba(255, 255, 255, 1);
1097
+ color: rgba(0, 0, 0, 1);
1098
+ opacity: 1;
618
1099
  }
619
1100
 
620
1101
  /* Top-right: rounded end on left, sticks out leftward on hover */
@@ -1021,6 +1502,43 @@
1021
1502
  width: auto;
1022
1503
  }
1023
1504
 
1505
+ .cv-tab-groups {
1506
+ margin-top: 20px;
1507
+ }
1508
+
1509
+ .cv-tab-group-control {
1510
+ margin-bottom: 15px;
1511
+ }
1512
+
1513
+ .cv-tab-group-control label {
1514
+ display: block;
1515
+ margin-bottom: 5px;
1516
+ font-weight: 500;
1517
+ font-size: 14px;
1518
+ }
1519
+
1520
+ .cv-tab-group-select {
1521
+ width: 100%;
1522
+ padding: 8px 12px;
1523
+ border: 1px solid #ced4da;
1524
+ border-radius: 4px;
1525
+ font-size: 14px;
1526
+ background-color: white;
1527
+ cursor: pointer;
1528
+ }
1529
+
1530
+ .cv-tab-group-select:focus {
1531
+ outline: none;
1532
+ border-color: #007bff;
1533
+ box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
1534
+ }
1535
+
1536
+ .cv-widget-theme-dark .cv-tab-group-select {
1537
+ background-color: #2d3748;
1538
+ border-color: #4a5568;
1539
+ color: #e2e8f0;
1540
+ }
1541
+
1024
1542
  .cv-custom-state-actions {
1025
1543
  display: flex;
1026
1544
  gap: 10px;
@@ -1209,7 +1727,8 @@
1209
1727
  description: options.description || 'Toggle different content sections to customize your view. Changes are applied instantly and the URL will be updated for sharing.',
1210
1728
  showWelcome: options.showWelcome ?? false,
1211
1729
  welcomeTitle: options.welcomeTitle || 'Welcome to Custom Views!',
1212
- welcomeMessage: options.welcomeMessage || 'This website uses Custom Views to let you personalize your experience. Use the widget on the side (⚙) to show or hide different content sections based on your preferences. Your selections will be saved and can be shared via URL.'
1730
+ welcomeMessage: options.welcomeMessage || 'This website uses Custom Views to let you personalize your experience. Use the widget on the side (⚙) to show or hide different content sections based on your preferences. Your selections will be saved and can be shared via URL.',
1731
+ showTabGroups: options.showTabGroups ?? true
1213
1732
  };
1214
1733
  // No external state manager to initialize
1215
1734
  }
@@ -1274,8 +1793,8 @@
1274
1793
  */
1275
1794
  openStateModal() {
1276
1795
  // Get toggles from current configuration and open the modal regardless of count
1277
- const localConfig = this.core.getLocalConfig();
1278
- const toggles = localConfig?.allToggles || [];
1796
+ const config = this.core.getConfig();
1797
+ const toggles = config?.allToggles || [];
1279
1798
  this.createCustomStateModal(toggles);
1280
1799
  }
1281
1800
  /**
@@ -1297,6 +1816,28 @@
1297
1816
  </div>
1298
1817
  `).join('')
1299
1818
  : `<p class="cv-no-toggles">No configurable sections available.</p>`;
1819
+ // Get tab groups
1820
+ const tabGroups = this.core.getTabGroups();
1821
+ let tabGroupsHTML = '';
1822
+ if (this.options.showTabGroups && tabGroups && tabGroups.length > 0) {
1823
+ const tabGroupControls = tabGroups.map(group => {
1824
+ const options = group.tabs.map(tab => `<option value="${tab.id}">${tab.label || tab.id}</option>`).join('');
1825
+ return `
1826
+ <div class="cv-tab-group-control">
1827
+ <label for="tab-group-${group.id}">${group.label || group.id}</label>
1828
+ <select id="tab-group-${group.id}" class="cv-tab-group-select" data-group-id="${group.id}">
1829
+ ${options}
1830
+ </select>
1831
+ </div>
1832
+ `;
1833
+ }).join('');
1834
+ tabGroupsHTML = `
1835
+ <h4>Tab Groups</h4>
1836
+ <div class="cv-tab-groups">
1837
+ ${tabGroupControls}
1838
+ </div>
1839
+ `;
1840
+ }
1300
1841
  this.modal.innerHTML = `
1301
1842
  <div class="cv-widget-modal cv-custom-state-modal">
1302
1843
  <div class="cv-widget-modal-header">
@@ -1312,6 +1853,8 @@
1312
1853
  ${toggleControls}
1313
1854
  </div>
1314
1855
 
1856
+ ${tabGroupsHTML}
1857
+
1315
1858
  <div class="cv-custom-state-actions">
1316
1859
  ${this.options.showReset ? `<button class="cv-custom-state-reset">Reset to Default</button>` : ''}
1317
1860
  <button class="cv-custom-state-copy-url">Copy Shareable URL</button>
@@ -1361,6 +1904,17 @@
1361
1904
  this.core.applyState(state);
1362
1905
  });
1363
1906
  });
1907
+ // Listen to tab group selects
1908
+ const tabGroupSelects = this.modal.querySelectorAll('.cv-tab-group-select');
1909
+ tabGroupSelects.forEach(select => {
1910
+ select.addEventListener('change', () => {
1911
+ const groupId = select.dataset.groupId;
1912
+ const tabId = select.value;
1913
+ if (groupId && tabId) {
1914
+ this.core.setActiveTab(groupId, tabId);
1915
+ }
1916
+ });
1917
+ });
1364
1918
  // Overlay click to close
1365
1919
  this.modal.addEventListener('click', (e) => {
1366
1920
  if (e.target === this.modal) {
@@ -1405,7 +1959,16 @@
1405
1959
  toggles.push(toggle);
1406
1960
  }
1407
1961
  });
1408
- return { toggles };
1962
+ // Collect tab selections
1963
+ const tabGroupSelects = this.modal.querySelectorAll('.cv-tab-group-select');
1964
+ const tabs = {};
1965
+ tabGroupSelects.forEach(select => {
1966
+ const groupId = select.dataset.groupId;
1967
+ if (groupId) {
1968
+ tabs[groupId] = select.value;
1969
+ }
1970
+ });
1971
+ return Object.keys(tabs).length > 0 ? { toggles, tabs } : { toggles };
1409
1972
  }
1410
1973
  /**
1411
1974
  * Copy shareable URL to clipboard
@@ -1441,6 +2004,15 @@
1441
2004
  }
1442
2005
  }
1443
2006
  });
2007
+ // Load tab group selections
2008
+ const activeTabs = this.core.getCurrentActiveTabs();
2009
+ const tabGroupSelects = this.modal.querySelectorAll('.cv-tab-group-select');
2010
+ tabGroupSelects.forEach(select => {
2011
+ const groupId = select.dataset.groupId;
2012
+ if (groupId && activeTabs[groupId]) {
2013
+ select.value = activeTabs[groupId];
2014
+ }
2015
+ });
1444
2016
  }
1445
2017
  /**
1446
2018
  * Format toggle name for display
@@ -1534,61 +2106,204 @@
1534
2106
  }
1535
2107
  }
1536
2108
 
1537
- class CustomViews {
1538
- // Entry Point to use CustomViews
1539
- static async initFromJson(opts) {
1540
- // Load assets JSON if provided
1541
- let assetsManager;
1542
- const baseURL = opts.baseURL || '';
1543
- if (opts.assetsJsonPath) {
1544
- const assetsJson = await (await fetch(opts.assetsJsonPath)).json();
1545
- assetsManager = new AssetsManager(assetsJson, baseURL);
1546
- }
1547
- else {
1548
- assetsManager = new AssetsManager({}, baseURL);
1549
- }
1550
- // Load config JSON if provided, else just log error and don't load the custom views
1551
- let localConfig;
1552
- if (opts.config) {
1553
- localConfig = opts.config;
2109
+ /**
2110
+ * Custom Elements for Tab Groups and Tabs
2111
+ */
2112
+ /**
2113
+ * <cv-tab> element - represents a single tab panel
2114
+ */
2115
+ class CVTab extends HTMLElement {
2116
+ connectedCallback() {
2117
+ // Element is managed by TabManager
2118
+ }
2119
+ }
2120
+ /**
2121
+ * <cv-tabgroup> element - represents a group of tabs
2122
+ */
2123
+ class CVTabgroup extends HTMLElement {
2124
+ connectedCallback() {
2125
+ // Element is managed by TabManager
2126
+ // Emit ready event after a brief delay to ensure children are parsed
2127
+ setTimeout(() => {
2128
+ const event = new CustomEvent('cv:tabgroup-ready', {
2129
+ bubbles: true,
2130
+ detail: { groupId: this.getAttribute('id') }
2131
+ });
2132
+ this.dispatchEvent(event);
2133
+ }, 0);
2134
+ }
2135
+ }
2136
+ /**
2137
+ * Register custom elements
2138
+ */
2139
+ function registerCustomElements() {
2140
+ // Only register if not already defined
2141
+ if (!customElements.get('cv-tab')) {
2142
+ customElements.define('cv-tab', CVTab);
2143
+ }
2144
+ if (!customElements.get('cv-tabgroup')) {
2145
+ customElements.define('cv-tabgroup', CVTabgroup);
2146
+ }
2147
+ }
2148
+
2149
+ /**
2150
+ * Initialize CustomViews from script tag attributes and config file
2151
+ * This function handles the automatic initialization of CustomViews when included via script tag
2152
+ *
2153
+ * Data attributes supported:
2154
+ * - data-base-url: Base URL for the site (e.g., "/customviews" for subdirectory deployments)
2155
+ * - data-config-path: Path to config file (default: "/customviews.config.json")
2156
+ *
2157
+ * The function fetches the config file and uses it directly to initialize CustomViews.
2158
+ * Widget visibility is controlled via the config file (widget.enabled property).
2159
+ */
2160
+ function initializeFromScript() {
2161
+ // Only run in browser environment
2162
+ if (typeof window === 'undefined')
2163
+ return;
2164
+ // Use the typed global `window` (see src/types/global.d.ts)
2165
+ // Idempotency guard: if already initialized, skip setting up listener again.
2166
+ if (window.__customViewsInitialized) {
2167
+ // Informational for developers; harmless in production.
2168
+ console.info('[CustomViews] Auto-init skipped: already initialized.');
2169
+ return;
2170
+ }
2171
+ document.addEventListener('DOMContentLoaded', async function () {
2172
+ // Prevent concurrent initialization runs (race conditions when script is loaded twice)
2173
+ if (window.__customViewsInitInProgress || window.__customViewsInitialized) {
2174
+ return;
1554
2175
  }
1555
- else {
1556
- if (!opts.configPath) {
1557
- console.error("No config path provided, skipping custom views");
1558
- return null;
2176
+ window.__customViewsInitInProgress = true;
2177
+ // Register custom elements early
2178
+ registerCustomElements();
2179
+ try {
2180
+ // Find the script tag
2181
+ let scriptTag = document.currentScript;
2182
+ // Fallback if currentScript is not available (executed after page load)
2183
+ if (!scriptTag) {
2184
+ // Try to find the script tag by looking for our script
2185
+ const scripts = document.querySelectorAll('script[src*="custom-views"]');
2186
+ if (scripts.length > 0) {
2187
+ // Find the most specific match (to avoid matching other custom-views scripts)
2188
+ for (let i = 0; i < scripts.length; i++) {
2189
+ const script = scripts[i];
2190
+ const src = script.getAttribute('src') || '';
2191
+ // Look for .min.js or .js at the end
2192
+ if (src.match(/custom-views(\.min)?\.js($|\?)/)) {
2193
+ scriptTag = script;
2194
+ break;
2195
+ }
2196
+ }
2197
+ // If no specific match found, use the first one
2198
+ if (!scriptTag) {
2199
+ scriptTag = scripts[0];
2200
+ }
2201
+ }
2202
+ }
2203
+ // Read data attributes from script tag
2204
+ let baseURL = '';
2205
+ let configPath = '/customviews.config.json';
2206
+ if (scriptTag) {
2207
+ baseURL = scriptTag.getAttribute('data-base-url') || '';
2208
+ configPath = scriptTag.getAttribute('data-config-path') || configPath;
1559
2209
  }
2210
+ // Fetch config file
2211
+ let configFile;
1560
2212
  try {
1561
- localConfig = await (await fetch(opts.configPath)).json();
2213
+ const fullConfigPath = prependBaseUrl(configPath, baseURL);
2214
+ console.log(`[CustomViews] Loading config from: ${fullConfigPath}`);
2215
+ const response = await fetch(fullConfigPath);
2216
+ if (!response.ok) {
2217
+ console.warn(`[CustomViews] Config file not found at ${fullConfigPath}. Using defaults.`);
2218
+ // Provide minimal default config structure
2219
+ configFile = {
2220
+ config: {
2221
+ allToggles: [],
2222
+ defaultState: { toggles: [] }
2223
+ },
2224
+ widget: {
2225
+ enabled: true
2226
+ }
2227
+ };
2228
+ }
2229
+ else {
2230
+ configFile = await response.json();
2231
+ console.log('[CustomViews] Config loaded successfully');
2232
+ }
1562
2233
  }
1563
2234
  catch (error) {
1564
- console.error("Error loading config:", error);
1565
- return null;
2235
+ console.error('[CustomViews] Error loading config file:', error);
2236
+ return; // Abort initialization
2237
+ }
2238
+ // Determine effective baseURL (data attribute takes precedence over config)
2239
+ const effectiveBaseURL = baseURL || configFile.baseURL || '';
2240
+ const options = {
2241
+ config: configFile.config,
2242
+ assetsJsonPath: configFile.assetsJsonPath,
2243
+ baseURL: effectiveBaseURL,
2244
+ };
2245
+ if (configFile.showUrl !== undefined) {
2246
+ options.showUrl = configFile.showUrl;
1566
2247
  }
2248
+ // Initialize CustomViews core
2249
+ const core = await CustomViews.init(options);
2250
+ if (!core) {
2251
+ console.error('[CustomViews] Failed to initialize core.');
2252
+ return; // Abort widget creation
2253
+ }
2254
+ // Store instance
2255
+ window.customViewsInstance = { core };
2256
+ // Initialize widget if enabled in config
2257
+ let widget;
2258
+ if (configFile.widget?.enabled !== false) {
2259
+ widget = new CustomViewsWidget({
2260
+ core,
2261
+ ...configFile.widget
2262
+ });
2263
+ widget.render();
2264
+ // Store widget instance
2265
+ window.customViewsInstance.widget = widget;
2266
+ console.log('[CustomViews] Widget initialized and rendered');
2267
+ }
2268
+ else {
2269
+ console.log('[CustomViews] Widget disabled in config - skipping initialization');
2270
+ }
2271
+ // Dispatch ready event
2272
+ const readyEvent = new CustomEvent('customviews:ready', {
2273
+ detail: {
2274
+ core,
2275
+ widget
2276
+ }
2277
+ });
2278
+ document.dispatchEvent(readyEvent);
2279
+ // Mark initialized and clear in-progress flag
2280
+ window.__customViewsInitialized = true;
2281
+ window.__customViewsInitInProgress = false;
1567
2282
  }
1568
- const coreOptions = {
1569
- assetsManager,
1570
- config: localConfig,
1571
- rootEl: opts.rootEl,
1572
- };
1573
- const core = new CustomViewsCore(coreOptions);
1574
- core.init();
1575
- return core;
1576
- }
2283
+ catch (error) {
2284
+ // Clear in-progress flag so a future attempt can retry
2285
+ window.__customViewsInitInProgress = false;
2286
+ console.error('[CustomViews] Auto-initialization error:', error);
2287
+ }
2288
+ });
1577
2289
  }
2290
+
2291
+ // Import from new modules
2292
+ // Set up globals and auto-initialization
1578
2293
  if (typeof window !== "undefined") {
1579
- // @ts-ignore
2294
+ // Expose to window to enable usage (e.g. const app = new window.CustomViews(...))
1580
2295
  window.CustomViews = CustomViews;
1581
- // @ts-ignore
1582
2296
  window.CustomViewsWidget = CustomViewsWidget;
2297
+ // Run auto-initialization
2298
+ initializeFromScript();
1583
2299
  }
1584
2300
 
1585
2301
  exports.AssetsManager = AssetsManager;
1586
2302
  exports.CustomViews = CustomViews;
1587
2303
  exports.CustomViewsCore = CustomViewsCore;
1588
2304
  exports.CustomViewsWidget = CustomViewsWidget;
1589
- exports.LocalConfig = Config;
1590
2305
  exports.PersistenceManager = PersistenceManager;
1591
2306
  exports.URLStateManager = URLStateManager;
1592
2307
 
1593
2308
  }));
1594
- //# sourceMappingURL=custom-views.umd.js.map
2309
+ //# sourceMappingURL=custom-views.js.map