1
0

app.js 4.7 KB

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