📜

Redmine ViewCustomize Plugin でチケット一覧のテーブルヘッダを固定する

に公開

この記事は、Redmine Advent Calendar 2025 の6日目の記事です。前日の記事は、Susumu Yamasakiさんの「Redmine6.1.0をmacOSにインストール」でした。

はじめに

まずは実際に動いているところを見てください。6.1 + Redmine View Customize Plugin でしか試していませんが、多分、6.0 や 5.1 でも動くと思います。

コードはこちらです。

ViewCustomize のパスのパターンは、/issues$ 、挿入位置は「全ページのヘッダ」としてください。

document.addEventListener('DOMContentLoaded', () => {
  const table = document.querySelector('table.list.issues'); 

  table.insertAdjacentHTML('beforebegin', '<table class="list sticky" style="display:none"></table>');
  const sticky = document.querySelector('table.list.sticky');

  const tableHeader = new StickyTableHeader(table, sticky);
})

class StickyTableHeader {
  constructor(table, sticky) {
    const head = table.querySelector('thead');
    const body = table.querySelector('tbody');
    this.element = table;
    this.sectionValue = 'query_form'
    this.head = head;
    this.bodyColumns = body.querySelectorAll('tr:first-child td');
    this.sticky = sticky;

    this.stickyHeader = this.head.cloneNode(true);
    this.sticky.appendChild(this.stickyHeader);
    this.stickyHeaderColumns = this.stickyHeader.querySelectorAll('tr th');
    this.stickyHeader.style.top = '0';

    if (!this.isIntersecting && !this.isHeaderOverflowX) {
      this.setSticky(true)
    }

    const section = document.getElementById(this.sectionValue);
    if (section !== null) {
      this.observe(section);
    }
  }

  observe(section) {
    this.intersectionObserver = new IntersectionObserver(entries => {
      entries.forEach(entry => {
        if (!entry.isIntersecting && !this.isHeaderOverflowX) {
            // console.log('1 => 3')
            this.setSticky(true)
        }
        if (entry.isIntersecting && !this.isHeaderOverflowX) {
            // console.log('3 => 1')
            this.setSticky(false)
        }
      })
    });

    this.resizeObserver = new ResizeObserver(() => {
      this.syncWidth();
      if (!this.isIntersecting && this.isHeaderOverflowX) {
        // console.log('3 => 4')
        this.setSticky(false)
      }
      if (!this.isIntersecting && !this.isHeaderOverflowX) {
        // console.log('4 => 3')
        this.setSticky(true)
      }
    });

    this.intersectionObserver.observe(section);
    this.resizeObserver.observe(this.head);
  }

  setSticky(value) {
    if (value) {
      this.setHeaderSticky();
    } else {
      this.clearHeaderSticky();
    }
  }

  syncWidth(e) {
    this.stickyHeader.style.width = window.getComputedStyle(this.head).width;
    this.bodyColumns.forEach((col, i) => {
      const style = window.getComputedStyle(col);
      if (!col.classList.contains('id') && !col.classList.contains('checkbox')) {
        col.style.width = style.width;
        this.stickyHeaderColumns[i].style.width = style.width;
        this.stickyHeaderColumns[i].style.padding = style.padding;
      }
    });
  }

  setHeaderSticky() {
    this.head.style.visibility = 'hidden';
    this.sticky.style.display  = '';
    this.stickyHeader.style.position = 'fixed';
    this.stickyHeader.style.zIndex   = '1';
  }

  clearHeaderSticky() {
    this.head.style.visibility = 'visible';
    this.sticky.style.display  = 'none';
  }

  get isIntersecting() {
    return this.element.getBoundingClientRect().top > 0;
  }

  get isHeaderOverflowX() {
    return this.element.parentElement.scrollWidth > this.element.parentElement.clientWidth;
  }
}

制限事項

  • ウィンドウ幅が狭く、チケット一覧に横スクロールバーが表示される状態ではヘッダは固定されません。
  • モバイルモードではヘッダは固定されません。

解説

Redmine 6.1 では、Feature #42684: Add a sticky header to keep the issue subject visible on scroll により、チケットの標題が画面上部に固定表示されるように改善されました。これと同様に、チケット一覧画面でもヘッダを固定する、というカスタマイズは、過去にも何種類か発表されてきました。

