MediaWiki:Common.js
From zunagi
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/* ==========================================================
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 );
} );
} );