JavaScript
/********************************************************************
* 1. 計算だけの純粋関数群(DOM / Swiper に依存しない)★テスト対象
********************************************************************/
/** breakpoints をマージした 1設定に正規化 */
function normalizeConfig(base, override) {
const bp = override || {};
const slidesPerView =
bp.slidesPerView !== undefined ? bp.slidesPerView : base.slidesPerView;
const slidesPerGroup =
bp.slidesPerGroup !== undefined ? bp.slidesPerGroup : base.slidesPerGroup;
const centeredSlides =
bp.centeredSlides !== undefined ? bp.centeredSlides : base.centeredSlides;
return { slidesPerView, slidesPerGroup, centeredSlides };
}
/**
* ある1つの設定(spv/spg/centered)について
* 「loop が破綻しにくい最低スライド数」を返す
*/
function getRequiredSlidesForConfig(config, autoVisibleEstimate) {
const isAuto = config.slidesPerView === 'auto';
const spvRaw = isAuto ? autoVisibleEstimate : config.slidesPerView;
const spv = typeof spvRaw === 'number' && spvRaw > 0 ? spvRaw : 1;
const spg = config.slidesPerGroup && config.slidesPerGroup > 0
? config.slidesPerGroup
: 1;
const centered = !!config.centeredSlides;
// 基本条件: totalSlides >= slidesPerView + slidesPerGroup
let min = spv + spg;
// centeredSlides 調整
if (centered && !isAuto) {
// slidesPerView が数値のとき:
// 左右に見切れを出すぶん +1 しつつ、「spv*2 + spg」くらいまで持ち上げる
const safeCentered = spv * 2 + spg;
min = Math.max(min + 1, safeCentered);
} else if (centered && isAuto) {
// auto のときは少し控えめに
min = Math.max(min + 1, spv * 2);
}
// auto のときは余裕を少し盛る
if (isAuto) {
const safeAuto = spv + spg * 2;
min = Math.max(min, safeAuto);
}
// この設定の中では slidesPerGroup で割り切れるように
const remainder = min % spg;
if (remainder !== 0) {
min += spg - remainder;
}
return min;
}
/** base + 全 breakpoints の設定リストを返す */
function getAllConfigs(swiperOptions) {
const base = normalizeConfig(swiperOptions, null);
const configs = [base];
const bps = swiperOptions.breakpoints || {};
Object.values(bps).forEach((bp) => {
configs.push(normalizeConfig(swiperOptions, bp));
});
return configs;
}
/**
* 全 breakpoints の中で
* 「必要枚数が最大のパターン」を取り、それより多く、
* かつ全 slidesPerGroup で割り切れるスライド数にそろえる
*/
function getGlobalRequiredSlides(swiperOptions, autoVisibleEstimate) {
const configs = getAllConfigs(swiperOptions);
let maxRequired = 0;
const groups = new Set();
configs.forEach((cfg) => {
const required = getRequiredSlidesForConfig(cfg, autoVisibleEstimate);
if (required > maxRequired) maxRequired = required;
const spg =
cfg.slidesPerGroup && cfg.slidesPerGroup > 0
? cfg.slidesPerGroup
: 1;
groups.add(spg);
});
// 最大 requiredSlides 以上かつ、全 slidesPerGroup で割り切れる値に持ち上げる
let globalRequired = maxRequired;
groups.forEach((spg) => {
if (spg <= 1) return;
const remainder = globalRequired % spg;
if (remainder !== 0) {
globalRequired += spg - remainder;
}
});
return globalRequired;
}
/** Swiper.realIndex → 「元スライド index」に変換するマッピング */
function mapRealIndexToLogical(realIndex, originalCount) {
if (originalCount <= 0) return 0;
return realIndex % originalCount;
}
/********************************************************************
* 2. DOM を触る処理(テスト時はモックしてもOKな層)
********************************************************************/
/** bullets を元スライド数ぶん生成して返す */
function createPaginationBullets(paginationEl, count) {
paginationEl.innerHTML = '';
const bullets = [];
for (let i = 0; i < count; i++) {
const bullet = document.createElement('button');
bullet.type = 'button';
bullet.className = 'swiper-pagination-bullet';
bullet.setAttribute('aria-label', `ページ ${i + 1} へ`);
paginationEl.appendChild(bullet);
bullets.push(bullet);
}
return bullets;
}
/** bullets の active を更新 */
function updatePaginationBullets(bullets, activeIndex) {
bullets.forEach((bullet, i) => {
bullet.classList.toggle(
'swiper-pagination-bullet-active',
i === activeIndex
);
});
}
/**
* 「今のグループサイズ」を Swiper から取る関数
*/
function getCurrentSlidesPerGroup(swiper) {
// v11 だと「最終的に解決された params」がここに入ってる
const spg = swiper.params.slidesPerGroup;
return typeof spg === 'number' && spg > 0 ? spg : 1;
}
/**
* bullet を「ページ数ぶん」生成する
*/
function createPaginationBullets(paginationEl, count) {
paginationEl.innerHTML = '';
const bullets = [];
for (let i = 0; i < count; i++) {
const bullet = document.createElement('button');
bullet.type = 'button';
bullet.className = 'swiper-pagination-bullet';
bullet.setAttribute('aria-label', `ページ ${i + 1} へ`);
paginationEl.appendChild(bullet);
bullets.push(bullet);
}
return bullets;
}
/********************************************************************
* 3. Swiper 初期化ラッパー
* - loop: true のときだけ事前クローン
* - destroy() 時にクローンを掃除
********************************************************************/
function initLoopFriendlySwiper(swiperEl, baseSwiperOptions) {
const wrapperEl = swiperEl.querySelector('.swiper-wrapper');
const paginationEl = swiperEl.querySelector('.swiper-pagination');
const originalSlides = Array.from(
wrapperEl.querySelectorAll('.swiper-slide')
);
const originalCount = originalSlides.length;
originalSlides.forEach((slide) => {
slide.dataset.duplicated = 'false';
});
const clonedSlides = [];
let swiper = null;
let bullets = [];
let currentGroupSize = 1;
const loopRequested = baseSwiperOptions.loop === true;
const shouldLoop = loopRequested && originalCount > 1;
// Swiperに渡すオプション
// pagination は全部こちらでやるので削除しておく
const swiperOptions = {
...baseSwiperOptions,
loop: shouldLoop,
breakpoints: baseSwiperOptions.breakpoints
? { ...baseSwiperOptions.breakpoints }
: undefined,
};
delete swiperOptions.pagination;
const AUTO_VISIBLE_ESTIMATE = 3;
// ★ loop:true のときだけ「事前クローン」する
if (shouldLoop) {
const requiredSlides = getGlobalRequiredSlides(
swiperOptions,
AUTO_VISIBLE_ESTIMATE
);
if (originalCount < requiredSlides) {
let currentCount = originalCount;
let i = 0;
while (currentCount < requiredSlides) {
const baseSlide = originalSlides[i % originalCount];
const clone = baseSlide.cloneNode(true);
clone.dataset.duplicated = 'true';
wrapperEl.appendChild(clone);
clonedSlides.push(clone);
i++;
currentCount++;
}
}
}
// Swiper 初期化
swiper = new Swiper(swiperEl, swiperOptions);
// ▼ 自前で prev/next ボタンを Swiper に紐づける
const prevBtn = swiperEl.querySelector('.swiper-button-prev');
const nextBtn = swiperEl.querySelector('.swiper-button-next');
// destroy 時に removeEventListener するためにハンドラを保持
let onPrevClick = null;
let onNextClick = null;
if (prevBtn) {
onPrevClick = () => {
if (!swiper) return;
swiper.slidePrev();
};
prevBtn.addEventListener('click', onPrevClick);
}
if (nextBtn) {
onNextClick = () => {
if (!swiper) return;
swiper.slideNext();
};
nextBtn.addEventListener('click', onNextClick);
}
// 現在の slidesPerGroup を Swiper から取得
function getCurrentSlidesPerGroup(swiperInstance) {
const spg = swiperInstance.params.slidesPerGroup;
return typeof spg === 'number' && spg > 0 ? spg : 1;
}
// bullets を再生成(ページ数 or スライド数ぶん)
function rebuildBullets() {
if (!swiper) return;
const group = getCurrentSlidesPerGroup(swiper);
currentGroupSize = group;
const pageCount =
group > 1 ? Math.ceil(originalCount / group) : originalCount;
bullets = createPaginationBullets(paginationEl, pageCount);
bullets.forEach((bullet, pageIndex) => {
bullet.addEventListener('click', () => {
if (!swiper) return;
const targetLogicalIndex = pageIndex * currentGroupSize;
if (shouldLoop) {
swiper.slideToLoop(targetLogicalIndex);
} else {
swiper.slideTo(targetLogicalIndex);
}
});
});
handleUpdateBullets();
}
// bullets の active を更新する
function handleUpdateBullets() {
if (!swiper || bullets.length === 0) return;
let logicalIndex;
if (shouldLoop) {
// loop:true のときは realIndex を mod で戻す
const realIndex = swiper.realIndex;
logicalIndex = mapRealIndexToLogical(realIndex, originalCount);
} else {
// loop:false のときは activeIndex を素直に使う
logicalIndex =
typeof swiper.activeIndex === 'number'
? swiper.activeIndex
: swiper.realIndex;
}
const group = currentGroupSize > 0 ? currentGroupSize : 1;
let pageIndex;
if (!shouldLoop && group > 1) {
// 非ループ + ページUIのとき、最後のページだけ特別扱い
const spvParam = swiper.params.slidesPerView;
const isAutoSpv = spvParam === 'auto';
const spvNumeric = isAutoSpv
? AUTO_VISIBLE_ESTIMATE
: typeof spvParam === 'number' && spvParam > 0
? spvParam
: 1;
const lastStart = Math.max(originalCount - spvNumeric, 0);
const pageCount = bullets.length;
if (logicalIndex >= lastStart && pageCount > 0) {
pageIndex = pageCount - 1; // 末尾ゾーンは全部「最後のページ」
} else {
pageIndex = Math.floor(logicalIndex / group);
}
} else {
// loop:true または group=1 のときは普通に割る
pageIndex = group > 1
? Math.floor(logicalIndex / group)
: logicalIndex;
}
updatePaginationBullets(bullets, pageIndex);
}
rebuildBullets();
swiper.on('slideChange', handleUpdateBullets);
swiper.on('resize', rebuildBullets);
swiper.on('breakpoint', rebuildBullets);
// 破棄時にクローンと bullets を掃除
function destroy() {
if (!swiper) return;
// ▼ 先にナビゲーションのイベントを外す
if (prevBtn && onPrevClick) {
prevBtn.removeEventListener('click', onPrevClick);
}
if (nextBtn && onNextClick) {
nextBtn.removeEventListener('click', onNextClick);
}
// Swiper 本体の destroy
swiper.destroy(true, true);
swiper = null;
// ループ用に自前で追加したクローンを削除
clonedSlides.forEach((node) => {
if (node.parentNode) node.parentNode.removeChild(node);
});
clonedSlides.length = 0;
// bullets の active をリセット(-1 で全 off)
updatePaginationBullets(bullets, -1);
}
return { swiper, destroy };
}
/********************************************************************
* 4. 実際の呼び出し(プロジェクト側から使う想定)
********************************************************************/
(function bootstrap() {
const swiperEl = document.getElementById('my-swiper');
// プロジェクト側で普通に Swiper オプションを定義
const baseSwiperOptions = {
loop: true, // ← ここを true/false 切り替えるだけでOK
slidesPerView: 1,
slidesPerGroup: 1,
centeredSlides: true,
spaceBetween: 16,
breakpoints: {
768: {
slidesPerView: 2,
slidesPerGroup: 1,
centeredSlides: true,
},
1024: {
slidesPerView: 3,
slidesPerGroup: 1,
centeredSlides: false,
},
},
// pagination は指定しない(全部このラッパー側でやる)
};
const controller = initLoopFriendlySwiper(swiperEl, baseSwiperOptions);
// デモ用に destroy / 再初期化ボタンを付けておく
const destroyBtn = document.getElementById('destroy-btn');
const initBtn = document.getElementById('init-btn');
destroyBtn.addEventListener('click', () => {
controller.destroy();
});
initBtn.addEventListener('click', () => {
controller.destroy();
controller = initLoopFriendlySwiper(swiperEl, baseSwiperOptions);
});
})();