やり方によっては、画面のちらつきが目立ったり、ブラウザからパフォーマンス上の警告が出たりといったこともあったのですが、近年のブラウザの機能向上にともない、今回の実装ではそうした問題もなくヘッダ固定を実現できるようになったと思います。

ちなみに今回の実装、見る人が見れば分かると思いますが、ほぼそのまま stimulus のコントローラに変換できるものなので、改変して本家に投稿したいと思います。

ポイント1. ヘッダを固定するタイミングをどう検知するか?

IntersectionObserver を使用しました。これによって、scrollイベントが発生するたびに画面とtable要素の重なり具合を検知して、といったようなことをしてFirefoxから警告を受けなくても良くなります。

ポイント2. 横スクロールバーにどう対応するか?

これは対応を断念しました。この実装ではヘッダを画面に固定するとき、position: fixed を使用しています。これによりヘッダは表の親要素(横スクロールバーを表示するかどうかを決めている)とは関係のない要素として扱われるようになります。

横スクロールバーが表示されていない状態では、親要素の幅の変更にあわせてヘッダの各項目の幅を変更し続けるという形で見た目を維持しているのですが、横スクロールバーが表示され、その中でチケット一覧が横に移動するとき、ヘッダをそれに追従させることはできませんでした。

今回は、横スクロールバーが発生した時点でヘッダの固定を解除するようにしていますが、そのあたり対応しないと以下のようになってしまいます。

同じ理由でモバイルモードにも対応していません。

ポイント3. position: sticky 使わないの?

cssの position: fiexd に似たもので、position: sticky というものがあります。いかにもヘッダを固定するのに使えそうな名前ですが、今回の用途には合わず使っていません。

position: stickyは、固定したい要素の親要素を順番に見ていって、一番最初に見つかった「スクロールバーを出す可能性のある要素」に要素を固定させる機能です。親要素を順番に見ていって、「スクロールバーを出す可能性のある要素」が一つも見つからなかった場合に、画面(ビューポートという言い方をします)に固定されます。
現状の Redmine では、チケット一覧の表からビューポートに至るまでに「スクロールバーを出す可能性のある要素」が何個もあるので、position: stickyではヘッダの固定はできませんでした。

状態遷移表

そこまで複雑なことやってる訳でもないんだし、と何も考えずコード書いてたらいろいろバグを入れていたので素直に状態遷移表を書きました。

状態: 横スクロールバー 状態: 表の位置 イベント: 表示開始 イベント: 横スクロールバー表示の変化 イベント: 表と画面の交差状態が変化
1 なし 画面内 ヘッダを固定しない →2 ヘッダを固定
→3
2 あり 画面内 ヘッダを固定しない →1 →4
3 なし 画面上部と交差 ヘッダを固定する ヘッダ固定を解除
→4
ヘッダ固定を解除
→1
4 あり 画面上部と交差 ヘッダを固定しない ヘッダを固定
→3
→2

今後の課題 モバイルモードにどう対応するか?

参考になるかと思って、gmailのブラウザ版など眺めてみると、gmailでは画面のスクロールバーは表示させず、メール一覧、連絡先一覧などそれぞれの要素にスクロールバーを表示させる仕組みになっていました。

昔のブラウザはスクロールバーの表示自体がいかにも野暮ったく、できるだけ表示を避けたいと思ったものですが、近年のブラウザでは(昔にくらべれば)スクロールバーの表示も洗練されてきているので、こうした方向性もアリかもしれません。

Redmineの場合、すぐにそうした画面構成に変えようとすると色々とレイアウトの変更が必要になりそうですが、モバイルモードに限ってチケット一覧を画面の横幅いっぱいに表示した上で画面の縦スクロールバーを表示させず要素の縦スクロールバーを表示させる、というやり方であれば対応できそうです。

参考にした記事

SEじゃない人にウケが良かったRedmainのカスタマイズ
今回の記事の出発点になった記事です。感謝。

第14回/チケット一覧のヘッダーを画面外に出ないよう固定したい
Redmineパッチ会の記録。主要な検討事項はほぼ出ている。

チケット一覧のヘッダー行を固定
Lychee Redmine のコミュニティの方のカスタマイズ案。ヘッダを画面に固定せず、チケット一覧の親要素に固定させる、ということで上述のgmailの発想に近いです(position: stickyを使用しています)。モバイルモード改善の参考になると思います。

Discussion