Webサイトで内部ページへのリンクをクリックした際、新しいページに遷移するのではなく、モーダル(ポップアップウィンドウ)で表示する仕組みを作成しました。WordPressのContent Viewsプラグインなど、記事一覧からの詳細表示に特に有効です。
使用例・想定される用途
1. ポートフォリオサイトでの作品詳細表示
<div class="pt-cv-view target-modal" data-modal-width="800" data-modal-height="500">
<a href="/works/post-001/" class="_self">プロジェクト詳細を見る</a>
</div>
2. ブログ記事の概要からの詳細表示
- 記事一覧ページで概要を表示
- 「続きを読む」リンクをモーダルで開く
- ユーザーは元のページを離れることなく詳細を確認可能
3. 商品カタログでの商品詳細
- 商品一覧からの詳細情報表示
- スペック表や詳細画像をモーダルで表示
- カタログ閲覧の流れを中断しない
仕組みの概要
このシステムは2つのスクリプトで構成されています:
- リンク属性変換スクリプト:
_selfクラスを持つリンクを自動でモーダル対応に変換 - モーダル表示スクリプト:
target="_modal"のリンクをモーダル表示
1. リンク属性変換スクリプト
//DOMContentLoaded後に実行
document.addEventListener('DOMContentLoaded', function () {
//設定オブジェクト
const config = {
//対象となるコンテナセレクタ
containerSelector: '.pt-cv-view.target-modal',
//対象リンクのセレクタパターン(クラス名ベース)
linkSelector: 'a[href][class*="_self"]',
//デフォルトのモーダルサイズ
defaultWidth: '800',
defaultHeight: '500',
//コンテナ別の個別設定(data属性で上書き可能)
//data-modal-width, data-modal-height でコンテナごとに設定可能
containerSpecificSettings: true
};
//.pt-cv-view.target-modal 内の処理
const targetModalViews = document.querySelectorAll(config.containerSelector);
targetModalViews.forEach(function(view) {
//コンテナ固有の設定を取得
const containerWidth = view.getAttribute('data-modal-width') || config.defaultWidth;
const containerHeight = view.getAttribute('data-modal-height') || config.defaultHeight;
//対象リンクを検索(クラス名ベース)
const targetLinks = view.querySelectorAll(config.linkSelector);
targetLinks.forEach(function(link) {
const href = link.getAttribute('href');
//hrefが存在し、_selfクラスを持つリンクを対象
if (href && link.classList.contains('_self')) {
//既にtarget="_modal"が設定されていない場合のみ処理
if (link.getAttribute('target') !== '_modal') {
//target="_modal" を追加
link.setAttribute('target', '_modal');
//モーダルサイズを設定
link.setAttribute('data-popwidth', containerWidth);
link.setAttribute('data-popheight', containerHeight);
//デバッグ用ログ
console.log('Modal attributes added:', {
url: href,
width: containerWidth,
height: containerHeight,
className: link.className
});
}
}
});
console.log(`Processed ${view.querySelectorAll('a[target="_modal"]').length} modal links in container`);
});
});
2. モーダル表示スクリプト
//DOMContentLoaded後に実行
document.addEventListener('DOMContentLoaded', function () {
//_link-openModal.js
const arrModalLinks = Array.prototype.slice.call(document.querySelectorAll('[href][target="_modal"]:not([href*="#"])'));
//モーダル用のスタイルを動的に追加
const modalStyle = document.createElement('style');
modalStyle.textContent = `
/* linkModal:CSS
-------------------------------------- */
.linkModal.linkModal-container {
position: fixed;
z-index: 9999;
/* inset: 上 右 下 左 (ヘッダー分調整) */
inset: calc(var(--⅝fem) * 5) 0 0 0;
margin: auto;
/* Grid */
display: grid;
place-content: center;
place-items: center;
transition: opacity 0.3s ease-in;
max-width: 100%;
max-height: 100%;
opacity: 0;
}
/* オーバーレイ */
.linkModal.is-opened::before {
content: "";
position: fixed;
z-index: -1;
display: block;
top: 0px;
bottom: 0px;
left: 0px;
right: 0px;
margin: auto;
width: 100%;
height: 100%;
background: hsla(0, 0%, 0%, 0.7);
}
.linkModal.linkModal-container.is-opened {
opacity: 1;
pointer-events: auto;
}
.linkModal .linkModal-wrapper {
position: relative;
overflow: hidden;
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
background: var(--c-base, hsl(223, 6%, 100%));
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.linkModal-header {
position: relative;
z-index: 10;
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid #eee;
background: #f8f9fa;
flex-shrink: 0;
}
.linkModal .linkModal-title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 1.125rem;
font-weight: 600;
color: var(--c-silver, hsl(223, 6%, 75%));
}
.linkModal-content {
padding: 1.5rem;
overflow-y: auto;
flex: 1;
min-height: 0;
position: relative;
}
/* モーダル内コンテンツの基本スタイル */
.linkModal-content * {
max-width: 100% !important;
box-sizing: border-box !important;
}
.linkModal-content img {
width: auto !important;
height: auto !important;
max-width: 100% !important;
display: block !important;
margin: 0 auto !important;
}
.linkModal-content p,
.linkModal-content div {
line-height: 1.6 !important;
margin-bottom: 1rem !important;
}
.linkModal-loading {
text-align: center;
padding: 3rem 2rem;
color: #666;
font-size: 1rem;
}
.linkModal-error {
text-align: center;
padding: 3rem 2rem;
color: #dc3545;
font-size: 1rem;
line-height: 1.5;
}
/* モーダルを閉じるボタン */
.linkModal.is-opened .linkModal-closer {
position: absolute;
top: -0.3em;
right: 0.3em;
margin: auto;
display: inline-flex;
justify-content: center;
align-items: center;
width: 0;
height: 0;
font-size: 2pc;
color: #FFF;
}
.linkModal.is-opened .linkModal-closer::before {
position: relative;
top: 0px;
display: flex;
justify-content: center;
align-items: center;
width: 0;
height: 0;
font-size: 100%;
/* Material Symbols */
font-family: 'Material Symbols Sharp';
font-variation-settings:
'FILL' 0,
'wght' 200;
content: "\\e5cd";
}
body.is-dialoged {
overflow: hidden;
}
`;
document.head.appendChild(modalStyle);
//既存のモーダルを削除する関数
function removeExistingModal() {
const existingModal = document.querySelector('.linkModal-container');
if (existingModal) {
existingModal.remove();
}
}
//モーダルを作成する関数
function createModal(width, height, title) {
const modalHTML = `
<div class="linkModal linkModal-container" style="width:${width}px; height:${height}px;">
<div class="linkModal-wrapper">
<div class="linkModal-header">
<h3 class="linkModal-title">${title}</h3>
</div>
<div class="linkModal-content">
<div class="linkModal-loading">読み込み中...</div>
</div>
</div>
<button class="linkModal-closer" aria-label="閉じる"></button>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
return document.querySelector('.linkModal-container:last-child');
}
//HTMLからbodyの内容とタイトルを抽出する関数(改良版)
function extractContent(html) {
console.log('Original HTML length:', html.length);
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
//タイトルを取得してサイト名をトリム
let cleanTitle = '';
const titleElement = doc.querySelector('title');
if (titleElement) {
const fullTitle = titleElement.textContent.trim();
console.log('Original title:', fullTitle);
const siteName = '- [サイト名.jp]';
cleanTitle = fullTitle.replace(siteName, '').trim();
console.log('Clean title:', cleanTitle);
}
//bodyの内容を取得
const bodyContent = doc.body;
let bodyHTML = '';
if (bodyContent) {
//不要な要素を削除
const elementsToRemove = [
'script',
'noscript',
'style',
'#wpadminbar',
'.header',
'.trunker .flanker',
'.footer',
'.breadcrumb',
'.post-edit-link',
'.prxtPost',
'.veu_contentAddSection',
'.pageBtn-group'
];
elementsToRemove.forEach(selector => {
const elements = bodyContent.querySelectorAll(selector);
elements.forEach(el => {
console.log('Removing element:', el.tagName, el.className);
el.remove();
});
});
bodyHTML = bodyContent.innerHTML;
console.log('Processed content length:', bodyHTML.length);
console.log('Content preview:', bodyHTML.substring(0, 500));
}
return {
title: cleanTitle,
content: bodyHTML || html
};
}
//コンテンツを読み込む関数(改良版)
async function loadContent(url) {
console.log('Loading content from:', url);
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const text = await response.text();
console.log('Response received, length:', text.length);
const extracted = extractContent(text);
//コンテンツが空の場合のフォールバック
if (!extracted.content || extracted.content.trim().length === 0) {
console.log('Content is empty, using fallback');
return {
title: extracted.title,
content: `
<div style="padding: 2rem; text-align: center;">
<p>コンテンツを表示できませんでした。</p>
<a href="${url}" target="_blank" rel="noopener">新しいタブで開く</a>
</div>
`
};
}
return extracted;
} catch (error) {
console.error('Content loading error:', error);
return {
title: 'エラー',
content: `
<div class="linkModal-error">
<strong>コンテンツの読み込みに失敗しました</strong><br>
エラー: ${error.message}<br><br>
<a href="${url}" target="_blank" rel="noopener">新しいタブで開く</a>
</div>
`
};
}
}
//モーダルを閉じる関数
function closeModal(modal) {
modal.classList.remove('is-opened');
document.body.classList.remove('is-dialoged');
setTimeout(() => {
modal.remove();
}, 300);
}
//各リンクにイベントリスナーを追加
arrModalLinks.forEach(function (modalLink) {
modalLink.addEventListener('click', async function (e) {
e.preventDefault();
console.log('Modal link clicked:', this.href);
//既存のモーダルを削除
removeExistingModal();
//data属性からサイズを取得(なければデフォルト値)
const popWidth = parseInt(modalLink.getAttribute('data-popwidth')) || 800;
const popHeight = parseInt(modalLink.getAttribute('data-popheight')) || 600;
//仮のタイトルでモーダルを作成
const tempTitle = '';
console.log('Creating modal:', { width: popWidth, height: popHeight, title: tempTitle });
//モーダルを作成
const modal = createModal(popWidth, popHeight, tempTitle);
const contentContainer = modal.querySelector('.linkModal-content');
//モーダルを表示
document.body.classList.add('is-dialoged');
setTimeout(() => {
modal.classList.add('is-opened');
}, 10);
//コンテンツを非同期で読み込み
try {
const result = await loadContent(this.href);
console.log('Setting content to modal');
contentContainer.innerHTML = result.content;
//リンク先のタイトルでモーダルタイトルを更新
const modalTitleElement = modal.querySelector('.linkModal-title');
if (modalTitleElement && result.title) {
modalTitleElement.textContent = result.title;
console.log('Updated modal title to:', result.title);
}
//読み込み完了後に画像の遅延読み込みを無効化
const lazyImages = contentContainer.querySelectorAll('img[loading="lazy"]');
lazyImages.forEach(img => {
img.removeAttribute('loading');
});
console.log('Content loaded successfully');
} catch (error) {
console.error('Error loading content:', error);
contentContainer.innerHTML = `
<div class="linkModal-error">
<strong>コンテンツの読み込みに失敗しました</strong><br>
エラー: ${error.message}<br><br>
<a href="${this.href}" target="_blank" rel="noopener">新しいタブで開く</a>
</div>
`;
}
//閉じるボタンのイベントリスナー
const closeBtn = modal.querySelector('.linkModal-closer');
closeBtn.addEventListener('click', () => {
closeModal(modal);
});
//背景クリックで閉じる
modal.addEventListener('click', function (e) {
if (e.target === modal) {
closeModal(modal);
}
});
//ESCキーで閉じる
const escHandler = function (e) {
if (e.key === 'Escape') {
closeModal(modal);
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
});
});
console.log(`_link-openModal.js: ${arrModalLinks.length} modal links initialized`);
});
コードの詳細解説
1. 対象リンクの自動検出と変換
const config = {
containerSelector: '.pt-cv-view.target-modal',
linkSelector: 'a[href][class*="_self"]',
defaultWidth: '800',
defaultHeight: '500'
};
設定オブジェクトで対象範囲とデフォルトサイズを定義します。.target-modalクラスを持つコンテナ内の_selfクラス付きリンクが対象となります。
targetLinks.forEach(function(link) {
if (href && link.classList.contains('_self')) {
link.setAttribute('target', '_modal');
link.setAttribute('data-popwidth', containerWidth);
link.setAttribute('data-popheight', containerHeight);
}
});
対象リンクにtarget="_modal"属性とサイズ情報を自動付与します。
2. モーダルのスタイリング
const modalStyle = document.createElement('style');
modalStyle.textContent = `
.linkModal.linkModal-container {
position: fixed;
z-index: 9999;
inset: calc(var(--⅝fem) * 5) 0 0 0;
/* ... */
}
`;
document.head.appendChild(modalStyle);
CSS-in-JSでモーダルのスタイルを動的に追加。外部CSSファイル不要で完全に自己完結しています。
3. コンテンツの取得と処理
async function loadContent(url) {
const response = await fetch(url);
const text = await response.text();
const extracted = extractContent(text);
return extracted;
}
fetch()APIでリンク先のHTMLを非同期取得します。
function extractContent(html) {
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
// タイトル抽出とサイト名トリム
const titleElement = doc.querySelector('title');
const siteName = '- [サイト名.jp]';
cleanTitle = fullTitle.replace(siteName, '').trim();
// 不要要素の削除
const elementsToRemove = [
'#wpadminbar', '.header', '.footer', '.breadcrumb'
];
}
HTMLをパースし、タイトルからサイト名を除去、不要な要素(ヘッダー、フッター等)を自動削除してコンテンツのみを抽出します。
カスタマイズ方法
1. デザインの変更
スタイル部分を修正してデザインをカスタマイズできます:
modalStyle.textContent = `
.linkModal-wrapper {
background: #f0f0f0; /* 背景色変更 */
border-radius: 12px; /* 角丸調整 */
box-shadow: 0 8px 32px rgba(0,0,0,0.3); /* 影の調整 */
}
.linkModal-header {
background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); /* グラデーション */
color: white;
}
`;
2. サイト名の変更
タイトル処理部分でサイト名を変更:
const siteName = '- [サイト名.jp]'; // ここを変更
cleanTitle = fullTitle.replace(siteName, '').trim();
3. 不要要素の追加削除
削除対象要素をカスタマイズ:
const elementsToRemove = [
'#wpadminbar',
'.header',
'.footer',
'.sidebar', // サイドバー追加
'.comments', // コメント欄追加
'.related-posts' // 関連記事追加
];
4. 対象リンクの条件変更
クラス名ベースではなく、URL パターンで判定したい場合:
// 現在の方式(クラス名ベース)
linkSelector: 'a[href][class*="_self"]',
// URL パターンベースに変更
const urlPattern = /\/post-\d+\/$/;
if (href && urlPattern.test(href)) {
// 処理
}
5. モーダルサイズの個別設定
コンテナごとに異なるサイズを設定:
<!-- 大きなモーダル -->
<div class="pt-cv-view target-modal" data-modal-width="1200" data-modal-height="800">
<!-- リンクここ -->
</div>
<!-- 小さなモーダル -->
<div class="pt-cv-view target-modal" data-modal-width="600" data-modal-height="400">
<!-- リンクここ -->
</div>
実装時の注意点
同一オリジン制限
このスクリプトは同一ドメイン内のページにのみ対応しています。外部サイトを表示したい場合は<iframe>を使用する必要があります。
まとめ
この実装により、WordPressサイトや静的サイトで簡単にモーダル表示機能を追加できます。また、Content Viewsプラグイン等での記事一覧からでもモーダル表示することができるようになります。
コードは完全に自己完結しており、外部ライブラリに依存しないため、軽量で導入も簡単です。カスタマイズ性も高く、様々なサイトのデザインに合わせて調整可能です。

