app.js 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111
  1. /**
  2. * Site-wide behaviour. Three concerns:
  3. * 1. Vanilla-JS click delegation for [data-href] rows (sprint list).
  4. * 2. Alpine CSP component: appMenu (hamburger).
  5. * 3. Alpine CSP component: themeToggle (dark-mode flip).
  6. *
  7. * htmx is loaded site-wide and sends X-CSRF-Token automatically per the
  8. * `htmx:configRequest` listener below.
  9. */
  10. (function () {
  11. 'use strict';
  12. // ----- data-href click delegation ------------------------------------
  13. function navigate(el, ev) {
  14. const href = String(el.getAttribute('data-href') || '');
  15. if (href === '') { return; }
  16. if (ev.metaKey || ev.ctrlKey || ev.button === 1) {
  17. window.open(href, '_blank');
  18. } else {
  19. window.location.href = href;
  20. }
  21. }
  22. document.addEventListener('click', function (ev) {
  23. const el = ev.target.closest('[data-href]');
  24. if (!el) { return; }
  25. // Ignore clicks that originated on an interactive descendant.
  26. if (ev.target.closest('a, button, input, select, textarea, label')) { return; }
  27. navigate(el, ev);
  28. });
  29. document.addEventListener('keydown', function (ev) {
  30. if (ev.key !== 'Enter') { return; }
  31. const el = ev.target.closest('[data-href]');
  32. if (!el) { return; }
  33. if (ev.target.closest('a, button, input, select, textarea, label')) { return; }
  34. ev.preventDefault();
  35. window.location.href = String(el.getAttribute('data-href') || '');
  36. });
  37. // Make every data-href row keyboard-reachable + screenreader-friendly.
  38. document.querySelectorAll('[data-href]').forEach(function (el) {
  39. el.setAttribute('role', 'link');
  40. el.setAttribute('tabindex', '0');
  41. });
  42. // ----- htmx CSRF wiring ---------------------------------------------
  43. // The CSRF token lives on every layout's form (_csrf hidden input) and on
  44. // the [data-sprint-root] element (data-csrf). Pull from a hidden input if
  45. // present so non-sprint pages also get the header attached.
  46. function readCsrfToken() {
  47. const inp = document.querySelector('input[name="_csrf"]');
  48. if (inp && inp.value) { return inp.value; }
  49. const root = document.querySelector('[data-sprint-root]');
  50. if (root && root.getAttribute('data-csrf')) {
  51. return root.getAttribute('data-csrf');
  52. }
  53. return '';
  54. }
  55. document.addEventListener('htmx:configRequest', function (ev) {
  56. const tok = readCsrfToken();
  57. if (tok && ev.detail && ev.detail.headers) {
  58. ev.detail.headers['X-CSRF-Token'] = tok;
  59. }
  60. });
  61. // ----- Alpine component registrations -------------------------------
  62. document.addEventListener('alpine:init', function () {
  63. // Hamburger menu — toggle, close on outside / Escape / item click.
  64. window.Alpine.data('appMenu', function () {
  65. return {
  66. open: false,
  67. toggle() { this.open = !this.open; },
  68. close() { this.open = false; },
  69. closeAndFocus() {
  70. if (!this.open) { return; }
  71. this.open = false;
  72. if (this.$refs.trigger) { this.$refs.trigger.focus(); }
  73. },
  74. closeOnItem(ev) {
  75. if (ev.target.closest('[role="menuitem"]')) { this.open = false; }
  76. },
  77. };
  78. });
  79. // Theme toggle — flips <html class="dark">, persists in localStorage,
  80. // mirrors a "Dark" / "Light" label into the menu row.
  81. window.Alpine.data('themeToggle', function () {
  82. return {
  83. label: '',
  84. init() {
  85. this.stamp();
  86. // If theme-init.js loaded after Alpine for some reason,
  87. // reflect the current class state on next tick.
  88. this.$nextTick(() => this.stamp());
  89. },
  90. stamp() {
  91. this.label = document.documentElement.classList.contains('dark') ? 'Dark' : 'Light';
  92. },
  93. flip() {
  94. const nowDark = document.documentElement.classList.toggle('dark');
  95. try { localStorage.setItem('sp:theme', nowDark ? 'dark' : 'light'); }
  96. catch (e) { /* private mode quota — ignore */ }
  97. this.stamp();
  98. },
  99. };
  100. });
  101. });
  102. })();