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つのスクリプトで構成されています:

  1. リンク属性変換スクリプト_selfクラスを持つリンクを自動でモーダル対応に変換
  2. モーダル表示スクリプト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プラグイン等での記事一覧からでもモーダル表示することができるようになります。

コードは完全に自己完結しており、外部ライブラリに依存しないため、軽量で導入も簡単です。カスタマイズ性も高く、様々なサイトのデザインに合わせて調整可能です。