/** * Site-wide behaviour. Three concerns: * 1. Vanilla-JS click delegation for [data-href] rows (sprint list). * 2. Alpine CSP component: appMenu (hamburger). * 3. Alpine CSP component: themeToggle (dark-mode flip). * * htmx is loaded site-wide and sends X-CSRF-Token automatically per the * `htmx:configRequest` listener below. */ (function () { 'use strict'; // ----- data-href click delegation ------------------------------------ function navigate(el, ev) { const href = String(el.getAttribute('data-href') || ''); if (href === '') { return; } if (ev.metaKey || ev.ctrlKey || ev.button === 1) { window.open(href, '_blank'); } else { window.location.href = href; } } document.addEventListener('click', function (ev) { const el = ev.target.closest('[data-href]'); if (!el) { return; } // Ignore clicks that originated on an interactive descendant. if (ev.target.closest('a, button, input, select, textarea, label')) { return; } navigate(el, ev); }); document.addEventListener('keydown', function (ev) { if (ev.key !== 'Enter') { return; } const el = ev.target.closest('[data-href]'); if (!el) { return; } if (ev.target.closest('a, button, input, select, textarea, label')) { return; } ev.preventDefault(); window.location.href = String(el.getAttribute('data-href') || ''); }); // Make every data-href row keyboard-reachable + screenreader-friendly. document.querySelectorAll('[data-href]').forEach(function (el) { el.setAttribute('role', 'link'); el.setAttribute('tabindex', '0'); }); // ----- htmx CSRF wiring --------------------------------------------- // The CSRF token lives on every layout's form (_csrf hidden input) and on // the [data-sprint-root] element (data-csrf). Pull from a hidden input if // present so non-sprint pages also get the header attached. function readCsrfToken() { const inp = document.querySelector('input[name="_csrf"]'); if (inp && inp.value) { return inp.value; } const root = document.querySelector('[data-sprint-root]'); if (root && root.getAttribute('data-csrf')) { return root.getAttribute('data-csrf'); } return ''; } document.addEventListener('htmx:configRequest', function (ev) { const tok = readCsrfToken(); if (tok && ev.detail && ev.detail.headers) { ev.detail.headers['X-CSRF-Token'] = tok; } }); // ----- Alpine component registrations ------------------------------- document.addEventListener('alpine:init', function () { // Hamburger menu — toggle, close on outside / Escape / item click. window.Alpine.data('appMenu', function () { return { open: false, toggle() { this.open = !this.open; }, close() { this.open = false; }, closeAndFocus() { if (!this.open) { return; } this.open = false; if (this.$refs.trigger) { this.$refs.trigger.focus(); } }, closeOnItem(ev) { if (ev.target.closest('[role="menuitem"]')) { this.open = false; } }, }; }); // Theme toggle — flips , persists in localStorage, // mirrors a "Dark" / "Light" label into the menu row. window.Alpine.data('themeToggle', function () { return { label: '', init() { this.stamp(); // If theme-init.js loaded after Alpine for some reason, // reflect the current class state on next tick. this.$nextTick(() => this.stamp()); }, stamp() { this.label = document.documentElement.classList.contains('dark') ? 'Dark' : 'Light'; }, flip() { const nowDark = document.documentElement.classList.toggle('dark'); try { localStorage.setItem('sp:theme', nowDark ? 'dark' : 'light'); } catch (e) { /* private mode quota — ignore */ } this.stamp(); }, }; }); }); })();