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