added on local at 2026-07-01 22:09:29
| 1 | /* ===================================================================== | |
| 2 | ShopCart — site.js | |
| 3 | Light interactivity for every page: tabs, toggles, dropzone hover, | |
| 4 | global keyboard shortcuts. Charts live in graphs.js. | |
| 5 | ||
| 6 | The sidebar/topbar shell is rendered server-side by Perl | |
| 7 | (MODS::ShopCart::Wrapper). The client does not inject the chrome. | |
| 8 | ===================================================================== */ | |
| 9 | ||
| 10 | (function () { | |
| 11 | 'use strict'; | |
| 12 | ||
| 13 | /* ------------------------------------------------------------------ | |
| 14 | Toggle pills (.toggle) — click flips the .on class | |
| 15 | ------------------------------------------------------------------ */ | |
| 16 | function bindToggles(root) { | |
| 17 | (root || document).querySelectorAll('.toggle').forEach(function (el) { | |
| 18 | if (el._f3dBound) return; | |
| 19 | el._f3dBound = true; | |
| 20 | el.addEventListener('click', function () { | |
| 21 | el.classList.toggle('on'); | |
| 22 | // Mirror state into a hidden input if present | |
| 23 | const input = el.querySelector('input[type="hidden"]') || | |
| 24 | (el.dataset.input && document.querySelector(el.dataset.input)); | |
| 25 | if (input) input.value = el.classList.contains('on') ? '1' : '0'; | |
| 26 | }); | |
| 27 | }); | |
| 28 | } | |
| 29 | ||
| 30 | /* ------------------------------------------------------------------ | |
| 31 | Tab groups — switches active class and shows the matching pane | |
| 32 | <div class="tabs" data-tab-group> | |
| 33 | <div class="tab active" data-target="overview">Overview</div> | |
| 34 | ... | |
| 35 | </div> | |
| 36 | <div data-pane="overview">...</div> | |
| 37 | <div data-pane="other" class="hide">...</div> | |
| 38 | ------------------------------------------------------------------ */ | |
| 39 | function bindTabs(root) { | |
| 40 | (root || document).querySelectorAll('[data-tab-group]').forEach(function (group) { | |
| 41 | if (group._f3dBound) return; | |
| 42 | group._f3dBound = true; | |
| 43 | ||
| 44 | const tabs = group.querySelectorAll('.tab'); | |
| 45 | tabs.forEach(function (t) { | |
| 46 | t.addEventListener('click', function () { | |
| 47 | tabs.forEach(function (x) { x.classList.remove('active'); }); | |
| 48 | t.classList.add('active'); | |
| 49 | const target = t.dataset.target; | |
| 50 | if (!target) return; | |
| 51 | // Walk up until we find an ancestor that contains [data-pane] | |
| 52 | // siblings. Layouts that wrap the tabs in an extra flex row | |
| 53 | // (e.g. storefront.cgi's tabs+stats row) put the panes one | |
| 54 | // level higher than the original assumption of "panes are | |
| 55 | // siblings of .tabs". | |
| 56 | let scope = group.parentElement || document; | |
| 57 | while (scope && scope !== document && | |
| 58 | !scope.querySelector('[data-pane]')) { | |
| 59 | scope = scope.parentElement; | |
| 60 | } | |
| 61 | if (!scope) scope = document; | |
| 62 | scope.querySelectorAll('[data-pane]').forEach(function (p) { | |
| 63 | p.classList.toggle('hide', p.dataset.pane !== target); | |
| 64 | }); | |
| 65 | }); | |
| 66 | }); | |
| 67 | }); | |
| 68 | } | |
| 69 | ||
| 70 | /* ------------------------------------------------------------------ | |
| 71 | File dropzone — visual hover state only; submission still | |
| 72 | handled by the form on the page. | |
| 73 | ------------------------------------------------------------------ */ | |
| 74 | function bindDropzone(root) { | |
| 75 | (root || document).querySelectorAll('.dropzone').forEach(function (zone) { | |
| 76 | if (zone._f3dBound) return; | |
| 77 | zone._f3dBound = true; | |
| 78 | ['dragenter', 'dragover'].forEach(function (ev) { | |
| 79 | zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.add('drag'); }); | |
| 80 | }); | |
| 81 | ['dragleave', 'drop'].forEach(function (ev) { | |
| 82 | zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.remove('drag'); }); | |
| 83 | }); | |
| 84 | }); | |
| 85 | } | |
| 86 | ||
| 87 | /* ------------------------------------------------------------------ | |
| 88 | ⌘K / Ctrl-K — focus the topbar search if present | |
| 89 | ------------------------------------------------------------------ */ | |
| 90 | function bindShortcuts() { | |
| 91 | document.addEventListener('keydown', function (e) { | |
| 92 | if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { | |
| 93 | const input = document.querySelector('.search-box input'); | |
| 94 | if (input) { e.preventDefault(); input.focus(); } | |
| 95 | } | |
| 96 | if (e.key === 'Escape') { | |
| 97 | document.activeElement && document.activeElement.blur(); | |
| 98 | } | |
| 99 | }); | |
| 100 | } | |
| 101 | ||
| 102 | /* ------------------------------------------------------------------ | |
| 103 | Hash → tab — settings.html?#modules opens that tab on load. | |
| 104 | Works alongside bindTabs. | |
| 105 | ------------------------------------------------------------------ */ | |
| 106 | function activateHashTab() { | |
| 107 | const hash = (location.hash || '').replace('#', ''); | |
| 108 | if (!hash) return; | |
| 109 | const target = document.querySelector('.tab[data-target="' + hash + '"]'); | |
| 110 | if (target) target.click(); | |
| 111 | } | |
| 112 | ||
| 113 | /* ------------------------------------------------------------------ | |
| 114 | Tab links — any <a data-tab-link="X"> switches the active tab | |
| 115 | to X when clicked, even if the URL hash is already #X (so the | |
| 116 | hashchange event wouldn't fire). | |
| 117 | ------------------------------------------------------------------ */ | |
| 118 | function bindTabLinks(root) { | |
| 119 | (root || document).querySelectorAll('[data-tab-link]').forEach(function (link) { | |
| 120 | if (link._f3dTabLinkBound) return; | |
| 121 | link._f3dTabLinkBound = true; | |
| 122 | link.addEventListener('click', function (e) { | |
| 123 | const key = link.dataset.tabLink; | |
| 124 | if (!key) return; | |
| 125 | const tab = document.querySelector('.tab[data-target="' + key + '"]'); | |
| 126 | if (tab) { | |
| 127 | e.preventDefault(); | |
| 128 | tab.click(); | |
| 129 | tab.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| 130 | } | |
| 131 | }); | |
| 132 | }); | |
| 133 | } | |
| 134 | ||
| 135 | /* ------------------------------------------------------------------ | |
| 136 | Bootstrap | |
| 137 | ------------------------------------------------------------------ */ | |
| 138 | function init() { | |
| 139 | bindToggles(); | |
| 140 | bindTabs(); | |
| 141 | bindTabLinks(); | |
| 142 | bindDropzone(); | |
| 143 | bindShortcuts(); | |
| 144 | activateHashTab(); | |
| 145 | // Re-run on hash changes so in-page anchors like | |
| 146 | // <a href="#design"> switch the tab without a full reload. | |
| 147 | window.addEventListener('hashchange', activateHashTab); | |
| 148 | } | |
| 149 | ||
| 150 | if (document.readyState === 'loading') { | |
| 151 | document.addEventListener('DOMContentLoaded', init); | |
| 152 | } else { | |
| 153 | init(); | |
| 154 | } | |
| 155 | ||
| 156 | // Public surface for pages that build content after first paint. | |
| 157 | window.ShopCart = { | |
| 158 | bindToggles: bindToggles, | |
| 159 | bindTabs: bindTabs, | |
| 160 | bindDropzone: bindDropzone, | |
| 161 | }; | |
| 162 | })(); |