| 1 |
/** |
| 2 |
* SliderBerg Frontend Entry Point |
| 3 |
* |
| 4 |
* Initializes Swiper instances from data-swiper-data attributes |
| 5 |
* rendered by the PHP slider-renderer. |
| 6 |
*/ |
| 7 |
|
| 8 |
import Swiper from "swiper"; |
| 9 |
import { |
| 10 |
Navigation, |
| 11 |
Pagination, |
| 12 |
Autoplay, |
| 13 |
EffectFade, |
| 14 |
EffectCreative, |
| 15 |
Keyboard, |
| 16 |
A11y, |
| 17 |
} from "swiper/modules"; |
| 18 |
import type { SwiperModule, SwiperOptions } from "swiper/types"; |
| 19 |
|
| 20 |
import "swiper/css"; |
| 21 |
import "swiper/css/navigation"; |
| 22 |
import "swiper/css/pagination"; |
| 23 |
import "swiper/css/effect-fade"; |
| 24 |
import "swiper/css/effect-creative"; |
| 25 |
|
| 26 |
// Extend Window interface for public API and WP hooks |
| 27 |
declare const jQuery: any; |
| 28 |
declare global { |
| 29 |
interface Window { |
| 30 |
SliderBerg: { |
| 31 |
init: () => void; |
| 32 |
destroyAll: () => void; |
| 33 |
getInstance: (element: Element) => Swiper | undefined; |
| 34 |
getInstances: () => Map<Element, Swiper>; |
| 35 |
next: (element: Element) => void; |
| 36 |
prev: (element: Element) => void; |
| 37 |
goTo: (element: Element, index: number) => void; |
| 38 |
pause: (element: Element) => void; |
| 39 |
play: (element: Element) => void; |
| 40 |
}; |
| 41 |
wp?: { |
| 42 |
hooks?: { |
| 43 |
applyFilters: (filter: string, value: any, ...args: any[]) => any; |
| 44 |
}; |
| 45 |
}; |
| 46 |
} |
| 47 |
} |
| 48 |
|
| 49 |
/** All active Swiper instances keyed by their container element */ |
| 50 |
const instances = new Map<Element, Swiper>(); |
| 51 |
|
| 52 |
/** |
| 53 |
* AbortControllers for slide-link DOM listeners, keyed by container. |
| 54 |
* Aborting removes all associated click/keydown handlers in one call, |
| 55 |
* preventing duplicate listeners when the slider is reinitialized after |
| 56 |
* a bfcache restore (back/forward navigation). |
| 57 |
*/ |
| 58 |
const slideControllers = new Map<Element, AbortController>(); |
| 59 |
|
| 60 |
const defaultModules: SwiperModule[] = [ |
| 61 |
Navigation, |
| 62 |
Pagination, |
| 63 |
Autoplay, |
| 64 |
EffectFade, |
| 65 |
EffectCreative, |
| 66 |
Keyboard, |
| 67 |
A11y, |
| 68 |
]; |
| 69 |
|
| 70 |
function applySliderbergFilter<T>(filter: string, value: T, ...args: any[]): T { |
| 71 |
if (!window.wp?.hooks?.applyFilters) { |
| 72 |
return value; |
| 73 |
} |
| 74 |
|
| 75 |
return window.wp.hooks.applyFilters(filter, value, ...args) as T; |
| 76 |
} |
| 77 |
|
| 78 |
function getSwiperModules( |
| 79 |
container: HTMLElement, |
| 80 |
config: SwiperOptions, |
| 81 |
): SwiperModule[] { |
| 82 |
const modules = applySliderbergFilter( |
| 83 |
"sliderberg.swiperModules", |
| 84 |
defaultModules.slice(), |
| 85 |
container, |
| 86 |
config, |
| 87 |
); |
| 88 |
|
| 89 |
return Array.isArray(modules) ? modules : defaultModules; |
| 90 |
} |
| 91 |
|
| 92 |
/** |
| 93 |
* Initialize all SliderBerg sliders on the page. |
| 94 |
*/ |
| 95 |
function initializeSliders(): void { |
| 96 |
const containers = |
| 97 |
document.querySelectorAll<HTMLElement>(".sliderberg-swiper"); |
| 98 |
|
| 99 |
if (!containers.length) { |
| 100 |
return; |
| 101 |
} |
| 102 |
|
| 103 |
// Allow pro features to override initialization entirely |
| 104 |
if (window.wp?.hooks?.applyFilters) { |
| 105 |
const customInit = window.wp.hooks.applyFilters( |
| 106 |
"sliderberg.frontendInit", |
| 107 |
null, |
| 108 |
containers, |
| 109 |
); |
| 110 |
if (customInit && typeof customInit === "function") { |
| 111 |
customInit(containers); |
| 112 |
return; |
| 113 |
} |
| 114 |
} |
| 115 |
|
| 116 |
containers.forEach((container) => { |
| 117 |
// Skip already-initialized containers |
| 118 |
if (instances.has(container)) { |
| 119 |
return; |
| 120 |
} |
| 121 |
|
| 122 |
// Allow pro features to veto individual slider init |
| 123 |
if (window.wp?.hooks?.applyFilters) { |
| 124 |
const shouldInit = window.wp.hooks.applyFilters( |
| 125 |
"sliderberg.beforeSliderInit", |
| 126 |
true, |
| 127 |
container, |
| 128 |
); |
| 129 |
if (!shouldInit) { |
| 130 |
return; |
| 131 |
} |
| 132 |
} |
| 133 |
|
| 134 |
// Parse Swiper config from data attribute |
| 135 |
const dataAttr = container.getAttribute("data-swiper-data"); |
| 136 |
if (!dataAttr) { |
| 137 |
return; |
| 138 |
} |
| 139 |
|
| 140 |
let config: SwiperOptions; |
| 141 |
try { |
| 142 |
config = JSON.parse(dataAttr) as SwiperOptions; |
| 143 |
} catch { |
| 144 |
return; |
| 145 |
} |
| 146 |
|
| 147 |
// Scope navigation/pagination selectors to this container instance. |
| 148 |
// Both nav buttons and pagination live OUTSIDE the swiper container as |
| 149 |
// siblings inside .sliderberg-swiper-wrap, so query from the parent. |
| 150 |
if (config.navigation) { |
| 151 |
const prevEl = |
| 152 |
container.parentElement?.querySelector(".swiper-button-prev") ?? |
| 153 |
container.querySelector(".swiper-button-prev"); |
| 154 |
const nextEl = |
| 155 |
container.parentElement?.querySelector(".swiper-button-next") ?? |
| 156 |
container.querySelector(".swiper-button-next"); |
| 157 |
if (prevEl && nextEl) { |
| 158 |
config.navigation = { prevEl, nextEl }; |
| 159 |
} |
| 160 |
} |
| 161 |
if (config.pagination) { |
| 162 |
// Pagination is a sibling of the swiper container inside |
| 163 |
// .sliderberg-swiper-wrap, so query from the parent element. |
| 164 |
const paginationEl = |
| 165 |
container.parentElement?.querySelector(".swiper-pagination") ?? |
| 166 |
container.querySelector(".swiper-pagination"); |
| 167 |
if (paginationEl) { |
| 168 |
config.pagination = { |
| 169 |
...config.pagination, |
| 170 |
el: paginationEl, |
| 171 |
}; |
| 172 |
} |
| 173 |
} |
| 174 |
|
| 175 |
const swiperConfig = applySliderbergFilter( |
| 176 |
"sliderberg.swiperConfig", |
| 177 |
config, |
| 178 |
container, |
| 179 |
); |
| 180 |
const modules = getSwiperModules(container, swiperConfig); |
| 181 |
|
| 182 |
// Create Swiper instance |
| 183 |
const swiper = new Swiper(container, { |
| 184 |
modules, |
| 185 |
...swiperConfig, |
| 186 |
}); |
| 187 |
|
| 188 |
instances.set(container, swiper); |
| 189 |
initSlideLinkHandlers(container, swiper); |
| 190 |
}); |
| 191 |
} |
| 192 |
|
| 193 |
/** |
| 194 |
* Wire up click-to-navigate behaviour for slides that have data-slide-link. |
| 195 |
* Uses a JS handler rather than wrapping in <a> so nested interactive |
| 196 |
* elements (buttons, links) continue to work normally. |
| 197 |
* |
| 198 |
* Listeners are tied to an AbortController stored in slideControllers so they |
| 199 |
* can be cancelled cleanly on reinit without leaving orphaned handlers on the |
| 200 |
* DOM nodes. |
| 201 |
*/ |
| 202 |
function initSlideLinkHandlers(container: HTMLElement, swiper: Swiper): void { |
| 203 |
const linkedSlides = Array.from( |
| 204 |
container.querySelectorAll<HTMLElement>(".sliderberg-slide[data-slide-link]"), |
| 205 |
); |
| 206 |
if (!linkedSlides.length) return; |
| 207 |
|
| 208 |
const ac = new AbortController(); |
| 209 |
const { signal } = ac; |
| 210 |
slideControllers.set(container, ac); |
| 211 |
|
| 212 |
// Prevent navigation when the user drags to the next slide |
| 213 |
let isDragging = false; |
| 214 |
swiper.on("touchStart", () => { |
| 215 |
isDragging = false; |
| 216 |
}); |
| 217 |
swiper.on("touchMove", () => { |
| 218 |
isDragging = true; |
| 219 |
}); |
| 220 |
|
| 221 |
// Only the active slide should be reachable via Tab |
| 222 |
const updateTabindex = () => { |
| 223 |
linkedSlides.forEach((slide) => { |
| 224 |
slide.tabIndex = slide.classList.contains("swiper-slide-active") ? 0 : -1; |
| 225 |
}); |
| 226 |
}; |
| 227 |
updateTabindex(); |
| 228 |
swiper.on("slideChange", updateTabindex); |
| 229 |
|
| 230 |
linkedSlides.forEach((slide) => { |
| 231 |
const url = slide.dataset.slideLink!; |
| 232 |
const linkTarget = slide.dataset.slideLinkTarget ?? "_self"; |
| 233 |
|
| 234 |
const navigate = () => { |
| 235 |
if (linkTarget === "_blank") { |
| 236 |
window.open(url, "_blank", "noopener,noreferrer"); |
| 237 |
} else { |
| 238 |
window.location.href = url; |
| 239 |
} |
| 240 |
}; |
| 241 |
|
| 242 |
slide.addEventListener( |
| 243 |
"click", |
| 244 |
(e) => { |
| 245 |
if (isDragging) { |
| 246 |
isDragging = false; |
| 247 |
return; |
| 248 |
} |
| 249 |
// Let clicks on interactive elements through unchanged |
| 250 |
const clickedEl = e.target as Element; |
| 251 |
if ( |
| 252 |
clickedEl.closest( |
| 253 |
"a, button, input, select, textarea, [role='button'], [role='link']", |
| 254 |
) |
| 255 |
) { |
| 256 |
return; |
| 257 |
} |
| 258 |
navigate(); |
| 259 |
}, |
| 260 |
{ signal }, |
| 261 |
); |
| 262 |
|
| 263 |
slide.addEventListener( |
| 264 |
"keydown", |
| 265 |
(e) => { |
| 266 |
if (e.key === "Enter" || e.key === " ") { |
| 267 |
e.preventDefault(); |
| 268 |
navigate(); |
| 269 |
} |
| 270 |
}, |
| 271 |
{ signal }, |
| 272 |
); |
| 273 |
}); |
| 274 |
} |
| 275 |
|
| 276 |
/** |
| 277 |
* Destroy all Swiper instances and clear the maps. |
| 278 |
*/ |
| 279 |
function destroyAll(): void { |
| 280 |
slideControllers.forEach((ac) => ac.abort()); |
| 281 |
slideControllers.clear(); |
| 282 |
instances.forEach((swiper) => { |
| 283 |
swiper.destroy(true, true); |
| 284 |
}); |
| 285 |
instances.clear(); |
| 286 |
} |
| 287 |
|
| 288 |
// --- Initialization --- |
| 289 |
|
| 290 |
if (document.readyState === "loading") { |
| 291 |
document.addEventListener("DOMContentLoaded", initializeSliders); |
| 292 |
} else { |
| 293 |
initializeSliders(); |
| 294 |
} |
| 295 |
|
| 296 |
// On bfcache restore (back/forward navigation on desktop), the entire JS heap |
| 297 |
// is preserved — instances Map, Swiper objects, and DOM event listeners all |
| 298 |
// survive the freeze intact. DOMContentLoaded does NOT re-fire, but we do not |
| 299 |
// need to destroy/reinitialize. We only need to restart autoplay (setInterval |
| 300 |
// timers are paused during the freeze) and recalculate layout in case the |
| 301 |
// viewport changed. Destroying and reinitializing would strip inline styles |
| 302 |
// (background-image etc.) and cause a visible flash. |
| 303 |
window.addEventListener("pageshow", (event) => { |
| 304 |
if (event.persisted) { |
| 305 |
instances.forEach((swiper) => { |
| 306 |
swiper.update(); |
| 307 |
if (swiper.params.autoplay) { |
| 308 |
swiper.autoplay.start(); |
| 309 |
} |
| 310 |
}); |
| 311 |
} |
| 312 |
}); |
| 313 |
|
| 314 |
// Re-initialize for AJAX / dynamic content |
| 315 |
document.addEventListener("DOMContentLoaded", () => { |
| 316 |
// jQuery AJAX support for legacy themes |
| 317 |
if (typeof jQuery !== "undefined") { |
| 318 |
jQuery(document).on("ajaxComplete", (_event: any, xhr: any) => { |
| 319 |
if (xhr?.responseText?.includes("sliderberg-swiper")) { |
| 320 |
setTimeout(initializeSliders, 150); |
| 321 |
} |
| 322 |
}); |
| 323 |
} |
| 324 |
// Custom theme event |
| 325 |
document.addEventListener("content-updated", () => |
| 326 |
setTimeout(initializeSliders, 150), |
| 327 |
); |
| 328 |
}); |
| 329 |
|
| 330 |
// --- Public API --- |
| 331 |
|
| 332 |
window.SliderBerg = { |
| 333 |
init: initializeSliders, |
| 334 |
destroyAll, |
| 335 |
getInstance: (element: Element) => instances.get(element), |
| 336 |
getInstances: () => instances, |
| 337 |
next: (element: Element) => instances.get(element)?.slideNext(), |
| 338 |
prev: (element: Element) => instances.get(element)?.slidePrev(), |
| 339 |
goTo: (element: Element, index: number) => |
| 340 |
instances.get(element)?.slideTo(index), |
| 341 |
pause: (element: Element) => instances.get(element)?.autoplay?.stop(), |
| 342 |
play: (element: Element) => instances.get(element)?.autoplay?.start(), |
| 343 |
}; |
| 344 |
|