MediaWiki:Common.js: Difference between revisions
From zunagi
mNo edit summary |
mNo edit summary |
||
| (133 intermediate revisions by the same user not shown) | |||
| Line 1: | Line 1: | ||
/* | /* ========================================================== | ||
ZUNAGI BEACH VOLLEYBALL CALENDAR | |||
========================================================== */ | |||
$( function () { | $( function () { | ||
if ( ! | /* | ||
* IMPORTANT: this check runs BEFORE requesting the | |||
* DataTables module, not after. mw.loader.using() is a | |||
* client-side call that fetches the module over the | |||
* network the moment it runs, regardless of what | |||
* LocalSettings.php's addModules() gate decided | |||
* server-side - so checking for the table only inside | |||
* the .then() callback (after the module was already | |||
* requested) meant the full DataTables library was being | |||
* downloaded on every single page, even ones with no | |||
* calendar table at all, wasting bandwidth and defeating | |||
* the point of the category-based loading gate. | |||
*/ | |||
const table = | |||
document.querySelector( '.zunagi-calendar' ); | |||
if ( !table ) { | |||
return; | return; | ||
} | } | ||
const | |||
' | mw.loader.using( 'ext.zunagi.datatables' ).then( function () { | ||
/* ================================================== | |||
* 1. FIX MEDIAWIKI HEADER | |||
* ================================================== | |||
*/ | |||
if ( !table.tHead ) { | |||
const firstRow = | |||
table.querySelector( | |||
'tbody > tr:first-child' | |||
); | |||
if ( | |||
firstRow && | |||
firstRow.querySelectorAll( 'th' ).length > 0 | |||
) { | |||
const thead = | |||
table.createTHead(); | |||
thead.appendChild( | |||
firstRow | |||
); | |||
} | |||
} | |||
/* ================================================== | |||
* 2. COLUMN MAP | |||
* ================================================== | |||
* | |||
* 0 = Month hidden | |||
* 1 = Date | |||
* 2 = Org | |||
* 3 = Event | |||
* 4 = Region | |||
* 5 = Category | |||
* 6 = Level | |||
* 7 = Venue | |||
* 8 = Major Event hidden | |||
* 9 = End Date hidden - falls back to Date (col 1) when blank | |||
*/ | |||
const filterColumns = [ | |||
0, // Month | |||
2, // Org | |||
4, // Region | |||
5 // Category | |||
]; | |||
const filterSelects = {}; | |||
let searchInput = null; | |||
let majorEventCheckbox = null; | |||
let showPastCheckbox = null; | |||
/* ================================================== | |||
* 3. CELL HELPERS | |||
* ================================================== | |||
*/ | |||
function getCellFilterValue( cell ) { | |||
if ( !cell ) { | |||
return ''; | |||
} | |||
const dataSearch = | |||
cell.getAttribute( | |||
'data-search' | |||
); | |||
if ( dataSearch !== null ) { | |||
return dataSearch.trim(); | |||
} | |||
return cell.textContent.trim(); | |||
} | |||
function getCellOrderValue( cell ) { | |||
if ( !cell ) { | |||
return ''; | |||
} | |||
const dataOrder = | |||
cell.getAttribute( | |||
'data-order' | |||
); | |||
if ( dataOrder !== null ) { | |||
return dataOrder.trim(); | |||
} | |||
return getCellFilterValue( | |||
cell | |||
); | |||
} | |||
/* ================================================== | |||
* 4. TODAY | |||
* ================================================== | |||
*/ | |||
function getLocalToday() { | |||
const now = | |||
new Date(); | |||
const year = | |||
now.getFullYear(); | |||
const month = | |||
String( | |||
now.getMonth() + 1 | |||
).padStart( | |||
2, | |||
'0' | |||
); | |||
const day = | |||
String( | |||
now.getDate() | |||
).padStart( | |||
2, | |||
'0' | |||
); | |||
return ( | |||
year + | |||
'-' + | |||
month + | |||
'-' + | |||
day | |||
); | |||
} | |||
/* ================================================== | |||
* 5. CACHE SOURCE ROWS | |||
* ================================================== | |||
*/ | |||
const sourceRows = | |||
Array.from( | |||
table.querySelectorAll( | |||
'tbody > tr' | |||
) | |||
) | |||
.map( | |||
function ( row ) { | |||
const cells = | |||
Array.from( | |||
row.querySelectorAll( | |||
'td' | |||
) | |||
); | |||
if ( cells.length !== 10 ) { // was 9 | |||
console.warn( | |||
'Zunagi calendar row contains ' + | |||
cells.length + | |||
' cells; expected 10.', // was 9 | |||
row | |||
); | |||
return null; | |||
} | |||
const date = | |||
getCellOrderValue( | |||
cells[ 1 ] | |||
); | |||
const endDate = | |||
getCellOrderValue( | |||
cells[ 9 ] | |||
) || date; | |||
row.dataset.zunagiDate = | |||
date; | |||
row.dataset.zunagiEndDate = | |||
endDate; | |||
row.dataset.zunagiMajor = | |||
getCellFilterValue( | |||
cells[ 8 ] | |||
); | |||
return { | |||
row: row, | |||
month: getCellFilterValue( cells[ 0 ] ), | |||
monthOrder: getCellOrderValue( cells[ 0 ] ), | |||
date: date, | |||
endDate: endDate, // new | |||
org: | |||
getCellFilterValue( | |||
cells[ 2 ] | |||
), | |||
event: | |||
getCellFilterValue( | |||
cells[ 3 ] | |||
), | |||
region: | |||
getCellFilterValue( | |||
cells[ 4 ] | |||
), | |||
category: | |||
getCellFilterValue( | |||
cells[ 5 ] | |||
), | |||
level: | |||
getCellFilterValue( | |||
cells[ 6 ] | |||
), | |||
venue: | |||
getCellFilterValue( | |||
cells[ 7 ] | |||
), | |||
majorEvent: | |||
getCellFilterValue( | |||
cells[ 8 ] | |||
), | |||
searchText: | |||
row.textContent | |||
.replace( | |||
/\s+/g, | |||
' ' | |||
) | |||
.trim() | |||
.toLowerCase() | |||
}; | |||
} | |||
) | |||
.filter( | |||
function ( row ) { | |||
return row !== null; | |||
} | |||
); | |||
/* ================================================== | |||
* 6. PAST EVENT CLASSES | |||
* ================================================== | |||
*/ | |||
function applyPastEventClasses() { | |||
const today = | |||
getLocalToday(); | |||
sourceRows.forEach( | |||
function ( rowData ) { | |||
rowData.row.classList.toggle( | |||
'zunagi-past-event', | |||
Boolean( | |||
rowData.endDate && | |||
rowData.endDate < today | |||
) | |||
); | |||
} | |||
); | |||
} | |||
/* ================================================== | |||
* 7. ORGANISATION COLOURS | |||
* ================================================== | |||
*/ | |||
function applyOrgColours( api ) { | |||
api.rows().every( | |||
function () { | |||
const row = | |||
this.node(); | |||
if ( !row ) { | |||
return; | |||
} | |||
const orgCell = | |||
api | |||
.cell( | |||
row, | |||
2 | |||
) | |||
.node(); | |||
if ( !orgCell ) { | |||
return; | |||
} | |||
const org = | |||
getCellFilterValue( | |||
orgCell | |||
); | |||
orgCell.classList.remove( | |||
'org-vnsw', | |||
'org-qbvt', | |||
'org-nbva', | |||
'org-va', | |||
'org-vsa', | |||
'org-vv', | |||
'org-vact', | |||
'org-vwa' | |||
); | |||
if ( org === 'VNSW' ) { | |||
orgCell.classList.add( | |||
'org-vnsw' | |||
); | |||
} else if ( org === 'QBVT' ) { | |||
orgCell.classList.add( | |||
'org-qbvt' | |||
); | |||
} else if ( org === 'NBVA' ) { | |||
orgCell.classList.add( | |||
'org-nbva' | |||
); | |||
} else if ( org === 'VA' ) { | |||
orgCell.classList.add( | |||
'org-va' | |||
); | |||
} else if ( org === 'VSA' ) { | |||
orgCell.classList.add( | |||
'org-vsa' | |||
); | |||
} else if ( org === 'VACT' ) { | |||
orgCell.classList.add( | |||
'org-vact' | |||
); | |||
} else if ( org === 'VWA' ) { | |||
orgCell.classList.add( | |||
'org-vwa' | |||
); | |||
} else if ( org === 'VV' ) { | |||
orgCell.classList.add( | |||
'org-vv' | |||
); | |||
} | |||
} | |||
); | |||
} | |||
/* ================================================== | |||
* 8. MAJOR EVENT BADGES | |||
* ================================================== | |||
*/ | |||
function applyMajorEventBadges( api ) { | |||
api.rows().every( | |||
function () { | |||
const row = | |||
this.node(); | |||
if ( !row ) { | |||
return; | |||
} | |||
const eventCell = | |||
api | |||
.cell( | |||
row, | |||
3 | |||
) | |||
.node(); | |||
const majorCell = | |||
api | |||
.cell( | |||
row, | |||
8 | |||
) | |||
.node(); | |||
if ( | |||
!eventCell || | |||
!majorCell | |||
) { | |||
return; | |||
} | |||
/* | |||
* Remove any existing badge first. | |||
*/ | |||
eventCell | |||
.querySelectorAll( | |||
'.zunagi-major-badge' | |||
) | |||
.forEach( | |||
function ( badge ) { | |||
badge.remove(); | |||
} | |||
); | |||
const isMajor = | |||
getCellFilterValue( | |||
majorCell | |||
) === 'Yes'; | |||
if ( !isMajor ) { | |||
return; | |||
} | |||
const badge = | |||
document.createElement( | |||
'span' | |||
); | |||
badge.className = | |||
'zunagi-major-badge'; | |||
badge.textContent = | |||
'MAJOR'; | |||
badge.style.marginLeft = | |||
'0'; | |||
badge.style.marginRight = | |||
'6px'; | |||
eventCell.insertBefore( | |||
badge, | |||
eventCell.querySelector( | |||
'a' | |||
) | |||
); | |||
} | |||
); | |||
} | |||
/* ================================================== | |||
* 8b. YOUTH EVENT BADGES | |||
* ================================================== | |||
*/ | |||
function applyYouthEventBadges( api ) { | |||
api.rows().every( | |||
function () { | |||
const row = | |||
this.node(); | |||
if ( !row ) { | |||
return; | |||
} | |||
const eventCell = | |||
api | |||
.cell( | |||
row, | |||
3 | |||
) | |||
.node(); | |||
const categoryCell = | |||
api | |||
.cell( | |||
row, | |||
5 | |||
) | |||
.node(); | |||
if ( | |||
!eventCell || | |||
!categoryCell | |||
) { | |||
return; | |||
} | |||
/* | |||
* Remove any existing badge first. | |||
*/ | |||
eventCell | |||
.querySelectorAll( | |||
'.zunagi-youth-badge' | |||
) | |||
.forEach( | |||
function ( badge ) { | |||
badge.remove(); | |||
} | |||
); | |||
const isYouth = | |||
getCellFilterValue( | |||
categoryCell | |||
) === 'Youth'; | |||
if ( !isYouth ) { | |||
return; | |||
} | |||
const badge = | |||
document.createElement( | |||
'span' | |||
); | |||
badge.className = | |||
'zunagi-youth-badge'; | |||
badge.textContent = | |||
'YOUTH'; | |||
badge.style.marginLeft = | |||
'0'; | |||
badge.style.marginRight = | |||
'6px'; | |||
eventCell.insertBefore( | |||
badge, | |||
eventCell.querySelector( | |||
'a' | |||
) | |||
); | |||
} | |||
); | |||
} | |||
/* ================================================== | |||
* 8c. JUNIOR EVENT BADGES | |||
* ================================================== | |||
*/ | |||
function applyJuniorEventBadges( api ) { | |||
api.rows().every( | |||
function () { | |||
const row = | |||
this.node(); | |||
if ( !row ) { | |||
return; | |||
} | |||
const eventCell = | |||
api | |||
.cell( | |||
row, | |||
3 | |||
) | |||
.node(); | |||
const categoryCell = | |||
api | |||
.cell( | |||
row, | |||
5 | |||
) | |||
.node(); | |||
if ( | |||
!eventCell || | |||
!categoryCell | |||
) { | |||
return; | |||
} | |||
/* | |||
* Remove any existing badge first. | |||
*/ | |||
eventCell | |||
.querySelectorAll( | |||
'.zunagi-junior-badge' | |||
) | |||
.forEach( | |||
function ( badge ) { | |||
badge.remove(); | |||
} | |||
); | |||
const isJunior = | |||
getCellFilterValue( | |||
categoryCell | |||
) === 'Junior'; | |||
if ( !isJunior ) { | |||
return; | |||
} | |||
const badge = | |||
document.createElement( | |||
'span' | |||
); | |||
badge.className = | |||
'zunagi-junior-badge'; | |||
badge.textContent = | |||
'JUNIOR'; | |||
badge.style.marginLeft = | |||
'0'; | |||
badge.style.marginRight = | |||
'6px'; | |||
eventCell.insertBefore( | |||
badge, | |||
eventCell.querySelector( | |||
'a' | |||
) | |||
); | |||
} | |||
); | ); | ||
} | |||
if ( ! | /* ================================================== | ||
* 8d. FILTER STATE PERSISTENCE (event-page round trip only) | |||
* ================================================== | |||
* | |||
* Filters are remembered across exactly one kind of trip: leaving | |||
* the calendar by clicking through to an event's own page, then | |||
* coming back - either via that page's "Back to Calendar" link or | |||
* the browser's own Back button. A direct or fresh visit to the | |||
* calendar (Main Page, a bookmark, a new tab, a nav-menu click) | |||
* never restores anything, because nothing is ever saved except at | |||
* the moment of clicking an event link. | |||
*/ | |||
const FILTER_STATE_KEY = | |||
'zunagiCalendarFilterState'; | |||
function saveFilterState() { | |||
const selects = {}; | |||
filterColumns.forEach( | |||
function ( columnIndex ) { | |||
if ( filterSelects[ columnIndex ] ) { | |||
selects[ columnIndex ] = | |||
filterSelects[ columnIndex ].value; | |||
} | |||
} | |||
); | |||
const payload = { | |||
selects: selects, | |||
major: | |||
Boolean( | |||
majorEventCheckbox && | |||
majorEventCheckbox.checked | |||
), | |||
past: | |||
Boolean( | |||
showPastCheckbox && | |||
showPastCheckbox.checked | |||
), | |||
search: | |||
searchInput ? | |||
searchInput.value : | |||
'' | |||
}; | |||
try { | |||
sessionStorage.setItem( | |||
FILTER_STATE_KEY, | |||
JSON.stringify( payload ) | |||
); | |||
} catch ( e ) { | |||
/* | |||
* Ignore - e.g. private-browsing storage restrictions. | |||
* Worst case the round trip just doesn't restore. | |||
*/ | |||
} | |||
} | |||
function restoreFilterState( api ) { | |||
let raw; | |||
try { | |||
raw = | |||
sessionStorage.getItem( | |||
FILTER_STATE_KEY | |||
); | |||
} catch ( e ) { | |||
return; | |||
} | |||
if ( !raw ) { | |||
return; | return; | ||
} | } | ||
/* | |||
* Consume it immediately - it must only ever apply to the one | |||
* page load right after clicking an event link, never to any | |||
* later visit. | |||
*/ | |||
try { | |||
sessionStorage.removeItem( | |||
FILTER_STATE_KEY | |||
); | |||
} catch ( e ) {} | |||
function | let state; | ||
try { | |||
state = | |||
JSON.parse( raw ); | |||
} catch ( e ) { | |||
return; | |||
} | |||
if ( !state ) { | |||
return; | |||
} | |||
filterColumns.forEach( | |||
function ( columnIndex ) { | |||
const value = | |||
state.selects && | |||
state.selects[ columnIndex ]; | |||
if ( !value ) { | |||
return; | |||
} | |||
if ( filterSelects[ columnIndex ] ) { | |||
filterSelects[ columnIndex ].value = | |||
value; | |||
} | |||
api | |||
.column( columnIndex ) | |||
.search( | |||
value, | |||
{ | |||
exact: true | |||
} | |||
); | |||
} | |||
); | |||
if ( majorEventCheckbox ) { | |||
majorEventCheckbox.checked = | |||
Boolean( state.major ); | |||
} | } | ||
if ( showPastCheckbox ) { | |||
showPastCheckbox.checked = | |||
Boolean( state.past ); | |||
} | } | ||
if ( searchInput && state.search ) { | |||
searchInput.value = | |||
state.search; | |||
api.search( state.search ); | |||
} | } | ||
} | |||
/* ================================================== | |||
* 8e. SAVE FILTER STATE WHEN LEAVING FOR AN EVENT PAGE | |||
* ================================================== | |||
*/ | |||
table.addEventListener( | |||
'click', | |||
function ( event ) { | |||
const link = | |||
event.target.closest( | |||
'td.zunagi-event-column a' | |||
); | |||
if ( !link ) { | |||
return; | return; | ||
} | } | ||
saveFilterState(); | |||
const | } | ||
); | |||
/* ================================================== | |||
* 9. TABLE ADJUSTMENT | |||
* ================================================== | |||
*/ | |||
function adjustTable( api ) { | |||
requestAnimationFrame( | |||
function () { | |||
api.columns.adjust(); | |||
rebuildFixedHeader(); | |||
} | |||
); | |||
} | |||
/* ================================================== | |||
* 9b. FIXED HEADER (JS affix pattern) | |||
* ================================================== | |||
*/ | |||
let wrapperEl = null; | |||
let dtApi = null; | |||
let fixedHeaderOuter = null; | |||
let fixedHeaderInner = null; | |||
let fixedHeaderVisible = false; | |||
const FIXED_HEADER_TOP_OFFSET = 0; | |||
const DEBUG_FIXED_HEADER = false; | |||
let debugOverlay = null; | |||
function updateDebugOverlay() { | |||
if ( !DEBUG_FIXED_HEADER ) { | |||
return; | |||
} | |||
if ( !debugOverlay ) { | |||
debugOverlay = | |||
document.createElement( 'div' ); | |||
debugOverlay.style.position = | |||
'fixed'; | |||
debugOverlay.style.bottom = | |||
'8px'; | |||
debugOverlay.style.right = | |||
'8px'; | |||
debugOverlay.style.zIndex = | |||
'99999'; | |||
debugOverlay.style.background = | |||
'rgba(0,0,0,0.75)'; | |||
debugOverlay.style.color = | |||
'#0f0'; | |||
debugOverlay.style.font = | |||
'11px monospace'; | |||
debugOverlay.style.padding = | |||
'4px 6px'; | |||
debugOverlay.style.pointerEvents = | |||
'none'; | |||
debugOverlay.style.whiteSpace = | |||
'pre'; | |||
document.body.appendChild( | |||
debugOverlay | |||
); | |||
} | |||
debugOverlay.textContent = | |||
'wrapperEl.scrollLeft: ' + | |||
( wrapperEl ? wrapperEl.scrollLeft : 'n/a' ) + | |||
'\nfixedHeaderVisible: ' + | |||
fixedHeaderVisible; | |||
} | |||
function buildFixedHeader() { | |||
if ( !wrapperEl || !table.tHead ) { | |||
return; | |||
} | |||
if ( fixedHeaderOuter ) { | |||
fixedHeaderOuter.remove(); | |||
fixedHeaderOuter = null; | |||
fixedHeaderInner = null; | |||
} | |||
let realThs; | |||
if ( dtApi ) { | |||
realThs = | |||
dtApi | |||
.columns( ':visible' ) | |||
.header() | |||
.toArray(); | |||
} else { | |||
realThs = | |||
Array.from( | |||
table.tHead.querySelectorAll( 'th' ) | |||
).filter( | |||
function ( th ) { | |||
const rect = | |||
th.getBoundingClientRect(); | |||
return ( | |||
rect.width > 0 && | |||
rect.height > 0 | |||
); | |||
} | |||
); | |||
} | } | ||
box.appendChild( | if ( realThs.length === 0 ) { | ||
} ); | return; | ||
} | |||
const theadRect = | |||
table.tHead.getBoundingClientRect(); | |||
const tableRect = | |||
table.getBoundingClientRect(); | |||
fixedHeaderInner = | |||
document.createElement( 'div' ); | |||
fixedHeaderInner.className = | |||
'zunagi-fixed-header-inner ' + | |||
table.className; | |||
fixedHeaderInner.style.position = | |||
'relative'; | |||
const headerHeight = | |||
Math.round( theadRect.height ); | |||
fixedHeaderInner.style.height = | |||
headerHeight + 'px'; | |||
let maxRight = 0; | |||
realThs.forEach( | |||
function ( th ) { | |||
const rect = | |||
th.getBoundingClientRect(); | |||
const left = | |||
Math.round( | |||
rect.left - tableRect.left | |||
); | |||
const width = | |||
Math.round( | |||
rect.right - tableRect.left | |||
) - left; | |||
maxRight = | |||
Math.max( | |||
maxRight, | |||
left + width | |||
); | |||
const cell = | |||
th.cloneNode( true ); | |||
cell | |||
.querySelectorAll( | |||
'.dt-column-order' | |||
) | |||
.forEach( | |||
function ( orderEl ) { | |||
orderEl.remove(); | |||
} | |||
); | |||
const textAlign = | |||
getComputedStyle( th ) | |||
.textAlign; | |||
let justify = | |||
'flex-start'; | |||
if ( textAlign === 'center' ) { | |||
justify = | |||
'center'; | |||
} else if ( | |||
textAlign === 'right' || | |||
textAlign === 'end' | |||
) { | |||
justify = | |||
'flex-end'; | |||
} | |||
const innerHeader = | |||
cell.querySelector( | |||
'.dt-column-header' | |||
); | |||
if ( innerHeader ) { | |||
innerHeader.style.display = | |||
'flex'; | |||
innerHeader.style.justifyContent = | |||
justify; | |||
innerHeader.style.alignItems = | |||
'center'; | |||
innerHeader.style.width = | |||
'100%'; | |||
innerHeader.style.height = | |||
'100%'; | |||
const sortDirection = | |||
th.getAttribute( 'aria-sort' ); | |||
if ( | |||
sortDirection === 'ascending' || | |||
sortDirection === 'descending' | |||
) { | |||
const arrow = | |||
document.createElement( | |||
'span' | |||
); | |||
arrow.className = | |||
'zunagi-fixed-header-sort-arrow'; | |||
arrow.setAttribute( | |||
'data-direction', | |||
sortDirection | |||
); | |||
innerHeader.insertBefore( | |||
arrow, | |||
innerHeader.firstChild | |||
); | |||
const titleEl = | |||
innerHeader.querySelector( | |||
'.dt-column-title' | |||
); | |||
if ( titleEl ) { | |||
titleEl.style.flex = | |||
'0 0 auto'; | |||
titleEl.style.width = | |||
'auto'; | |||
} | |||
arrow.style.flex = | |||
'0 0 auto'; | |||
} | |||
} | |||
cell.style.position = | |||
'absolute'; | |||
cell.style.left = | |||
left + 'px'; | |||
cell.style.top = | |||
'0'; | |||
cell.style.setProperty( | |||
'width', | |||
width + 'px', | |||
'important' | |||
); | |||
cell.style.setProperty( | |||
'min-width', | |||
width + 'px', | |||
'important' | |||
); | |||
cell.style.setProperty( | |||
'max-width', | |||
width + 'px', | |||
'important' | |||
); | |||
cell.style.height = | |||
headerHeight + 'px'; | |||
cell.style.boxSizing = | |||
'border-box'; | |||
cell.style.display = | |||
'flex'; | |||
cell.style.alignItems = | |||
'center'; | |||
cell.style.justifyContent = | |||
justify; | |||
fixedHeaderInner.appendChild( | |||
cell | |||
); | |||
} | |||
); | |||
fixedHeaderInner.style.width = | |||
maxRight + 'px'; | |||
fixedHeaderOuter = | |||
document.createElement( 'div' ); | |||
fixedHeaderOuter.className = | |||
'zunagi-fixed-header-outer'; | |||
fixedHeaderOuter.appendChild( | |||
fixedHeaderInner | |||
); | |||
document.body.appendChild( | |||
fixedHeaderOuter | |||
); | |||
fixedHeaderVisible = false; | |||
} | |||
function positionFixedHeader() { | |||
if ( | |||
!fixedHeaderOuter || | |||
!wrapperEl | |||
) { | |||
return; | |||
} | |||
const wrapperRect = | |||
wrapperEl.getBoundingClientRect(); | |||
fixedHeaderOuter.style.left = | |||
wrapperRect.left + 'px'; | |||
fixedHeaderOuter.style.width = | |||
wrapperRect.width + 'px'; | |||
fixedHeaderOuter.style.top = | |||
FIXED_HEADER_TOP_OFFSET + 'px'; | |||
if ( fixedHeaderInner ) { | |||
fixedHeaderInner.style.transform = | |||
'translateX(-' + | |||
Math.round( wrapperEl.scrollLeft ) + | |||
'px)'; | |||
} | |||
} | |||
function updateFixedHeaderVisibility() { | |||
if ( | |||
!wrapperEl || | |||
!fixedHeaderOuter || | |||
!table.tHead | |||
) { | |||
return; | |||
} | |||
const theadRect = | |||
table.tHead.getBoundingClientRect(); | |||
const wrapperRect = | |||
wrapperEl.getBoundingClientRect(); | |||
const shouldShow = | |||
theadRect.top < FIXED_HEADER_TOP_OFFSET && | |||
wrapperRect.bottom > | |||
FIXED_HEADER_TOP_OFFSET + 40; | |||
if ( | |||
shouldShow && | |||
!fixedHeaderVisible | |||
) { | |||
fixedHeaderOuter.style.display = | |||
'block'; | |||
fixedHeaderVisible = true; | |||
} else if ( | |||
!shouldShow && | |||
fixedHeaderVisible | |||
) { | |||
fixedHeaderOuter.style.display = | |||
'none'; | |||
fixedHeaderVisible = false; | |||
} | |||
if ( fixedHeaderVisible ) { | |||
positionFixedHeader(); | |||
} | |||
} | |||
function rebuildFixedHeader() { | |||
const wasVisible = | |||
fixedHeaderVisible; | |||
buildFixedHeader(); | |||
if ( | |||
wasVisible && | |||
fixedHeaderOuter | |||
) { | |||
fixedHeaderOuter.style.display = | |||
'block'; | |||
fixedHeaderVisible = true; | |||
positionFixedHeader(); | |||
} | |||
} | |||
function fixedHeaderSyncLoop() { | |||
updateFixedHeaderVisibility(); | |||
updateDebugOverlay(); | |||
requestAnimationFrame( | |||
fixedHeaderSyncLoop | |||
); | |||
} | |||
/* ================================================== | |||
* 10. INITIALISE DATATABLE | |||
* ================================================== | |||
*/ | |||
const dt = | |||
new DataTable( | |||
table, | |||
{ | |||
paging: false, | |||
searching: true, | |||
ordering: true, | |||
info: false, | |||
autoWidth: false, | |||
order: [ | |||
[ | |||
1, | |||
'asc' | |||
] | |||
], | |||
columnDefs: [ | |||
/* | |||
* Month - hidden/filterable. | |||
*/ | |||
{ | |||
targets: 0, | |||
visible: false, | |||
searchable: true, | |||
orderable: false | |||
}, | |||
/* | |||
* Date. | |||
*/ | |||
{ | |||
targets: 1, | |||
width: '95px', | |||
orderable: true, | |||
className: | |||
'zunagi-date-column' | |||
}, | |||
/* | |||
* Organisation - hidden/filterable. | |||
*/ | |||
{ | |||
targets: 2, | |||
visible: false, | |||
searchable: true, | |||
orderable: false | |||
}, | |||
/* | |||
* Event. | |||
*/ | |||
{ | |||
targets: 3, | |||
width: '330px', | |||
orderable: false, | |||
className: | |||
'zunagi-event-column' | |||
}, | |||
/* | |||
* Region. | |||
*/ | |||
{ | |||
targets: 4, | |||
width: '85px', | |||
orderable: false, | |||
className: | |||
'zunagi-region-column' | |||
}, | |||
/* | |||
* Category. | |||
*/ | |||
{ | |||
targets: 5, | |||
width: '100px', | |||
orderable: false, | |||
className: | |||
'zunagi-category-column' | |||
}, | |||
/* | |||
* Level. | |||
*/ | |||
{ | |||
targets: 6, | |||
width: '85px', | |||
orderable: false, | |||
className: | |||
'zunagi-level-column' | |||
}, | |||
/* | |||
* Venue. | |||
*/ | |||
{ | |||
targets: 7, | |||
width: '200px', | |||
orderable: false, | |||
className: | |||
'zunagi-venue-column' | |||
}, | |||
/* | |||
* Major Event - hidden/filterable. | |||
*/ | |||
{ | |||
targets: 8, | |||
visible: false, | |||
searchable: true, | |||
orderable: false | |||
}, | |||
{ | |||
targets: 9, | |||
visible: false, | |||
searchable: false, | |||
orderable: false | |||
} | |||
], | |||
layout: { | |||
topStart: null, | |||
topEnd: null, | |||
bottomStart: null, | |||
bottomEnd: null | |||
}, | |||
initComplete: | |||
function () { | |||
const api = | |||
this.api(); | |||
dtApi = api; | |||
const filterBar = | |||
document.createElement( | |||
'div' | |||
); | |||
filterBar.className = | |||
'zunagi-calendar-filters'; | |||
filterColumns.forEach( | |||
function ( | |||
columnIndex | |||
) { | |||
const column = | |||
api.column( | |||
columnIndex | |||
); | |||
const heading = | |||
column | |||
.header() | |||
.textContent | |||
.trim(); | |||
const wrapper = | |||
document.createElement( | |||
'div' | |||
); | |||
wrapper.className = | |||
'zunagi-filter'; | |||
const label = | |||
document.createElement( | |||
'label' | |||
); | |||
label.textContent = | |||
heading; | |||
const select = | |||
document.createElement( | |||
'select' | |||
); | |||
filterSelects[ | |||
columnIndex | |||
] = select; | |||
select.addEventListener( | |||
'change', | |||
function () { | |||
column | |||
.search( | |||
this.value, | |||
{ | |||
exact: | |||
true | |||
} | |||
) | |||
.draw(); | |||
} | |||
); | |||
wrapper.appendChild( | |||
label | |||
); | |||
wrapper.appendChild( | |||
select | |||
); | |||
filterBar.appendChild( | |||
wrapper | |||
); | |||
} | |||
); | |||
const majorWrapper = | |||
document.createElement( | |||
'div' | |||
); | |||
majorWrapper.className = | |||
'zunagi-filter zunagi-checkbox-filter'; | |||
const majorHeading = | |||
document.createElement( | |||
'span' | |||
); | |||
majorHeading.className = | |||
'zunagi-checkbox-heading'; | |||
majorHeading.textContent = | |||
'Major Events'; | |||
const majorLabel = | |||
document.createElement( | |||
'label' | |||
); | |||
majorLabel.className = | |||
'zunagi-checkbox-label'; | |||
majorEventCheckbox = | |||
document.createElement( | |||
'input' | |||
); | |||
majorEventCheckbox.type = | |||
'checkbox'; | |||
majorEventCheckbox.checked = | |||
false; | |||
const majorText = | |||
document.createElement( | |||
'span' | |||
); | |||
majorText.textContent = | |||
'Show'; | |||
majorEventCheckbox.addEventListener( | |||
'change', | |||
function () { | |||
api.draw(); | |||
} | |||
); | |||
majorLabel.appendChild( | |||
majorEventCheckbox | |||
); | |||
majorLabel.appendChild( | |||
majorText | |||
); | |||
majorWrapper.appendChild( | |||
majorHeading | |||
); | |||
majorWrapper.appendChild( | |||
majorLabel | |||
); | |||
filterBar.appendChild( | |||
majorWrapper | |||
); | |||
api.search.fixed( | |||
'zunagiMajorEvents', | |||
function ( | |||
searchString, | |||
rowData, | |||
rowIndex | |||
) { | |||
if ( | |||
!majorEventCheckbox || | |||
!majorEventCheckbox.checked | |||
) { | |||
return true; | |||
} | |||
const row = | |||
api | |||
.row( | |||
rowIndex | |||
) | |||
.node(); | |||
if ( !row ) { | |||
return true; | |||
} | |||
return ( | |||
row.dataset.zunagiMajor === | |||
'Yes' | |||
); | |||
} | |||
); | |||
const pastWrapper = | |||
document.createElement( | |||
'div' | |||
); | |||
pastWrapper.className = | |||
'zunagi-filter zunagi-checkbox-filter'; | |||
const pastHeading = | |||
document.createElement( | |||
'span' | |||
); | |||
pastHeading.className = | |||
'zunagi-checkbox-heading'; | |||
pastHeading.textContent = | |||
'Past Events'; | |||
const pastLabel = | |||
document.createElement( | |||
'label' | |||
); | |||
pastLabel.className = | |||
'zunagi-checkbox-label'; | |||
showPastCheckbox = | |||
document.createElement( | |||
'input' | |||
); | |||
showPastCheckbox.type = | |||
'checkbox'; | |||
showPastCheckbox.checked = | |||
false; | |||
const pastText = | |||
document.createElement( | |||
'span' | |||
); | |||
pastText.textContent = | |||
'Show'; | |||
showPastCheckbox | |||
.addEventListener( | |||
'change', | |||
function () { | |||
api.draw(); | |||
} | |||
); | |||
pastLabel.appendChild( | |||
showPastCheckbox | |||
); | |||
pastLabel.appendChild( | |||
pastText | |||
); | |||
pastWrapper.appendChild( | |||
pastHeading | |||
); | |||
pastWrapper.appendChild( | |||
pastLabel | |||
); | |||
filterBar.appendChild( | |||
pastWrapper | |||
); | |||
api.search.fixed( | |||
'zunagiPastEvents', | |||
function ( | |||
searchString, | |||
rowData, | |||
rowIndex | |||
) { | |||
if ( | |||
showPastCheckbox && | |||
showPastCheckbox.checked | |||
) { | |||
return true; | |||
} | |||
const row = | |||
api | |||
.row( | |||
rowIndex | |||
) | |||
.node(); | |||
if ( !row ) { | |||
return true; | |||
} | |||
const eventEndDate = | |||
row.dataset.zunagiEndDate || | |||
row.dataset.zunagiDate; | |||
if ( !eventEndDate ) { | |||
return true; | |||
} | |||
return ( | |||
eventEndDate >= getLocalToday() | |||
); | |||
} | |||
); | |||
const searchWrapper = | |||
document.createElement( | |||
'div' | |||
); | |||
searchWrapper.className = | |||
'zunagi-filter zunagi-search-filter'; | |||
const searchLabel = | |||
document.createElement( | |||
'label' | |||
); | |||
searchLabel.textContent = | |||
'Search'; | |||
searchInput = | |||
document.createElement( | |||
'input' | |||
); | |||
searchInput.type = | |||
'search'; | |||
searchInput.placeholder = | |||
'Search events...'; | |||
searchInput.addEventListener( | |||
'input', | |||
function () { | |||
api | |||
.search( | |||
this.value | |||
) | |||
.draw(); | |||
} | |||
); | |||
searchWrapper.appendChild( | |||
searchLabel | |||
); | |||
searchWrapper.appendChild( | |||
searchInput | |||
); | |||
filterBar.appendChild( | |||
searchWrapper | |||
); | |||
const resetWrapper = | |||
document.createElement( | |||
'div' | |||
); | |||
resetWrapper.className = | |||
'zunagi-filter zunagi-reset-filter'; | |||
const resetButton = | |||
document.createElement( | |||
'button' | |||
); | |||
resetButton.type = | |||
'button'; | |||
resetButton.className = | |||
'zunagi-reset-button'; | |||
resetButton.textContent = | |||
'Reset filters'; | |||
resetButton.addEventListener( | |||
'click', | |||
function () { | |||
filterColumns.forEach( | |||
function ( | |||
columnIndex | |||
) { | |||
api | |||
.column( | |||
columnIndex | |||
) | |||
.search( | |||
'' | |||
); | |||
if ( | |||
filterSelects[ | |||
columnIndex | |||
] | |||
) { | |||
filterSelects[ | |||
columnIndex | |||
].value = | |||
''; | |||
} | |||
} | |||
); | |||
if ( | |||
majorEventCheckbox | |||
) { | |||
majorEventCheckbox.checked = | |||
false; | |||
} | |||
if ( | |||
showPastCheckbox | |||
) { | |||
showPastCheckbox.checked = | |||
false; | |||
} | |||
api.search( '' ); | |||
if ( | |||
searchInput | |||
) { | |||
searchInput.value = | |||
''; | |||
} | |||
api.order( | |||
[ | |||
[ | |||
1, | |||
'asc' | |||
] | |||
] | |||
); | |||
api.draw(); | |||
} | |||
); | |||
resetWrapper.appendChild( | |||
resetButton | |||
); | |||
filterBar.appendChild( | |||
resetWrapper | |||
); | |||
const calendarWrapper = | |||
table.closest( | |||
'.zunagi-calendar-table' | |||
); | |||
if ( calendarWrapper ) { | |||
calendarWrapper | |||
.parentNode | |||
.insertBefore( | |||
filterBar, | |||
calendarWrapper | |||
); | |||
} | |||
wrapperEl = | |||
calendarWrapper; | |||
applyPastEventClasses(); | |||
applyOrgColours( | |||
api | |||
); | |||
applyMajorEventBadges( | |||
api | |||
); | |||
applyYouthEventBadges( | |||
api | |||
); | |||
applyJuniorEventBadges( | |||
api | |||
); | |||
rebuildFilters(); | |||
restoreFilterState( | |||
api | |||
); | |||
rebuildFilters(); | |||
api.draw(); | |||
adjustTable( | |||
api | |||
); | |||
buildFixedHeader(); | |||
updateFixedHeaderVisibility(); | |||
fixedHeaderSyncLoop(); | |||
} | |||
} | |||
); | ); | ||
/* ================================================== | |||
* 11. CASCADING FILTER MATCH | |||
* ================================================== | |||
*/ | |||
function rowMatchesOtherFilters( | |||
row, | |||
targetColumn | |||
) { | |||
if ( | |||
!showPastCheckbox || | |||
!showPastCheckbox.checked | |||
) { | |||
if ( | |||
row.endDate && | |||
row.endDate < | |||
getLocalToday() | |||
) { | |||
return false; | |||
} | |||
} | |||
if ( | |||
majorEventCheckbox && | |||
majorEventCheckbox.checked && | |||
row.majorEvent !== 'Yes' | |||
) { | |||
return false; | |||
} | |||
if ( | |||
searchInput && | |||
searchInput.value.trim() | |||
) { | |||
const query = | |||
searchInput | |||
.value | |||
.trim() | |||
.toLowerCase(); | |||
if ( | |||
!row.searchText.includes( | |||
query | |||
) | |||
) { | |||
return false; | |||
} | |||
} | |||
const rowValues = { | |||
0: | |||
row.month, | |||
2: | |||
row.org, | |||
4: | |||
row.region, | |||
5: | |||
row.category | |||
}; | |||
for ( | |||
const filterColumn | |||
of filterColumns | |||
) { | |||
if ( | |||
filterColumn === | |||
targetColumn | |||
) { | |||
continue; | |||
} | |||
const select = | |||
filterSelects[ | |||
filterColumn | |||
]; | |||
if ( | |||
!select || | |||
!select.value | |||
) { | |||
continue; | |||
} | |||
if ( | |||
rowValues[ | |||
filterColumn | |||
] !== | |||
select.value | |||
) { | |||
return false; | |||
} | |||
} | |||
return true; | |||
return; | |||
} | } | ||
new | /* ================================================== | ||
* 12. REBUILD CASCADING FILTERS | |||
* ================================================== | |||
*/ | |||
function rebuildFilters() { | |||
filterColumns.forEach( | |||
function ( | |||
targetColumn | |||
) { | |||
const select = | |||
filterSelects[ | |||
targetColumn | |||
]; | |||
if ( !select ) { | |||
return; | |||
} | |||
const selectedValue = | |||
select.value; | |||
const availableValues = | |||
new Set(); | |||
const sortValues = | |||
new Map(); | |||
sourceRows.forEach( | |||
function ( row ) { | |||
if ( | |||
!rowMatchesOtherFilters( | |||
row, | |||
targetColumn | |||
) | |||
) { | |||
return; | |||
} | |||
let value = | |||
''; | |||
switch ( | |||
targetColumn | |||
) { | |||
case 0: | |||
value = | |||
row.month; | |||
break; | |||
case 2: | |||
value = | |||
row.org; | |||
break; | |||
case 4: | |||
value = | |||
row.region; | |||
break; | |||
case 5: | |||
value = | |||
row.category; | |||
break; | |||
} | |||
if ( !value ) { | |||
return; | |||
} | |||
availableValues.add( | |||
value | |||
); | |||
if ( | |||
targetColumn === 0 | |||
) { | |||
sortValues.set( | |||
value, | |||
row.monthOrder | |||
); | |||
} | |||
} | |||
); | |||
select.innerHTML = | |||
''; | |||
const allOption = | |||
document.createElement( | |||
'option' | |||
); | |||
allOption.value = | |||
''; | |||
allOption.textContent = | |||
'All'; | |||
select.appendChild( | |||
allOption | |||
); | |||
const sortedValues = | |||
Array.from( | |||
availableValues | |||
); | |||
if ( | |||
targetColumn === 0 | |||
) { | |||
sortedValues.sort( | |||
function ( | |||
a, | |||
b | |||
) { | |||
const orderA = | |||
sortValues.get( | |||
a | |||
) || a; | |||
const orderB = | |||
sortValues.get( | |||
b | |||
) || b; | |||
return orderA.localeCompare( | |||
orderB | |||
); | |||
} | |||
); | |||
} else { | |||
sortedValues.sort( | |||
function ( | |||
a, | |||
b | |||
) { | |||
return a.localeCompare( | |||
b, | |||
undefined, | |||
{ | |||
numeric: | |||
true, | |||
sensitivity: | |||
'base' | |||
} | |||
); | |||
} | |||
); | |||
} | |||
sortedValues.forEach( | |||
function ( | |||
value | |||
) { | |||
const option = | |||
document.createElement( | |||
'option' | |||
); | |||
option.value = | |||
value; | |||
option.textContent = | |||
value; | |||
select.appendChild( | |||
option | |||
); | |||
} | |||
); | |||
if ( | |||
selectedValue && | |||
availableValues.has( | |||
selectedValue | |||
) | |||
) { | |||
select.value = | |||
selectedValue; | |||
} else { | |||
select.value = | |||
''; | |||
} | |||
} | } | ||
); | |||
} | |||
/* ================================================== | |||
* 13. AFTER DRAW | |||
* ================================================== | |||
*/ | |||
dt.on( | |||
'draw', | |||
function () { | |||
rebuildFilters(); | |||
applyPastEventClasses(); | |||
applyOrgColours( | |||
dt | |||
); | |||
applyMajorEventBadges( | |||
dt | |||
); | |||
applyYouthEventBadges( | |||
dt | |||
); | ); | ||
applyJuniorEventBadges( | |||
dt | |||
'. | ); | ||
adjustTable( | |||
dt | |||
); | |||
} | |||
); | |||
// ========================================================== | |||
// COLLAPSIBLE CALENDAR FILTERS ON MOBILE | |||
// ========================================================== | |||
( function () { | |||
'use strict'; | |||
function initFilterToggle() { | |||
var filters = document.querySelector( '.zunagi-calendar-filters' ); | |||
if ( !filters ) { | |||
return; | |||
} | |||
var toggle = document.createElement( 'button' ); | |||
toggle.type = 'button'; | |||
toggle.className = 'zunagi-filters-toggle'; | |||
toggle.textContent = 'Show filters'; | |||
toggle.setAttribute( 'aria-expanded', 'false' ); | |||
toggle.addEventListener( 'click', function () { | |||
var isOpen = filters.classList.toggle( 'is-open' ); | |||
toggle.textContent = isOpen ? 'Hide filters' : 'Show filters'; | |||
toggle.setAttribute( 'aria-expanded', isOpen ? 'true' : 'false' ); | |||
} ); | |||
filters.parentNode.insertBefore( toggle, filters ); | |||
} | |||
if ( document.readyState === 'loading' ) { | |||
document.addEventListener( 'DOMContentLoaded', initFilterToggle ); | |||
} else { | |||
initFilterToggle(); | |||
} | |||
}() ); | |||
/* ================================================== | |||
* 14. WINDOW RESIZE | |||
* ================================================== | |||
*/ | |||
let resizeTimer = | |||
null; | |||
window.addEventListener( | |||
'resize', | |||
function () { | |||
clearTimeout( | |||
resizeTimer | |||
); | |||
resizeTimer = | |||
setTimeout( | |||
function () { | |||
dt.columns.adjust(); | |||
rebuildFixedHeader(); | |||
updateFixedHeaderVisibility(); | |||
}, | |||
150 | |||
); | |||
} | |||
); | |||
/* ================================================== | |||
* 15. ORIENTATION CHANGE | |||
* ================================================== | |||
*/ | |||
window.addEventListener( | |||
'orientationchange', | |||
function () { | |||
setTimeout( | |||
function () { | |||
dt.columns.adjust(); | |||
rebuildFixedHeader(); | |||
updateFixedHeaderVisibility(); | |||
}, | |||
300 | |||
); | |||
} | |||
); | ); | ||
} ).catch( | |||
function ( error ) { | |||
console.error( | |||
'Zunagi DataTables module failed to load:', | |||
error | |||
); | |||
} | } | ||
); | |||
} ); | |||
/* ========================================================== | |||
ABVRS POINTS TABLE — EQUAL COLUMN WIDTHS | |||
========================================================== */ | |||
$( function () { | |||
/* | |||
* table-layout:fixed with percentage column widths doesn't | |||
* reliably stretch to fill its wrapper in every browser once | |||
* the table sits inside the skin's flex-based grid layout | |||
* (Bootstrap's .row / .col-*) - the table's own outer box | |||
* stretches to 100% correctly, but its internal column grid | |||
* can fall back to each column's own content width instead, | |||
* leaving blank space to the right. Most visible on mobile, | |||
* where the page's flex column collapses to 100% width and | |||
* exposes the quirk (confirmed on zunagi.com directly: the | |||
* <table> box was the full container width, but its <tr> | |||
* inside was stuck at content width regardless of any CSS | |||
* width/table-layout/colgroup value thrown at it). | |||
* | |||
* Setting each column's width, min-width and max-width in | |||
* pixels — computed from the wrapper's own real rendered | |||
* width — forces every column to the same size regardless. | |||
* min-width matters as much as width here: table cells have | |||
* the same "shrink to at least my content" floor that flex | |||
* items do, and a plain width alone doesn't override it. | |||
*/ | |||
const tables = | |||
document.querySelectorAll( '.zunagi-points-table' ); | |||
if ( !tables.length ) { | |||
return; | |||
} | |||
function fixPointsTableColumns() { | |||
tables.forEach( function ( table ) { | |||
const wrapper = | |||
table.closest( '.zunagi-points-table-wrapper' ); | |||
if ( !wrapper || !table.rows.length ) { | |||
return; | |||
} | |||
const columnCount = | |||
table.rows[ 0 ].cells.length; | |||
if ( !columnCount ) { | |||
return; | |||
} | |||
const wrapperWidth = | |||
wrapper.clientWidth; | |||
if ( !wrapperWidth ) { | |||
return; | |||
} | |||
// Subtract a couple of pixels of headroom before dividing: an exact, | |||
// unpadded division (e.g. 800 / 5 = 160px each) can still overflow the | |||
// wrapper by a sub-pixel amount once collapsed cell borders are added in, | |||
// which is enough to trigger an unwanted scrollbar on an overflow:auto | |||
// wrapper even though the table visually looks like it fits. | |||
const columnWidth = | |||
Math.floor( ( wrapperWidth - 2 ) / columnCount ) + 'px'; | |||
for ( let r = 0; r < table.rows.length; r++ ) { | |||
const row = | |||
table.rows[ r ]; | |||
for ( let c = 0; c < row.cells.length; c++ ) { | |||
const cell = | |||
row.cells[ c ]; | |||
cell.style.width = columnWidth; | |||
cell.style.minWidth = columnWidth; | |||
cell.style.maxWidth = columnWidth; | |||
} | |||
} | |||
} ); | |||
} | |||
fixPointsTableColumns(); | |||
// Re-run on resize/orientation change so rotating a phone (or resizing a | |||
// desktop window) recalculates the column width for the new wrapper size. | |||
let resizeTimer = null; | |||
window.addEventListener( 'resize', function () { | |||
clearTimeout( resizeTimer ); | |||
resizeTimer = setTimeout( fixPointsTableColumns, 150 ); | |||
} ); | } ); | ||
} ); | |||
/* ========================================================== | |||
NBVA POINTS TABLE — MOBILE-ONLY DIVISION TABS | |||
========================================================== */ | |||
$( function () { | |||
const tabsContainer = | |||
document.querySelector( '.zunagi-nbva-points-tabs' ); | |||
if ( !tabsContainer ) { | |||
return; // Not an NBVA event page - nothing to do. | |||
} | |||
document. | const tabs = | ||
if ( | tabsContainer.querySelectorAll( '.zunagi-nbva-points-tab' ); | ||
! | // Both the NBVA and ABVRS tables share this class, so one query and one | ||
) { | // loop below covers whichever of the two (or both) are on the page. | ||
const tables = | |||
document.querySelectorAll( '.zunagi-points-table' ); | |||
function isMobile() { | |||
return window.matchMedia( '(max-width: 900px)' ).matches; | |||
} | |||
// The equal-column-width fix (claude/abvrs-points-table.md, section 6) | |||
// sizes every column in pixels based on the table's *total* column | |||
// count, and runs before it has any idea some of those columns are | |||
// about to be hidden by the tabs below. Once columns are hidden, the | |||
// ones left visible are still sized for the old (larger) column count | |||
// and no longer fill the wrapper - visible as blank space to the right | |||
// of a narrower table (found live 2026-09-18). This recomputes | |||
// width/min-width/max-width using only the currently-visible columns, | |||
// so it has to be re-run every time applyTab() changes which columns | |||
// are shown. | |||
function recalculateVisibleColumnWidths( table ) { | |||
const wrapper = | |||
table.closest( '.zunagi-points-table-wrapper' ); | |||
if ( !wrapper || !table.rows.length ) { | |||
return; | |||
} | |||
const visibleColumnCount = | |||
Array.prototype.filter.call( | |||
table.rows[ 0 ].cells, | |||
function ( cell ) { | |||
return cell.style.display !== 'none'; | |||
} | |||
).length; | |||
const wrapperWidth = | |||
wrapper.clientWidth; | |||
if ( !visibleColumnCount || !wrapperWidth ) { | |||
return; | |||
} | |||
// Same -2px headroom as the original fix, to avoid a 1px overflow | |||
// scrollbar from collapsed cell borders. | |||
const columnWidth = | |||
Math.floor( ( wrapperWidth - 2 ) / visibleColumnCount ) + 'px'; | |||
for ( let r = 0; r < table.rows.length; r++ ) { | |||
const row = table.rows[ r ]; | |||
for ( let c = 0; c < row.cells.length; c++ ) { | |||
const cell = row.cells[ c ]; | |||
if ( cell.style.display === 'none' ) { | |||
continue; | |||
} | |||
cell.style.width = columnWidth; | |||
cell.style.minWidth = columnWidth; | |||
cell.style.maxWidth = columnWidth; | |||
} | |||
} | } | ||
} | |||
function applyTab( tabNumber ) { | |||
tabs.forEach( function ( tab ) { | |||
tab.classList.toggle( | |||
'active', | |||
tab.dataset.tab === String( tabNumber ) | |||
); | |||
} ); | |||
tables.forEach( function ( table ) { | |||
if ( !table.rows.length ) { | |||
return; | |||
} | |||
// Includes the "Place" column at index 0. | |||
const columnCount = | |||
table.rows[ 0 ].cells.length; | |||
for ( let r = 0; r < table.rows.length; r++ ) { | |||
const cells = | |||
table.rows[ r ].cells; | |||
for ( let c = 1; c < cells.length; c++ ) { // skip Place | |||
if ( !isMobile() ) { | |||
// Desktop: tab bar is hidden by CSS - every column | |||
// always shows, regardless of which tab was last | |||
// clicked or whether the resize came from mobile. | |||
cells[ c ].style.display = ''; | |||
continue; | |||
} | |||
// A table with 4 or fewer total columns (Place + <=3 | |||
// divisions, e.g. a 3-Star ABVRS table) has nothing | |||
// beyond what tab 1 already shows - leave it fully | |||
// visible on both tabs rather than showing an | |||
// empty-looking table with only "Place" on tab 2. | |||
if ( columnCount <= 4 ) { | |||
cells[ c ].style.display = ''; | |||
continue; | |||
} | |||
const isFirstThreeDivisions = | |||
c <= 3; | |||
const shouldShow = | |||
tabNumber === 1 ? isFirstThreeDivisions : !isFirstThreeDivisions; | |||
cells[ c ].style.display = | |||
shouldShow ? '' : 'none'; | |||
} | |||
} | |||
// Re-fill the wrapper's width using only the columns left | |||
// visible above - see the function's own comment for why this | |||
// has to be redone every time, not just once on load. | |||
recalculateVisibleColumnWidths( table ); | |||
// Swap the "Division 1 is..."/"Division 4 is..." subheading text | |||
// to match the active tab. The subheading is the element | |||
// immediately before this table's wrapper in the DOM | |||
// (zunagi-points-subheading, then zunagi-points-table-wrapper - | |||
// see Module:NBVA Points / Module:ABVRS Points). Only swaps on | |||
// mobile (desktop always shows every column, so the subheading | |||
// always reads as Division 1), and only when the module actually | |||
// supplied a tab-2 variant - a table with 4 or fewer total | |||
// columns never gets data-tab2-text, so its subheading is left | |||
// alone, same as its columns are on the guard above. | |||
const wrapper = table.closest( '.zunagi-points-table-wrapper' ); | |||
const subheading = wrapper && wrapper.previousElementSibling; | |||
if ( | |||
subheading && | |||
subheading.classList.contains( 'zunagi-points-subheading' ) && | |||
subheading.dataset.tab2Text | |||
) { | |||
const showTab2Text = isMobile() && tabNumber === 2; | |||
subheading.textContent = showTab2Text ? subheading.dataset.tab2Text : subheading.dataset.tab1Text; | |||
} | |||
} ); | |||
} | |||
let activeTab = 1; | |||
tabs.forEach( function ( tab ) { | |||
tab.addEventListener( 'click', function () { | |||
activeTab = Number( tab.dataset.tab ); | |||
applyTab( activeTab ); | |||
} ); | |||
// <span role="button"> isn't natively keyboard-activatable the way a | |||
// real <button> is (MediaWiki's sanitizer strips raw <button> tags - | |||
// see the bug note above, which is why these are spans at all) - | |||
// Enter/Space need wiring up by hand to keep the tabs operable | |||
// without a mouse. | |||
tab.addEventListener( 'keydown', function ( e ) { | |||
if ( e.key === 'Enter' || e.key === ' ' ) { | |||
e.preventDefault(); | |||
activeTab = Number( tab.dataset.tab ); | |||
applyTab( activeTab ); | |||
} | |||
} ); | |||
} ); | } ); | ||
applyTab( activeTab ); // Set the initial state (tab 1, columns trimmed) on page load. | |||
// Re-run on resize/orientation change so crossing the mobile breakpoint | |||
// re-evaluates whether columns should be hidden - same debounce pattern | |||
// as the ABVRS points table's own equal-column-width fix above. | |||
let resizeTimer = null; | |||
window.addEventListener( 'resize', function () { | |||
clearTimeout( resizeTimer ); | |||
resizeTimer = setTimeout( function () { | |||
applyTab( activeTab ); | |||
}, 150 ); | |||
} ); | |||
} ); | } ); | ||
Latest revision as of 15:32, 18 September 2026
/* ==========================================================
ZUNAGI BEACH VOLLEYBALL CALENDAR
========================================================== */
$( function () {
/*
* IMPORTANT: this check runs BEFORE requesting the
* DataTables module, not after. mw.loader.using() is a
* client-side call that fetches the module over the
* network the moment it runs, regardless of what
* LocalSettings.php's addModules() gate decided
* server-side - so checking for the table only inside
* the .then() callback (after the module was already
* requested) meant the full DataTables library was being
* downloaded on every single page, even ones with no
* calendar table at all, wasting bandwidth and defeating
* the point of the category-based loading gate.
*/
const table =
document.querySelector( '.zunagi-calendar' );
if ( !table ) {
return;
}
mw.loader.using( 'ext.zunagi.datatables' ).then( function () {
/* ==================================================
* 1. FIX MEDIAWIKI HEADER
* ==================================================
*/
if ( !table.tHead ) {
const firstRow =
table.querySelector(
'tbody > tr:first-child'
);
if (
firstRow &&
firstRow.querySelectorAll( 'th' ).length > 0
) {
const thead =
table.createTHead();
thead.appendChild(
firstRow
);
}
}
/* ==================================================
* 2. COLUMN MAP
* ==================================================
*
* 0 = Month hidden
* 1 = Date
* 2 = Org
* 3 = Event
* 4 = Region
* 5 = Category
* 6 = Level
* 7 = Venue
* 8 = Major Event hidden
* 9 = End Date hidden - falls back to Date (col 1) when blank
*/
const filterColumns = [
0, // Month
2, // Org
4, // Region
5 // Category
];
const filterSelects = {};
let searchInput = null;
let majorEventCheckbox = null;
let showPastCheckbox = null;
/* ==================================================
* 3. CELL HELPERS
* ==================================================
*/
function getCellFilterValue( cell ) {
if ( !cell ) {
return '';
}
const dataSearch =
cell.getAttribute(
'data-search'
);
if ( dataSearch !== null ) {
return dataSearch.trim();
}
return cell.textContent.trim();
}
function getCellOrderValue( cell ) {
if ( !cell ) {
return '';
}
const dataOrder =
cell.getAttribute(
'data-order'
);
if ( dataOrder !== null ) {
return dataOrder.trim();
}
return getCellFilterValue(
cell
);
}
/* ==================================================
* 4. TODAY
* ==================================================
*/
function getLocalToday() {
const now =
new Date();
const year =
now.getFullYear();
const month =
String(
now.getMonth() + 1
).padStart(
2,
'0'
);
const day =
String(
now.getDate()
).padStart(
2,
'0'
);
return (
year +
'-' +
month +
'-' +
day
);
}
/* ==================================================
* 5. CACHE SOURCE ROWS
* ==================================================
*/
const sourceRows =
Array.from(
table.querySelectorAll(
'tbody > tr'
)
)
.map(
function ( row ) {
const cells =
Array.from(
row.querySelectorAll(
'td'
)
);
if ( cells.length !== 10 ) { // was 9
console.warn(
'Zunagi calendar row contains ' +
cells.length +
' cells; expected 10.', // was 9
row
);
return null;
}
const date =
getCellOrderValue(
cells[ 1 ]
);
const endDate =
getCellOrderValue(
cells[ 9 ]
) || date;
row.dataset.zunagiDate =
date;
row.dataset.zunagiEndDate =
endDate;
row.dataset.zunagiMajor =
getCellFilterValue(
cells[ 8 ]
);
return {
row: row,
month: getCellFilterValue( cells[ 0 ] ),
monthOrder: getCellOrderValue( cells[ 0 ] ),
date: date,
endDate: endDate, // new
org:
getCellFilterValue(
cells[ 2 ]
),
event:
getCellFilterValue(
cells[ 3 ]
),
region:
getCellFilterValue(
cells[ 4 ]
),
category:
getCellFilterValue(
cells[ 5 ]
),
level:
getCellFilterValue(
cells[ 6 ]
),
venue:
getCellFilterValue(
cells[ 7 ]
),
majorEvent:
getCellFilterValue(
cells[ 8 ]
),
searchText:
row.textContent
.replace(
/\s+/g,
' '
)
.trim()
.toLowerCase()
};
}
)
.filter(
function ( row ) {
return row !== null;
}
);
/* ==================================================
* 6. PAST EVENT CLASSES
* ==================================================
*/
function applyPastEventClasses() {
const today =
getLocalToday();
sourceRows.forEach(
function ( rowData ) {
rowData.row.classList.toggle(
'zunagi-past-event',
Boolean(
rowData.endDate &&
rowData.endDate < today
)
);
}
);
}
/* ==================================================
* 7. ORGANISATION COLOURS
* ==================================================
*/
function applyOrgColours( api ) {
api.rows().every(
function () {
const row =
this.node();
if ( !row ) {
return;
}
const orgCell =
api
.cell(
row,
2
)
.node();
if ( !orgCell ) {
return;
}
const org =
getCellFilterValue(
orgCell
);
orgCell.classList.remove(
'org-vnsw',
'org-qbvt',
'org-nbva',
'org-va',
'org-vsa',
'org-vv',
'org-vact',
'org-vwa'
);
if ( org === 'VNSW' ) {
orgCell.classList.add(
'org-vnsw'
);
} else if ( org === 'QBVT' ) {
orgCell.classList.add(
'org-qbvt'
);
} else if ( org === 'NBVA' ) {
orgCell.classList.add(
'org-nbva'
);
} else if ( org === 'VA' ) {
orgCell.classList.add(
'org-va'
);
} else if ( org === 'VSA' ) {
orgCell.classList.add(
'org-vsa'
);
} else if ( org === 'VACT' ) {
orgCell.classList.add(
'org-vact'
);
} else if ( org === 'VWA' ) {
orgCell.classList.add(
'org-vwa'
);
} else if ( org === 'VV' ) {
orgCell.classList.add(
'org-vv'
);
}
}
);
}
/* ==================================================
* 8. MAJOR EVENT BADGES
* ==================================================
*/
function applyMajorEventBadges( api ) {
api.rows().every(
function () {
const row =
this.node();
if ( !row ) {
return;
}
const eventCell =
api
.cell(
row,
3
)
.node();
const majorCell =
api
.cell(
row,
8
)
.node();
if (
!eventCell ||
!majorCell
) {
return;
}
/*
* Remove any existing badge first.
*/
eventCell
.querySelectorAll(
'.zunagi-major-badge'
)
.forEach(
function ( badge ) {
badge.remove();
}
);
const isMajor =
getCellFilterValue(
majorCell
) === 'Yes';
if ( !isMajor ) {
return;
}
const badge =
document.createElement(
'span'
);
badge.className =
'zunagi-major-badge';
badge.textContent =
'MAJOR';
badge.style.marginLeft =
'0';
badge.style.marginRight =
'6px';
eventCell.insertBefore(
badge,
eventCell.querySelector(
'a'
)
);
}
);
}
/* ==================================================
* 8b. YOUTH EVENT BADGES
* ==================================================
*/
function applyYouthEventBadges( api ) {
api.rows().every(
function () {
const row =
this.node();
if ( !row ) {
return;
}
const eventCell =
api
.cell(
row,
3
)
.node();
const categoryCell =
api
.cell(
row,
5
)
.node();
if (
!eventCell ||
!categoryCell
) {
return;
}
/*
* Remove any existing badge first.
*/
eventCell
.querySelectorAll(
'.zunagi-youth-badge'
)
.forEach(
function ( badge ) {
badge.remove();
}
);
const isYouth =
getCellFilterValue(
categoryCell
) === 'Youth';
if ( !isYouth ) {
return;
}
const badge =
document.createElement(
'span'
);
badge.className =
'zunagi-youth-badge';
badge.textContent =
'YOUTH';
badge.style.marginLeft =
'0';
badge.style.marginRight =
'6px';
eventCell.insertBefore(
badge,
eventCell.querySelector(
'a'
)
);
}
);
}
/* ==================================================
* 8c. JUNIOR EVENT BADGES
* ==================================================
*/
function applyJuniorEventBadges( api ) {
api.rows().every(
function () {
const row =
this.node();
if ( !row ) {
return;
}
const eventCell =
api
.cell(
row,
3
)
.node();
const categoryCell =
api
.cell(
row,
5
)
.node();
if (
!eventCell ||
!categoryCell
) {
return;
}
/*
* Remove any existing badge first.
*/
eventCell
.querySelectorAll(
'.zunagi-junior-badge'
)
.forEach(
function ( badge ) {
badge.remove();
}
);
const isJunior =
getCellFilterValue(
categoryCell
) === 'Junior';
if ( !isJunior ) {
return;
}
const badge =
document.createElement(
'span'
);
badge.className =
'zunagi-junior-badge';
badge.textContent =
'JUNIOR';
badge.style.marginLeft =
'0';
badge.style.marginRight =
'6px';
eventCell.insertBefore(
badge,
eventCell.querySelector(
'a'
)
);
}
);
}
/* ==================================================
* 8d. FILTER STATE PERSISTENCE (event-page round trip only)
* ==================================================
*
* Filters are remembered across exactly one kind of trip: leaving
* the calendar by clicking through to an event's own page, then
* coming back - either via that page's "Back to Calendar" link or
* the browser's own Back button. A direct or fresh visit to the
* calendar (Main Page, a bookmark, a new tab, a nav-menu click)
* never restores anything, because nothing is ever saved except at
* the moment of clicking an event link.
*/
const FILTER_STATE_KEY =
'zunagiCalendarFilterState';
function saveFilterState() {
const selects = {};
filterColumns.forEach(
function ( columnIndex ) {
if ( filterSelects[ columnIndex ] ) {
selects[ columnIndex ] =
filterSelects[ columnIndex ].value;
}
}
);
const payload = {
selects: selects,
major:
Boolean(
majorEventCheckbox &&
majorEventCheckbox.checked
),
past:
Boolean(
showPastCheckbox &&
showPastCheckbox.checked
),
search:
searchInput ?
searchInput.value :
''
};
try {
sessionStorage.setItem(
FILTER_STATE_KEY,
JSON.stringify( payload )
);
} catch ( e ) {
/*
* Ignore - e.g. private-browsing storage restrictions.
* Worst case the round trip just doesn't restore.
*/
}
}
function restoreFilterState( api ) {
let raw;
try {
raw =
sessionStorage.getItem(
FILTER_STATE_KEY
);
} catch ( e ) {
return;
}
if ( !raw ) {
return;
}
/*
* Consume it immediately - it must only ever apply to the one
* page load right after clicking an event link, never to any
* later visit.
*/
try {
sessionStorage.removeItem(
FILTER_STATE_KEY
);
} catch ( e ) {}
let state;
try {
state =
JSON.parse( raw );
} catch ( e ) {
return;
}
if ( !state ) {
return;
}
filterColumns.forEach(
function ( columnIndex ) {
const value =
state.selects &&
state.selects[ columnIndex ];
if ( !value ) {
return;
}
if ( filterSelects[ columnIndex ] ) {
filterSelects[ columnIndex ].value =
value;
}
api
.column( columnIndex )
.search(
value,
{
exact: true
}
);
}
);
if ( majorEventCheckbox ) {
majorEventCheckbox.checked =
Boolean( state.major );
}
if ( showPastCheckbox ) {
showPastCheckbox.checked =
Boolean( state.past );
}
if ( searchInput && state.search ) {
searchInput.value =
state.search;
api.search( state.search );
}
}
/* ==================================================
* 8e. SAVE FILTER STATE WHEN LEAVING FOR AN EVENT PAGE
* ==================================================
*/
table.addEventListener(
'click',
function ( event ) {
const link =
event.target.closest(
'td.zunagi-event-column a'
);
if ( !link ) {
return;
}
saveFilterState();
}
);
/* ==================================================
* 9. TABLE ADJUSTMENT
* ==================================================
*/
function adjustTable( api ) {
requestAnimationFrame(
function () {
api.columns.adjust();
rebuildFixedHeader();
}
);
}
/* ==================================================
* 9b. FIXED HEADER (JS affix pattern)
* ==================================================
*/
let wrapperEl = null;
let dtApi = null;
let fixedHeaderOuter = null;
let fixedHeaderInner = null;
let fixedHeaderVisible = false;
const FIXED_HEADER_TOP_OFFSET = 0;
const DEBUG_FIXED_HEADER = false;
let debugOverlay = null;
function updateDebugOverlay() {
if ( !DEBUG_FIXED_HEADER ) {
return;
}
if ( !debugOverlay ) {
debugOverlay =
document.createElement( 'div' );
debugOverlay.style.position =
'fixed';
debugOverlay.style.bottom =
'8px';
debugOverlay.style.right =
'8px';
debugOverlay.style.zIndex =
'99999';
debugOverlay.style.background =
'rgba(0,0,0,0.75)';
debugOverlay.style.color =
'#0f0';
debugOverlay.style.font =
'11px monospace';
debugOverlay.style.padding =
'4px 6px';
debugOverlay.style.pointerEvents =
'none';
debugOverlay.style.whiteSpace =
'pre';
document.body.appendChild(
debugOverlay
);
}
debugOverlay.textContent =
'wrapperEl.scrollLeft: ' +
( wrapperEl ? wrapperEl.scrollLeft : 'n/a' ) +
'\nfixedHeaderVisible: ' +
fixedHeaderVisible;
}
function buildFixedHeader() {
if ( !wrapperEl || !table.tHead ) {
return;
}
if ( fixedHeaderOuter ) {
fixedHeaderOuter.remove();
fixedHeaderOuter = null;
fixedHeaderInner = null;
}
let realThs;
if ( dtApi ) {
realThs =
dtApi
.columns( ':visible' )
.header()
.toArray();
} else {
realThs =
Array.from(
table.tHead.querySelectorAll( 'th' )
).filter(
function ( th ) {
const rect =
th.getBoundingClientRect();
return (
rect.width > 0 &&
rect.height > 0
);
}
);
}
if ( realThs.length === 0 ) {
return;
}
const theadRect =
table.tHead.getBoundingClientRect();
const tableRect =
table.getBoundingClientRect();
fixedHeaderInner =
document.createElement( 'div' );
fixedHeaderInner.className =
'zunagi-fixed-header-inner ' +
table.className;
fixedHeaderInner.style.position =
'relative';
const headerHeight =
Math.round( theadRect.height );
fixedHeaderInner.style.height =
headerHeight + 'px';
let maxRight = 0;
realThs.forEach(
function ( th ) {
const rect =
th.getBoundingClientRect();
const left =
Math.round(
rect.left - tableRect.left
);
const width =
Math.round(
rect.right - tableRect.left
) - left;
maxRight =
Math.max(
maxRight,
left + width
);
const cell =
th.cloneNode( true );
cell
.querySelectorAll(
'.dt-column-order'
)
.forEach(
function ( orderEl ) {
orderEl.remove();
}
);
const textAlign =
getComputedStyle( th )
.textAlign;
let justify =
'flex-start';
if ( textAlign === 'center' ) {
justify =
'center';
} else if (
textAlign === 'right' ||
textAlign === 'end'
) {
justify =
'flex-end';
}
const innerHeader =
cell.querySelector(
'.dt-column-header'
);
if ( innerHeader ) {
innerHeader.style.display =
'flex';
innerHeader.style.justifyContent =
justify;
innerHeader.style.alignItems =
'center';
innerHeader.style.width =
'100%';
innerHeader.style.height =
'100%';
const sortDirection =
th.getAttribute( 'aria-sort' );
if (
sortDirection === 'ascending' ||
sortDirection === 'descending'
) {
const arrow =
document.createElement(
'span'
);
arrow.className =
'zunagi-fixed-header-sort-arrow';
arrow.setAttribute(
'data-direction',
sortDirection
);
innerHeader.insertBefore(
arrow,
innerHeader.firstChild
);
const titleEl =
innerHeader.querySelector(
'.dt-column-title'
);
if ( titleEl ) {
titleEl.style.flex =
'0 0 auto';
titleEl.style.width =
'auto';
}
arrow.style.flex =
'0 0 auto';
}
}
cell.style.position =
'absolute';
cell.style.left =
left + 'px';
cell.style.top =
'0';
cell.style.setProperty(
'width',
width + 'px',
'important'
);
cell.style.setProperty(
'min-width',
width + 'px',
'important'
);
cell.style.setProperty(
'max-width',
width + 'px',
'important'
);
cell.style.height =
headerHeight + 'px';
cell.style.boxSizing =
'border-box';
cell.style.display =
'flex';
cell.style.alignItems =
'center';
cell.style.justifyContent =
justify;
fixedHeaderInner.appendChild(
cell
);
}
);
fixedHeaderInner.style.width =
maxRight + 'px';
fixedHeaderOuter =
document.createElement( 'div' );
fixedHeaderOuter.className =
'zunagi-fixed-header-outer';
fixedHeaderOuter.appendChild(
fixedHeaderInner
);
document.body.appendChild(
fixedHeaderOuter
);
fixedHeaderVisible = false;
}
function positionFixedHeader() {
if (
!fixedHeaderOuter ||
!wrapperEl
) {
return;
}
const wrapperRect =
wrapperEl.getBoundingClientRect();
fixedHeaderOuter.style.left =
wrapperRect.left + 'px';
fixedHeaderOuter.style.width =
wrapperRect.width + 'px';
fixedHeaderOuter.style.top =
FIXED_HEADER_TOP_OFFSET + 'px';
if ( fixedHeaderInner ) {
fixedHeaderInner.style.transform =
'translateX(-' +
Math.round( wrapperEl.scrollLeft ) +
'px)';
}
}
function updateFixedHeaderVisibility() {
if (
!wrapperEl ||
!fixedHeaderOuter ||
!table.tHead
) {
return;
}
const theadRect =
table.tHead.getBoundingClientRect();
const wrapperRect =
wrapperEl.getBoundingClientRect();
const shouldShow =
theadRect.top < FIXED_HEADER_TOP_OFFSET &&
wrapperRect.bottom >
FIXED_HEADER_TOP_OFFSET + 40;
if (
shouldShow &&
!fixedHeaderVisible
) {
fixedHeaderOuter.style.display =
'block';
fixedHeaderVisible = true;
} else if (
!shouldShow &&
fixedHeaderVisible
) {
fixedHeaderOuter.style.display =
'none';
fixedHeaderVisible = false;
}
if ( fixedHeaderVisible ) {
positionFixedHeader();
}
}
function rebuildFixedHeader() {
const wasVisible =
fixedHeaderVisible;
buildFixedHeader();
if (
wasVisible &&
fixedHeaderOuter
) {
fixedHeaderOuter.style.display =
'block';
fixedHeaderVisible = true;
positionFixedHeader();
}
}
function fixedHeaderSyncLoop() {
updateFixedHeaderVisibility();
updateDebugOverlay();
requestAnimationFrame(
fixedHeaderSyncLoop
);
}
/* ==================================================
* 10. INITIALISE DATATABLE
* ==================================================
*/
const dt =
new DataTable(
table,
{
paging: false,
searching: true,
ordering: true,
info: false,
autoWidth: false,
order: [
[
1,
'asc'
]
],
columnDefs: [
/*
* Month - hidden/filterable.
*/
{
targets: 0,
visible: false,
searchable: true,
orderable: false
},
/*
* Date.
*/
{
targets: 1,
width: '95px',
orderable: true,
className:
'zunagi-date-column'
},
/*
* Organisation - hidden/filterable.
*/
{
targets: 2,
visible: false,
searchable: true,
orderable: false
},
/*
* Event.
*/
{
targets: 3,
width: '330px',
orderable: false,
className:
'zunagi-event-column'
},
/*
* Region.
*/
{
targets: 4,
width: '85px',
orderable: false,
className:
'zunagi-region-column'
},
/*
* Category.
*/
{
targets: 5,
width: '100px',
orderable: false,
className:
'zunagi-category-column'
},
/*
* Level.
*/
{
targets: 6,
width: '85px',
orderable: false,
className:
'zunagi-level-column'
},
/*
* Venue.
*/
{
targets: 7,
width: '200px',
orderable: false,
className:
'zunagi-venue-column'
},
/*
* Major Event - hidden/filterable.
*/
{
targets: 8,
visible: false,
searchable: true,
orderable: false
},
{
targets: 9,
visible: false,
searchable: false,
orderable: false
}
],
layout: {
topStart: null,
topEnd: null,
bottomStart: null,
bottomEnd: null
},
initComplete:
function () {
const api =
this.api();
dtApi = api;
const filterBar =
document.createElement(
'div'
);
filterBar.className =
'zunagi-calendar-filters';
filterColumns.forEach(
function (
columnIndex
) {
const column =
api.column(
columnIndex
);
const heading =
column
.header()
.textContent
.trim();
const wrapper =
document.createElement(
'div'
);
wrapper.className =
'zunagi-filter';
const label =
document.createElement(
'label'
);
label.textContent =
heading;
const select =
document.createElement(
'select'
);
filterSelects[
columnIndex
] = select;
select.addEventListener(
'change',
function () {
column
.search(
this.value,
{
exact:
true
}
)
.draw();
}
);
wrapper.appendChild(
label
);
wrapper.appendChild(
select
);
filterBar.appendChild(
wrapper
);
}
);
const majorWrapper =
document.createElement(
'div'
);
majorWrapper.className =
'zunagi-filter zunagi-checkbox-filter';
const majorHeading =
document.createElement(
'span'
);
majorHeading.className =
'zunagi-checkbox-heading';
majorHeading.textContent =
'Major Events';
const majorLabel =
document.createElement(
'label'
);
majorLabel.className =
'zunagi-checkbox-label';
majorEventCheckbox =
document.createElement(
'input'
);
majorEventCheckbox.type =
'checkbox';
majorEventCheckbox.checked =
false;
const majorText =
document.createElement(
'span'
);
majorText.textContent =
'Show';
majorEventCheckbox.addEventListener(
'change',
function () {
api.draw();
}
);
majorLabel.appendChild(
majorEventCheckbox
);
majorLabel.appendChild(
majorText
);
majorWrapper.appendChild(
majorHeading
);
majorWrapper.appendChild(
majorLabel
);
filterBar.appendChild(
majorWrapper
);
api.search.fixed(
'zunagiMajorEvents',
function (
searchString,
rowData,
rowIndex
) {
if (
!majorEventCheckbox ||
!majorEventCheckbox.checked
) {
return true;
}
const row =
api
.row(
rowIndex
)
.node();
if ( !row ) {
return true;
}
return (
row.dataset.zunagiMajor ===
'Yes'
);
}
);
const pastWrapper =
document.createElement(
'div'
);
pastWrapper.className =
'zunagi-filter zunagi-checkbox-filter';
const pastHeading =
document.createElement(
'span'
);
pastHeading.className =
'zunagi-checkbox-heading';
pastHeading.textContent =
'Past Events';
const pastLabel =
document.createElement(
'label'
);
pastLabel.className =
'zunagi-checkbox-label';
showPastCheckbox =
document.createElement(
'input'
);
showPastCheckbox.type =
'checkbox';
showPastCheckbox.checked =
false;
const pastText =
document.createElement(
'span'
);
pastText.textContent =
'Show';
showPastCheckbox
.addEventListener(
'change',
function () {
api.draw();
}
);
pastLabel.appendChild(
showPastCheckbox
);
pastLabel.appendChild(
pastText
);
pastWrapper.appendChild(
pastHeading
);
pastWrapper.appendChild(
pastLabel
);
filterBar.appendChild(
pastWrapper
);
api.search.fixed(
'zunagiPastEvents',
function (
searchString,
rowData,
rowIndex
) {
if (
showPastCheckbox &&
showPastCheckbox.checked
) {
return true;
}
const row =
api
.row(
rowIndex
)
.node();
if ( !row ) {
return true;
}
const eventEndDate =
row.dataset.zunagiEndDate ||
row.dataset.zunagiDate;
if ( !eventEndDate ) {
return true;
}
return (
eventEndDate >= getLocalToday()
);
}
);
const searchWrapper =
document.createElement(
'div'
);
searchWrapper.className =
'zunagi-filter zunagi-search-filter';
const searchLabel =
document.createElement(
'label'
);
searchLabel.textContent =
'Search';
searchInput =
document.createElement(
'input'
);
searchInput.type =
'search';
searchInput.placeholder =
'Search events...';
searchInput.addEventListener(
'input',
function () {
api
.search(
this.value
)
.draw();
}
);
searchWrapper.appendChild(
searchLabel
);
searchWrapper.appendChild(
searchInput
);
filterBar.appendChild(
searchWrapper
);
const resetWrapper =
document.createElement(
'div'
);
resetWrapper.className =
'zunagi-filter zunagi-reset-filter';
const resetButton =
document.createElement(
'button'
);
resetButton.type =
'button';
resetButton.className =
'zunagi-reset-button';
resetButton.textContent =
'Reset filters';
resetButton.addEventListener(
'click',
function () {
filterColumns.forEach(
function (
columnIndex
) {
api
.column(
columnIndex
)
.search(
''
);
if (
filterSelects[
columnIndex
]
) {
filterSelects[
columnIndex
].value =
'';
}
}
);
if (
majorEventCheckbox
) {
majorEventCheckbox.checked =
false;
}
if (
showPastCheckbox
) {
showPastCheckbox.checked =
false;
}
api.search( '' );
if (
searchInput
) {
searchInput.value =
'';
}
api.order(
[
[
1,
'asc'
]
]
);
api.draw();
}
);
resetWrapper.appendChild(
resetButton
);
filterBar.appendChild(
resetWrapper
);
const calendarWrapper =
table.closest(
'.zunagi-calendar-table'
);
if ( calendarWrapper ) {
calendarWrapper
.parentNode
.insertBefore(
filterBar,
calendarWrapper
);
}
wrapperEl =
calendarWrapper;
applyPastEventClasses();
applyOrgColours(
api
);
applyMajorEventBadges(
api
);
applyYouthEventBadges(
api
);
applyJuniorEventBadges(
api
);
rebuildFilters();
restoreFilterState(
api
);
rebuildFilters();
api.draw();
adjustTable(
api
);
buildFixedHeader();
updateFixedHeaderVisibility();
fixedHeaderSyncLoop();
}
}
);
/* ==================================================
* 11. CASCADING FILTER MATCH
* ==================================================
*/
function rowMatchesOtherFilters(
row,
targetColumn
) {
if (
!showPastCheckbox ||
!showPastCheckbox.checked
) {
if (
row.endDate &&
row.endDate <
getLocalToday()
) {
return false;
}
}
if (
majorEventCheckbox &&
majorEventCheckbox.checked &&
row.majorEvent !== 'Yes'
) {
return false;
}
if (
searchInput &&
searchInput.value.trim()
) {
const query =
searchInput
.value
.trim()
.toLowerCase();
if (
!row.searchText.includes(
query
)
) {
return false;
}
}
const rowValues = {
0:
row.month,
2:
row.org,
4:
row.region,
5:
row.category
};
for (
const filterColumn
of filterColumns
) {
if (
filterColumn ===
targetColumn
) {
continue;
}
const select =
filterSelects[
filterColumn
];
if (
!select ||
!select.value
) {
continue;
}
if (
rowValues[
filterColumn
] !==
select.value
) {
return false;
}
}
return true;
}
/* ==================================================
* 12. REBUILD CASCADING FILTERS
* ==================================================
*/
function rebuildFilters() {
filterColumns.forEach(
function (
targetColumn
) {
const select =
filterSelects[
targetColumn
];
if ( !select ) {
return;
}
const selectedValue =
select.value;
const availableValues =
new Set();
const sortValues =
new Map();
sourceRows.forEach(
function ( row ) {
if (
!rowMatchesOtherFilters(
row,
targetColumn
)
) {
return;
}
let value =
'';
switch (
targetColumn
) {
case 0:
value =
row.month;
break;
case 2:
value =
row.org;
break;
case 4:
value =
row.region;
break;
case 5:
value =
row.category;
break;
}
if ( !value ) {
return;
}
availableValues.add(
value
);
if (
targetColumn === 0
) {
sortValues.set(
value,
row.monthOrder
);
}
}
);
select.innerHTML =
'';
const allOption =
document.createElement(
'option'
);
allOption.value =
'';
allOption.textContent =
'All';
select.appendChild(
allOption
);
const sortedValues =
Array.from(
availableValues
);
if (
targetColumn === 0
) {
sortedValues.sort(
function (
a,
b
) {
const orderA =
sortValues.get(
a
) || a;
const orderB =
sortValues.get(
b
) || b;
return orderA.localeCompare(
orderB
);
}
);
} else {
sortedValues.sort(
function (
a,
b
) {
return a.localeCompare(
b,
undefined,
{
numeric:
true,
sensitivity:
'base'
}
);
}
);
}
sortedValues.forEach(
function (
value
) {
const option =
document.createElement(
'option'
);
option.value =
value;
option.textContent =
value;
select.appendChild(
option
);
}
);
if (
selectedValue &&
availableValues.has(
selectedValue
)
) {
select.value =
selectedValue;
} else {
select.value =
'';
}
}
);
}
/* ==================================================
* 13. AFTER DRAW
* ==================================================
*/
dt.on(
'draw',
function () {
rebuildFilters();
applyPastEventClasses();
applyOrgColours(
dt
);
applyMajorEventBadges(
dt
);
applyYouthEventBadges(
dt
);
applyJuniorEventBadges(
dt
);
adjustTable(
dt
);
}
);
// ==========================================================
// COLLAPSIBLE CALENDAR FILTERS ON MOBILE
// ==========================================================
( function () {
'use strict';
function initFilterToggle() {
var filters = document.querySelector( '.zunagi-calendar-filters' );
if ( !filters ) {
return;
}
var toggle = document.createElement( 'button' );
toggle.type = 'button';
toggle.className = 'zunagi-filters-toggle';
toggle.textContent = 'Show filters';
toggle.setAttribute( 'aria-expanded', 'false' );
toggle.addEventListener( 'click', function () {
var isOpen = filters.classList.toggle( 'is-open' );
toggle.textContent = isOpen ? 'Hide filters' : 'Show filters';
toggle.setAttribute( 'aria-expanded', isOpen ? 'true' : 'false' );
} );
filters.parentNode.insertBefore( toggle, filters );
}
if ( document.readyState === 'loading' ) {
document.addEventListener( 'DOMContentLoaded', initFilterToggle );
} else {
initFilterToggle();
}
}() );
/* ==================================================
* 14. WINDOW RESIZE
* ==================================================
*/
let resizeTimer =
null;
window.addEventListener(
'resize',
function () {
clearTimeout(
resizeTimer
);
resizeTimer =
setTimeout(
function () {
dt.columns.adjust();
rebuildFixedHeader();
updateFixedHeaderVisibility();
},
150
);
}
);
/* ==================================================
* 15. ORIENTATION CHANGE
* ==================================================
*/
window.addEventListener(
'orientationchange',
function () {
setTimeout(
function () {
dt.columns.adjust();
rebuildFixedHeader();
updateFixedHeaderVisibility();
},
300
);
}
);
} ).catch(
function ( error ) {
console.error(
'Zunagi DataTables module failed to load:',
error
);
}
);
} );
/* ==========================================================
ABVRS POINTS TABLE — EQUAL COLUMN WIDTHS
========================================================== */
$( function () {
/*
* table-layout:fixed with percentage column widths doesn't
* reliably stretch to fill its wrapper in every browser once
* the table sits inside the skin's flex-based grid layout
* (Bootstrap's .row / .col-*) - the table's own outer box
* stretches to 100% correctly, but its internal column grid
* can fall back to each column's own content width instead,
* leaving blank space to the right. Most visible on mobile,
* where the page's flex column collapses to 100% width and
* exposes the quirk (confirmed on zunagi.com directly: the
* <table> box was the full container width, but its <tr>
* inside was stuck at content width regardless of any CSS
* width/table-layout/colgroup value thrown at it).
*
* Setting each column's width, min-width and max-width in
* pixels — computed from the wrapper's own real rendered
* width — forces every column to the same size regardless.
* min-width matters as much as width here: table cells have
* the same "shrink to at least my content" floor that flex
* items do, and a plain width alone doesn't override it.
*/
const tables =
document.querySelectorAll( '.zunagi-points-table' );
if ( !tables.length ) {
return;
}
function fixPointsTableColumns() {
tables.forEach( function ( table ) {
const wrapper =
table.closest( '.zunagi-points-table-wrapper' );
if ( !wrapper || !table.rows.length ) {
return;
}
const columnCount =
table.rows[ 0 ].cells.length;
if ( !columnCount ) {
return;
}
const wrapperWidth =
wrapper.clientWidth;
if ( !wrapperWidth ) {
return;
}
// Subtract a couple of pixels of headroom before dividing: an exact,
// unpadded division (e.g. 800 / 5 = 160px each) can still overflow the
// wrapper by a sub-pixel amount once collapsed cell borders are added in,
// which is enough to trigger an unwanted scrollbar on an overflow:auto
// wrapper even though the table visually looks like it fits.
const columnWidth =
Math.floor( ( wrapperWidth - 2 ) / columnCount ) + 'px';
for ( let r = 0; r < table.rows.length; r++ ) {
const row =
table.rows[ r ];
for ( let c = 0; c < row.cells.length; c++ ) {
const cell =
row.cells[ c ];
cell.style.width = columnWidth;
cell.style.minWidth = columnWidth;
cell.style.maxWidth = columnWidth;
}
}
} );
}
fixPointsTableColumns();
// Re-run on resize/orientation change so rotating a phone (or resizing a
// desktop window) recalculates the column width for the new wrapper size.
let resizeTimer = null;
window.addEventListener( 'resize', function () {
clearTimeout( resizeTimer );
resizeTimer = setTimeout( fixPointsTableColumns, 150 );
} );
} );
/* ==========================================================
NBVA POINTS TABLE — MOBILE-ONLY DIVISION TABS
========================================================== */
$( function () {
const tabsContainer =
document.querySelector( '.zunagi-nbva-points-tabs' );
if ( !tabsContainer ) {
return; // Not an NBVA event page - nothing to do.
}
const tabs =
tabsContainer.querySelectorAll( '.zunagi-nbva-points-tab' );
// Both the NBVA and ABVRS tables share this class, so one query and one
// loop below covers whichever of the two (or both) are on the page.
const tables =
document.querySelectorAll( '.zunagi-points-table' );
function isMobile() {
return window.matchMedia( '(max-width: 900px)' ).matches;
}
// The equal-column-width fix (claude/abvrs-points-table.md, section 6)
// sizes every column in pixels based on the table's *total* column
// count, and runs before it has any idea some of those columns are
// about to be hidden by the tabs below. Once columns are hidden, the
// ones left visible are still sized for the old (larger) column count
// and no longer fill the wrapper - visible as blank space to the right
// of a narrower table (found live 2026-09-18). This recomputes
// width/min-width/max-width using only the currently-visible columns,
// so it has to be re-run every time applyTab() changes which columns
// are shown.
function recalculateVisibleColumnWidths( table ) {
const wrapper =
table.closest( '.zunagi-points-table-wrapper' );
if ( !wrapper || !table.rows.length ) {
return;
}
const visibleColumnCount =
Array.prototype.filter.call(
table.rows[ 0 ].cells,
function ( cell ) {
return cell.style.display !== 'none';
}
).length;
const wrapperWidth =
wrapper.clientWidth;
if ( !visibleColumnCount || !wrapperWidth ) {
return;
}
// Same -2px headroom as the original fix, to avoid a 1px overflow
// scrollbar from collapsed cell borders.
const columnWidth =
Math.floor( ( wrapperWidth - 2 ) / visibleColumnCount ) + 'px';
for ( let r = 0; r < table.rows.length; r++ ) {
const row = table.rows[ r ];
for ( let c = 0; c < row.cells.length; c++ ) {
const cell = row.cells[ c ];
if ( cell.style.display === 'none' ) {
continue;
}
cell.style.width = columnWidth;
cell.style.minWidth = columnWidth;
cell.style.maxWidth = columnWidth;
}
}
}
function applyTab( tabNumber ) {
tabs.forEach( function ( tab ) {
tab.classList.toggle(
'active',
tab.dataset.tab === String( tabNumber )
);
} );
tables.forEach( function ( table ) {
if ( !table.rows.length ) {
return;
}
// Includes the "Place" column at index 0.
const columnCount =
table.rows[ 0 ].cells.length;
for ( let r = 0; r < table.rows.length; r++ ) {
const cells =
table.rows[ r ].cells;
for ( let c = 1; c < cells.length; c++ ) { // skip Place
if ( !isMobile() ) {
// Desktop: tab bar is hidden by CSS - every column
// always shows, regardless of which tab was last
// clicked or whether the resize came from mobile.
cells[ c ].style.display = '';
continue;
}
// A table with 4 or fewer total columns (Place + <=3
// divisions, e.g. a 3-Star ABVRS table) has nothing
// beyond what tab 1 already shows - leave it fully
// visible on both tabs rather than showing an
// empty-looking table with only "Place" on tab 2.
if ( columnCount <= 4 ) {
cells[ c ].style.display = '';
continue;
}
const isFirstThreeDivisions =
c <= 3;
const shouldShow =
tabNumber === 1 ? isFirstThreeDivisions : !isFirstThreeDivisions;
cells[ c ].style.display =
shouldShow ? '' : 'none';
}
}
// Re-fill the wrapper's width using only the columns left
// visible above - see the function's own comment for why this
// has to be redone every time, not just once on load.
recalculateVisibleColumnWidths( table );
// Swap the "Division 1 is..."/"Division 4 is..." subheading text
// to match the active tab. The subheading is the element
// immediately before this table's wrapper in the DOM
// (zunagi-points-subheading, then zunagi-points-table-wrapper -
// see Module:NBVA Points / Module:ABVRS Points). Only swaps on
// mobile (desktop always shows every column, so the subheading
// always reads as Division 1), and only when the module actually
// supplied a tab-2 variant - a table with 4 or fewer total
// columns never gets data-tab2-text, so its subheading is left
// alone, same as its columns are on the guard above.
const wrapper = table.closest( '.zunagi-points-table-wrapper' );
const subheading = wrapper && wrapper.previousElementSibling;
if (
subheading &&
subheading.classList.contains( 'zunagi-points-subheading' ) &&
subheading.dataset.tab2Text
) {
const showTab2Text = isMobile() && tabNumber === 2;
subheading.textContent = showTab2Text ? subheading.dataset.tab2Text : subheading.dataset.tab1Text;
}
} );
}
let activeTab = 1;
tabs.forEach( function ( tab ) {
tab.addEventListener( 'click', function () {
activeTab = Number( tab.dataset.tab );
applyTab( activeTab );
} );
// <span role="button"> isn't natively keyboard-activatable the way a
// real <button> is (MediaWiki's sanitizer strips raw <button> tags -
// see the bug note above, which is why these are spans at all) -
// Enter/Space need wiring up by hand to keep the tabs operable
// without a mouse.
tab.addEventListener( 'keydown', function ( e ) {
if ( e.key === 'Enter' || e.key === ' ' ) {
e.preventDefault();
activeTab = Number( tab.dataset.tab );
applyTab( activeTab );
}
} );
} );
applyTab( activeTab ); // Set the initial state (tab 1, columns trimmed) on page load.
// Re-run on resize/orientation change so crossing the mobile breakpoint
// re-evaluates whether columns should be hidden - same debounce pattern
// as the ABVRS points table's own equal-column-width fix above.
let resizeTimer = null;
window.addEventListener( 'resize', function () {
clearTimeout( resizeTimer );
resizeTimer = setTimeout( function () {
applyTab( activeTab );
}, 150 );
} );
} );