1/** 2 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3 * â YETI REPORT KIT â JavaScript Utilities â 4 * â Version: 1.6.4 â 5 * â Zero dependencies (optional SheetJS for .xlsx export). â 6 * â â 7 * â v1.6.4 â Stored student name sorting (opt-in type) â 8 * â â 9 * â v1.6.3 â 'not-assigned' status vocabulary key â 10 * â ⢠STATUS_VOCAB/STATUS_RANK gain 'not-assigned' so a â 11 * â student with no curriculum grant is distinguishable â 12 * â from one who simply hasn't started. Additive only. â 13 * â â 14 * â v1.6.2 â Async export builders â 15 * â ⢠initExportMenu awaits Promise-based sheet builders â 16 * â with loading, success, and error feedback. â 17 * â â 18 * â v1.6.0 â Shared report controls (§21 + report-controls.css)â 19 * â ⢠YR.initReportTabs â one accessible tablist for every â 20 * â report's Table/Graph view switch: exclusive panels â 21 * â via the `hidden` attribute, aria-selected + roving â 22 * â tabindex, Arrow/Home/End keys, idempotent re-init. â 23 * â ⢠report-controls.css â shared control tokens/classes â 24 * â (.yr-report-context/-card-head/-controls/-search/ â 25 * â -tabs/-segment), loaded shell-wide by YetiReportAsset â 26 * â and allow-listed in ajax-nav.js SHELL_CSS_PATHS. â 27 * â â 28 * â v1.5.1 â Opt-in measured inline-detail animation â 29 * â ⢠createDetailManager measured height + state callback â 30 * â â 31 * â v1.5.0 â Reports V2 components (Report 1) â 32 * â ⢠YR.charts â sparkHTML/hbar/line/scatter/heatmap (SVG) â 33 * â ⢠YR.sortTable type-aware comparators (number/date/ â 34 * â status/string) + aria-sort; STATUS_RANK/_comparatorForâ 35 * â ⢠YR.statusHTML master §9 vocabulary (legacy keys kept) â 36 * â â 37 * â v1.4.2 â Persistent modal registry (#2394) â 38 * â ⢠YR.registerPersistentModal(name, api) â modals self- â 39 * â register once; ajax-nav.js runHydrators() pulls the â 40 * â registry after every swap. Replaces per-view persist()â 41 * â boot lists. See YR.persistRegisteredModals(). â 42 * â â 43 * â v1.4.1 â Mix-up filter + miss-chip variant â 44 * â ⢠YR.filterMixups(arr, opts) â exclude unactionable â 45 * â chips (no `from`), apply minCount threshold with â 46 * â fallback for sparse data, sort by count, cap. â 47 * â ⢠YR.mixupChipHTML now renders a "miss" variant when â 48 * â `t` is empty (light amber tint, "missed" label, â 49 * â `.is-miss` class). Misses are a different error type â 50 * â from substitutions and now read as a distinct chip. â 51 * â â 52 * â v1.4.0 â Cross-report consistency components â 53 * â ⢠YR.initDropdown(config) â styled custom dropdown â 54 * â Replaces native <select> with a fully styled, â 55 * â keyboard-navigable, ARIA-compliant popup. Use for â 56 * â class pickers, student pickers, filters. (§18) â
57 * â ⢠YR.skeletonStatCard(tone) â skeleton stat card HTML â 58 * â YR.skeletonTableRow(n, opts) â skeleton table row â 59 * â Use during async filter changes / data fetch. (§19) â 60 * â ⢠YR.initExportMenu(config) â Export â¼ dropdown menu â 61 * â Wraps YR.exportXLSX / YR.exportCSV / window.print â 62 * â with consistent UI + filename convention. (§20) â 63 * â â 64 * â v1.3.4 â Table pagination â 65 * â ⢠YR.initPagination() â client-side pager with windowed â 66 * â page numbers, ellipses, page-size selector â 67 * â ⢠YR.applySortToData() â re-sort helper for filter flow â 68 * â ⢠YR.sortTable now resets state.page = 1 if defined â 69 * â â 70 * â v1.3.3 â Documentation sync (no behavior changes) â 71 * â ⢠SheetJS dependency explicitly documented below â 72 * â ⢠Dev guide updated with Excel export prerequisites â 73 * â â 74 * â v1.3.2 â Real Excel export via SheetJS â 75 * â ⢠YR.exportXLSX() â native .xlsx writer (multi-sheet) â 76 * â ⢠Auto-sized columns, frozen header row, styled header â 77 * â ⢠Requires xlsx.full.min.js loaded on page â 78 * â â 79 * â v1.3.1 â Participation-aware summary averages â 80 * â ⢠YR.computeSummary() now excludes students with â 81 * â sessions=0 from avgWpm/avgAcc/avgStars â 82 * â ⢠New fields: participating, notStarted â 83 * â ⢠Data shape: add `participated` bool or let function â 84 * â infer from `sessions` > 0 â 85 * â â 86 * â v1.3.0 â Mobile compliance (iPad/iPhone fixes) â 87 * â ⢠YR.lockBody() / YR.unlockBody() â iOS-safe scroll â 88 * â lock using position:fixed + scrollY preservation â 89 * â ⢠Sidebar mobile overlay uses lockBody/unlockBody â 90 * â ⢠Accessibility modal uses lockBody/unlockBody â 91 * â ⢠Backward compatible â no HTML or API changes â 92 * â â 93 * â v1.2.0 â Layout init functions + accessibility â 94 * â ⢠YR.initSidebar() â generates sidebar, collapse, â 95 * â hamburger, mobile overlay â 96 * â ⢠YR.initUserMenu() â popup menu with keyboard nav â 97 * â ⢠YR.initAccessibility() â modal, toggles, cookies â 98 * â ⢠YR.headScript() â inline <head> flash prevention â 99 * â ⢠Cookie helpers for preference persistence â 100 * ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 101 * 102 * Usage in Yii2 view: 103 * <link rel="stylesheet" href="/css/yeti-report.css"> 104 * <script src="/js/yeti-report.js" defer></script> 105 * 106 * <!-- Optional: required ONLY if calling YR.exportXLSX() --> 107 * <script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script> 108 * 109 * <script> 110 * YR.initSidebar({ 111 * user: 'Agent Gadgets', 112 * school: 'Citi School 5', 113 * initials: 'AG', 114 * activePage: 'reports', 115 * logoSrc: '/images/TA-logo.svg' 116 * }); 117 * YR.initUserMenu(); 118 * YR.initAccessibility(); 119 * </script> 120 */ 121 122// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 123// §1 â TIER SYSTEM 124// Central logic for mapping values to color tiers. 125// Used by all components: pills, bars, badges, finger cells. 126// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 127 128const YR = {}; 129 130/** 131 * Map an accuracy percentage to a tier name 132 * @param {number} value â 0-100 133 * @param {object} [thresholds] â override defaults 134 * @returns {string} 'excellent'|'good'|'ok'|'warn'|'caution'|'danger' 135 */ 136YR.accTier = function (value, thresholds) { 137 const t = Object.assign( 138 { excellent: 95, good: 90, ok: 85, warn: 80, caution: 70 }, 139 thresholds 140 ); 141 if (value >= t.excellent) return 'excellent'; 142 if (value >= t.good) return 'good'; 143 if (value >= t.ok) return 'ok'; 144 if (value >
144= t.warn) return 'warn'; 145 if (value >= t.caution) return 'caution'; 146 return 'danger'; 147}; 148 149/** 150 * Map a finger strength percentage to a finger tier class 151 * @param {number} value â 0-100 152 * @returns {string} 'yr-f-excellent'|'yr-f-good'|...|'yr-f-danger' 153 */ 154YR.fingerTier = function (value) { 155 if (value >= 90) return 'yr-f-excellent'; 156 if (value >= 80) return 'yr-f-good'; 157 if (value >= 70) return 'yr-f-ok'; 158 if (value >= 60) return 'yr-f-warn'; 159 return 'yr-f-danger'; 160}; 161 162/** 163 * Map WPM to a CSS color variable 164 * @param {number} wpm 165 * @returns {string} CSS var() value 166 */ 167YR.wpmColor = function (wpm) { 168 if (wpm >= 55) return 'var(--yr-excellent)'; 169 if (wpm >= 45) return 'var(--yr-blue)'; 170 if (wpm >= 35) return 'var(--yr-warn)'; 171 return 'var(--yr-danger)'; 172}; 173 174 175// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 176// §2 â HTML BUILDERS 177// Reusable snippet generators. Return HTML strings. 178// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 179 180/** 181 * Render 3 stars (filled/empty) 182 * @param {number} count â 0-3 183 * @returns {string} HTML 184 */ 185YR.starsHTML = function (count) { 186 let html = ''; 187 for (let i = 0; i < 3; i++) { 188 html += i < count 189 ? '<span class="yr-star-on">â </span>' 190 : '<span class="yr-star-off">â </span>'; 191 } 192 return html; 193}; 194 195/** 196 * Stars as plain text (for CSV export) 197 */ 198YR.starsText = function (count) { 199 return 'â '.repeat(count) + 'â'.repeat(3 - count); 200}; 201 202/** 203 * Student avatar circle 204 * @param {string} name â full name 205 * @param {string} color â hex or CSS color 206 * @param {string} [size=''] â 'lg' for large 207 */ 208YR.avatarHTML = function (name, color, size) { 209 // Guard against a blank/missing name so the avatar never renders empty 210 // (QA #2247). Take up to two initials; fall back to '?' when nothing usable. 211 const clean = String(name == null ? '' : name).trim(); 212 const initials = clean 213 ? clean.split(/\s+/).map(w => w[0]).join('').toUpperCase().slice(0, 2) 214 : '?'; 215 const cls = size === 'lg' ? 'yr-avatar yr-avatar-lg' : 'yr-avatar'; 216 return `<div class="${cls}" style="background:${color}">${initials}</div>`; 217}; 218 219/** 220 * Accuracy pill 221 * @param {number} value â 0-100 222 */ 223YR.accPillHTML = function (value) { 224 return `<span class="yr-pill" data-tier="${YR.accTier(value)}">${value}%</span>`; 225}; 226 227/** 228 * WPM cell with mini bar 229 * @param {number} wpm 230 * @param {number} [max=70] â scale reference 231 */ 232YR.wpmCellHTML = function (wpm, max) { 233 max = max || 70; 234 const pct = Math.min(100, (wpm / max) * 100); 235 const color = YR.wpmColor(wpm); 236 return `<div class="yr-wpm-cell"> 237 <span class="yr-wpm-val" style="color:${color}">${wpm}</span> 238 <div class="yr-bar-track w60"> 239 <div class="yr-bar-fill" style="width:${pct}%;background:${color}"></div> 240 </div> 241 </div>`; 242}; 243 244/** 245 * Problem keys mini badges 246 * @param {string[]} topKeys â first 4 keys 247 * @param {number} totalCount â total problem key count 248 */ 249YR.problemKeysHTML = function (topKeys, totalCount) { 250 if (totalCount === 0) { 251 return '<span style="font-size:12px" class="yr-c-muted">None</span>'; 252 } 253 let html = '<div class="yr-key-list">'; 254 topKeys.slice(0, 4).forEach(k => { 255 html += `<div class="yr-key-mini yr-bg-caution">${k}</div>`; 256 }); 257 if (totalCount > 4) { 258 html += `<span class="yr-key-more">+${totalCount - 4}</span>`; 259 } 260 html += '</div>'; 261 return html; 262}; 263 264/** 265 * Status cell with dot. Renders the master §9 cross-report vocabulary 266 * (active, improving, on-track, cooling, stuck, inactive, not-started, 267 * complete) plus the three legacy keys (completed | working | not_started), 268 * which render exactly as before for existing reports. 269 * 270 * @param {string} status â a vocabulary key (see above). 271 */ 272YR.STATUS_VOCAB = { 273 // master §9 274 'active': { color: 'var(--yr-green)', label: 'Active' }, 275 'improving': { color: 'var(--yr-blue)', label: 'Improving' }, 276 'on-track': { color: 'var(--yr-green)', label: 'On track' }, 277 'cooling': { color: 'var(--yr-warn, #F0A202)', label: 'Cooling off' }, 278 'stuck': { color: 'var(--yr-warn, #F0A202)', label: 'Stuck' }, 279 'inactive': { color: 'var(--yr-danger, #D64545)', label: 'Inactive' }, 280 'not-started': { color: '#cdd5de', label: 'Not started' }, 281 'not-assigned': { color: '#cdd5de', label: 'Not assigned' }, 282 'in-progress': { color: 'var(--yr-blue)', label: 'In progress' }, 283 'complete': { color: 'var(--yr-green)', label: 'Complete' }, 284 // legacy keys (unchanged) 285 'completed': { color: 'var(--yr-blue)', label: 'Completed' }, 286 'not_started': { color: '#cdd5de', label: 'Not Started' }, 287 'working': { color: 'var(--yr-green)', label: 'Working' } 288}; 289YR.statusHTML = function (status) { 290 var s = YR.STATUS_VOCAB[status] || YR.STATUS_VOCAB['working']; 291 return '<div class="yr-status">' + 292 '<div class="yr-dot" style="background:' + s.color + '"></div>' + s.label + 293 '</div>'; 294}; 295 296/** 297 * Key accuracy row (for detail panels) 298 * @param {object} key â { k, acc, ms } 299 */ 300YR.keyRowHTML = function (key) { 301 const tier = YR.accTier(key.acc); 302 return `<div class="yr-key-row"> 303 <div class="yr-key-badge yr-bg-${tier}">${key.k}</div> 304 <div class="yr-bar-track full" style="height:4px"> 305 <div class="yr-bar-fill yr-bg-${tier}" data-width="${key.acc}" style="width:0%"></div> 306 </div> 307 <div class="yr-key-pct">${key.acc}%</div> 308 ${key.ms ? `<div class="yr-key-time">${key.ms}ms</div>` : ''} 309 </div>`; 310}; 311 312/** 313 * Mix-up chip â renders an intended-vs-typed pair with count. 314 * 315 * Two variants based on whether `mixup.t` (what they typed) is present: 316 * - Substitution (both `f` and `t`): "d â a 3Ã" 317 * - Miss (only `f`, `t` empty): "d â missed 2Ã" 318 * Rendered with .is-miss class â light amber tint, "missed"
319 * label in the to-position, aria-label + title for a11y. 320 * A miss means the student was expected to type `f` but typed 321 * nothing (skipped / deleted / paused out); it's a distinct and 322 * actionable error type vs. a substitution. 323 * 324 * Chips with no `from` are unactionable (we don't know what the 325 * student was aiming for) and should be excluded upstream via 326 * YR.filterMixups â this function still renders them gracefully 327 * but the result is hard to read. 328 * 329 * @param {object} mixup â { f, t, c } 330 * @param {string} [size=''] â 'lg' for large 331 */ 332YR.mixupChipHTML = function (mixup, size) { 333 var isMiss = !mixup.t || String(mixup.t).length === 0; 334 var classes = 'yr-mixup-chip'; 335 if (size === 'lg') classes += ' lg'; 336 if (isMiss) classes += ' is-miss'; 337 338 var toCell = isMiss 339 ? '<span class="yr-mixup-to yr-mixup-miss-label">missed</span>' 340 : '<span class="yr-mixup-to">' + mixup.t + '</span>'; 341 var ariaAttrs = isMiss 342 ? ' aria-label="Missed key: ' + mixup.f + ', ' + mixup.c + ' times" title="Missed keystroke"' 343 : ' aria-label="' + mixup.f + ' typed as ' + mixup.t + ', ' + mixup.c + ' times"'; 344 345 return '<div class="' + classes + '"' + ariaAttrs + '>' + 346 '<span class="yr-mixup-from">' + mixup.f + '</span>' + 347 '<span class="yr-mixup-arrow">â</span>' + 348 toCell + 349 '<span class="yr-mixup-count">' + mixup.c + 'Ã</span>' + 350 '</div>'; 351}; 352 353/** 354 * Filter and rank mix-ups for the "Common Mix-Ups" card. 355 * 356 * Rules (all overridable via opts): 357 * 1. Exclude chips with empty `from` â we don't know what the 358 * student was aiming for, so there's no teaching action. 359 * 2. Sort remaining chips by count desc. 360 * 3. Apply minCount threshold (default 2): a single occurrence 361 * is a typo, not a pattern; the card should surface patterns. 362 * 4. Fallback: if filtering leaves fewer than `fallbackThreshold` 363 * chips, drop the threshold to `fallbackMin` so the card isn't 364 * empty for newer students with sparse data. 365 * 5. Cap at `max` chips. 366 * 367 * Chips where `to` is empty (missed keys) are KEPT â they're a 368 * distinct, actionable error type and YR.mixupChipHTML renders 369 * them with the .is-miss variant. 370 * 371 * @param {Array<{f, t, c}>} mixups 372 * @param {object} [opts] 373 * @param {number} [opts.minCount=2] â primary threshold 374 * @param {number} [opts.max=8] â chip count cap 375 * @param {number} [opts.fallbackMin=1] â relaxed threshold 376 * @param {number} [opts.fallbackThreshold=3] â when to relax 377 * @returns {Array<{f, t, c}>} filtered + sorted + capped 378 */ 379YR.filterMixups = function (mixups, opts) { 380 opts = opts || {}; 381 var minCount = opts.minCount != null ? opts.minCount : 2; 382 var max = opts.max != null ? opts.max : 8; 383 var fallbackMin = opts.fallbackMin != null ? opts.fallbackMin : 1; 384 var fallbackThreshold = opts.fallbackThreshold != null ? opts.fallbackThreshold : 3; 385 386 if (!Array.isArray(mixups)) return []; 387 388 // 1. Exclude chips with no `from` 389 var usable = mixups.filter(function (m) { 390 return m && m.f != null && String(m.f).length > 0; 391 }); 392 393 // 2. Sort by count desc 394 usable.sort(function (a, b) { return (b.c || 0) - (a.c || 0); }); 395 396 // 3. Apply primary threshold 397 var primary = usable.filter(function (m) { return (m.c || 0) >= minCount; }); 398 399 // 4. Fallback if too few 400 var result = primary.length >= fallbackThreshold 401 ? primary 402 : usable.filter(function (m) { return (m.c || 0) >= fallbackMin; }); 403 404 // 5. Cap 405 return result.slice(0, max); 406}; 407 408/** 409 * Finger cell (for finger map) 410 * @param {number} pct â accuracy percentage 411 * @param {string} label â e.g. 'L Pinky' 412 * @param {boolean} [compact=false] 413 */ 414YR.fingerCellHTML = function (pct, label, compact) { 415 const tier = YR.fingerTier(pct); 416 const cls = compact ? 'yr-finger-cell compact' : 'yr-finger-cell'; 417 return `<div class="${cls} ${tier}"> 418 <div class="yr-f-pct">${pct}%</div> 419 <div class="yr-f-lbl">${label}</div> 420 </div>`; 421}; 422 423/** 424 * Complete finger grid (compact, for detail panels) 425 * @param {object} f â { lp, lr, lm, li, ri, rm, rr, rp, th } 426 */ 427YR.fingerGridHTML = function (f) { 428 return `<div class="yr-finger-grid"> 429 ${YR.fingerCellHTML(f.lp, 'L Pinky', true)} 430 ${YR.fingerCellHTML(f.lr, 'L Ring', true)} 431 ${YR.fingerCellHTML(f.lm, 'L Mid', true)}
432 ${YR.fingerCellHTML(f.li, 'L Idx', true)} 433 ${YR.fingerCellHTML(f.ri, 'R Idx', true)} 434 ${YR.fingerCellHTML(f.rm, 'R Mid', true)} 435 ${YR.fingerCellHTML(f.rr, 'R Ring', true)} 436 ${YR.fingerCellHTML(f.rp, 'R Pinky', true)} 437 <div class="yr-finger-cell compact yr-finger-thumb ${YR.fingerTier(f.th)}"> 438 <div class="yr-f-pct">${f.th}%</div> 439 <div class="yr-f-lbl">Thumbs</div> 440 </div> 441 </div>`; 442}; 443 444/** 445 * Complete finger map (full size, for individual report) 446 * @param {object} f â { lp, lr, lm, li, ri, rm, rr, rp, th } 447 * @param {number} [cellWidth=110] â px width per cell 448 */ 449YR.fingerMapHTML = function (f, cellWidth) { 450 const w = cellWidth || 110; 451 return `<div class="yr-finger-map"> 452 <div class="yr-finger-row"> 453 <div style="width:${w}px">${YR.fingerCellHTML(f.lp, 'L Pinky')}</div> 454 <div style="width:${w}px">${YR.fingerCellHTML(f.lr, 'L Ring')}</div> 455 <div style="width:${w}px">${YR.fingerCellHTML(f.lm, 'L Middle')}</div> 456 <div style="width:${w}px">${YR.fingerCellHTML(f.li, 'L Index')}</div> 457 <div style="width:${w}px">${YR.fingerCellHTML(f.ri, 'R Index')}</div> 458 <div style="width:${w}px">${YR.fingerCellHTML(f.rm, 'R Middle')}</div> 459 <div style="width:${w}px">${YR.fingerCellHTML(f.rr, 'R Ring')}</div> 460 <div style="width:${w}px">${YR.fingerCellHTML(f.rp, 'R Pinky')}</div> 461 </div> 462 <div class="yr-finger-row"> 463 <div style="width:${w * 2 + 8}px">${YR.fingerCellHTML(f.th, 'Thumbs')}</div> 464 </div> 465 </div>`; 466}; 467 468/** 469 * Chevron icon for expand/collapse 470 */ 471YR.chevronSVG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>'; 472 473 474// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 475// §3 â TABLE SORTING 476// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 477 478/** 479 * Rank order for the master §9 status vocabulary â most urgent first, so a 480 * "status" comparator can sort by attention priority rather than alphabetically. 481 */ 482YR.STATUS_RANK = {
483 'inactive': 0, 'stuck': 1, 'cooling': 2, 'not-started': 3, 'not_started': 3, 484 'in-progress': 4, 'on-track': 5, 'active': 6, 'working': 6, 'improving': 7, 'complete': 8, 'completed': 8, 485 // No grant for the program at all â nothing for the teacher to act on in 486 // this report, so it ranks after every state that reflects real work. 487 'not-assigned': 9 488}; 489 490/** 491 * Compare stored student name fields, preserving compound surnames. 492 * @param {object} a Student row with firstname/lastname (or first/last). 493 * @param {object} b Student row with firstname/lastname (or first/last). 494 * @return {number} Last name, first name, then stable student id order. 495 */ 496YR.compareStudentNames = function (a, b) { 497 var options = { sensitivity: 'base' }; 498 var last = String(a.lastname == null ? (a.last || '') : a.lastname) 499 .localeCompare(String(b.lastname == null ? (b.last || '') : b.lastname), undefined, options); 500 var first = String(a.firstname == null ? (a.first || '') : a.firstname) 501 .localeCompare(String(b.firstname == null ? (b.first || '') : b.firstname), undefined, options); 502 return last || first || (Number(a.studentId || a.id) || 0) - (Number(b.studentId || b.id) || 0); 503}; 504 505/** 506 * Build a stable comparator for state.col honouring an optional per-column type 507 * map (state.types: { colKey: 'number'|'date'|'status'|'string'|'student' }). With no 508 * type declared it falls back to the historic behaviour: numeric-aware only 509 * when both values are non-string, otherwise case-insensitive string compare â 510 * so every existing call site sorts exactly as before. 511 * 512 * @param {object} state sort state ({ col, asc, types? }). 513 * @return {Function} an (a,b) comparator. 514 */ 515YR._comparatorFor = function (state) { 516 var col = state.col, asc = state.asc; 517 var type = (state.types && state.types[col]) || null; 518 var dir = asc ? 1 : -1; 519 return function (a, b) { 520 if (type === 'student') return YR.compareStudentNames(a, b) * dir; 521 var va = a[col], vb = b[col]; 522 var an = va === null || va === undefined, bn = vb === null || vb === undefined; 523 // Missing values always sort to the bottom regardless of direction. 524 if (an && bn) return 0; 525 if (an) return 1; 526 if (bn) return -1; 527 var r; 528 if (type === 'number') { 529 r = (parseFloat(va) || 0) - (parseFloat(vb) || 0); 530 } else if (type === 'date') { 531 r = (new Date(va)).getTime() - (new Date(vb)).getTime(); 532 } else if (type === 'status') { 533 var ra = YR.STATUS_RANK[va] !== undefined ? YR.STATUS_RANK[va] : 99; 534 var rb = YR.STATUS_RANK[vb] !== undefined ? YR.STATUS_RANK[vb] : 99; 535 r = ra - rb; 536 } else if (type === 'string') { 537 r = String(va).localeCompare(String(vb), undefined, { sensitivity: 'base', numeric: true }); 538 } else { 539 // Legacy fallback (no type declared). 540 if (typeof va === 'string' || typeof vb === 'string') { 541 va = String(va).toLowerCase(); vb = String(vb).toLowerCase(); 542 } 543 r = va < vb ? -1 : va > vb ? 1 : 0; 544 } 545 return r * dir; 546 }; 547}; 548 549/** 550 * Reflect the active sort onto the header row: `sorted`/`sort-desc` classes for 551 * styling and the ARIA `aria-sort` attribute for assistive tech. 552 * 553 * @param {object} state sort state ({ col, asc, colMap? }). 554 * @param {string} tableSelector table CSS selector. 555 * @return {void} 556 */ 557YR._reflectSortHeaders = function (state, tableSelector) { 558 var table = document.querySelector(tableSelector); 559 if (!table) return; 560 var ths = table.querySelectorAll('thead th'); 561 ths.forEach(function (th) { 562 th.classList.remove('sorted', 'sort-desc'); 563 if (th.hasAttribute('aria-sort')) th.setAttribute('aria-sort', 'none'); 564 }); 565 var idx = (state.colMap || {})[state.col]; 566 if (idx !== undefined && ths[idx]) { 567 ths[idx].classList.add('sorted'); 568 if (!state.asc) ths[idx].classList.add('sort-desc'); 569 ths[idx].setAttribute('aria-sort', state.asc ? 'ascending' : 'descending'); 570 } 571}; 572 573/** 574 * Sort an array and re-render a table. 575 * @param {object} state â { data, col, asc, renderFn, colMap?, types? } 576 * @param {string} newCol â column key to sort by 577 * @param {string} tableSelector â e.g. '#studentTable' 578 */ 579YR.sortTable = function (state, newCol, tableSelector) { 580 if (state.col === newCol) { 581 state.asc = !state.asc; 582 } else { 583 state.col = newCol; 584 state.asc = true; 585 } 586 587 // If pagination is wired (state.page set by YR.initPagination), reset to 588 // page 1 on sort. Sorting from page 5 of 10 then staying on page 5 hides 589 // the user's intent (they want to see the new top of the sort). 590 if (state.page !== undefined) state.page = 1; 591 592 state.data.sort(YR._comparatorFor(state)); 593 YR._reflectSortHeaders(state, tableSelector); 594 state.renderFn(); 595}; 596 597/** 598 * Re-apply state.col/state.asc to state.data in place. 599 * Useful when filtering rebuilds state.data from a source list â call this 600 * after replacing state.data so the data stays sorted by the active column. 601 */ 602YR.applySortToData = function (state) { 603 state.data.sort(YR._comparatorFor(state)); 604}; 605 606 607// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 608// §4 â EXPANDABLE DETAIL PANEL (Lazy Load) 609// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 610 611/** 612 * Create a lazy-load panel manager 613 * @param {object} config 614 * @param {Function} config.fetchDetail â async (id) => detailData 615 * @param {Function} config.buildHTML â (summaryRow, detailData) => htmlString 616 * @param {Function} config.findSummary â (id) => summaryRow from data array 617 * @param {boolean} [config.animateBars=true] â animate .yr-bar-fill on open 618 * @param {boolean} [config.measured=false] â animate to measured content height 619 * @param {Function} [config.onStateChange] â (id, open) ARIA/UI callback 620 * @returns {object} { toggle(id), closeAll(), currentId } 621 */ 622YR.createDetailManager = function (config) { 623 var cache = {}; 624 var currentId = null; 625 var requestSeq = 0; 626 var toastTimer = null; 627 628 function clearToast() { 629 if (toastTimer) { clearTimeout(toastTimer); toastTimer = null; } 630 YR.toast(); 631 } 632 633 function hideToastLater(ms) { 634 if (toastTimer) clearTimeout(toastTimer); 635 toastTimer = setTimeout(function () { toastTimer = null; YR.toast(); }, ms); 636 } 637 638 function closeAll() { 639 requestSeq++; // invalidate any detail continuation still awaiting a response 640 clearToast(); 641 if (currentId !== null) { 642 var panel = document.getElementById('yr-panel-' + currentId); 643 var row = document.querySelector('[data-yr-id="' + currentId + '"]'); 644 if (panel) { 645 if (config.measured) panel.style.maxHeight = '0px'; 646 panel.classList.remove('open'); 647 } 648 if (row) row.classList.remove('expanded'); 649 if (typeof config.onStateChange === 'function') config.onStateChange(currentId, false); 650 currentId = null; 651 } 652 } 653 654 async function toggle(id) { 655 var panel = document.getElementById('yr-panel-' + id); 656 var row = document.querySelector('[data-yr-id="' + id + '"]'); 657 var isOpen = panel && panel.classList.contains('open'); 658 659 // Close current 660 if (currentId !== null && currentId !== id) closeAll(); 661 662 // Toggle off 663 if (isOpen) { 664 requestSeq++; 665 clearToast(); 666 if (config.measured) panel.style.maxHeight = '0px'; 667 panel.classList.remove('open'); 668 row.classList.remove('expanded'); 669 if (typeof config.onStateChange === 'function') config.onStateChange(id, false); 670 currentId = null; 671 return; 672 } 673 674 // Open with loading 675 var myRequest = ++requestSeq; 676 currentId = id; 677 if (row) row.classList.add('expanded'); 678 panel.innerHTML = '<div class="yr-loading"><div class="yr-spinner"></div><div class="yr-loading-text">Loading details...</div></div>'; 679 panel.classList.add('open'); 680 if (config.measured) { 681 if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) panel.style.transition = '
681none'; 682 panel.style.maxHeight = panel.scrollHeight + 'px'; 683 } 684 if (typeof config.onStateChange === 'function') config.onStateChange(id, true); 685 686 try { 687 // Check cache 688 var detail; 689 if (cache[id]) { 690 detail = cache[id]; 691 } else { 692 YR.toast('loading', 'Fetching data...'); 693 detail = await config.fetchDetail(id); 694 if (myRequest !== requestSeq || currentId !== id || !panel.isConnected || 695 (typeof config.isCurrent === 'function' && !config.isCurrent(id))) return; 696 cache[id] = detail; 697 YR.toast('done', 'Loaded'); 698 hideToastLater(1500); 699 } 700 701 if (myRequest !== requestSeq || currentId !== id || !panel.isConnected || 702 (typeof config.isCurrent === 'function' && !config.isCurrent(id))) return; 703 704 var summary = config.findSummary(id); 705 panel.innerHTML = config.buildHTML(summary, detail); 706 if (config.measured) { 707 requestAnimationFrame(function () { panel.style.maxHeight = panel.scrollHeight + 'px'; }); 708 } 709 710 // Animate bars 711 if (config.animateBars !== false) { 712 requestAnimationFrame(function () { 713 panel.querySelectorAll('.yr-bar-fill[data-width]').forEach(function (bar) { 714 var w = bar.dataset.width; 715 bar.style.width = '0%'; 716 requestAnimationFrame(function () { bar.style.width = w + '%'; }); 717 }); 718 }); 719 } 720 } catch (err) { 721 if ((err && (err.name === 'AbortError' || err.name === 'StaleVisitError')) || 722 myRequest !== requestSeq || currentId !== id || !panel.isConnected || 723 (typeof config.isCurrent === 'function' && !config.isCurrent(id))) { 724 clearToast(); 725 return; 726 } 727 YR.toast('error', 'Failed'); 728 hideToastLater(2000); 729 panel.innerHTML = 730 '<div class="yr-loading">' + 731 '<div style="color:var(--yr-danger);font-size:14px;font-weight:600">Failed to load</div>' + 732 '<div style="font-size:12px" class="yr-c-muted">Check connection and try again</div>' + 733 '<button class="yr-btn" onclick="YR._retryDetail(' + id + ')" style="margin-top:8px">Retry</button>' + 734 '</div>'; 735 if (config.measured) panel.style.maxHeight = panel.scrollHeight + 'px'; 736 } 737 } 738 739 // Store toggle reference for retry 740 YR._retryDetail = toggle; 741 742 return { 743 toggle: toggle, 744 closeAll: closeAll, 745 clearCache: function () { cache = {}; requestSeq++; }, 746 get currentId() { return currentId; } 747 }; 748}; 749 750 751// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 752// §5 â TOAST NOTIFICATIONS 753// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 754 755/** 756 * Show/hide the network toast 757 * @param {string} [state] â 'loading'|'done'|'error' or falsy to hide 758 * @param {string} [text] 759 */ 760YR.toast = function (state, text) { 761 var el = document.getElementById('yr-toast'); 762 if (!el) return; 763 if (!state) { el.classList.remove('show'); return; } 764 var dot = el.querySelector('.yr-toast-dot'); 765 var txt = el.querySelector('.yr-toast-text'); 766 if (dot) dot.className = 'yr-toast-dot ' + state; 767 if (txt) txt.textContent = text || ''; 768 el.classList.add('show'); 769}; 770 771/** 772 * Toast HTML (add once to page bottom) 773 */ 774YR.toastHTML = function () { 775 return '<div class="yr-toast" id="yr-toast"><div class="yr-toast-dot"></div><span class="yr-toast-text"></span></div>'; 776}; 777 778 779// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 780// §6 â CSV / XLSX EXPORT 781// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 782 783/** 784 * Export data as CSV download 785 * @param {object} config 786 * @param {string[]} config.headers â column headers 787 * @param {Array[]} config.rows â array of row arrays 788 * @param {string}
788 [config.filename] â default: 'report-YYYY-MM-DD.csv' 789 */ 790YR.exportCSV = function (config) { 791 var escCSV = function (val) { 792 var str = String(val == null ? '' : val); 793 return (str.indexOf(',') >= 0 || str.indexOf('"') >= 0 || str.indexOf('\n') >= 0) 794 ? '"' + str.replace(/"/g, '""') + '"' 795 : str; 796 }; 797 798 var lines = [config.headers.map(escCSV).join(',')]; 799 config.rows.forEach(function (row) { 800 lines.push(row.map(escCSV).join(',')); 801 }); 802 var csv = lines.join('\n'); 803 804 // BOM prefix for Excel UTF-8 805 var blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' }); 806 var url = URL.createObjectURL(blob); 807 var a = document.createElement('a'); 808 a.href = url; 809 a.download = config.filename || ('report-' + new Date().toISOString().slice(0, 10) + '.csv'); 810 document.body.appendChild(a); 811 a.click(); 812 document.body.removeChild(a); 813 URL.revokeObjectURL(url); 814}; 815 816/** 817 * Export data as real .xlsx file (native Excel format). 818 * Requires SheetJS loaded on the page: 819 * <script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script> 820 * 821 * @param {object} config 822 * @param {Array} config.sheets â array of {name, headers, rows, colWidths?} 823 * OR for single-sheet exports, use top-level {headers, rows} instead. 824 * @param {string[]} [config.headers] â single-sheet column headers 825 * @param {Array[]} [config.rows] â single-sheet row arrays 826 * @param {string} [config.filename] â default: 'report-YYYY-MM-DD.xlsx' 827 * @param {string} [config.sheetName='Report'] â name for single-sheet workbooks 828 */ 829YR.exportXLSX = function (config) { 830 if (typeof XLSX === 'undefined') { 831 console.error('YR.exportXLSX requires SheetJS. Add xlsx.full.min.js to the page.'); 832 alert('Excel export unavailable â SheetJS library not loaded.'); 833 return; 834 } 835 836 var wb = XLSX.utils.book_new(); 837 var sheets = config.sheets || [{ 838 name: config.sheetName || 'Report', 839 headers: config.headers, 840 rows: config.rows, 841 colWidths: config.colWidths 842 }]; 843 844 sheets.forEach(function (sheet) { 845 var data = [sheet.headers].concat(sheet.rows); 846 var ws = XLSX.utils.aoa_to_sheet(data); 847 848 // Auto-size columns (or honor explicit widths) 849 if (sheet.colWidths) { 850 ws['!cols'] = sheet.colWidths.map(function (w) { return { wch: w }; }); 851 } else { 852 ws['!cols'] = sheet.headers.map(function (h, i) { 853 var maxLen = String(h).length; 854 sheet.rows.forEach(function (row) { 855 var cell = row[i] == null ? '' : String(row[i]); 856 if (cell.length > maxLen) maxLen = cell.length; 857 }); 858 return { wch: Math.min(Math.max(maxLen + 2, 10), 50) }; 859 }); 860 } 861 862 // Freeze header row 863 ws['!freeze'] = { xSplit: 0, ySplit: 1 }; 864 865 // Bold header row via cell styling 866 var range = XLSX.utils.decode_range(ws['!ref']); 867 for (var c = range.s.c; c <= range.e.c; c++) { 868 var addr = XLSX.utils.encode_cell({ r: 0, c: c }); 869 if (ws[addr]) { 870 ws[addr].s = { 871 font: { bold: true, color: { rgb: 'FFFFFF' } }, 872 fill: { fgColor: { rgb: '12A0DD' } }, 873 alignment: { horizontal: 'left', vertical: 'center' } 874 }; 875 } 876 } 877 878 XLSX.utils.book_append_sheet(wb, ws, (sheet.name || 'Sheet').slice(0, 31)); 879 }); 880 881 var filename = config.filename || ('report-' + new Date().toISOString().slice(0, 10) + '.xlsx'); 882 XLSX.writeFile(wb, filename); 883}; 884 885 886// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 887// §7 â PRINT 888// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 889 890/** 891 * Print the report (sets date, closes panels, triggers print) 892 * @param {object} [detailManager] â if provided, closes any open panel 893 */ 894YR.printReport = function (detailManager) { 895 // Set print date 896 var dateEl = document.getElementById('yr-print-date'); 897 if (dateEl) { 898 dateEl.textContent = new Date().toLocaleDateString('en-US', { 899 year: 'numeric', month: 'long', day: 'numeric' 900 }); 901 } 902 // Close detail panels 903 if (detailManager) detailManager.closeAll(); 904 905 window.print(); 906}; 907 908 909// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 910// §8 â DROPDOWN TOGGLE 911// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 912 913/** 914 * Toggle a dropdown and auto-close on outside click 915 * @param {Event} e â click event from trigger button 916 * @param {string} dropdownId â id of .yr-dropdown element 917 */ 918YR.toggleDropdown = function (e, dropdownId) { 919 e.stopPropagation(); 920 var dd = document.getElementById(dropdownId); 921 if (!dd) return; 922 dd.classList.toggle('open'); 923 924 var close = function (ev) { 925 if (!dd.contains(ev.target)) { 926 dd.classList.remove('open'); 927 document.removeEventListener('click', close); 928 } 929 }; 930 if (dd.classList.contains('open')) { 931 setTimeout(function () { document.addEventListener('click', close); }, 0); 932 } 933}; 934 935 936// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
937// §9 â SUMMARY CARD COMPUTATION 938// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 939 940/** 941 * Compute class-level summary stats from data array. 942 * 943 * IMPORTANT: Averages (WPM, accuracy, stars) are computed ONLY over 944 * students who have actually done a session. Students with sessions=0 945 * (or participated=false) are counted in "notStarted" but excluded 946 * from the averages so a single active student isn't diluted by 947 * zeroes from classmates who haven't started yet. 948 * 949 * @param {object[]} data â array of student summary objects 950 * @param {object} [fields] â field name overrides: 951 * { wpm, acc, stars, status, sessions, participated } 952 * Optional `participated` is a boolean field. If omitted, 953 * a student is considered participating when sessions > 0. 954 * @returns {object} { 955 * avgWpm, avgAcc, avgStars, // averaged over participants only 956 * completed, working, // status breakdown (participants) 957 * notStarted, // students with no sessions 958 * participating, // count included in averages 959 * total // total students in class 960 * } 961 */ 962YR.computeSummary = function (data, fields) { 963 var f = Object.assign( 964 { wpm: 'wpm', acc: 'acc', stars: 'stars', status: 'status', 965 sessions: 'sessions', participated: 'participated' }, 966 fields 967 ); 968 var total = data.length; 969 if (total === 0) { 970 return { avgWpm: 0, avgAcc: 0, avgStars: '0.0', 971 completed: 0, working: 0, notStarted: 0, 972 participating: 0, total: 0 }; 973 } 974 975 // Determine if a student has participated in this report's activity 976 function hasParticipated(s) { 977 // Explicit field wins if present 978 if (s[f.participated] !== undefined) return !!s[f.participated]; 979 // Otherwise infer from sessions count 980 if (s[f.sessions] !== undefined) return (s[f.sessions] || 0) > 0; 981 // Fall back to: any non-zero WPM or accuracy means they played 982 return (s[f.wpm] || 0) > 0 || (s[f.acc] || 0) > 0; 983 } 984 985 var sumWpm = 0, sumAcc = 0, sumStars = 0; 986 var completed = 0, working = 0, notStarted = 0, participating = 0; 987 988 data.forEach(function (s) { 989 if (hasParticipated(s)) { 990 participating++; 991 sumWpm += s[f.wpm] || 0; 992 sumAcc += s[f.acc] || 0; 993 sumStars += s[f.stars] || 0; 994 if (s[f.status] === 'completed') completed++; 995 else working++; 996 } else { 997 notStarted++; 998 } 999 }); 1000 1001 return { 1002 avgWpm: participating > 0 ? Math.round(sumWpm / participating) : 0, 1003 avgAcc: participating > 0 ? Math.round(sumAcc / participating) : 0, 1004 avgStars: participating > 0 ? (sumStars / participating).toFixed(1) : '0.0', 1005 completed: completed, 1006 working: working, 1007 notStarted: notStarted, 1008 participating: participating, 1009 total: total 1010 }; 1011}; 1012 1013 1014// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1015// §10 â AVATAR COLOR PALETTE 1016// Consistent, accessible colors for student avatars. 1017// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1018 1019YR.AVATAR_COLORS = [ 1020 '#139edd', '#0b7dbe', '#1abc9c', '#16a085', '#27ae60', 1021 '#2ecc71', '#f39c12', '#e67e22', '#e74c3c', '#9b59b6', 1022 '#3498db', '#2980b9', '#1abc9c', '#e74c3c', '#f39c12', 1023 '#27ae60', '#9b59b6', '#139edd', '#e67e22', '#2ecc71' 1024]; 1025 1026/** 1027 * Get avatar color by index (wraps around) 1028 */ 1029YR.avatarColor = function (index) { 1030 return YR.AVATAR_COLORS[index % YR.AVATAR_COLORS.length]; 1031}; 1032 1033 1034// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1035// §11 â SVG ICON LIBRARY 1036// Common icons used across reports (inline SVG strings). 1037// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1038 1039YR.ICONS = { 1040 bolt: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>', 1041 target: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg>', 1042 star: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>', 1043 wrench: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/></svg>', 1044 bars: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg>', 1045 grid: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 3H5a2 2 0 00-2 2v3m18 0V5a2 2 0 00-2-2h-3m0 18h3a2 2 0 002-2v-3M3 16v3a2 2 0 002 2h3"/></svg>', 1046 hand: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 11V6a2 2 0 00-4 0v5"/><path d="M14 10V4a2 2 0 00-4 0v6"/><path d="M10 10.5V5a2 2 0 00-4 0v9"/></svg>', 1047 users: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4-4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>', 1048 download: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>', 1049 printer: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 01-2-2v-5a2 2 0 012-2h16a2 2 0 012 2v5a2 2 0 01-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></svg>', 1050 external: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>', 1051 chevLeft: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>', 1052 chevDown: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="6 9 12 15 18 9"/></svg>', 1053 chart: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>', 1054 file: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>', 1055 retry: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg>', 1056}; 1057 1058 1059// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1060// §11b â CLASS DROPDOWN 1061// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1062 1063/** 1064 * Initialize a class dropdown (replaces native <select>) 1065 * @param {object} config 1066 * @param {string} [config.triggerId] â ID of trigger element (default: 'classTrigger') 1067 * @param {string} [config.menuId] â ID of menu element (default: 'classMenu') 1068 * @param {string} [config.wrapperId] â ID of wrapper for outside-click (default: 'classDropdown') 1069 * @param {function} [config.onSelect] â callback(value, text, element) 1070 */ 1071YR.initClassDropdown = function (config) { 1072 config = config || {}; 1073 var trigger = document.getElementById(config.triggerId || 'classTrigger') || 1074 document.querySelector('.yr-class-trigger'); 1075 var menu = document.getElementById(config.menuId || 'classMenu'); 1076 var wrapperId = config.wrapperId || 'classDropdown'; 1077 1078 if (!trigger || !menu) return; 1079
1080 function toggle() { 1081 var opening = !menu.classList.contains('open'); 1082 menu.classList.toggle('open'); 1083 trigger.classList.toggle('open'); 1084 if (opening) { 1085 setTimeout(function () { 1086 document.addEventListener('click', function closer(e) { 1087 if (!e.target.closest('#' + wrapperId)) { 1088 menu.classList.remove('open'); 1089 trigger.classList.remove('open'); 1090 document.removeEventListener('click', closer); 1091 } 1092 }); 1093 }, 0); 1094 } 1095 } 1096 1097 function select(el) { 1098 var text = el.textContent.trim(); 1099 var val = el.dataset.id || el.dataset.value || text; 1100 var textEl = trigger.querySelector('span') || trigger; 1101 textEl.textContent = text; 1102 menu.querySelectorAll('.yr-class-menu-item').forEach(function (i) { 1103 i.classList.remove('active'); 1104 }); 1105 el.classList.add('active'); 1106 menu.classList.remove('open'); 1107 trigger.classList.remove('open'); 1108 if (config.onSelect) config.onSelect(val, text, el); 1109 } 1110 1111 trigger.addEventListener('click', toggle); 1112 menu.addEventListener('click', function (e) { 1113 var item = e.target.closest('.yr-class-menu-item'); 1114 if (item) select(item); 1115 }); 1116 1117 return { toggle: toggle, select: select }; 1118}; 1119 1120/** 1121 * Chevron SVG for the class dropdown trigger 1122 */ 1123YR.classChevronSVG = '<svg class="yr-class-trigger-chevron" viewBox="0 0 10 6" fill="none"><path d="M1 1L5 5L9 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>'; 1124 1125 1126// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1127// §11c â iOS-SAFE SCROLL LOCK 1128// iOS Safari ignores overflow:hidden on body. This uses 1129// position:fixed with scrollY preservation â the only reliable 1130// cross-browser scroll lock for modals and mobile overlays. 1131// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1132 1133YR._scrollY = 0; 1134YR._lockCount = 0; 1135YR._bodyLockSeq = 0; 1136YR._bodyLockTokens = YR._bodyLockTokens || {}; 1137YR._legacyBodyLocks = YR._legacyBodyLocks || []; 1138YR._bodyLockPrior = null; 1139 1140/** 1141 * Resolve the active AjaxNav page identity without coupling the report kit to 1142 * ajax-nav.js's load order. 1143 * 1144 * @return {number|null} current navigation id, when available. 1145 */ 1146function yrCurrentNavigationId() { 1147 if (!window.AjaxNav || typeof window.AjaxNav.currentNavigationId !== 'function') return null; 1148 return window.AjaxNav.currentNavigationId(); 1149} 1150 1151/** 1152 * Apply the fixed-body lock and remember the exact inline state it replaces. 1153 * 1154 * @return {void} 1155 */ 1156function yrApplyBodyLock() { 1157 var b = document.body; 1158 if (!b) return; 1159 YR._scrollY = window.scrollY || window.pageYOffset || 0; 1160 YR._bodyLockPrior = { 1161 position: b.style.position, 1162 top: b.style.top, 1163 left: b.style.left, 1164 right: b.style.right, 1165 width: b.style.width 1166 }; 1167 b.style.position = 'fixed'; 1168 b.style.top = '-' + YR._scrollY + 'px'; 1169 b.style.left = '0'; 1170 b.style.right = '0'; 1171 b.style.width = '100%'; 1172} 1173 1174/** 1175 * Restore body state after the final owned lock token is released. 1176 * 1177 * @param {boolean} restoreScroll whether to restore the captured scroll offset. 1178 * @return {void} 1179 */ 1180function yrRestoreBodyLock(restoreScroll) { 1181 var b = document.body; 1182 if (!b) return; 1183 var prior = YR._bodyLockPrior || {}; 1184 b.style.position = prior.position || ''; 1185 b.style.top = prior.top || ''; 1186 b.style.left = prior.left || ''; 1187 b.style.right = prior.right || ''; 1188 b.style.width = prior.width || ''; 1189 YR._bodyLockPrior = null; 1190 if (restoreScroll !== false) window.scrollTo(0, YR._scrollY); 1191} 1192 1193/** 1194 * Acquire an owner-aware body lock. The returned release callback is 1195 * idempotent and accepts `{restoreScroll:false}` for navigation teardown. 1196 * 1197 * @param {Object} [options] lock metadata. 1198 * @return {Function} idempotent release callback. 1199 */ 1200YR.acquireBodyLock = function (options) { 1201 options = options || {}; 1202 var tokenId = ++YR._bodyLockSeq; 1203 var token = { 1204 tokenId: tokenId, 1205 owner: options.owner === 'shell' || options.owner === 'shell-critical' ? options.owner : 'page', 1206 id: String(options.id || 'legacy'),
1207 navigationId: options.navigationId != null ? options.navigationId : yrCurrentNavigationId(), 1208 released: false 1209 }; 1210 1211 if (YR._lockCount === 0) yrApplyBodyLock(); 1212 YR._bodyLockTokens[tokenId] = token; 1213 YR._lockCount++; 1214 1215 var release = function (releaseOptions) { 1216 if (token.released) return; 1217 token.released = true; 1218 delete YR._bodyLockTokens[tokenId]; 1219 if (YR._lockCount > 0) YR._lockCount--; 1220 if (YR._lockCount === 0) { 1221 yrRestoreBodyLock(!releaseOptions || releaseOptions.restoreScroll !== false); 1222 } 1223 }; 1224 token.release = release; 1225 release._yrToken = token; 1226 return release; 1227}; 1228 1229/** 1230 * Release matching owned body-lock tokens. 1231 * 1232 * @param {Object} [criteria] owner and/or navigationId filters. 1233 * @return {number} number of released tokens. 1234 */ 1235YR.releaseBodyLocks = function (criteria) { 1236 criteria = criteria || {}; 1237 var released = 0; 1238 Object.keys(YR._bodyLockTokens).forEach(function (key) { 1239 var token = YR._bodyLockTokens[key]; 1240 if (!token || token.released) return; 1241 if (criteria.owner && token.owner !== criteria.owner) return; 1242 if (criteria.navigationId != null && token.navigationId !== criteria.navigationId) return; 1243 token.release({ restoreScroll: criteria.restoreScroll !== false }); 1244 released++; 1245 }); 1246 YR._legacyBodyLocks = YR._legacyBodyLocks.filter(function (release) { 1247 return !(release && release._yrToken && release._yrToken.released); 1248 }); 1249 return released; 1250}; 1251 1252/** 1253 * Return non-sensitive body-lock ownership data for lifecycle diagnostics. 1254 * 1255 * @return {Array<Object>} active token summaries. 1256 */ 1257YR.getBodyLockSnapshot = function () { 1258 return Object.keys(YR._bodyLockTokens).map(function (key) { 1259 var token = YR._bodyLockTokens[key]; 1260 return { 1261 tokenId: token.tokenId, 1262 owner: token.owner, 1263 id: token.id, 1264 navigationId: token.navigationId 1265 }; 1266 }); 1267}; 1268 1269/** @return {boolean} whether any owner-aware body lock is active. */ 1270YR.hasActiveBodyLocks = function () { 1271 return YR._lockCount > 0; 1272}; 1273 1274// Compatibility adapters. Legacy callers remain balanced via a release stack; 1275// new code should retain the callback returned by acquireBodyLock(). 1276YR.lockBody = function (options) { 1277 var release = YR.acquireBodyLock(options || { owner: 'page', id: 'legacy/yr' }); 1278 YR._legacyBodyLocks.push(release); 1279 return release; 1280}; 1281 1282YR.unlockBody = function () { 1283 var release = null; 1284 while (YR._legacyBodyLocks.length && !release) { 1285 var candidate = YR._legacyBodyLocks.pop(); 1286 if (!candidate._yrToken || !candidate._yrToken.released) release = candidate; 1287 } 1288 if (release) release(); 1289}; 1290 1291// Compatibility residue repair used by older AjaxNav/page cleanup callers. 1292// It now releases PAGE tokens only and never neutralizes a valid shell lock. 1293YR._forceReleaseBody = function () { 1294 YR.releaseBodyLocks({ owner: 'page', restoreScroll: false }); 1295}; 1296 1297 1298// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1299// §12 â COOKIE HELPERS 1300// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1301 1302YR.COOKIE_NAME = 'yr_prefs'; 1303 1304YR.setCookie = function (name, value, days) { 1305 days = days || 365; 1306 var d = new Date(); 1307 d.setTime(d.getTime() + days * 86400000); 1308 document.cookie = name + '=' + encodeURIComponent(JSON.stringify(value)) + 1309 ';expires=' + d.toUTCString() + ';path=/;SameSite=Lax'; 1310}; 1311 1312YR.getCookie = function (name) { 1313 var match = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)')); 1314 if (!match) return null; 1315 try { return JSON.parse(decodeURIComponent(match[1])); } catch (e) { return null; } 1316}; 1317 1318 1319// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1320// §13 â SIDEBAR NAV CONFIG 1321// Default nav items. Override by passing navItems to initSidebar. 1322// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1323 1324YR.NAV_ICONS = { 1325 dashboard: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>', 1326 classes: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>', 1327 staff: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>', 1328 students: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/></svg>', 1329 reports: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></svg>', 1330 curriculum: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="12" y1="6" x2="12" y2="13"/><line x1="9" y1="10" x2="15" y2="10"/></svg>', 1331 assessments: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>', 1332 settings: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>', 1333 management: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>',
1334 help: '<svg class="yr-svg-overflow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>' 1335}; 1336 1337YR.DEFAULT_NAV = [ 1338 { key: 'dashboard', label: 'Dashboard' }, 1339 { key: 'classes', label: 'Classes' }, 1340 { key: 'staff', label: 'Staff' }, 1341 { key: 'students', label: 'Students' }, 1342 { key: 'reports', label: 'Reports' }, 1343 'separator', 1344 { key: 'curriculum', label: 'Curriculum' }, 1345 { key: 'assessments', label: 'Assessments' }, 1346 { key: 'settings', label: 'Settings' }, 1347 { key: 'management', label: 'Management' } 1348]; 1349 1350YR.USER_MENU_ITEMS = [ 1351 { label: 'My Profile', icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>' }, 1352 { label: 'Account', icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>' }, 1353 { label: 'Accessibility', icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="7" r="1.5" fill="currentColor" stroke="none"/><path d="M9 11h6"/><path d="M10 11v5l-1 2"/><path d="M14 11v5l1 2"/></svg>', id: 'a11yMenuItem' }, 1354 { label: 'Demo Student', icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M21 15l-3-3m0 0l-3 3m3-3v6"/></svg>' }, 1355 { label: "Teacher's Guides", icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>' }, 1356 'separator', 1357 { label: 'Pro Tips', icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>' }, 1358 { label: 'Logout', icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>', cls: 'yr-um-logout' } 1359]; 1360 1361 1362// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1363// §14 â INIT SIDEBAR 1364// Generates sidebar HTML, wires collapse, hamburger, overlay. 1365// 1366// Config: 1367// user â teacher display name (string) 1368// school â school name (string) 1369// initials â 2-letter avatar (string, auto-derived if omitted) 1370// activePage â key from NAV_ICONS to highlight (string) 1371// logoSrc â path to logo PNG (string) 1372// navItems â override YR.DEFAULT_NAV (array) 1373// target â container selector (default: '#sidebar' or prepend to body) 1374// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1375 1376YR.initSidebar = function (config) { 1377 config = config || {}; 1378 var user = config.user || 'Teacher'; 1379 var school = config.school || ''; 1380 var initials = config.initials || user.split(' ').map(function(w) { return w[0]; }).join('').toUpperCase().slice(0, 2); 1381 var active = config.activePage || ''; 1382 var logoSrc = config.logoSrc || 'TA-logo.svg'; 1383 var navItems = config.navItems || YR.DEFAULT_NAV; 1384 1385 // Build nav HTML 1386 var navHTML = ''; 1387 navItems.forEach(function (item) { 1388 if (item === 'separator') { 1389 navHTML += '<div class="yr-nav-sep" role="separator"></div>'; 1390 return; 1391 } 1392 var icon = YR.NAV_ICONS[item.key] || ''; 1393 var isActive = item.key === active; 1394 var href = item.href || '#'; 1395 navHTML += '<a href="' + href + '" data-label="' + item.label + '"' + 1396 (isActive ? ' class="active" aria-current="page"' : '') + 1397 '>' + icon + '<span class="yr-nav-label">' + item.label + '</span></a>'; 1398 }); 1399 1400 // Build full sidebar 1401 var html = 1402 '<div class="yr-sidebar-logo">' + 1403 '<img class="yr-logo-img" src="' + logoSrc + '" alt="Typing Agent">' + 1404 '<div class="yr-logo-compact" aria-hidden="true">' + 1405 '<span class="yr-lc-t">T</span><span class="yr-lc-a">a</span><span class="yr-lc-pipe">|</span>' + 1406 '</div>' + 1407 '<button class="yr-sidebar-toggle" id="sidebarToggle" aria-label="Collapse sidebar" aria-expanded="true">' + 1408 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>
1408' + 1409 '</button>' + 1410 '</div>' + 1411 '<nav class="yr-sidebar-nav" aria-label="Primary">' + navHTML + '</nav>' + 1412 '<div class="yr-sidebar-footer">' + 1413 '<a href="#" class="yr-sf-help" data-label="Solution Center">' + 1414 YR.NAV_ICONS.help + 1415 '<span class="yr-nav-label">Solution Center</span>' + 1416 '</a>' + 1417 '<div class="yr-sf-user" id="userMenuTrigger" role="button" tabindex="0" ' + 1418 'aria-haspopup="menu" aria-expanded="false" aria-label="Account menu for ' + user + '">' + 1419 '<div class="yr-sf-avatar" aria-hidden="true">' + initials + '</div>' + 1420 '<div class="yr-sf-info">' + 1421 '<div class="yr-sf-name">' + user + '</div>' + 1422 '<div class="yr-sf-role">' + school + '</div>' + 1423 '</div>' + 1424 '<span class="yr-sf-chevron" aria-hidden="true"><svg viewBox="0 0 10 6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><path d="M1 5l4-4 4 4"/></svg></span>' + 1425 '<div class="yr-user-menu" id="userMenu" role="menu" aria-label="Account menu"></div>' + 1426 '</div>' + 1427 '</div>'; 1428 1429 // Insert into DOM 1430 var sidebar = document.getElementById('sidebar'); 1431 if (!sidebar) { 1432 sidebar = document.createElement('aside'); 1433 sidebar.id = 'sidebar'; 1434 sidebar.className = 'yr-sidebar'; 1435 sidebar.setAttribute('aria-label', 'Main navigation'); 1436 document.body.insertBefore(sidebar, document.body.firstChild); 1437 } 1438 sidebar.innerHTML = html; 1439 1440 // Add overlay if missing 1441 if (!document.getElementById('sidebarOverlay')) { 1442 var overlay = document.createElement('div'); 1443 overlay.className = 'yr-sidebar-overlay'; 1444 overlay.id = 'sidebarOverlay'; 1445 overlay.setAttribute('aria-hidden', 'true'); 1446 sidebar.after(overlay); 1447 } 1448 1449 // Wire collapse toggle 1450 var toggle = document.getElementById('sidebarToggle'); 1451 if (toggle) { 1452 toggle.addEventListener('click', function () { 1453 var collapsed = sidebar.classList.toggle('collapsed'); 1454 toggle.setAttribute('aria-expanded', String(!collapsed)); 1455 toggle.setAttribute('aria-label', collapsed ? 'Expand sidebar' : 'Collapse sidebar'); 1456 }); 1457 } 1458 1459 // Wire hamburger (if present in main content) 1460 var hamburger = document.getElementById('hamburgerBtn'); 1461 var sidebarOverlay = document.getElementById('sidebarOverlay'); 1462 1463 function openMobile() { 1464 sidebar.classList.add('mobile-open'); 1465 if (sidebarOverlay) sidebarOverlay.classList.add('show'); 1466 if (hamburger) hamburger.setAttribute('aria-expanded', 'true'); 1467 YR.lockBody(); 1468 var firstLink = sidebar.querySelector('.yr-sidebar-nav a'); 1469 if (firstLink) firstLink.focus(); 1470 } 1471 1472 function closeMobile() { 1473 sidebar.classList.remove('mobile-open'); 1474 if (sidebarOverlay) sidebarOverlay.classList.remove('show'); 1475 if (hamburger) hamburger.setAttribute('aria-expanded', 'false'); 1476 YR.unlockBody(); 1477 if (hamburger) hamburger.focus(); 1478 } 1479 1480 if (hamburger) { 1481 hamburger.addEventListener('click', function () { 1482 sidebar.classList.contains('mobile-open') ? closeMobile() : openMobile(); 1483 }); 1484 } 1485 if (sidebarOverlay) { 1486 sidebarOverlay.addEventListener('click', closeMobile); 1487 } 1488 1489 // Close mobile on nav click 1490 sidebar.querySelectorAll('.yr-sidebar-nav a, .yr-sf-help').forEach(function (link) { 1491 link.addEventListener('click', function () { 1492 if (sidebar.classList.contains('mobile-open')) closeMobile(); 1493 }); 1494 }); 1495 1496 // Store references for other modules 1497 YR._sidebar = sidebar; 1498 YR._closeMobile = closeMobile; 1499 1500 return { sidebar: sidebar, openMobile: openMobile, closeMobile: closeMobile }; 1501}; 1502 1503 1504// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1505// §15 â INIT USER MENU 1506// Generates menu items, wires keyboard nav, focus management. 1507// Call AFTER initSidebar(). 1508// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1509 1510YR.initUserMenu = function (config) { 1511 config = config || {}; 1512 var items = config.items || YR.USER_MENU_ITEMS; 1513 var trigger = document.getElementById('userMenuTrigger'); 1514 var menu = document.getElementById('userMenu'); 1515 if (!trigger || !menu) return; 1516 1517 // Build menu HTML from config 1518 var headerHTML = trigger.querySelector('.yr-sf-info'); 1519 var name = headerHTML ? headerHTML.querySelector('.yr-sf-name').textContent : ''; 1520 var school = headerHTML ? headerHTML.querySelector('.yr-sf-role').textContent : ''; 1521 var initials = trigger.querySelector('.yr-sf-avatar') ? trigger.querySelector('.yr-sf-avatar').textContent.trim() : ''; 1522 1523 var html = '<div class="yr-user-menu-header" role="presentation">' + 1524 '<div class="yr-um-avatar" aria-hidden="true">' + initials + '</div>' + 1525 '<div><div class="yr-um-name">' + name + '</div>' + 1526 '<div class="yr-um-school">' + school + '</div></div></div>'; 1527
1528 items.forEach(function (item) { 1529 if (item === 'separator') { 1530 html += '<div class="yr-um-sep" role="separator"></div>'; 1531 return; 1532 } 1533 var cls = item.cls ? ' class="' + item.cls + '"' : ''; 1534 var id = item.id ? ' id="' + item.id + '"' : ''; 1535 var href = item.href || '#'; 1536 html += '<a href="' + href + '" role="menuitem"' + cls + id + '>' + 1537 item.icon + item.label + '</a>'; 1538 }); 1539 1540 menu.innerHTML = html; 1541 1542 // Open/close functions 1543 function getMenuItems() { return menu.querySelectorAll('a[role="menuitem"]'); } 1544 1545 function openMenu() { 1546 menu.classList.add('open'); 1547 trigger.classList.add('open'); 1548 trigger.setAttribute('aria-expanded', 'true'); 1549 var first = getMenuItems()[0]; 1550 if (first) first.focus(); 1551 } 1552 1553 function closeMenu(returnFocus) { 1554 menu.classList.remove('open'); 1555 trigger.classList.remove('open'); 1556 trigger.setAttribute('aria-expanded', 'false'); 1557 if (returnFocus !== false) trigger.focus(); 1558 } 1559 1560 // Click to toggle 1561 trigger.addEventListener('click', function (e) { 1562 e.stopPropagation(); 1563 menu.classList.contains('open') ? closeMenu() : openMenu(); 1564 }); 1565 1566 // Keyboard on trigger 1567 trigger.addEventListener('keydown', function (e) { 1568 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); menu.classList.contains('open') ? closeMenu() : openMenu(); } 1569 if (e.key === 'ArrowUp') { e.preventDefault(); openMenu(); var items = getMenuItems(); items[items.length - 1].focus(); } 1570 if (e.key === 'ArrowDown') { e.preventDefault(); openMenu(); } 1571 }); 1572 1573 // Keyboard inside menu 1574 menu.addEventListener('keydown', function (e) { 1575 var items = Array.from(getMenuItems()); 1576 var idx = items.indexOf(document.activeElement); 1577 if (e.key === 'ArrowDown') { e.preventDefault(); items[(idx + 1) % items.length].focus(); } 1578 if (e.key === 'ArrowUp') { e.preventDefault(); items[(idx - 1 + items.length) % items.length].focus(); } 1579 if (e.key === 'Escape') { e.preventDefault(); closeMenu(); } 1580 if (e.key === 'Tab') closeMenu(false); 1581 if (e.key === 'Home') { e.preventDefault(); items[0].focus(); } 1582 if (e.key === 'End') { e.preventDefault(); items[items.length - 1].focus(); } 1583 }); 1584 1585 // Close on item click
1586 getMenuItems().forEach(function (link) { 1587 link.addEventListener('click', function (e) { e.stopPropagation(); closeMenu(); }); 1588 }); 1589 1590 // Close on outside click 1591 document.addEventListener('click', function (e) { 1592 if (!trigger.contains(e.target)) closeMenu(false); 1593 }); 1594 1595 // ESC handler (integrates with sidebar) 1596 document.addEventListener('keydown', function (e) { 1597 if (e.key === 'Escape' && menu.classList.contains('open')) { 1598 closeMenu(); 1599 } 1600 }); 1601 1602 YR._closeUserMenu = closeMenu; 1603 YR._openUserMenu = openMenu; 1604 1605 return { open: openMenu, close: closeMenu }; 1606}; 1607 1608 1609// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1610// §16 â INIT ACCESSIBILITY 1611// Generates modal, wires toggles, persists to cookie, restores. 1612// Call AFTER initUserMenu() so the menu item exists. 1613// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1614 1615YR.A11Y_TOGGLES = [ 1616 { id: 'a11yLargeText', cls: 'yr-large-text', label: 'Larger Text', desc: 'Increases font sizes across the interface for easier reading.' }, 1617 { id: 'a11yHighContrast', cls: 'yr-high-contrast', label: 'High Contrast', desc: 'Stronger colors and thicker borders for improved visibility.' }, 1618 { id: 'a11yReducedMotion', cls: 'yr-reduced-motion', label: 'Reduced Motion', desc: 'Disables animations and transitions throughout the interface.' }, 1619 { id: 'a11ySpacing', cls: 'yr-extra-spacing', label: 'Increased Spacing', desc: 'More space between lines, letters, and interface elements.' } 1620]; 1621 1622YR.initAccessibility = function () { 1623 // Build modal HTML 1624 var optionsHTML = ''; 1625 YR.A11Y_TOGGLES.forEach(function (t) { 1626 optionsHTML += '<div class="yr-a11y-option">' + 1627 '<div class="yr-a11y-opt-info">' + 1628 '<div class="yr-a11y-opt-label">' + t.label + '</div>' + 1629 '<div class="yr-a11y-opt-desc">' + t.desc + '</div>' + 1630 '</div>' + 1631 '<label class="yr-toggle">' + 1632 '<input type="checkbox" id="' + t.id + '" aria-label="Enable ' + t.label.toLowerCase() + '">' + 1633 '<span class="yr-toggle-track"></span>' + 1634 '</label></div>'; 1635 }); 1636 1637 var modalHTML = 1638 '<div class="yr-a11y-modal">' + 1639 '<div class="yr-a11y-header">' + 1640 '<h2>' + 1641 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="7" r="1.5" fill="currentColor" stroke="none"/><path d="M9 11h6"/><path d="M10 11v5l-1 2"/><path d="M14 11v5l1 2"/></svg>' + 1642 ' Accessibility' + 1643 '</h2>' + 1644 '<button class="yr-a11y-close" id="a11yClose" aria-label="Close accessibility settings">' + 1645 '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>' + 1646 '</button>' + 1647 '</div>' + 1648 '<div class="yr-a11y-body">' + 1649 '<p class="yr-a11y-desc">Adjust how Typing Agent looks and behaves. Changes take effect immediately.</p>' + 1650 optionsHTML + 1651 '</div>' + 1652 '</div>'; 1653 1654 // Create overlay 1655 var overlay = document.getElementById('a11yOverlay'); 1656 if (!overlay) { 1657 overlay = document.createElement('div'); 1658 overlay.className = 'yr-a11y-overlay'; 1659 overlay.id = 'a11yOverlay'; 1660 overlay.setAttribute('role', 'dialog'); 1661 overlay.setAttribute('aria-modal', 'true'); 1662 overlay.setAttribute('aria-label', 'Accessibility settings'); 1663 document.body.appendChild(overlay); 1664 } 1665 overlay.innerHTML = modalHTML; 1666 1667 var closeBtn = document.getElementById('a11yClose'); 1668 1669 function openA11y() { 1670 if (YR._closeUserMenu) YR._closeUserMenu(false); 1671 overlay.classList.add('open'); 1672 YR.lockBody(); 1673 if (closeBtn) closeBtn.focus(); 1674 } 1675 1676 function closeA11y() { 1677 overlay.classList.remove('open'); 1678 YR.unlockBody(); 1679 var trigger = document.getElementById('userMenuTrigger'); 1680 if (trigger) trigger.focus(); 1681 } 1682 1683 // Wire accessibility menu item 1684 var menuItem = document.getElementById('a11yMenuItem'); 1685 if (menuItem) { 1686 menuItem.addEventListener('click', function (e) { 1687 e.preventDefault(); 1688 e.stopPropagation(); 1689 openA11y(); 1690 }); 1691 } 1692 1693 // Close handlers 1694 if (closeBtn) closeBtn.addEventListener('click', closeA11y); 1695 overlay.addEventListener('click', function (e) { if (e.target === overlay) closeA11y(); }); 1696 document.addEventListener('keydown', function (e) { 1697 if (e.key === 'Escape' && overlay.classList.contains('open')) closeA11y(); 1698 }); 1699 1700 // Save prefs to cookie 1701 function savePrefs() { 1702 var prefs = {}; 1703 YR.A11Y_TOGGLES.forEach(function (t) { 1704 var el = document.getElementById(t.id); 1705 if (el) prefs[t.id] = el.checked; 1706 }); 1707 YR.setCookie(YR.COOKIE_NAME, prefs); 1708 } 1709 1710 // Wire toggles 1711 YR.A11Y_TOGGLES.forEach(function (t) { 1712 var el = document.getElementById(t.id); 1713 if (!el) return; 1714 el.addEventListener('change', function () { 1715 document.body.classList.toggle(t.cls, this.checked); 1716 savePrefs(); 1717 }); 1718 }); 1719 1720 // Restore prefs from cookie 1721 var prefs = YR.getCookie(YR.COOKIE_NAME); 1722 if (prefs) { 1723 YR.A11Y_TOGGLES.forEach(function (t) { 1724 if (prefs[t.id]) { 1725 var el = document.getElementById(t.id); 1726 if (el) { 1727 el.checked = true; 1728 document.body.classList.add(t.cls); 1729 } 1730 } 1731 }); 1732 } 1733 1734 return { open: openA11y, close: closeA11y }; 1735}; 1736 1737 1738// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1739// §17 â HEAD SCRIPT (Flash Prevention) 1740// Returns a string to inject in <head> so accessibility prefs 1741// apply BEFORE the body renders. Prevents white-flash-then-dark. 1742// 1743// Usage in Yii2 layout <head>: 1744// <?= YR.headScript() ?> 1745// Or paste the output directly. 1746// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1747 1748YR.headScript = function () { 1749 return '<script>' + 1750 '(function(){try{' + 1751 'var m=document.cookie.match(/yr_prefs=([^;]*)/);' + 1752 'if(!m)return;' + 1753 'var p=JSON.parse(decodeURIComponent(m[1]));' + 1754 'var map={a11yLargeText:"yr-large-text",a11yHighContrast:"yr-high-contrast",' + 1755 'a11yReducedMotion:"yr-reduced-motion",a11ySpacing:"yr-extra-spacing"};' + 1756 'for(var k in map){if(p[k])document.documentElement.classList.add(map[k])}' + 1757 '}catch(e){}})();' + 1758 '</' + 'script>'; 1759}; 1760 1761 1762// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1763// §18 â STICKY TABLE HEADER (JS-based, works everywhere) 1764// CSS position:sticky fails when ANY ancestor has overflow clipping. 1765// This approach clones the <thead> into a fixed container that 1766// appears when the real header scrolls out of view, and disappears 1767// when you scroll back up. Works in iframes, Bootstrap wrappers, 1768// overflow:hidden ancestors â everywhere. 1769// 1770// Usage: YR.initStickyHeader('#reportTable'); 1771// (call after renderTable() so the table has content) 1772// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 1773 1774YR.initStickyHeader = function (tableSelector) { 1775 var table = document.querySelector(tableSelector); 1776 if (!table) return null; 1777 1778 var thead = table.querySelector('thead'); 1779 if (!thead) return null; 1780 1781 // Create the floating header container 1782 var floater = document.createElement('div'); 1783 floater.className = 'yr-sticky-floater'; 1784 floater.setAttribute('aria-hidden', 'true'); 1785 floater.style.display = 'none'; 1786 1787 // Re-entrancy guard for the cloneâreal proxies below. A proxied interaction 1788 // can make the host page re-render, which calls refresh() and rebuilds the 1789 // clone; without this flag a handler firing on the rebuilt clone could 1790 // bounce back into the real control and loop. 1791 var proxying = false;
1792 1793 /** 1794 * Forward one cloned form control's interactions to the real control. 1795 * cloneNode(true) copies markup but NOT event listeners, so the clone's own 1796 * control is inert â the host page's listener lives on the real element. 1797 * @param {HTMLElement} copyCtrl the control inside the cloned header. 1798 * @param {HTMLElement} realCtrl the matching control in the real header. 1799 * @return {void} 1800 */ 1801 function wireStickyControl(copyCtrl, realCtrl) { 1802 var type = (copyCtrl.type || '').toLowerCase(); 1803 1804 if (type === 'checkbox' || type === 'radio') { 1805 // The roster pages (students/staff/classes) listen for 'change' on the 1806 // real #headerCheck, so set the REAL control's state and dispatch there. 1807 copyCtrl.addEventListener('change', function () { 1808 if (proxying) return; 1809 proxying = true; 1810 try { 1811 realCtrl.checked = copyCtrl.checked; 1812 realCtrl.indeterminate = false; 1813 realCtrl.dispatchEvent(new Event('change', { bubbles: true })); 1814 } finally { proxying = false; } 1815 syncStickyState(); 1816 }); 1817 return; 1818 } 1819 1820 if (copyCtrl.tagName === 'BUTTON' || type === 'button' || type === 'submit') { 1821 copyCtrl.addEventListener('click', function (e) { 1822 e.stopPropagation(); // the <th> proxy must not also fire 1823 if (proxying) return; 1824 proxying = true; 1825 try { realCtrl.click(); } finally { proxying = false; } 1826 syncStickyState(); 1827 }); 1828 return; 1829 } 1830 1831 // Text inputs / selects: mirror the value onto the real control and replay 1832 // both events hosts commonly bind. 1833 ['input', 'change'].forEach(function (evt) { 1834 copyCtrl.addEventListener(evt, function (e) { 1835 e.stopPropagation(); 1836 if (proxying) return; 1837 proxying = true; 1838 try { 1839 realCtrl.value = copyCtrl.value; 1840 realCtrl.dispatchEvent(new Event(evt, { bubbles: true })); 1841 } finally { proxying = false; } 1842 syncStickyState(); 1843 }); 1844 }); 1845 } 1846 1847 /** 1848 * Build the floater's proxying copy of the real <thead>. 1849 * Used by BOTH the initial mount and refresh() so the two can't drift. 1850 * Strips every id in the subtree (a cloned #headerCheck would make the host's 1851 * document.getElementById() DOM-order dependent), then wires each cloned cell 1852 * to forward clicks to its real <th> and each cloned control to its twin. 1853 * @param {HTMLTableSectionElement} realHead the live <thead> to copy. 1854 * @return {HTMLTableSectionElement} the detached, proxied clone. 1855 */ 1856 function buildStickyHead(realHead) { 1857 var copy = realHead.cloneNode(true); 1858 1859 // The clone lives on <body> alongside the real table â ids must not double 1860 // up. Nothing in the floater is addressed by id (all wiring below is by 1861 // element reference), so stripping is safe. `for=` would otherwise re-point 1862 // the clone's labels at the REAL controls. 1863 if (copy.id) copy.removeAttribute('id'); 1864 copy.querySelectorAll('[id]').forEach(function (node) { node.removeAttribute('id'); }); 1865 copy.querySelectorAll('label[for]').forEach(function (node) { node.removeAttribute('for'); }); 1866 1867 var realCells = realHead.querySelectorAll('th'); 1868 var copyCells = copy.querySelectorAll('th'); 1869 1870 for (var i = 0; i < copyCells.length && i < realCells.length; i++) { 1871 (function (copyTh, realTh) { 1872 var copyCtrls = copyTh.querySelectorAll('input, select, textarea, button'); 1873 var realCtrls = realTh.querySelectorAll('input, select, textarea, button'); 1874 for (var j = 0; j < copyCtrls.length && j < realCtrls.length; j++) { 1875 wireStickyControl(copyCtrls[j], realCtrls[j]); 1876 } 1877 1878 // Forward the cell click to the real <th> UNCONDITIONALLY â not only 1879 // when origTh.onclick happens to exist. Every roster binds its sort 1880 // with addEventListener('click') on `#rosterTable thead th.sortable`, 1881 // which cloneNode never copies; clicking the real <th> runs whatever 1882 // the page bound (addEventListener or onclick). A <th> with no 1883 // listener is a harmless no-op. 1884 copyTh.addEventListener('click', function (e) { 1885 if (e.target.closest('input, select, textarea, button, label, a')) return; 1886 if (proxying) return; 1887 proxying = true; 1888 try { realTh.click(); } finally { proxying = false; } 1889 syncStickyState(); 1890 }); 1891 })(copyCells[i], realCells[i]); 1892 } 1893 1894 return copy; 1895 } 1896 1897 /** 1898 * Mirror the real header's live state onto the clone â sort markers 1899 * (class + aria-sort) and control state (checked/indeterminate/value). 1900 * cloneNode copies the `checked` ATTRIBUTE, not the live property, so this 1901 * also seeds a freshly built clone correctly. 1902 * @return {void} 1903 */ 1904 function syncStickyState() { 1905 var realCells = thead.querySelectorAll('th'); 1906 var copyCells = clonedHead.querySelectorAll('th'); 1907 for (var i = 0; i < copyCells.length && i < realCells.length; i++) { 1908 var realTh = realCells[i]; 1909 var copyTh = copyCells[i]; 1910 1911 copyTh.className = realTh.className; 1912 var sortState = realTh.getAttribute('aria-sort'); 1913 if (sortState) copyTh.setAttribute('aria-sort', sortState); 1914 else copyTh.removeAttribute('aria-sort'); 1915 1916 var realCtrls = realTh.querySelectorAll('input, select, textarea'); 1917 var copyCtrls = copyTh.querySelectorAll('input, select, textarea'); 1918 for (var j = 0; j < copyCtrls.length && j < realCtrls.length; j++) { 1919 var t = (copyCtrls[j].type || '').toLowerCase(); 1920 if (t === 'checkbox' || t === 'radio') { 1921 copyCtrls[j].checked = realCtrls[j].checked; 1922 copyCtrls[j].indeterminate = realCtrls[j].indeterminate; 1923 } else { 1924 copyCtrls[j].value = realCtrls[j].value; 1925 } 1926 } 1927 } 1928 } 1929 1930 // Clone the table structure with only the thead 1931 var clone = document.createElement('table'); 1932 clone.className = table.className; 1933 var clonedHead = buildStickyHead(thead); 1934 clone.appendChild(clonedHead); 1935 floater.appendChild(clone); 1936 document.body.appendChild(floater); 1937 1938 // Sync column widths from real table to cloned header 1939 function syncWidths() { 1940 var realCells = thead.querySelectorAll('th'); 1941 var cloneCells = clonedHead.querySelectorAll('th'); 1942 var tableRect = table.getBoundingClientRect(); 1943 1944 // The real header may have changed state (sorted, Select All toggled) while 1945 // the floater was hidden â re-mirror it on every show/resize. 1946 syncStickyState(); 1947 1948 floater.style.left = tableRect.left + 'px'; 1949 floater.style.width = tableRect.width + 'px'; 1950 1951 for (var i = 0; i < realCells.length && i < cloneCells.length; i++) { 1952 // Mirror the real header's per-column visibility onto the clone. Pages 1953 // hide columns via scoped CSS (e.g. the Columns picker toggles 1954 // .stu-cols-hidden-* on #rosterTable), but those rules are written as 1955 // `.yr-scope #rosterTableâ¦` and the floater is a bare clone on <body> 1956 // with no id and no .yr-scope ancestor â so they never reach it and the 1957 // sticky header would show columns the table is hiding. Copy the computed 1958 // display per-cell instead, which works regardless of how the host page 1959 // hides the column (class, nth-child, or data-col selector). 1960 var realHidden = window.getComputedStyle(realCells[i]).display === 'none'; 1961 cloneCells[i].style.display = realHidden ? 'none' : ''; 1962 if (realHidden) continue; 1963 cloneCells[i].style.width = realCells[i].getBoundingClientRect().width + 'px'; 1964 } 1965 } 1966 1967 // Show/hide based on scroll position 1968 var ticking = false;
1969 function onScroll() { 1970 if (ticking) return; 1971 ticking = true; 1972 requestAnimationFrame(function () { 1973 ticking = false; 1974 var theadRect = thead.getBoundingClientRect(); 1975 var tableRect = table.getBoundingClientRect(); 1976 1977 // Show when real thead is above viewport AND table bottom is still visible 1978 var shouldShow = theadRect.bottom < 0 && tableRect.bottom > 60; 1979 1980 if (shouldShow) { 1981 if (floater.style.display === 'none') { 1982 syncWidths(); 1983 floater.style.display = 'block'; 1984 } 1985 } else { 1986 floater.style.display = 'none'; 1987 } 1988 }); 1989 } 1990 1991 window.addEventListener('scroll', onScroll, { passive: true }); 1992 function onResize() { 1993 if (floater.style.display !== 'none') syncWidths(); 1994 } 1995 window.addEventListener('resize', onResize); 1996 1997 // Re-sync if table is re-rendered (e.g. after sort) 1998 var refresh = function () { 1999 // Re-clone the thead content 2000 var newClonedHead = buildStickyHead(thead); 2001 clone.replaceChild(newClonedHead, clone.querySelector('thead')); 2002 clonedHead = newClonedHead; 2003 syncStickyState(); 2004 if (floater.style.display !== 'none') syncWidths(); 2005 }; 2006 YR._stickyRefresh = refresh; 2007 2008 // Teardown handle. The floater lives on <body> and the scroll/resize 2009 // listeners live on window â all OUTSIDE #main, so an AjaxNav innerHTML 2010 // swap can't remove them. Pages MUST call destroy() from an 2011 // AjaxNav.registerPageCleanup, or this header rides onto the next page: 2012 // a fixed, pointer-events:auto floater left over the next page swallows its 2013 // clicks, and the orphaned scroll handlers keep firing against a detached 2014 // table on every page visited afterward. 2015 var destroyed = false; 2016 function destroy() { 2017 if (destroyed) return; // idempotent â callers may also call this 2018 destroyed = true; 2019 window.removeEventListener('scroll', onScroll, { passive: true }); 2020 window.removeEventListener('resize', onResize); 2021 if (floater.parentNode) floater.parentNode.removeChild(floater); 2022 // Only clear the shared refresh hook if it's still ours â a sticky header 2023 // initialized on the next page may have already replaced it. 2024 if (YR._stickyRefresh === refresh) YR._stickyRefresh = null; 2025 } 2026 2027 // Self-register teardown with the AjaxNav shell so EVERY caller is cleaned up 2028 // on nav-away without per-page wiring. registerPageCleanup runs each callback 2029 // once, right before #main is replaced, then clears its registry â and this 2030 // header was created during the current page's init, so the registration 2031 // lands in the current page's (fresh) registry and fires when you leave. 2032 // Guarded for non-AjaxNav hosts (e.g. the teacher-module reports, which do 2033 // full reloads and so never leak across an SPA swap). destroy() is idempotent, 2034 // so a caller that ALSO tears down explicitly (to rebuild in-place) is safe. 2035 if (window.AjaxNav && typeof window.AjaxNav.registerPageCleanup === 'function') { 2036 window.AjaxNav.registerPageCleanup(destroy); 2037 } 2038 2039 return { refresh: refresh, destroy: destroy }; 2040}; 2041 2042 2043// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2044// §19 â TABLE PAGINATION 2045// Client-side pager that composes with YR.sortTable + filtering. 2046// Replaces state.renderFn so the host's row renderer receives only 2047// the active page slice. Sort changes reset to page 1 automatically 2048// (see hook in YR.sortTable). Filter changes should also reset â 2049// call pager.reset() after rebuilding state.data. 2050// 2051// Usage: 2052// const pager = YR.initPagination({ 2053// state: sortState, // from YR.sortTable convention 2054// pageSize: 50, // rows per page 2055// mount: '#pagerMount', // selector or element 2056// renderRows: renderTbody, // function(rows) â write tbody 2057// pageSizeOptions: [25, 50, 100], // optional dropdown values 2058// label: 'teachers' // optional, used in info text 2059// }); 2060// 2061// // After filter rebuild: 2062// sortState.data = filtered(); 2063// YR.applySortToData(sortState); 2064// pager.reset(); 2065// 2066// Returns: { reset, refresh, goTo, setPageSize, getPage } 2067// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2068YR.initPagination = function (cfg) { 2069 cfg = cfg || {}; 2070 var state = cfg.state; 2071 if (!state) { console.warn('YR.initPagination: state is required'); return null; } 2072 if (!cfg.renderRows) { console.warn('YR.initPagination: renderRows is required'); return null; } 2073 2074 var mount = (typeof cfg.mount === 'string') ? document.querySelector(cfg.mount) : cfg.mount; 2075 if (!mount) { console.warn('YR.initPagination: mount not found', cfg.mount); return null; } 2076 2077 var renderRows = cfg.renderRows; 2078 var pageSizeOpts = cfg.pageSizeOptions || null; // null = no selector shown 2079 var label = cfg.label || 'rows'; 2080 2081 state.page = 1; 2082 state.pageSize = cfg.pageSize || 50; 2083
2084 // ââ Compute which page numbers to show (windowed with ellipses) â 2085 // For â¤7 pages â all numbers 2086 // For >7 â 1, â¦, current-1, current, current+1, â¦, last 2087 function pageWindow(current, total) { 2088 if (total <= 7) { 2089 var out = []; 2090 for (var i = 1; i <= total; i++) out.push(i); 2091 return out; 2092 } 2093 var w = [1]; 2094 if (current > 3) w.push('â¦'); 2095 var startN = Math.max(2, current - 1); 2096 var endN = Math.min(total - 1, current + 1); 2097 for (var n = startN; n <= endN; n++) w.push(n); 2098 if (current < total - 2) w.push('â¦'); 2099 w.push(total); 2100 return w; 2101 } 2102 2103 // ââ Build pager footer markup ââââââââââââââââââââââââââââââââ 2104 function buildPagerHTML(total, totalPages) { 2105 var first = total === 0 ? 0 : (state.page - 1) * state.pageSize + 1; 2106 var last = Math.min(state.page * state.pageSize, total); 2107 2108 var sizeSelector = ''; 2109 if (pageSizeOpts && pageSizeOpts.length) { 2110 sizeSelector = 2111 '<label class="yr-pg-size">' + 2112 '<span>Rows per page</span>' + 2113 '<select class="yr-pg-size-select" aria-label="Rows per page">' + 2114 pageSizeOpts.map(function (n) { 2115 return '<option value="' + n + '"' + (n === state.pageSize ? ' selected' : '') + '>' + n + '</option>'; 2116 }).join('') + 2117 '</select>' + 2118 '</label>'; 2119 } 2120 2121 var info = total === 0 2122 ? '<div class="yr-pager-info">No ' + label + '</div>' 2123 : '<div class="yr-pager-info">Showing <strong>' + first + 'â' + last + '</strong> of <strong>' + total + '</strong> ' + label + '</div>'; 2124 2125 var navButtons = ''; 2126 if (totalPages > 1) { 2127 navButtons += '<button class="yr-pg-btn yr-pg-arrow" data-page="prev" aria-label="Previous page"' + 2128 (state.page === 1 ? ' disabled' : '') + '>â¹</button>'; 2129 pageWindow(state.page, totalPages).forEach(function (p) { 2130 if (p === 'â¦') { 2131 navButtons += '<span class="yr-pg-ellipsis" aria-hidden="true">â¦</span>'; 2132 } else { 2133 navButtons += '<button class="yr-pg-btn yr-pg-num' + (p === state.page ? ' active' : '') + 2134 '" data-page="' + p + '"' + 2135 (p === state.page ? ' aria-current="page"' : '') + 2136 ' aria-label="Page ' + p + '">' + p + '</button>'; 2137 } 2138 }); 2139 navButtons += '<button class="yr-pg-btn yr-pg-arrow" data-page="next" aria-label="Next page"' + 2140 (state.page === totalPages ? ' disabled' : '') + '>âº</button>'; 2141 } 2142 2143 return '<div class="yr-pager" role="navigation" aria-label="Table pagination">' + 2144 '<div class="yr-pager-left">' + info + sizeSelector + '</div>' + 2145 '<div class="yr-pager-nav">' + navButtons + '</div>' + 2146 '</div>'; 2147 } 2148 2149 // ââ The new state.renderFn slices, renders rows, then footer â 2150 state.renderFn = function () { 2151 var total = state.data.length; 2152 var totalPages = Math.max(1, Math.ceil(total / state.pageSize)); 2153 2154 if (state.page > totalPages) state.page = totalPages; 2155 if (state.page < 1) state.page = 1; 2156 2157 var startIdx = (state.page - 1) * state.pageSize; 2158 var endIdx = Math.min(startIdx + state.pageSize, total); 2159 var slice = state.data.slice(startIdx, endIdx); 2160 2161 renderRows(slice); 2162 mount.innerHTML = buildPagerHTML(total, totalPages); 2163 2164 if (YR._stickyRefresh) YR._stickyRefresh(); 2165 }; 2166 2167 // ââ Click delegation on the pager mount ââââââââââââââââââââââ 2168 mount.addEventListener('click', function (e) { 2169 var btn = e.target.closest('.yr-pg-btn'); 2170 if (!btn || btn.disabled) return; 2171 2172 var total = state.data.length; 2173 var totalPages = Math.max(1, Math.ceil(total / state.pageSize)); 2174 var target = btn.dataset.page; 2175 2176 if (target === 'prev') state.page = Math.max(1, state.page - 1); 2177 else if (target === 'next') state.page = Math.min(totalPages, state.page + 1); 2178 else state.page = parseInt(target, 10); 2179 2180 state.renderFn(); 2181 }); 2182 2183 // Page size selector 2184 mount.addEventListener('change', function (e) { 2185 if (!e.target.classList || !e.target.classList.contains('yr-pg-size-select')) return; 2186 state.pageSize = parseInt(e.target.value, 10); 2187 state.page = 1; 2188 state.renderFn(); 2189 }); 2190 2191 // Initial render 2192 state.renderFn(); 2193 2194 // ââ Public API âââââââââââââââââââââââââââââââââââââââââââââââ 2195 return { 2196 reset: function () { state.page = 1; state.renderFn(); }, 2197 refresh: function () { state.renderFn(); }, 2198 goTo: function (n) { state.page = n; state.renderFn(); }, 2199 setPageSize: function (n) { state.pageSize = n; state.page = 1; state.renderFn(); }, 2200 getPage: function () { return state.page; } 2201 }; 2202}; 2203 2204 2205// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2206// Make globally available 2207// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2208 2209 2210/* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2211 §18 â DROPDOWN COMPONENT (YR.initDropdown) 2212 v1.4.0 â Generic styled dropdown. Replaces native <select> with 2213 a fully styled, keyboard-navigable, ARIA-compliant popup. Use 2214 for class pickers, student pickers, filters, etc. 2215 2216 Required markup: 2217 <div class="yr-dd-wrap" id="dd-X" data-value="initial"> 2218 <button class="yr-dd-button" type="button" 2219 aria-haspopup="listbox" aria-expanded="false">
2220 <span class="yr-dd-label"></span> 2221 <svg class="yr-dd-chevron" viewBox="0 0 12 12" fill="none" 2222 stroke="currentColor" stroke-width="2" 2223 stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"> 2224 <path d="M3 4.5 L6 7.5 L9 4.5"/> 2225 </svg> 2226 </button> 2227 <div class="yr-dd-menu is-hidden" role="listbox"></div> 2228 </div> 2229 2230 config: { wrapId, options: [{value, label, meta?}], onChange? } 2231 returns: { setValue, getValue, getLabel, setDisabled } 2232 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 2233 2234/** 2235 * Bind the document-level "close on outside click / Escape" behavior ONCE for 2236 * the whole page, operating on whatever dropdown/export menus are currently 2237 * open in the live DOM. Previously each initDropdown / initExportMenu call 2238 * added its own document listeners closing over that call's wrap node; under 2239 * AjaxNav (which re-runs init() on every visit against fresh nodes) those 2240 * listeners accumulated, each pinned to a now-detached node â a growing leak 2241 * and a source of cross-instance interference. The guard lives on `window` 2242 * (not YR) so it survives a kit reload / redefinition. 2243 */ 2244function ensureGlobalMenuHandlers() { 2245 if (window.__yrMenuGlobalBound) return; 2246 window.__yrMenuGlobalBound = true; 2247 2248 document.addEventListener('click', function (e) { 2249 var openMenus = document.querySelectorAll( 2250 '.yr-dd-wrap .yr-dd-menu:not(.is-hidden), .yr-export-wrap .yr-export-menu:not(.is-hidden)' 2251 ); 2252 for (var i = 0; i < openMenus.length; i++) { 2253 var menu = openMenus[i]; 2254 var wrap = menu.closest('.yr-dd-wrap, .yr-export-wrap'); 2255 if (!wrap || wrap.contains(e.target)) continue; 2256 menu.classList.add('is-hidden'); 2257 var btn = wrap.querySelector('.yr-dd-button, .yr-export-btn'); 2258 if (btn) btn.setAttribute('aria-expanded', 'false'); 2259 } 2260 }); 2261 2262 document.addEventListener('keydown', function (e) { 2263 if (e.key !== 'Escape') return; 2264 var openMenus = document.querySelectorAll( 2265 '.yr-dd-menu:not(.is-hidden), .yr-export-menu:not(.is-hidden)' 2266 ); 2267 for (var i = 0; i < openMenus.length; i++) { 2268 openMenus[i].classList.add('is-hidden'); 2269 var wrap = openMenus[i].closest('.yr-dd-wrap, .yr-export-wrap'); 2270 var btn = wrap && wrap.querySelector('.yr-dd-button, .yr-export-btn'); 2271 if (btn) btn.setAttribute('aria-expanded', 'false'); 2272 } 2273 }); 2274} 2275 2276YR.initDropdown = function (config) { 2277 var wrap = document.getElementById(config.wrapId); 2278 if (!wrap) { 2279 console.warn('YR.initDropdown: element not found:', config.wrapId); 2280 return null; 2281 } 2282 2283 // Idempotent binding. Under AjaxNav, init() can run more than once against 2284 // the same wrap (a re-init on a node the router kept alive, or a double boot 2285 // from both the inline boot script and the IIFE bootstrap). Binding again 2286 // would add a SECOND click listener to the button â one opening the menu, 2287 // the other immediately closing it â so the button appears dead. To stay 2288 // safe regardless of whether the node is fresh or reused, clone-and-replace 2289 // the button and menu first: cloning drops any listeners a previous init 2290 // left on them, and we then bind exactly once below. 2291 var btn = wrap.querySelector('.yr-dd-button'); 2292 var menu = wrap.querySelector('.yr-dd-menu'); 2293 if (btn && btn.parentNode) { 2294 var freshBtn = btn.cloneNode(true); 2295 btn.parentNode.replaceChild(freshBtn, btn); 2296 btn = freshBtn; 2297 } 2298 if (menu && menu.parentNode) { 2299 var freshMenu = menu.cloneNode(false); // empty; renderOptions fills it 2300 menu.parentNode.replaceChild(freshMenu, menu); 2301 menu = freshMenu; 2302 } 2303 var labelEl = btn ? btn.querySelector('.yr-dd-label') : wrap.querySelector('.yr-dd-label'); 2304 var value = wrap.dataset.value || (config.options[0] && config.options[0].value); 2305 2306 function escapeHtml(s) { 2307 return String(s).replace(/[&<>"']/g, function (c) { 2308 return { '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]; 2309 }); 2310 } 2311 2312 // data-* values always come back from the DOM as strings. Keep the public 2313 // value unchanged, but compare at that DOM boundary so numeric option IDs 2314 // still resolve to their labels and selected state. 2315 function valuesMatch(a, b) { 2316 return String(a) === String(b); 2317 } 2318 2319 // Render the option list and (re)bind per-option click handlers. Safe to 2320 // call repeatedly â innerHTML replaces the old option nodes (and their 2321 // listeners) so there is no double-binding, unlike re-running initDropdown. 2322 function renderOptions() { 2323 menu.innerHTML = config.options.map(function (o) { 2324 return '<button type="button" role="option" class="yr-dd-option" ' + 2325 'data-value="' + escapeHtml(o.value) + '" tabindex="-1">' + 2326 escapeHtml(o.label) + 2327 (o.meta ? '<span class="yr-dd-meta">' + escapeHtml(o.meta) + '</span>' : '') + 2328 '</button>'; 2329 }).join(''); 2330 2331 var optionNodes = menu.querySelectorAll('.yr-dd-option'); 2332 for (var i = 0; i < optionNodes.length; i++) { 2333 optionNodes[i].addEventListener('click', function (e) { 2334 e.stopPropagation(); 2335 setValue(this.dataset.value); 2336 close(); 2337 btn.focus(); 2338 }); 2339 } 2340 } 2341 2342 function open() { 2343 if (wrap.classList.contains('is-disabled')) return; 2344 menu.classList.remove('is-hidden'); 2345 btn.setAttribute('aria-expanded', 'true'); 2346 var sel = menu.querySelector('[aria-selected="true"]') || 2347 menu.querySelector('.yr-dd-option'); 2348 if (sel) sel.focus(); 2349 } 2350 function close() { 2351 menu.classList.add('is-hidden'); 2352 btn.setAttribute('aria-expanded', 'false'); 2353 } 2354 function setValue(v, opts) { 2355 opts = opts || {}; 2356 value = v; 2357 wrap.dataset.value = v; 2358 var opt = config.options.filter(function (o) { return valuesMatch(o.value, v); })[0]; 2359 labelEl.textContent = opt ? opt.label : ''; 2360 var nodes = menu.querySelectorAll('.yr-dd-option'); 2361 for (var i = 0; i < nodes.length; i++) { 2362 nodes[i].setAttribute('aria-selected', 2363 valuesMatch(nodes[i].dataset.value, v) ? 'true' : 'false'); 2364 } 2365 if (!opts.silent && config.onChange) config.onChange(v); 2366 } 2367 2368 btn.addEventListener('click', function (e) { 2369 e.stopPropagation(); 2370 if (menu.classList.contains('is-hidden')) open(); else close(); 2371 }); 2372 2373 renderOptions(); 2374 2375 menu.addEventListener('keydown', function (e) { 2376 var opts = Array.prototype.slice.call(menu.querySelectorAll('.yr-dd-option')); 2377 var idx = opts.indexOf(document.activeElement); 2378 if (e.key === 'ArrowDown') { 2379 e.preventDefault(); opts[Math.min(idx + 1, opts.length - 1)].focus(); 2380 } else if (e.key === 'ArrowUp') { 2381 e.preventDefault(); opts[Math.max(idx - 1, 0)].focus(); 2382 } else if (e.key === 'Home') { 2383 e.preventDefault(); opts[0].focus(); 2384 } else if (e.key === 'End') { 2385 e.preventDefault(); opts[opts.length - 1].focus(); 2386 } else if (e.key === 'Enter' || e.key === ' ') { 2387 e.preventDefault(); 2388 if (document.activeElement.classList.contains('yr-dd-option')) { 2389 setValue(document.activeElement.dataset.value); 2390 close(); 2391 btn.focus(); 2392 } 2393 } 2394 }); 2395 2396 // Outside-click / Escape closing is handled by one shared global listener 2397 // (bound once) instead of per-instance listeners that would leak under 2398 // AjaxNav re-inits. 2399 ensureGlobalMenuHandlers(); 2400 2401 setValue(value, { silent: true }); 2402 2403 var api = { 2404 setValue: setValue, 2405 getValue: function () { return value; }, 2406 getLabel: function () { 2407 var opt = config.options.filter(function (o) { return valuesMatch(o.value, value); })[0]; 2408 return opt ? opt.label : ''; 2409 }, 2410 setDisabled: function (d) { 2411 wrap.classList.toggle('is-disabled', d); 2412 if (d) close(); 2413 }, 2414 // Swap the option list in place (e.g. after the underlying data reloads). 2415 // opts.value sets the new selection (defaults to the first option);
2416 // selection is applied silently (no onChange). 2417 setOptions: function (newOptions, opts) { 2418 opts = opts || {}; 2419 config.options = newOptions || []; 2420 renderOptions(); 2421 var v = opts.value != null 2422 ? opts.value 2423 : (config.options[0] && config.options[0].value); 2424 setValue(v, { silent: true }); 2425 } 2426 }; 2427 2428 return api; 2429}; 2430 2431 2432/* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2433 §19 â SKELETON HELPERS 2434 v1.4.0 â Convenience renderers for the two skeleton patterns 2435 most reports need: stat cards and table rows. For graphs and 2436 custom layouts, compose the .yr-skel* CSS primitives directly. 2437 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 2438YR.skeletonStatCard = function (tone) { 2439 function escapeAttr(s) { 2440 return String(s).replace(/[&<>"']/g, function (c) { 2441 return { '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]; 2442 }); 2443 } 2444 return '<div class="yr-stat-card yr-card-loading"' + 2445 (tone ? ' data-tone="' + escapeAttr(tone) + '"' : '') + '>' + 2446 '<div class="yr-loading-bar"></div>' + 2447 '<span class="yr-skel" style="width:55%; margin-bottom:10px;"></span>' + 2448 '<span class="yr-skel yr-skel-tall" style="width:70%; margin-bottom:8px;"></span>' + 2449 '<span class="yr-skel" style="width:65%;"></span>' + 2450 '</div>'; 2451}; 2452 2453YR.skeletonTableRow = function (colCount, opts) { 2454 opts = opts || {}; 2455 var firstColIsStudent = opts.firstColIsStudent !== false; 2456 var widths = opts.cellWidths || []; 2457 var cells = ''; 2458 for (var i = 0; i < colCount; i++) { 2459 var w = widths[i] || (i === 0 ? 'auto' : '60%'); 2460 if (i === 0 && firstColIsStudent) { 2461 cells += 2462 '<td>' + 2463 '<div class="yr-skel-cell">' + 2464 '<span class="yr-skel yr-skel-circle"></span>' + 2465 '<span style="display:inline-block;">' + 2466 '<span class="yr-skel" style="width:120px; margin-bottom:5px;"></span>' + 2467 '<span class="yr-skel" style="width:60px; height:10px;"></span>' + 2468 '</span>' + 2469 '</div>' + 2470 '</td>'; 2471 } else { 2472 cells += '<td><span class="yr-skel" style="width:' + w + '; margin-left:auto;"></span></td>'; 2473 } 2474 } 2475 return '<tr class="yr-skel-row">' + cells + '</tr>'; 2476}; 2477 2478 2479/* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2480 §20 â EXPORT MENU (YR.initExportMenu) 2481 v1.4.0 â Standard Export â¼ button + grouped popup. The kit 2482 owns UI, behavior, dispatch, and filename convention. Reports 2483 provide the option list with data builders. 2484 2485 Required markup: 2486 <div class="yr-export-wrap" id="export-X"> 2487 <button class="yr-btn yr-btn-ghost yr-export-btn" type="button" 2488 aria-haspopup="true" aria-expanded="false"> 2489 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" 2490 stroke="currentColor" stroke-width="2" stroke-linecap="round" 2491 stroke-linejoin="round" aria-hidden="true"> 2492 <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/> 2493 <polyline points="7 10 12 15 17 10"/> 2494 <line x1="12" y1="15" x2="12" y2="3"/> 2495 </svg> 2496 Export 2497 <svg class="yr-caret" viewBox="0 0 12 12" fill="none" 2498 stroke="currentColor" stroke-width="2" stroke-linecap="round" 2499 stroke-linejoin="round" aria-hidden="true"> 2500 <path d="M3 4.5 L6 7.5 L9 4.5"/> 2501 </svg> 2502 </button> 2503 <div class="yr-export-menu is-hidden" role="menu"></div> 2504 </div> 2505 2506 config: { wrapId, filenamePrefix, contextFn?, options: [...] } 2507 Each option: { format: 'xlsx'|'csv'|'print', scope, label, 2508 description, buildSheet?, buildSheets? } 2509 returns: { open, close, refresh } 2510 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 2511YR.initExportMenu = function (config) { 2512 var wrap = document.getElementById(config.wrapId); 2513 if (!wrap) { 2514 console.warn('YR.initExportMenu: element not found:', config.wrapId); 2515 return null; 2516 } 2517 2518 // Idempotent binding â see YR.initDropdown. Clone-and-replace the button and 2519 // menu so a previous init()'s listeners are dropped, then bind exactly once. 2520 var btn = wrap.querySelector('.yr-export-btn'); 2521 var menu = wrap.querySelector('.yr-export-menu'); 2522 if (btn && btn.parentNode) { 2523 var freshBtn = btn.cloneNode(true); 2524 btn.parentNode.replaceChild(freshBtn, btn);
2525 btn = freshBtn; 2526 } 2527 if (menu && menu.parentNode) { 2528 var freshMenu = menu.cloneNode(false); // empty; buildMenu fills it 2529 menu.parentNode.replaceChild(freshMenu, menu); 2530 menu = freshMenu; 2531 } 2532 2533 function escapeHtml(s) { 2534 return String(s).replace(/[&<>"']/g, function (c) { 2535 return { '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]; 2536 }); 2537 } 2538 2539 function buildMenu() { 2540 var sections = { xlsx: [], csv: [], print: [] }; 2541 config.options.forEach(function (o) { sections[o.format].push(o); }); 2542 2543 var html = ''; 2544 if (sections.xlsx.length) { 2545 html += '<div class="yr-export-menu-section">Excel (.xlsx)</div>'; 2546 sections.xlsx.forEach(function (o) { html += renderOption(o, 'XLS'); }); 2547 } 2548 if (sections.csv.length) { 2549 if (html) html += '<div class="yr-menu-divider"></div>'; 2550 html += '<div class="yr-export-menu-section">CSV</div>'; 2551 sections.csv.forEach(function (o) { html += renderOption(o, 'CSV'); }); 2552 } 2553 if (sections.print.length) { 2554 if (html) html += '<div class="yr-menu-divider"></div>'; 2555 html += '<div class="yr-export-menu-section">Print</div>'; 2556 sections.print.forEach(function (o) { html += renderOption(o, 'PDF'); }); 2557 } 2558 menu.innerHTML = html; 2559 } 2560 2561 function renderOption(o, iconLabel) { 2562 return '<button type="button" role="menuitem" ' + 2563 'data-format="' + escapeHtml(o.format) + '" ' + 2564 'data-scope="' + escapeHtml(o.scope) + '">' + 2565 '<span class="yr-opt-icon">' + iconLabel + '</span>' + 2566 '<span class="yr-opt-text">' + 2567 '<span class="yr-opt-name">' + escapeHtml(o.label) + '</span>' + 2568 (o.description ? '<span class="yr-opt-meta">' + escapeHtml(o.description) + '</span>' : '') + 2569 '</span>' + 2570 '</button>'; 2571 } 2572 2573 function open() { menu.classList.remove('is-hidden'); btn.setAttribute('aria-expanded', 'true'); } 2574 function close() { menu.classList.add('is-hidden'); btn.setAttribute('aria-expanded', 'false'); } 2575 2576 function buildFilename(ext) { 2577 var parts = [config.filenamePrefix]; 2578 if (typeof config.contextFn === 'function') { 2579 var ctx = config.contextFn(); 2580 if (ctx) parts.push(String(ctx).replace(/\W+/g, '-').toLowerCase().replace(/^-|-$/g, '')); 2581 } 2582 parts.push(new Date().toISOString().slice(0, 10)); 2583 return parts.filter(Boolean).join('_') + '.' + ext; 2584 } 2585 2586 function showExportError(err) { 2587 if (!wrap.isConnected) return; 2588 console.error('YR.initExportMenu: export failed', err); 2589 if (typeof YR.toast === 'function') { 2590 YR.toast('error', 'Could not prepare export. Try again.'); 2591 setTimeout(function () { YR.toast(); }, 2600); 2592 } 2593 } 2594 2595 function finishAsync(result, complete) { 2596 if (!result || typeof result.then !== 'function') { 2597 complete(result); 2598 return null; 2599 } 2600 btn.setAttribute('aria-busy', 'true'); 2601 if (typeof YR.toast === 'function') YR.toast('loading', 'Preparing exportâ¦'); 2602 return result.then(function (value) { 2603 // AjaxNav may replace the page while an async sheet builder is fetching. 2604 // A departed export menu must not download or toast into the next page. 2605 if (!wrap.isConnected) return; 2606 complete(value); 2607 if (typeof YR.toast === 'function') { 2608 YR.toast('done', 'Export ready'); 2609 setTimeout(function () { YR.toast(); }, 1500); 2610 } 2611 }).catch(showExportError).then(function () { 2612 btn.removeAttribute('aria-busy'); 2613 }); 2614 } 2615 2616 function dispatch(opt) { 2617 if (opt.format === 'print') { window.print(); return; } 2618 if (opt.format === 'xlsx') { 2619 if (typeof YR.exportXLSX !== 'function') { 2620 console.warn('YR.initExportMenu: YR.exportXLSX not available (load SheetJS)'); 2621 return; 2622 } 2623 var sheets = opt.buildSheets ? opt.buildSheets() : 2624 opt.buildSheet ? [opt.buildSheet()] : []; 2625 if (!opt.buildSheets && sheets[0] && typeof sheets[0].then === 'function') { 2626 sheets = sheets[0].then(function (sheet) { return [sheet]; }); 2627 } 2628 return finishAsync(sheets, function (resolvedSheets) { 2629 YR.exportXLSX({ filename: buildFilename('xlsx'), sheets: resolvedSheets || [] }); 2630 }); 2631 } 2632 if (opt.format === 'csv') { 2633 if (typeof YR.exportCSV !== 'function') { 2634 console.warn('YR.initExportMenu: YR.exportCSV not available'); 2635 return; 2636 } 2637 var sheet = opt.buildSheet ? opt.buildSheet() : null; 2638 return finishAsync(sheet, function (resolvedSheet) { 2639 if (!resolvedSheet) return; 2640 YR.exportCSV({ 2641 filename: buildFilename('csv'), 2642 headers: resolvedSheet.headers, 2643 rows: resolvedSheet.rows 2644 }); 2645 }); 2646 } 2647 } 2648 2649 btn.addEventListener('click', function (e) { 2650 e.stopPropagation(); 2651 if (menu.classList.contains('is-hidden')) open(); else close(); 2652 }); 2653 // Outside-click / Escape closing handled by the shared global listener. 2654 ensureGlobalMenuHandlers(); 2655 menu.addEventListener('click', function (e) { 2656 var optBtn = e.target.closest('button[data-format]'); 2657 if (!optBtn) return; 2658 e.stopPropagation(); 2659 var format = optBtn.dataset.format; 2660 var scope = optBtn.dataset.scope; 2661 var opt = config.options.filter(function (o) { 2662 return o.format === format && o.scope === scope; 2663 })[0]; 2664 close(); 2665 if (opt) {
2666 try { 2667 dispatch(opt); 2668 } catch (err) { 2669 showExportError(err); 2670 btn.removeAttribute('aria-busy'); 2671 } 2672 } 2673 }); 2674 2675 buildMenu(); 2676 2677 return { open: open, close: close, refresh: buildMenu }; 2678}; 2679 2680 2681/* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2682 §21 â REPORT VIEW TABS (YR.initReportTabs) 2683 v1.6.0 â One accessible tablist implementation for every report's 2684 Table/Graph (or equivalent) view switch. Panels are mutually 2685 exclusive; the HTML `hidden` attribute is the visibility source 2686 of truth (report-controls.css backs it with display:none). 2687 2688 Required markup (ids are per-page; relationships are required): 2689 <div class="yr-report-tabs" id="sc-view-tabs" role="tablist" 2690 aria-label="Report view"> 2691 <button class="yr-report-tab" id="sc-tab-table" type="button" 2692 role="tab" data-tab="table" aria-controls="sc-table-panel" 2693 aria-selected="true" tabindex="0">Table</button> 2694 <button class="yr-report-tab" id="sc-tab-graph" type="button" 2695 role="tab" data-tab="graph" aria-controls="sc-graph-panel" 2696 aria-selected="false" tabindex="-1">Graph</button> 2697 </div> 2698 <section class="yr-report-panel" id="sc-table-panel" role="tabpanel" 2699 aria-labelledby="sc-tab-table">â¦</section> 2700 <section class="yr-report-panel" id="sc-graph-panel" role="tabpanel" 2701 aria-labelledby="sc-tab-graph" hidden>â¦</section> 2702 2703 Segmented VALUE controls (Metric, date ranges) are NOT tabs â keep 2704 them as aria-pressed button groups; this helper refuses nothing but 2705 should only wrap role="tab" markup. 2706 2707 config: { 2708 rootId: tablist element id (required) 2709 initial: value (data-tab) to select on init; defaults to the tab 2710 already marked aria-selected, else the first tab 2711 onChange: function (value) â fired once per GENUINE change (not 2712 during init, not on re-selecting the active tab) 2713 } 2714 returns: { getValue, setValue(value, opts), destroy } 2715 setValue opts: { silent: true } applies DOM state without onChange. 2716 2717 Idempotent under AjaxNav: re-initializing the same live root tears 2718 down the previous instance's listeners first. Keyboard follows the 2719 WAI-ARIA tabs pattern with automatic activation: ArrowLeft/Right 2720 move AND select (wrapping), Home/End jump to first/last. 2721 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 2722YR.initReportTabs = function (config) { 2723 var root = document.getElementById(config.rootId); 2724 if (!root) { 2725 console.warn('YR.initReportTabs: element not found:', config.rootId); 2726 return null; 2727 } 2728 2729 // Re-init on the same live node replaces the previous handlers. 2730 if (root.__yrReportTabs && typeof root.__yrReportTabs.destroy === 'function') { 2731 root.__yrReportTabs.destroy(); 2732 } 2733 2734 function tabs() { 2735 return Array.prototype.slice.call(root.querySelectorAll('[role="tab"]')); 2736 } 2737 2738 function panelFor(tab) { 2739 var id = tab.getAttribute('aria-controls'); 2740 return id ? document.getElementById(id) : null; 2741 } 2742 2743 function current() { 2744 var list = tabs(); 2745 for (var i = 0; i < list.length; i++) { 2746 if (list[i].getAttribute('aria-selected') === 'true') return list[i]; 2747 } 2748 return list[0] || null; 2749 } 2750 2751 function apply(tab, opts) { 2752 if (!tab) return; 2753 var changed = false;
2754 tabs().forEach(function (t) { 2755 var selected = t === tab; 2756 if (selected && t.getAttribute('aria-selected') !== 'true') changed = true; 2757 t.setAttribute('aria-selected', selected ? 'true' : 'false'); 2758 t.setAttribute('tabindex', selected ? '0' : '-1'); 2759 var panel = panelFor(t); 2760 if (panel) { 2761 if (selected) panel.removeAttribute('hidden'); 2762 else panel.setAttribute('hidden', ''); 2763 } 2764 }); 2765 if (changed && !(opts && opts.silent) && typeof config.onChange === 'function') { 2766 config.onChange(tab.dataset.tab); 2767 } 2768 } 2769 2770 function onClick(e) { 2771 var tab = e.target.closest('[role="tab"]'); 2772 if (!tab || !root.contains(tab)) return; 2773 apply(tab); 2774 } 2775 2776 function onKeydown(e) { 2777 var tab = e.target.closest('[role="tab"]'); 2778 if (!tab) return; 2779 var list = tabs(); 2780 var idx = list.indexOf(tab); 2781 if (idx === -1) return; 2782 var next = null; 2783 if (e.key === 'ArrowRight') next = list[(idx + 1) % list.length]; 2784 else if (e.key === 'ArrowLeft') next = list[(idx - 1 + list.length) % list.length]; 2785 else if (e.key === 'Home') next = list[0]; 2786 else if (e.key === 'End') next = list[list.length - 1]; 2787 if (!next) return; 2788 e.preventDefault(); 2789 next.focus(); 2790 apply(next); // automatic activation: moving focus selects 2791 } 2792 2793 root.addEventListener('click', onClick); 2794 root.addEventListener('keydown', onKeydown); 2795 2796 var api = { 2797 getValue: function () { 2798 var t = current(); 2799 return t ? t.dataset.tab : null; 2800 }, 2801 setValue: function (value, opts) { 2802 var match = tabs().filter(function (t) { return t.dataset.tab === value; })[0]; 2803 if (match) apply(match, opts); 2804 }, 2805 destroy: function () { 2806 root.removeEventListener('click', onClick); 2807 root.removeEventListener('keydown', onKeydown); 2808 delete root.__yrReportTabs; 2809 } 2810 }; 2811 root.__yrReportTabs = api; 2812 2813 // Normalize DOM to the initial value without firing onChange â the page 2814 // renders its initial panel itself (and re-rendering during init causes 2815 // double work / flicker under AjaxNav). 2816 var initialTab = null; 2817 if (config.initial) { 2818 initialTab = tabs().filter(function (t) { return t.dataset.tab === config.initial; })[0]; 2819 } 2820 apply(initialTab || current(), { silent: true }); 2821 2822 return api; 2823}; 2824 2825 2826// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2827// §31 â CHARTS (pure SVG, zero dependencies) â kit v1.5.0 2828// Builders take (el, data, options) and render into el, EXCEPT sparkHTML which 2829// returns an inline SVG string for use inside grid cells. AA palette from 2830// master §5. Every chart is wrapped in a <figure> with an aria-label. 2831// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 2832YR.CHART_COLORS = { 2833 primary: '#12A0DD', success: '#69b64b', warn: '#F0A202', danger: '#D64545', 2834 grid: '#e6ebf0', axis: '#8a97a5', text: '#41505f' 2835}; 2836 2837/** Escape an attribute value for inline SVG. @param {*} s @return {string} */ 2838function chEsc(s) { 2839 return String(s == null ? '' : s) 2840 .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>') 2841 .replace(/"/g, '"'); 2842} 2843 2844YR.charts = { 2845 /** 2846 * Inline sparkline SVG string (for grid cells). Draws the value series as a 2847 * filled area + line; flat/empty series render a baseline. 2848 * @param {number[]} values series (e.g. daily minutes). 2849 * @param {object} [opts] { w, h, color }. 2850 * @return {string} SVG markup. 2851 */ 2852 sparkHTML: function (values, opts) { 2853 opts = opts || {}; 2854 var w = opts.w || 84, h = opts.h || 22, pad = 2; 2855 var color = opts.color || YR.CHART_COLORS.primary; 2856 var data = (values || []).map(function (v) { return +v || 0; }); 2857 if (!data.length) data = [0]; 2858 var max = Math.max.apply(null, data), min = 0; 2859 var span = (max - min) || 1; 2860 var n = data.length; 2861 var stepX = n > 1 ? (w - pad * 2) / (n - 1) : 0; 2862 var pts = data.map(function (v, i) { 2863 var x = pad + i * stepX; 2864 var y = h - pad - ((v - min) / span) * (h - pad * 2); 2865 return [x, y]; 2866 }); 2867 var line = pts.map(function (p, i) { return (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1); }).join(' '); 2868 var area = line + ' L' + (pad + (n - 1) * stepX).toFixed(1) + ' ' + (h - pad
2868) + ' L' + pad + ' ' + (h - pad) + ' Z'; 2869 return '<svg class="yr-spark" width="' + w + '" height="' + h + '" viewBox="0 0 ' + w + ' ' + h + 2870 '" role="img" aria-label="' + chEsc(max > 0 ? 'Recent activity trend' : 'No recent activity') + '">' + 2871 '<path d="' + area + '" fill="' + color + '" fill-opacity="0.14"/>' + 2872 '<path d="' + line + '" fill="none" stroke="' + color + '" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>' + 2873 '</svg>'; 2874 }, 2875 2876 /** 2877 * Horizontal bar chart. data: [{label, value}]. options: { title, unit, 2878 * medianLine (bool), color, formatValue(fn) }. 2879 * @param {Element} el mount. 2880 * @param {Array} data rows. 2881 * @param {object} [options] 2882 * @return {void} 2883 */ 2884 hbar: function (el, data, options) { 2885 options = options || {}; 2886 if (!el) return; 2887 data = data || []; 2888 if (!data.length) { el.innerHTML = YR.charts._empty(options.title, 'No data in range'); return; } 2889 var color = options.color || YR.CHART_COLORS.primary; 2890 var max = Math.max.apply(null, data.map(function (d) { return +d.value || 0; })) || 1; 2891 var fmt = options.formatValue || function (v) { return v; }; 2892 var rows = data.map(function (d) { 2893 var pct = Math.max(0, (+d.value || 0) / max * 100); 2894 return '<div class="yr-hbar-row"><span class="yr-hbar-label" title="' + chEsc(d.label) + '">' + chEsc(d.label) + '</span>' + 2895 '<span class="yr-hbar-track"><span class="yr-hbar-fill" style="width:' + pct.toFixed(1) + '%;background:' + color + '"></span></span>' + 2896 '<span class="yr-hbar-val">' + chEsc(fmt(d.value)) + '</span></div>'; 2897 }).join(''); 2898 var caption = ''; 2899 if (options.medianLine) { 2900 var vals = data.map(function (d) { return +d.value || 0; }).sort(function (a, b) { return a - b; }); 2901 var m = vals.length % 2 ? vals[(vals.length - 1) / 2] : (vals[vals.length / 2 - 1] + vals[vals.length / 2]) / 2; 2902 caption = '<div class="yr-hbar-note">Class median: ' + chEsc(fmt(Math.round(m))) + '</div>'; 2903 } 2904 el.innerHTML = YR.charts._fig(options.title, options.ariaLabel || options.title, 2905 '<div class="yr-hbar">' + rows + '</div>' + caption); 2906 }, 2907 2908 /** 2909 * Line chart over a labelled x-series. data: { labels:[], series:[{name,values,color}] }. 2910 * @param {Element} el mount. 2911 * @param {object} data { labels, series }. 2912 * @param {object} [options] { title, height }. 2913 * @return {void} 2914 */ 2915 line: function (el, data, options) { 2916 options = options || {}; 2917 if (!el) return; 2918 data = data || {}; 2919 var series = data.series || []; 2920 var labels = data.labels || []; 2921 var flat = series.reduce(function (a, s) { return a.concat(s.values || []); }, []); 2922 if (!flat.length) { el.innerHTML = YR.charts._empty(options.title, 'No data in range'); return; } 2923 var w = 520, h = options.height || 160, padL = 34, padR = 10, padT = 12, padB = 22; 2924 var max = Math.max.apply(null, flat) || 1, min = 0; 2925 var span = (max - min) || 1, n = labels.length || 1; 2926 var stepX = n > 1 ? (w - padL - padR) / (n - 1) : 0; 2927 var yOf = function (v) { return h - padB - ((v - min) / span) * (h - padT - padB); }; 2928 var xOf = function (i) { return padL + i * stepX; }; 2929 var grid = ''; 2930 for (var g = 0; g <= 2; g++) { 2931 var gy = padT + (h - padT - padB) * g / 2; 2932 grid += '<line x1="' + padL + '" y1="' + gy.toFixed(1) + '" x2="' + (w - padR) + '" y2="' + gy.toFixed(1) + 2933 '" stroke="' + YR.CHART_COLORS.grid + '" stroke-width="1"/>'; 2934 } 2935 var paths = series.map(function (s, si) { 2936 var c = s.color || [YR.CHART_COLORS.primary, YR.CHART_COLORS.success, YR.CHART_COLORS.warn][si % 3]; 2937 var d = (s.values || []).map(function (v, i) { return (i ? 'L' : 'M') + xOf(i).toFixed(1) + ' ' + yOf(+v || 0).toFixed(1); }).join(' '); 2938 return '<path d="' + d + '" fill="none" stroke="' + c + '" stroke-width="2" stroke-linejoin="round"/>'; 2939 }).join(''); 2940 var svg = '<svg width="100%" viewBox="0 0 ' + w + ' ' + h + '" preserveAspectRatio="none" class="yr-chart-svg" role="img" aria-label="' + 2941 chEsc(options.ariaLabel || options.title) + '">' + grid + paths + 2942 '<text x="' + padL + '" y="' + (padT - 2) + '" font-size="9" fill="' + YR.CHART_COLORS.axis + '">' + chEsc(Math.round(max)) + '</text>' + 2943 '</svg>'; 2944 el.innerHTML = YR.charts._fig(options.title, options.ariaLabel || options.title, svg); 2945 }, 2946 2947 /** 2948 * Scatter/quadrant chart. data: [{x, y, label}]. options: { title, xLabel, 2949 * yLabel, quadrantLabels:[tl,tr,bl,br], onPoint(label) }. 2950 * @param {Element} el mount. 2951 * @param {Array} data points. 2952 * @param {object} [options] 2953 * @return {void} 2954 */ 2955 scatter: function (el, data, options) { 2956 options = options || {}; 2957 if (!el) return; 2958 data = (data || []).filter(function (d) { return d.x != null && d.y != null; }); 2959 if (!data.length) { el.innerHTML = YR.charts._empty(options.title, 'No data in range'); return; } 2960 var w = 320, h = 220, pad = 26; 2961 var xs = data.map(function (d) { return +d.x; }), ys = data.map(function (d) { return +d.y; }); 2962 var xmax = Math.max.apply(null, xs) || 1, ymax = Math.max.apply(null, ys) || 1; 2963 var med = function (a) { var s = a.slice().sort(function (p, q) { return p - q; }); return s.length % 2 ? s[(s.length - 1) / 2] : (s[s.length / 2 - 1] + s[s.length / 2]) / 2; }; 2964 var mx = med(xs), my = med(ys); 2965 var xOf = function (v) { return pad + (v / xmax) * (w - pad * 2); }; 2966 var yOf = function (v) { return h - pad - (v / ymax) * (h - pad * 2); }; 2967 var pts = data.map(function (d) { 2968 return '<circle class="yr-scatter-pt" cx="' + xOf(+d.x).toFixed(1) + '" cy="' + yOf(+d.y).toFixed(1) + 2969 '" r="4" fill="' + YR.CHART_COLORS.primary + '" fill-opacity="0.75" data-label="' + chEsc(d.label) + '"><title>' + 2970 chEsc(d.label + ' â ' + (options.xLabel || 'x') + ' ' + d.x + ', ' + (options.yLabel || 'y') + ' ' + d.y) + '</title></circle>'; 2971 }).join('');
2972 var svg = '<svg width="100%" viewBox="0 0 ' + w + ' ' + h + '" class="yr-chart-svg" role="img" aria-label="' + chEsc(options.ariaLabel || options.title) + '">' + 2973 '<line x1="' + xOf(mx).toFixed(1) + '" y1="' + pad + '" x2="' + xOf(mx).toFixed(1) + '" y2="' + (h - pad) + '" stroke="' + YR.CHART_COLORS.grid + '" stroke-dasharray="3 3"/>' + 2974 '<line x1="' + pad + '" y1="' + yOf(my).toFixed(1) + '" x2="' + (w - pad) + '" y2="' + yOf(my).toFixed(1) + '" stroke="' + YR.CHART_COLORS.grid + '" stroke-dasharray="3 3"/>' + 2975 pts + '</svg>'; 2976 el.innerHTML = YR.charts._fig(options.title, options.ariaLabel || options.title, svg); 2977 if (options.onPoint) { 2978 el.querySelectorAll('.yr-scatter-pt').forEach(function (c) { 2979 c.style.cursor = 'pointer'; 2980 c.addEventListener('click', function () { options.onPoint(c.getAttribute('data-label')); }); 2981 }); 2982 } 2983 }, 2984 2985 /** 2986 * Heatmap grid. rows: [{label, cells:[{v}]}], cols: [labels]. Intensity is 2987 * value / maxValue mapped to the primary color opacity. 2988 * @param {Element} el mount. 2989 * @param {object} data { rows, cols }. 2990 * @param {object} [options] { title, color }. 2991 * @return {void} 2992 */ 2993 heatmap: function (el, data, options) { 2994 options = options || {}; 2995 if (!el) return; 2996 data = data || {}; 2997 var rows = data.rows || [], cols = data.cols || []; 2998 var allV = rows.reduce(function (a, r) { return a.concat((r.cells || []).map(function (c) { return +c.v || 0; })); }, []); 2999 var max = Math.max.apply(null, allV.length ? allV : [0]) || 1; 3000 if (!rows.length) { el.innerHTML = YR.charts._empty(options.title, 'No data in range'); return; } 3001 var color = options.color || YR.CHART_COLORS.primary; 3002 var head = '<div class="yr-hm-row yr-hm-head"><span class="yr-hm-label"></span>' + 3003 cols.map(function (c) { return '<span class="yr-hm-col">' + chEsc(c) + '</span>'; }).join('') + '</div>'; 3004 var body = rows.map(function (r) { 3005 var cells = (r.cells || []).map(function (c) { 3006 var op = (+c.v || 0) / max; 3007 return '<span class="yr-hm-cell" style="background:' + color + ';opacity:' + (op ? (0.12 + op * 0.88).toFixed(2) : 0.06) + 3008 '" title="' + chEsc(r.label + ': ' + (c.v || 0)) + '"></span>'; 3009 }).join(''); 3010 return '<div class="yr-hm-row"><span class="yr-hm-label" title="' + chEsc(r.label) + '">' + chEsc(r.label) + '</span>' + cells + '</div>'; 3011 }).join(''); 3012 el.innerHTML = YR.charts._fig(options.title, options.ariaLabel || options.title, '<div class="yr-hm">' + head + body + '</div>'); 3013 }, 3014 3015 /** @return {string} figure wrapper. */ 3016 _fig: function (title, aria, inner) { 3017 return '<figure class="yr-figure" aria-label="' + chEsc(aria || title) + '">' + 3018 (title ? '<figcaption class="yr-fig-title">' + chEsc(title) + '</figcaption>' : '') + inner + '</figure>'; 3019 }, 3020 3021 /** @return {string} empty-state figure. */ 3022 _empty: function (title, msg) { 3023 return '<figure class="yr-figure yr-figure-empty">' + 3024 (title ? '<figcaption class="yr-fig-title">' + chEsc(title) + '</figcaption>' : '') + 3025 '<div class="yr-chart-empty">' + chEsc(msg || 'No data') + '</div></figure>'; 3026 } 3027}; 3028 3029 3030window.YR = YR; 3031 3032/* âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 3033 v1.5.0 â Persistent surface registry + lifecycle ownership (#2394) 3034 3035 Central home for body-hoisted staff-v6 modals to register themselves ONCE 3036 at parse time, so the AjaxNav shell can rehydrate them after every main-area 3037 swap without each view maintaining a hand-written list of persist() calls. 3038 3039 Coupling direction matters: yeti-report.js is POS_HEAD and parses BEFORE 3040 ajax-nav.js, so this file CANNOT reach into AjaxNav at parse time. ajax-nav.js 3041 PULLS this registry from its runHydrators() instead. `window.YR` IS available 3042 when page modal scripts parse (they load after this POS_HEAD file), so a modal 3043 calling YR.registerPersistentModal(...) at the bottom of its IIFE is safe. 3044 âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ */ 3045YR._persistentModals = YR._persistentModals || {}; 3046 3047/**
3048 * Register a body-hoisted modal's API so the shell can rehydrate it after 3049 * AjaxNav swaps. Re-registering the same name replaces the prior API. 3050 * 3051 * @param {string} name - unique modal key (e.g. 'reset-password'). 3052 * @param {Object} api - the modal's public API; should expose persist() 3053 * (preferred) or init() for rehydration. 3054 * @param {Object} [metadata] ownership and navigation policy. Legacy entries 3055 * default to `{owner:'page', navigation:'close'}`. 3056 * @return {void} 3057 */ 3058YR.registerPersistentModal = function (name, api, metadata) { 3059 if (!name || !api) return; 3060 metadata = metadata || {}; 3061 YR._persistentModals[name] = { 3062 api: api, 3063 metadata: { 3064 owner: metadata.owner === 'shell' || metadata.owner === 'shell-critical' 3065 ? metadata.owner : 'page', 3066 navigation: metadata.navigation === 'preserve' ? 'preserve' : 'close', 3067 css: Array.isArray(metadata.css) ? metadata.css.slice() : [] 3068 } 3069 }; 3070}; 3071 3072/** 3073 * Normalize registry entries created before ownership metadata was introduced. 3074 * 3075 * @param {Object} entry raw registry value. 3076 * @return {Object} `{api, metadata}` entry. 3077 */ 3078function yrPersistentEntry(entry) { 3079 if (entry && entry.api) return entry; 3080 return { 3081 api: entry, 3082 metadata: { owner: 'page', navigation: 'close', css: [] } 3083 }; 3084} 3085 3086/** 3087 * Rehydrate every registered persistent modal once. Called by ajax-nav.js's 3088 * runHydrators() after each main-area swap and on the initial full page load. 3089 * Prefers persist(), falls back to init(). A throwing modal is isolated. 3090 * 3091 * @return {void} 3092 */ 3093YR.persistRegisteredModals = function (context) { 3094 var errors = []; 3095 Object.keys(YR._persistentModals).forEach(function (name) { 3096 var api = yrPersistentEntry(YR._persistentModals[name]).api; 3097 try { 3098 if (api && typeof api.persist === 'function') api.persist(context || {}); 3099 else if (api && typeof api.init === 'function') api.init(context || {}); 3100 } catch (e) { 3101 errors.push({ owner: name, error: e }); 3102 console.error('[YR] persistent modal failed:', name, e); 3103 } 3104 }); 3105 return errors; 3106}; 3107 3108/** 3109 * Ask every registered modal to close before a main-area navigation. This is 3110 * the semantic close barrier: modules can remove listeners, restore focus, 3111 * and release their own locks before the outgoing page disappears. 3112 * 3113 * @param {Object} [context] navigation context supplied by the shell. 3114 * @return {void} 3115 */ 3116YR.closeRegisteredModals = function (context) { 3117 var errors = []; 3118 Object.keys(YR._persistentModals).forEach(function (name) { 3119 var entry = yrPersistentEntry(YR._persistentModals[name]); 3120 var api = entry.api; 3121 if (entry.metadata.navigation === 'preserve') return; 3122 try { 3123 if (api && typeof api.closeForNavigation === 'function') { 3124 api.closeForNavigation(context || {}); 3125 } else if (api && typeof api.close === 'function') { 3126 api.close(context || {}); 3127 } 3128 } catch (e) { 3129 errors.push({ owner: name, error: e }); 3130 console.error('[YR] persistent modal close failed:', name, e); 3131 } 3132 }); 3133 return errors; 3134}; 3135 3136/** 3137 * Run shell-surface navigation guards before a hash/request commits. Guards 3138 * may return a boolean or a promise resolving to a boolean. 3139 * 3140 * @param {Object} context proposed navigation context. 3141 * @return {Promise<boolean>} whether navigation may continue. 3142 */ 3143YR.beforeNavigation = function (context) { 3144 var names = Object.keys(YR._persistentModals); 3145 return names.reduce(function (chain, name) { 3146 return chain.then(function (allowed) { 3147 if (!allowed) return false; 3148 var entry = yrPersistentEntry(YR._persistentModals[name]); 3149 if (entry.metadata.owner === 'page' || !entry.api || 3150 typeof entry.api.beforeNavigation !== 'function') return true; 3151 return Promise.resolve(entry.api.beforeNavigation(context || {})) 3152 .then(function (result) { return result !== false; }); 3153 }); 3154 }, Promise.resolve(true)); 3155}; 3156 3157/** 3158 * Whether a node belongs to a registered shell-owned surface. Used by the 3159 * AjaxNav residue sweep so it never hides shell-critical UI. 3160 * 3161 * @param {Element}
3161 node candidate overlay node. 3162 * @return {boolean} true when a shell registry entry owns the node. 3163 */ 3164YR.isRegisteredShellNode = function (node) { 3165 if (!node) return false; 3166 return Object.keys(YR._persistentModals).some(function (name) { 3167 var entry = yrPersistentEntry(YR._persistentModals[name]); 3168 if (entry.metadata.owner === 'page' || !entry.api || 3169 typeof entry.api.debugNode !== 'function') return false; 3170 var owned = entry.api.debugNode(); 3171 return !!owned && (owned === node || owned.contains(node) || node.contains(owned)); 3172 }); 3173}; 3174 3175/** 3176 * Return non-sensitive modal ownership/open-state data for tests and traces. 3177 * 3178 * @return {Array<Object>} registry snapshot. 3179 */ 3180YR.getPersistentModalSnapshot = function () { 3181 return Object.keys(YR._persistentModals).map(function (name) { 3182 var entry = yrPersistentEntry(YR._persistentModals[name]); 3183 var api = entry.api; 3184 return { 3185 name: name, 3186 owner: entry.metadata.owner, 3187 navigation: entry.metadata.navigation, 3188 open: !!(api && typeof api.isOpen === 'function' && api.isOpen()), 3189 connected: !!(api && typeof api.debugNode === 'function' && api.debugNode() && api.debugNode().isConnected) 3190 }; 3191 }); 3192};
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.