1/** 2 * OUR SERVICES â Interactive JS (v6 "Five-Panel" Edition) 3 * TAHDCO Website Rewamp â app/views/partials/schemes-apply.php 4 * ============================================================ 5 * Drives all 5 panels rendered by the PHP partial: 6 * 1. Schemes (one explorer per tab key) â left-nav + detail cards 7 * 2. TNCWWB â same explorer pattern 8 * 3. Skill Development â card grid + live counters 9 * 4. Industries â sub-sector filter pills 10 * 5. Tenders â intro stats + login card 11 * 12 * Highlights over the previous version: 13 * - Single "active category" model: only ONE autoplay timer ever 14 * runs at a time (old version leaked a setInterval per category). 15 * - Animated count-up for the UPSC "success" counter AND the 16 * Tenders stats row (parses prefixes/suffixes like "â¹240 Cr"). 17 * - Staggered reveal-in animation for Skill cards, Industry detail 18 * blocks and Tender cards whenever their tab is opened. 19 * - Industries sub-sector pills wired up (click + arrow-key nav). 20 * - Swipe-left / swipe-right on touch devices to move between 21 * scheme detail cards. 22 * - Full keyboard support + correct aria-selected/aria-hidden sync. 23 * - Respects prefers-reduced-motion (and reacts live if it changes). 24 * ============================================================ 25 */ 26(function () { 27 'use strict'; 28 29 /* ============================================================ 30 0. CONFIG, SELECTORS & STATE 31 ============================================================ */ 32 const CONFIG = { 33 autoplayDelay: 6000, // ms between auto-advances in a scheme explorer 34 colorDuration: 550, // ms accent colour cross-fade 35 counterDuration: 1800, // ms count-up animation 36 revealStep: 70, // ms stagger between sibling cards 37 revealDuration: 600, // ms per-item reveal animation 38 swipeThreshold: 48, // px horizontal distance to count as a swipe 39 }; 40 41 // Fallback accent palette â mirrors $schemeColorPalette in the PHP partial 42 const PALETTE = ['#1565c0', '#0d7a6b', '#b45309', '#6d28d9', '#be185d', '#15803d']; 43 44 const SEL = { 45 section: '.sa-section', 46 tabsWrap: '.sa-tabs-wrap', 47 catTabs: '.sa-category-tabs', 48 catBtn: '.sa-cat-btn', 49 50 explorer: '.sa-explorer', 51 schemeNav: '.sa-scheme-nav', 52 schemeBtn: '.sa-scheme-btn', 53 detailCard: '.sa-detail-card', 54 navProgress: '.sa-nav-progress', 55 detailWrap: '.sa-detail-wrap', 56 57 skillPanel: '.sa-skill-panel', 58 skCard: '.sa-sk-card', 59 skCount: '.sa-sk-success__count', 60 61 indPanel: '.sa-ind-panel', 62 indFilter: '.sa-ind-filter', 63 indDetail: '.sa-ind-detail', 64 indModel: '.sa-ind-model', 65 indIntro: '.sa-ind-intro', 66 67 tenderPanel: '.sa-tenders-panel', 68 tenderHero: '.sa-tender-hero', 69 tenderStat: '.sa-tender-stat-item', 70 tenderNum: '.sa-tender-stat-item__num', 71 tenderCard: '.sa-tender-card', 72 }; 73 74 let REDUCED_MOTION = !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches); 75 if (window.matchMedia) { 76 const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); 77 const onMqChange = function (e) { REDUCED_MOTION = e.matches; }; 78 if (mq.addEventListener) mq.addEventListener('change', onMqChange); 79 else if (mq.addListener) mq.addListener(onMqChange); // older Safari 80 } 81 82 const autoplayTimers = {}; // { [category]: intervalId } 83 const colorAnims = {}; // { [category]: rafId } 84 let activeCategory = null; 85 let sectionVisible = true; 86 87 /* ============================================================ 88 1. GENERIC HELPERS 89 ============================================================ */ 90 function qsa(selector, scope) { return Array.prototype.slice.call((scope || document).querySelectorAll(selector)); } 91 function q(selector, scope) { return (scope || document).querySelector(selector); } 92 93 function debounce(fn, wait) { 94 let t; 95 return function () { 96 const args = arguments; 97 clearTimeout(t);
98 t = setTimeout(function () { fn.apply(null, args); }, wait); 99 }; 100 } 101 102 function colorForIndex(i) { return PALETTE[i % PALETTE.length]; } 103 104 function hexToRgb(hex) { 105 if (!hex) return null; 106 hex = String(hex).trim().replace('#', ''); 107 if (hex.length === 3) hex = hex.split('').map(function (c) { return c + c; }).join(''); 108 if (hex.length !== 6) return null; 109 const num = parseInt(hex, 16); 110 if (isNaN(num)) return null; 111 return [(num >> 16) & 255, (num >> 8) & 255, num & 255]; 112 } 113 114 function getCssColor(el) { 115 if (!el) return null; 116 const v = getComputedStyle(el).getPropertyValue('--scheme-color'); 117 return v ? v.trim() : null; 118 } 119 120 function explorerFor(category) { return q(SEL.explorer + '[data-sa-panel="' + category + '"]'); } 121 122 /** 123 * Centers `target` inside `container`'s own horizontal scrollbar by 124 * adjusting container.scrollLeft directly. 125 * 126 * Deliberately NOT using Element.scrollIntoView() here â that API walks 127 * every scrollable ancestor up to (and including) the browser window 128 * and tries to "center" the target in each one. On this page that meant 129 * clicking a tab on the right side of the bar (Skill Development / 130 * Industries / Tenders) would also re-center the whole WINDOW 131 * horizontally, visibly shifting the entire section left. scrollBy() 132 * only ever touches the element it's called on, so it can never escape 133 * to the window. 134 */ 135 function scrollPillIntoView(container, target) { 136 if (!container || !target) return; 137 // Only meaningful if this container actually scrolls horizontally. 138 if (container.scrollWidth <= container.clientWidth + 1) return; 139 140 const cRect = container.getBoundingClientRect(); 141 const tRect = target.getBoundingClientRect(); 142 const delta = (tRect.left + tRect.width / 2) - (cRect.left + cRect.width / 2); 143 if (Math.abs(delta) < 1) return; 144 145 container.scrollBy({ left: delta, behavior: REDUCED_MOTION ? 'auto' : 'smooth' }); 146 } 147 148 /* ============================================================ 149 2. ACCENT COLOUR (per explorer) 150 ============================================================ */ 151 function setAccentInstant(explorer, hex) { 152 const rgb = hexToRgb(hex); 153 if (!rgb || !explorer) return; 154 explorer.style.setProperty('--sa-r', rgb[0]); 155 explorer.style.setProperty('--sa-g', rgb[1]); 156 explorer.style.setProperty('--sa-b', rgb[2]); 157 } 158 159 function animateAccent(explorer, hex) { 160 const target = hexToRgb(hex); 161 if (!target || !explorer) return; 162 163 if (REDUCED_MOTION) { setAccentInstant(explorer, hex); return; } 164 165 const cs = getComputedStyle(explorer); 166 const start = [ 167 parseFloat(cs.getPropertyValue('--sa-r')) || target[0], 168 parseFloat(cs.getPropertyValue('--sa-g')) || target[1], 169 parseFloat(cs.getPropertyValue('--sa-b')) || target[2], 170 ]; 171 172 const key = explorer.dataset.saPanel || 'default'; 173 if (colorAnims[key]) cancelAnimationFrame(colorAnims[key]); 174 175 const t0 = performance.now(); 176 function step(now) { 177 const t = Math.min(1, (now - t0) / CONFIG.colorDuration); 178 const e = 1 - Math.pow(1 - t, 3); 179 explorer.style.setProperty('--sa-r', Math.round(start[0] + (target[0] - start[0]) * e)); 180 explorer.style.setProperty('--sa-g', Math.round(start[1] + (target[1] - start[1]) * e)); 181 explorer.style.setProperty('--sa-b', Math.round(start[2] + (target[2] - start[2]) * e)); 182 if (t < 1) colorAnims[key] = requestAnimationFrame(step); 183 else delete colorAnims[key]; 184 } 185 colorAnims[key] = requestAnimationFrame(step); 186 } 187 188 /* ============================================================ 189 3. SCHEME EXPLORER (Schemes tabs + TNCWWB) â colours, dots, bars 190 ============================================================ */ 191 function ensureSwatch(btn) { 192 if (q('.sa-scheme-btn__swatch', btn)) return; 193 const swatch = document.createElement('span'); 194 swatch.className = 'sa-scheme-btn__swatch'; 195 swatch.setAttribute('aria-hidden', 'true'); 196 btn.insertBefore(swatch, btn.firstChild); 197 } 198 function ensureBar(btn) { 199 let bar = q('.sa-scheme-btn__bar', btn); 200 if (!bar) { 201 bar = document.createElement('span'); 202 bar.className = 'sa-scheme-btn__bar'; 203 bar.setAttribute('aria-hidden', 'true'); 204 btn.appendChild(bar); 205 } 206 bar.style.setProperty('--sa-autoplay-duration', CONFIG.auto
206playDelay + 'ms'); 207 } 208 209 function assignColors() { 210 qsa(SEL.explorer).forEach(function (explorer) { 211 if (!q(SEL.schemeNav, explorer)) return; // skip skill / industries / tenders panels 212 213 const category = explorer.dataset.saPanel; 214 const btns = qsa(SEL.schemeBtn + '[data-sa-category="' + category + '"]', explorer); 215 const cards = qsa(SEL.detailCard, explorer); 216 if (!btns.length) return; 217 218 const colors = btns.map(function (btn, i) { 219 const color = btn.dataset.saColor || (cards[i] && cards[i].dataset.saColor) || colorForIndex(i); 220 btn.style.setProperty('--scheme-color', color); 221 if (cards[i]) cards[i].style.setProperty('--scheme-color', color); 222 ensureSwatch(btn); 223 ensureBar(btn); 224 return color; 225 }); 226 227 let activeIndex = cards.findIndex(function (c) { return c.classList.contains('is-active'); }); 228 if (activeIndex < 0) activeIndex = 0;
229 setAccentInstant(explorer, colors[activeIndex] || colorForIndex(0)); 230 }); 231 } 232 233 function buildProgressDots(explorer) { 234 const category = explorer.dataset.saPanel; 235 const nav = q(SEL.schemeNav, explorer); 236 const btns = qsa(SEL.schemeBtn + '[data-sa-category="' + category + '"]', explorer); 237 if (!nav || btns.length < 2) return; 238 239 const old = q(SEL.navProgress, explorer); 240 if (old) old.remove(); 241 242 const progress = document.createElement('div'); 243 progress.className = 'sa-nav-progress'; 244 progress.setAttribute('aria-hidden', 'true'); 245 246 btns.forEach(function (btn, i) { 247 const dot = document.createElement('span'); 248 dot.className = 'sa-nav-dot' + (btn.classList.contains('is-active') ? ' is-active' : ''); 249 dot.style.setProperty('--scheme-color', getCssColor(btn) || colorForIndex(i)); 250 progress.appendChild(dot); 251 }); 252 nav.appendChild(progress); 253 } 254 255 function updateProgressDots(explorer, activeIndex) { 256 qsa('.sa-nav-dot', explorer).forEach(function (dot, i) { 257 dot.classList.toggle('is-active', i === activeIndex); 258 }); 259 } 260 261 function restartAutoplayBar(activeBtn) { 262 if (!activeBtn) return; 263 const category = activeBtn.dataset.saCategory; 264 qsa(SEL.schemeBtn + '[data-sa-category="' + category + '"]').forEach(function (b) { 265 const bar = q('.sa-scheme-btn__bar', b); 266 if (bar) bar.classList.remove('is-running'); 267 }); 268 if (REDUCED_MOTION) return; 269 const bar = q('.sa-scheme-btn__bar', activeBtn); 270 if (!bar) return; 271 void bar.offsetWidth; // force reflow so the animation restarts cleanly 272 bar.classList.add('is-running'); 273 } 274 275 function pauseAutoplayBars(category) { 276 qsa(SEL.schemeBtn + '[data-sa-category="' + category + '"]').forEach(function (b) { 277 const bar = q('.sa-scheme-btn__bar', b); 278 if (bar) bar.classList.remove('is-running'); 279 }); 280 } 281 282 /* ââ Switching the active scheme inside one explorer âââââââââââ */ 283 function switchScheme(category, targetIndex) { 284 const explorer = explorerFor(category); 285 if (!explorer) return; 286 287 const schemeBtns = qsa(SEL.schemeBtn + '[data-sa-category="' + category + '"]', explorer); 288 const cards = qsa(SEL.detailCard, explorer); 289 if (!cards[targetIndex]) return; 290 291 const currentIndex = cards.findIndex(function (c) { return c.classList.contains('is-active'); }); 292 if (currentIndex === targetIndex) return; 293 294 if (currentIndex > -1) { 295 const currentCard = cards[currentIndex]; 296 currentCard.classList.add('is-leaving'); 297 currentCard.classList.remove('is-active'); 298 currentCard.setAttribute('aria-hidden', 'true'); 299 300 const cleanup = function (e) { 301 if (e && e.propertyName !== 'opacity') return; 302 currentCard.classList.remove('is-leaving'); 303 currentCard.removeEventListener('transitionend', cleanup); 304 }; 305 currentCard.addEventListener('transitionend', cleanup); 306 setTimeout(cleanup, REDUCED_MOTION ? 0 : 480); // safety net 307 } 308 309 const targetCard = cards[targetIndex]; 310 const activate = function () { 311 targetCard.classList.add('is-active'); 312 targetCard.setAttribute('aria-hidden', 'false'); 313 }; 314 if (REDUCED_MOTION) activate(); 315 else setTimeout(activate, 50); 316 317 schemeBtns.forEach(function (btn, i) { 318 const isActive = i === targetIndex; 319 btn.classList.toggle('is-active', isActive); 320 btn.setAttribute('aria-selected', isActive ? 'true' : 'false'); 321 }); 322 323 updateProgressDots(explorer, targetIndex); 324 animateAccent(explorer, getCssColor(targetCard) || colorForIndex(targetIndex)); 325 if (schemeBtns[targetIndex]) restartAutoplayBar(schemeBtns[targetIndex]); 326 } 327 328 function initSchemeBtns() {
329 qsa(SEL.schemeBtn).forEach(function (btn) { 330 btn.addEventListener('click', function () { 331 const explorer = btn.closest(SEL.explorer); 332 const category = btn.dataset.saCategory; 333 const siblings = qsa(SEL.schemeBtn + '[data-sa-category="' + category + '"]', explorer || document); 334 const index = siblings.indexOf(btn); 335 336 stopAutoplay(category); 337 switchScheme(category, index); 338 if (activeCategory === category) startAutoplay(category); 339 }); 340 341 // Up/Down arrow roving navigation within a scheme nav list 342 btn.addEventListener('keydown', function (e) { 343 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); btn.click(); return; } 344 if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; 345 346 e.preventDefault(); 347 const explorer = btn.closest(SEL.explorer); 348 const category = btn.dataset.saCategory; 349 const siblings = qsa(SEL.schemeBtn + '[data-sa-category="' + category + '"]', explorer || document); 350 const i = siblings.indexOf(btn); 351 const nextI = e.key === 'ArrowDown' ? (i + 1) % siblings.length : (i - 1 + siblings.length) % siblings.length; 352 siblings[nextI].focus(); 353 siblings[nextI].click(); 354 }); 355 }); 356 } 357 358 /* ============================================================ 359 4. AUTOPLAY â exactly one running timer at any moment 360 ============================================================ */ 361 function startAutoplay(category) { 362 if (!category) return; 363 stopAutoplay(category); 364 if (REDUCED_MOTION || !sectionVisible) return; 365 366 const explorer = explorerFor(category); 367 if (!explorer) return; 368 const cards = qsa(SEL.detailCard, explorer); 369 if (cards.length <= 1) return; 370 371 const activeBtn = q(SEL.schemeBtn + '.is-active[data-sa-category="' + category + '"]', explorer); 372 if (activeBtn) restartAutoplayBar(activeBtn); 373 374 autoplayTimers[category] = setInterval(function () { 375 const current = cards.findIndex(function (c) { return c.classList.contains('is-active'); }); 376 switchScheme(category, (current + 1) % cards.length); 377 }
377, CONFIG.autoplayDelay); 378 } 379 380 function stopAutoplay(category) { 381 if (!category) return; 382 if (autoplayTimers[category]) { clearInterval(autoplayTimers[category]); delete autoplayTimers[category]; } 383 pauseAutoplayBars(category); 384 } 385 386 function setActiveCategory(category) { 387 if (activeCategory && activeCategory !== category) stopAutoplay(activeCategory); 388 activeCategory = category; 389 startAutoplay(category); 390 } 391 392 function initPauseOnHover() { 393 qsa(SEL.explorer).forEach(function (explorer) { 394 if (!q(SEL.schemeNav, explorer)) return; 395 const category = explorer.dataset.saPanel; 396 397 explorer.addEventListener('mouseenter', function () { stopAutoplay(category); }); 398 explorer.addEventListener('focusin', function () { stopAutoplay(category); }); 399 explorer.addEventListener('mouseleave', function () { if (activeCategory === category) startAutoplay(category); }); 400 explorer.addEventListener('focusout', function (e) { 401 if (activeCategory === category && !explorer.contains(e.relatedTarget)) startAutoplay(category); 402 }); 403 }); 404 } 405 406 function initVisibilityObserver() { 407 if (!window.IntersectionObserver) return; 408 const section = q(SEL.section); 409 if (!section) return; 410 411 new IntersectionObserver(function (entries) { 412 entries.forEach(function (entry) { 413 sectionVisible = entry.isIntersecting; 414 if (sectionVisible) startAutoplay(activeCategory); 415 else stopAutoplay(activeCategory); 416 }); 417 }, { threshold: 0.25 }).observe(section); 418 } 419 420 /* ============================================================ 421 5. SWIPE GESTURES (touch) â move between scheme detail cards 422 ============================================================ */ 423 function initSwipeGestures() { 424 qsa(SEL.detailWrap).forEach(function (wrap) { 425 let startX = 0, startY = 0, tracking = false; 426 427 wrap.addEventListener('touchstart', function (e) { 428 const t = e.touches[0]; 429 startX = t.clientX; startY = t.clientY; tracking = true; 430 }, { passive: true }); 431 432 wrap.addEventListener('touchend', function (e) { 433 if (!tracking) return; 434 tracking = false; 435 const t = e.changedTouches[0]; 436 const dx = t.clientX - startX, dy = t.clientY - startY; 437 if (Math.abs(dx) < CONFIG.swipeThreshold || Math.abs(dx) < Math.abs(dy)) return; 438 439 const explorer = wrap.closest(SEL.explorer); 440 if (!explorer) return; 441 const category = explorer.dataset.saPanel; 442 const cards = qsa(SEL.detailCard, explorer); 443 const current = cards.findIndex(function (c) { return c.classList.contains('is-active'); }); 444 if (current < 0) return; 445 446 let next = dx < 0 ? current + 1 : current - 1; 447 if (next < 0) next = cards.length - 1; 448 if (next >= cards.length) next = 0; 449 450 stopAutoplay(category); 451 switchScheme(category, next); 452 if (activeCategory === category) startAutoplay(category); 453 }, { passive: true }); 454 }); 455 } 456 457 /* ============================================================ 458 6. NUMBER COUNT-UP (Skill success counter + Tender stats) 459 ============================================================ */ 460 function parseNumberText(text) { 461 const match = String(text || '').trim().match(/^([^\d]*)([\d,]+(?:\.\d+)?)(.*)$/); 462 if (!match) return null; 463 const numStr = match[2]; 464 return { 465 prefix: match[1], 466 suffix: match[3], 467 value: parseFloat(numStr.replace(/,/g, '')), 468 decimals: (numStr.split('.')[1] || '').length, 469 useComma: numStr.indexOf(',') !== -1, 470 }; 471 } 472 473 function formatNumber(num, decimals, useComma) { 474 let str = num.toFixed(decimals); 475 if (useComma) { 476 const parts = str.split('.'); 477 parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ','); 478 str = parts.join('.'); 479 } 480 return str; 481 } 482 483 function prepareCounter(el) { 484 if (el.dataset.targetCaptured === 'true') return; 485 el.dataset.target = el.dataset.target !== undefined ? el.dataset.target : el.textContent.trim(); 486 el.dataset.targetCaptured = 'true'; 487 } 488 489 function animateCounter(el) { 490 if (!el || el.dataset.counting === 'true') return; 491 const parsed = parseNumberText(el.dataset.target); 492 if (!parsed) return; 493 494 const render = function (value) { 495 el.textContent = parsed.prefix + formatNumber(value, parsed.decimals, parsed.useComma) + parsed.suffix; 496 }; 497 498 if (REDUCED_MOTION) { render(parsed.value); return; } 499 500 el.dataset.counting = 'true'; 501 render(0); 502 const t0 = performance.now(); 503 function step(now) { 504 const t = Math.min(1, (now - t0) / CONFIG.counterDuration); 505 const eased = 1 - Math.pow(1 - t, 3); 506 render(parsed.value * eased); 507 if (t < 1) requestAnimationFrame(step); 508 else { render(parsed.value); el.dataset.counting = 'false'; } 509 } 510 requestAnimationFrame(step); 511 } 512 513 function runCounters(scopeEl, selector) { 514 qsa(selector, scopeEl).forEach(function (el) { 515 prepareCounter(el); 516 animateCounter(el); 517 }); 518 } 519 520 /* ============================================================ 521 7. STAGGERED REVEAL â fades/lifts a group of cards into view 522 ============================================================ */ 523 function revealStagger(scopeEl, selector) { 524 if (REDUCED_MOTION) return; 525 qsa(selector, scopeEl).forEach(function (item, i) {
526 item.style.transition = 'none'; 527 item.style.opacity = '0'; 528 item.style.transform = 'translateY(18px)'; 529 void item.offsetWidth; // force reflow 530 const delay = i * CONFIG.revealStep; 531 item.style.transition = 532 'opacity ' + CONFIG.revealDuration + 'ms cubic-bezier(.22,1,.36,1) ' + delay + 'ms, ' + 533 'transform ' + CONFIG.revealDuration + 'ms cubic-bezier(.22,1,.36,1) ' + delay + 'ms'; 534 requestAnimationFrame(function () { 535 item.style.opacity = '1'; 536 item.style.transform = 'none'; 537 }); 538 }); 539 } 540 541 /* ============================================================ 542 8. INDUSTRIES PANEL â sub-sector filter pills 543 ============================================================ */ 544 function initIndustriesFilters() { 545 const panel = q(SEL.indPanel); 546 if (!panel) return; 547 548 const filters = qsa(SEL.indFilter, panel); 549 const details = qsa(SEL.indDetail, panel); 550 if (!filters.length) return; 551 552 filters.forEach(function (btn) { 553 btn.setAttribute('aria-pressed', btn.classList.contains('is-active') ? 'true' : 'false'); 554 }); 555 556 function activate(key) { 557 filters.forEach(function (f) { 558 const match = f.dataset.indKey === key; 559 f.classList.toggle('is-active', match); 560 f.setAttribute('aria-pressed', match ? 'true' : 'false'); 561 }); 562 details.forEach(function (d) { d.classList.toggle('is-active', d.dataset.indDetail === key); }); 563 564 const activeDetail = details.find(function (d) { return d.dataset.indDetail === key; }); 565 if (activeDetail) { 566 revealStagger(activeDetail, SEL.indModel); 567 revealStagger(panel, SEL.indIntro); 568 } 569 } 570 571 filters.forEach(function (btn, i) { 572 btn.addEventListener('click', function () { 573 activate(btn.dataset.indKey); 574 // NOTE: deliberately no scrollIntoView here â .sa-ind-filters wraps 575 // (no horizontal overflow), so there is nothing to scroll, and 576 // scrollIntoView can otherwise bubble up and shift the whole page. 577 }); 578 btn.addEventListener('keydown', function (e) { 579 if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return; 580 e.preventDefault(); 581 const nextI = e.key === 'ArrowRight' ? (i + 1) % filters.length : (i - 1 + filters.length) % filters.length; 582 filters[nextI].focus(); 583 activate(filters[nextI].dataset.indKey); 584 }); 585 }); 586 } 587 588 /* ============================================================ 589 9. PANEL ACTIVATION â extras beyond simple show/hide 590 ============================================================ */ 591 function activatePanelExtras(category) { 592 const explorer = explorerFor(category); 593 if (!explorer) return; 594 595 if (explorer.classList.contains('sa-skill-panel')) { 596 return; // stp render handles its own transitions 597 } 598 599 if (explorer.classList.contains('sa-ind-panel')) { 600 const activeDetail = q(SEL.indDetail + '.is-active', explorer) || q(SEL.indDetail, explorer); 601 if (activeDetail) revealStagger(activeDetail, SEL.indModel); 602 revealStagger(explorer, SEL.indIntro); 603 return; 604 } 605 606 if (explorer.classList.contains('sa-tenders-panel')) { 607 revealStagger(explorer, SEL.tenderStat); 608 revealStagger(explorer, SEL.tenderCard); 609 runCounters(explorer, SEL.tenderNum); 610 return; 611 } 612 613 // Schemes / TNCWWB style explorer 614 if (q(SEL.schemeNav, explorer)) setActiveCategory(category); 615 } 616 617 /* ============================================================ 618 10. CATEGORY TABS (top pill bar) 619 ============================================================ */ 620 function ensureTabIndicator(tabsEl) { 621 let indicator = q('.sa-cat-indicator', tabsEl); 622 if (!indicator) { 623 indicator = document.createElement('span'); 624 indicator.className = 'sa-cat-indicator'; 625 indicator.setAttribute('aria-hidden', 'true'); 626 tabsEl.insertBefore(indicator, tabsEl.firstChild); 627 } 628 return indicator; 629 } 630 631 function updateTabIndicator(tabsEl) { 632 const indicator = ensureTabIndicator(tabsEl); 633 const active = q(SEL.catBtn + '.is-active', tabsEl); 634 if (!active) { indicator.style.width = '0px'; return; } 635 indicator.style.width = active.offsetWidth + 'px'; 636 indicator.style.transform = 'translateX(' + active.offsetLeft + 'px)'; 637 } 638 639 function goToCategory(cat, btn) { 640 if (!cat) return; 641
642 qsa(SEL.catBtn).forEach(function (b) { b.classList.toggle('is-active', b.dataset.saCategory === cat); }); 643 qsa(SEL.explorer).forEach(function (panel) { panel.classList.toggle('is-active', panel.dataset.saPanel === cat); }); 644 645 const tabsEl = (btn && btn.closest(SEL.catTabs)) || q(SEL.catTabs); 646 if (tabsEl) updateTabIndicator(tabsEl); 647 648 activatePanelExtras(cat); 649 650 // Keep the clicked tab visible inside the pill bar's own scrollbar 651 // ONLY (mobile, when the bar overflows) â never the window. See 652 // scrollPillIntoView() for why scrollIntoView() is avoided here. 653 const wrapEl = (btn && btn.closest(SEL.tabsWrap)) || q(SEL.tabsWrap); 654 if (btn && wrapEl) scrollPillIntoView(wrapEl, btn); 655 } 656 657 function initCategoryTabs() { 658 qsa(SEL.catTabs).forEach(function (tabsEl) { ensureTabIndicator(tabsEl); updateTabIndicator(tabsEl); }); 659 660 const allBtns = qsa(SEL.catBtn); 661 allBtns.forEach(function (btn, i) { 662 btn.addEventListener('click', function () { goToCategory(btn.dataset.saCategory, btn); }); 663 664 btn.addEventListener('keydown', function (e) { 665 if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return; 666 e.preventDefault(); 667 const nextI = e.key === 'ArrowRight' ? (i + 1) % allBtns.length : (i - 1 + allBtns.length) % allBtns.length; 668 allBtns[nextI].focus(); 669 goToCategory(allBtns[nextI].dataset.saCategory, allBtns[nextI]); 670 }); 671 }); 672 } 673 674 /* ============================================================ 675 11. BOOT 676 ============================================================ */ 677 /* ââ Skill Development panel (embedded /skilldev design) ââ */ 678 let STP_CATEGORIES = []; 679 let STP_BY_ID = {}; 680 681 function stpEscapeHtml(str) { 682 if (str === undefined || str === null) return ''; 683 return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>'); 684 } 685 function stpEscapeAttr(str) { return stpEscapeHtml(str).replace(/"/g, '"'); } 686 function stpIsExternal(url) { return !!url && /^https?:\/\//i.test(url); } 687 function stpExtAttr(url) { return stpIsExternal(url) ? ' target="_blank" rel="noopener noreferrer"' : ''; } 688 689 function stpRender(cat) { 690 const detail = document.getElementById('stpDetailHome'); 691 if (!detail) return; 692 693 const tagsHtml = (cat.tags || []).map(function (t) { 694 return '<span class="stp-tag">' + stpEscapeHtml(t) + '</span>'; 695 }).join(''); 696 697 const statsHtml = (cat.stats || []).map(function (s) { 698 return '<div class="stp-stat">' + 699 '<span class="stp-stat__icon"><i class="bi ' + s.icon + '"></i></span>' + 700 '<div>' + 701 '<div class="stp-stat__value">' + stpEscapeHtml(s.value) + '</div>' + 702 '<div class="stp-stat__label">' + stpEscapeHtml(s.label) + '</div>' + 703 '</div></div>'; 704 }).join(''); 705 706 const programmesHtml = (cat.programmes || []).map(function (p) { 707 return '<div class="stp-programme">' + 708 '<span class="stp-programme__icon"><i class="bi bi-mortarboard-fill"></i></span>' + 709 '<div class="stp-programme__body">' + 710 '<h4 class="stp-programme__title">' + stpEscapeHtml(p.title) + '</h4>' + 711 '<p class="stp-programme__desc">' + stpEscapeHtml(p.description) + '</p>' + 712 '</div>' + 713 '<div class="stp-programme__actions">' + 714 '<a class="stp-action" href="' + stpEscapeAttr(p.view_link) + '"' + stpExtAttr(p.view_link) + '><i class="bi bi-eye"></i>View</a>' + 715 '<a class="stp-action" href="' + stpEscapeAttr(p.brochure_link) + '"' + stpExtAttr(p.brochure_link) + '><i class="bi bi-file-earmark-pdf"></i>Brochure</a>' + 716 '<a class="stp-action" href="' + stpEscapeAttr(p.video_link) + '"' + stpExtAttr(p.video_link) + '><i class="bi bi-play-circle"></i>Video</a>' + 717 '<a class="stp-programme__apply" href="' + stpEscapeAttr(p.apply_link) + '"' + stpExtAttr(p.apply_link) + '>Apply Online</a>' + 718 '</div></div>'; 719 }).join(''); 720 721 const assetBase = window.STP_ASSET_BASE_HOME || ''; 722 723 detail.innerHTML = 724 '<div class="stp-detail__inner">' + 725 '<div class="stp-detail__top">' + 726 '<div class="stp-detail__image">' + 727 '<img src="' + assetBase + cat.image + '" alt="' + stpEscapeHtml(cat.label) + '" ' + 728 'onerror="this.parentElement.style.background=\'color-mix(in srgb, var(--stp-accent) 15%, white)\'; this.remove();">' + 729 '</div>' + 730 '<div class="stp-detail__info">' + 731 '<div class="stp-detail__heading">' + 732 '<span class="stp-detail__icon"><i class="bi ' + cat.icon + '"></i></span>' + 733 '<h3>' + stpEscapeHtml(cat.label) + '</h3>' + 734 '</div>' + 735 '<p class="stp-detail__desc">' + stpEscapeHtml(cat.description) + '</p>' + 736 '<div class="stp-detail__tags">' + tagsHtml + '</div>' + 737 '<div class="stp-detail__stats">' + statsHtml + '</div>' + 738 '</div>' + 739 '</div>' + 740 '<h4 class="stp-programmes__title">Available Programmes in ' + stpEscapeHtml(cat.label) + '</h4>' + 741 programmesHtml + 742 '</div>'; 743 } 744 745 function switchSkillCategory(catId) { 746 const cat = STP_BY_ID[catId]; 747 const detail = document.getElementById('stpDetailHome'); 748 if (!cat || !detail) return; 749
750 qsa('.stp-category', q(SEL.skillPanel)).forEach(function (btn) { 751 btn.classList.toggle('is-active', btn.getAttribute('data-category-id') === catId); 752 }); 753 754 detail.style.setProperty('--stp-accent', cat.color); 755 detail.classList.add('is-fading'); 756 757 window.setTimeout(function () { 758 stpRender(cat); 759 detail.classList.remove('is-fading'); 760 }, 160); 761 } 762 763 function initSkillPanel() { 764 const panel = q(SEL.skillPanel); 765 if (!panel) return; 766 767 const dataEl = document.getElementById('stpDataHome'); 768 if (!dataEl) return; 769 770 try { 771 STP_CATEGORIES = JSON.parse(dataEl.textContent || '[]'); 772 } catch (e) { 773 STP_CATEGORIES = []; 774 } 775 STP_BY_ID = {}; 776 STP_CATEGORIES.forEach(function (c) { STP_BY_ID[c.id] = c; }); 777 778 qsa('.stp-category', panel).forEach(function (btn) { 779 btn.addEventListener('click', function () { 780 switchSkillCategory(btn.getAttribute('data-category-id')); 781 }); 782 }); 783 784 // Initial render 785 const initialId = window.STP_DEFAULT_CATEGORY_HOME || (STP_CATEGORIES[0] && STP_CATEGORIES[0].id); 786 if (initialId && STP_BY_ID[initialId]) { 787 const detail = document.getElementById('stpDetailHome'); 788 if (detail) detail.style.setProperty('--stp-accent', STP_BY_ID[initialId].color); 789 stpRender(STP_BY_ID[initialId]); 790 } 791 } 792 793 function init() { 794 assignColors(); 795 initSchemeBtns(); 796 initCategoryTabs(); 797 initIndustriesFilters(); 798 initSkillPanel(); 799 initSwipeGestures(); 800 801 qsa(SEL.explorer).forEach(function (explorer) { 802 if (q(SEL.schemeNav, explorer)) buildProgressDots(explorer); 803 }); 804 805 initPauseOnHover(); 806 initVisibilityObserver(); 807 808 // Run the extras (counters / reveal / autoplay) for whichever panel 809 // the server already rendered as active. 810 const initialBtn = q(SEL.catBtn + '.is-active') || q(SEL.catBtn); 811 const initialCategory = initialBtn ? initialBtn.dataset.saCategory : (q(SEL.explorer) || {}).dataset && q(SEL.explorer).dataset.saPanel; 812 requestAnimationFrame(function () { activatePanelExtras(initialCategory); }); 813 814 window.addEventListener('resize', debounce(function () { 815 qsa(SEL.catTabs).forEach(updateTabIndicator); 816 }, 150)); 817 818 window.addEventListener('load', function () { 819 qsa(SEL.catTabs).forEach(updateTabIndicator); 820 }); 821 } 822 823 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); 824 else init(); 825 826})();
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.