PageSourceSearch

https://fiscomania.com/wp-content/themes/fiscomania/assets/js/nav.js?ver=1780519245

js fiscomania.com collected 2026-10-02 02:38:24 UTC 14,167 bytes, 434 lines download raw bytes

1/**
2 * Fiscomania — nav.js
3 * Ticker, search overlay (con live search), dropdown a11y, sticky offset.
4 */
5
6(function () {
7  'use strict';
8
9  /* ── TICKER ── */
10  const Ticker = {
11    items: [],
12    idx: 0,
13    interval: null,
14
15    init(posts) {
16      this.items = posts;
17      if (!this.items.length) return;
18      const slot = document.getElementById('ticker-slot');
19      if (!slot) return;
20      const el = slot.querySelector('#ticker-current');
21      if (!el) return;
22      el.innerHTML = `<a href="${this.items[0].url}">${this.items[0].title}</a>`;
23      el.className = 'ticker-item in';
24      this.interval = setInterval(() => this.next(), 4500);
25    },
26
27    next() {
28      this.idx = (this.idx + 1) % this.items.length;
29      const el = document.getElementById('ticker-current');
30      if (!el) return;
31      el.classList.replace('in', 'out');
32      setTimeout(() => {
33        el.innerHTML = `<a href="${this.items[this.idx].url}">${this.items[this.idx].title}</a>`;
34        el.classList.replace('out', 'in');
35      }, 420);
36    }
37  };
38
39  /* ── LIVE SEARCH (REST: /wp-json/wp/v2/posts) ── */
40  const LiveSearch = {
41    input: null,
42    results: null,
43    controller: null,
44    debounceTimer: 0,
45    minChars: 2,
46    debounceMs: 220,
47
48    init() {
49      this.input   = document.getElementById('search-input');
50      this.results = document.getElementById('search-results');
51      if (!this.input || !this.results) return;
52
53      this.input.addEventListener('input', () => this.onInput());
54      this.input.addEventListener('keydown', (e) => this.onKeydown(e));
55    },
56
57    onInput() {
58      clearTimeout(this.debounceTimer);
59      const q = this.input.value.trim();
60      if (q.length < this.minChars) {
61        this.clear();
62        return;
63      }
64      this.results.setAttribute('aria-busy', 'true');
65      this.debounceTimer = setTimeout(() => this.search(q), this.debounceMs);
66    },
67
68    search(q) {
69      if (this.controller) this.controller.abort();
70      this.controller = new AbortController();
71
72      const url = '/wp-json/wp/v2/posts?per_page=10&_fields=id,link,title,date,excerpt&search='
73        + encodeURIComponent(q);
74
75      fetch(url, { signal: this.controller.signal })
76        .then((r) => r.ok ? r.json() : Promise.reject(r))
77        .then((data) => this.render(data || []))
78        .catch((err) => {
79          if (err && err.name === 'AbortError') return;
80          this.showError();
81        })
82        .finally(() => {
83          this.results.removeAttribute('aria-busy');
84        });
85    },
86
87    render(items) {
88      if (!items.length) {
89        this.results.innerHTML = '<div class="search-results-empty">Nessun risultato.</div>';
90        return;
91      }
92      const fmt = new Intl.DateTimeFormat('it-IT', { day: 'numeric', month: 'short', year: 'numeric' });
93      this.results.innerHTML = items.map((p, i) => {
94        const title = (p.title && p.title.rendered) || '';
95        const date  = p.date ? fmt.format(new Date(p.date)) : '';
96        return '<a class="search-results-item" href="' + p.link + '" role="option" data-idx="' + i + '">'
97          + '<span class="search-results-title">' + title + '</span>'
98          + '<span class="search-results-meta">' + date + '</span>'
99          + '</a>';
100      }).join('');
101    },
102
103    clear() {
104      if (this.controller) this.controller.abort();
105      if (this.results) this.results.innerHTML = '';
106    },
107
108    showError() {
109      if (this.results) {
110        this.results.innerHTML = '<div class="search-results-error">Errore nella ricerca.</div>';
111      }
112    },
113
114    onKeydown(e) {
115      const items = this.results.querySelectorAll('.search-results-item');
116      if (!items.length) return;
117      const cur = this.results.querySelector('.search-results-item.is-highlighted');
118      let idx = cur ? parseInt(cur.dataset.idx, 10) : -1;
119
120      if (e.key === 'ArrowDown') {
121        e.preventDefault();
122        idx = (idx + 1) % items.length;
123      } else if (e.key === 'ArrowUp') {
124        e.preventDefault();
125        idx = (idx - 1 + items.length) % items.length;
126      } else if (e.key === 'Enter' && cur) {
127        e.preventDefault();
128        window.location = cur.href;
129        return;
130      } else {
131        return;
132      }
133
134      items.forEach((el) => el.classList.remove('is-highlighted'));
135      items[idx].classList.add('is-highlighted');
136      items[idx].scrollIntoView({ block: 'nearest' });
137    }
138  };
139
140  /* ── SEARCH OVERLAY (open/close + keyboard) ── */
141  const fiscoSearch = {
142    overlay: null,
143    input: null,
144
145    init() {
146      this.overlay = document.getElementById('search-overlay');
147      this.input   = document.getElementById('search-input');
148      if (!this.overlay) return;
149
150      this.overlay.addEventListener('click', (e) => {
151        if (e.target === this.overlay) this.close();
152      });
153
154      document.addEventListener('keydown', (e) => {
155        if (e.key === 'Escape') this.close();
156        if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
157          e.preventDefault();
158          this.toggle();
159        }
160      });
161    },
162
163    toggle() {
164      if (!this.overlay) return;
165      this.overlay.classList.toggle('open');
166      if (this.overlay.classList.contains('open')) {
167        if (this.input) this.input.focus();
168      } else {
169        LiveSearch.clear();
170      }
171    },
172
173    close() {
174      if (!this.overlay) return;
175      this.overlay.classList.remove('open');
176      LiveSearch.clear();
177    }
178  };
179
180  /* ── DROPDOWN A11Y ── */
181  function initDropdowns() {
182    document.querySelectorAll('.topbar-drop').forEach((drop) => {
183      const btn   = drop.querySelector('.topbar-drop-btn');
184      const panel = drop.querySelector('.topbar-drop-panel');
185      if (!btn || !panel) return;
186
187      btn.addEventListener('click', () => {
188        const expanded = btn.getAttribute('aria-expanded') === 'true';
189        btn.setAttribute('aria-expanded', String(!expanded));
190        panel.style.display = expanded ? '' : 'grid';
191      });
192
193      document.addEventListener('click', (e) => {
194        if (!drop.contains(e.target)) {
195          btn.setAttribute('aria-expanded', 'false');
196          panel.style.display = '';
197        }
198      });
199
200      panel.addEventListener('keydown', (e) => {
201        if (e.key === 'Escape') {
202          btn.setAttribute('aria-expanded', 'false');
203          panel.style.display = '';
204          btn.focus();
205        }
206      });
207    });
208  }
209
210  /* Sticky offset gestito via CSS var --sticky-top (default 30px). Niente JS dinamico. */
211
212  /* ── ARTICLE READING PROGRESS + TOC SCROLL-SPY ── */
213  const ArticleProgress = {
214    bar: null,
215    track: null,
216    body: null,
217    headings: [],
218    items: [],
219    activeIdx: -1,
220    rafPending: false,
221
222    init() {
223      this.bar   = document.getElementById('reading-progress-bar');
224      this.track = this.bar ? this.bar.parentElement : null;
225      this.body  = document.querySelector('.art-main') || document.querySelector('.prose');
226
227      // Headings dell'articolo che corrispondono alle voci TOC
228      const tocItems = document.querySelectorAll('.toc-aside-item');
229      this.items = Array.from(tocItems);
230      this.headings = this.items
231        .map((li) => {
232          const id = li.getAttribute('data-target');
233          return id ? document.getElementById(id) : null;
234        })
235        .filter(Boolean);
236
237      // Niente da fare se non c'è né progress né TOC
238      if (!this.bar && !this.headings.length) return;
239
240      this.update = this.update.bind(this);
241      window.addEventListener('scroll', () => this.requestUpdate(), { passive: true });
242      window.addEventListener('resize', () => this.requestUpdate(), { passive: true });
243      this.update();
244    },
245
246    reinitTOC() {
247      const tocItems = document.querySelectorAll('.toc-aside-item');
248      this.items = Array.from(tocItems);
249      this.headings = this.items
250        .map((li) => {
251          const id = li.getAttribute('data-target');
252          return id ? document.getElementById(id) : null;
253        })
254        .filter(Boolean);
255      this.activeIdx = -1;
256      if (this.bar || this.headings.length) this.requestUpdate();
257    },
258
259    requestUpdate() {
260      if (this.rafPending) return;
261      this.rafPending = true;
262      requestAnimationFrame(this.update);
263    },
264
265    update() {
266      this.rafPending = false;
267
268      // Progress bar
269      if (this.bar && this.body) {
270        const rect = this.body.getBoundingClientRect();
271        const total = rect.height + window.innerHeight;
272        const scrolled = -rect.top + window.innerHeight;
273        const pct = Math.max(0, Math.min(1, scrolled / total));
274        this.bar.style.width = (pct * 100).toFixed(1) + '%';
275        if (this.track) {
276          this.track.setAttribute('aria-valuenow', String(Math.round(pct * 100)));
277        }
278      }
279
280      // Scroll-spy: trova l'ultimo heading sopra la trigger zone
281      if (this.headings.length && this.items.length) {
282        const trigger = window.innerHeight * 0.25;
283        let active = -1;
284        for (let i = 0; i < this.headings.length; i++) {
285          const top = this.headings[i].getBoundingClientRect().top;
286          if (top <= trigger) active = i;
287          else break;
288        }
289        if (active !== this.activeIdx) {
290          this.activeIdx = active;
291          this.items.forEach((li, i) => {
292            li.classList.toggle('is-active', i === active);
293            li.classList.toggle('is-passed', i < active);
294          });
295        }
296      }
297    }
298  };
299
300  /* ── FOOTER DETAILS: forza open su desktop (fix Chrome 120+ <details> behavior) ── */
301  function fmSyncFooterDetails() {
302    const isDesktop = window.matchMedia('(min-width:769px)').matches;
303    document.querySelectorAll('.f-col-wrap').forEach(function (d) { d.open = isDesktop; });
304  }
305
306  /* ── SMART STICKY HEADER ──
307     Sticky di base; sopra 300px nasconde su scroll-down, mostra su scroll-up.
308     rAF-throttled, passive listeners. Aggiorna --sticky-top dinamicamente
309     così le sidebar sticky scendono/salgono in sync col header. */
310  const StickyHeader = {
311    el: null,
312    lastY: 0,
313    threshold: 300,
314    delta: 6,        // ignora micro-jitter
315    raf: false,
316    headerH: 0,
317    baseStickyTop: 30,
318    isHidden: false,
319    init() {
320      this.el = document.querySelector('.site-header');
321      if (!this.el) return;
322      this.lastY = window.scrollY;
323      this.headerH = this.el.offsetHeight || 60;
324      // Default: header visibile → sticky-top più basso (sotto header)
325      this.applyStickyOffset(false);
326      window.addEventListener('scroll', () => this.request(), { passive: true });
327      window.addEventListener('resize', () => {
328        this.headerH = this.el.offsetHeight || 60;
329        this.applyStickyOffset(this.isHidden);
330      }, { passive: true });
331    },
332    request() {
333      if (this.raf) return;
334      this.raf = true;
335      requestAnimationFrame(() => { this.raf = false; this.update(); });
336    },
337    update() {
338      const y = window.scrollY;
339      const dy = y - this.lastY;
340      if (Math.abs(dy) < this.delta) return;
341
342      this.el.classList.toggle('is-stuck', y > 4);
343
344      let nextHidden = this.isHidden;
345      if (y < this.threshold) {
346        nextHidden = false;
347      } else if (dy > 0) {
348        nextHidden = true;
349      } else if (dy < 0) {
350        nextHidden = false;
351      }
352
353      if (nextHidden !== this.isHidden) {
354        this.isHidden = nextHidden;
355        this.el.classList.toggle('is-hidden', nextHidden);
356        this.applyStickyOffset(nextHidden);
357      }
358      this.lastY = y;
359    },
360    /** Quando header visibile: --sticky-top = headerH + 16px buffer.
361     *  Quando hidden: torna al base 30px. Le sidebar transitano via CSS `transition: top .28s`. */
362    applyStickyOffset(headerHidden) {
363      const value = headerHidden ? this.baseStickyTop : (this.headerH + 16);
364      document.documentElement.style.setProperty('--sticky-top', value + 'px');
365    }
366  };
367
368  /* ── MOBILE CTA FIXED (scroll > 300px) ── */
369  const MobileCTA = {
370    el: null,
371    init() {
372      this.el = document.getElementById('fm-mob-cta');
373      if (!this.el) return;
374      // Sposta a radice di <body>: evita che transform su antenati rompa position:fixed
375      document.body.appendChild(this.el);
376      window.addEventListener('scroll', () => this.tick(), { passive: true });
377      this.tick();
378      document.querySelectorAll('.fm-cta-modal-trigger').forEach(function (btn) {
379        btn.addEventListener('click', function () { FmCTAModal.open(); });
380      });
381    },
382    tick() {
383      if (!this.el) return;
384      const show = window.scrollY > 300;
385      this.el.classList.toggle('is-visible', show);
386      this.el.setAttribute('aria-hidden', String(!show));
387    }
388  };
389
390  /* ── FORM MODAL (Richiedi Consulenza) ── */
391  const FmCTAModal = {
392    dialog: null,
393    init() {
394      this.dialog = document.getElementById('fm-cta-dialog');
395      if (!this.dialog) return;
396      const closeBtn = this.dialog.querySelector('.fm-cta-dialog-close');
397      if (closeBtn) closeBtn.addEventListener('click', () => this.close());
398      this.dialog.addEventListener('click', (e) => { if (e.target === this.dialog) this.close(); });
399      document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.dialog.open) this.close(); });
400    },
401    open() {
402      if (!this.dialog) return;
403      this.dialog.showModal();
404      document.body.classList.add('fm-no-scroll');
405    },
406    close() {
407      if (!this.dialog) return;
408      this.dialog.close();
409      document.body.classList.remove('fm-no-scroll');
410    }
411  };
412
413  /* ── INIT ── */
414  document.addEventListener('DOMContentLoaded', () => {
415    fiscoSearch.init();
416    LiveSearch.init();
417    initDropdowns();
418    ArticleProgress.init();
419    fmSyncFooterDetails();
420    window.matchMedia('(min-width:769px)').addEventListener('change', fmSyncFooterDetails);
421    MobileCTA.init();
422    FmCTAModal.init();
423    StickyHeader.init();
424
425    // Expose globally for header.php onclick
426    window.fiscoSearch = fiscoSearch;
427    window.fiscoArticleProgress = ArticleProgress;
428
429    if (typeof fiscoTickerData !== 'undefined') {
430      Ticker.init(fiscoTickerData.posts || []);
431    }
432  });
433
434})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.