Project

General

Profile

Patch #43641 » 0001-Make-the-issues-table-header-sticky.patch

Takashi Kato, 2026-01-02 01:38

View differences:

app/assets/stylesheets/application.css
363 363
  margin-bottom: 4px;
364 364
  overflow: hidden;
365 365
}
366
table.list.sticky thead {
367
  position: fixed;
368
  top: 0;
369
  z-index: 1;
370
}
366 371
table.list th, .table-list-header { background-color:var(--oc-gray-2); padding: 4px; white-space:nowrap; font-weight:bold; border-bottom: 2px solid var(--oc-gray-4); }
367 372
table.list th.whitespace-normal {white-space: normal;}
368 373
table.list td {text-align:center; vertical-align:middle; padding-top: 3px; padding-right: 10px; padding-bottom: 3px; border-top: 1px solid var(--oc-gray-4);}
......
375 380
table.list td.attachments span.attachment-filename a.icon-download {visibility: hidden;}
376 381
table.list td.attachments span.attachment-filename:hover a.icon-download {visibility: visible;}
377 382
table.list td.tick {width:15%}
378
table.list td.checkbox { width: 15px; padding: 2px 0 0 0; }
383
table.list td.checkbox { width: 15px; padding: 4px; }
379 384
table.list .checkbox input {padding:0px; height: initial;}
380 385
table.list td.buttons, div.buttons { white-space:nowrap; text-align: right; }
381 386
table.list td.buttons a, div.buttons a, table.list td.buttons span.icon-only { margin-right: 0.6em; }
app/javascript/controllers/sticky_table_header_controller.js
1
/**
2
 * Redmine - project management software
3
 * Copyright (C) 2006-  Jean-Philippe Lang
4
 * This code is released under the GNU General Public License.
5
 */
6
import { Controller } from "@hotwired/stimulus"
7

  
8
// Connects to data-controller="sticky-table-header"
9
export default class extends Controller {
10
  static targets = ['head', 'body', 'sticky']
11
  static values = {sticky: Boolean}
12

  
13
  connect() {
14
    if (!this.isIntersecting && !this.isHeaderOverflowX) {
15
      this.stickyValue = true;
16
    }
17

  
18
    this.observe();
19
  }
20

  
21
  prepare() {
22
    if (!!this.prepared === false) {
23
      this.bodyColumns = this.bodyTarget.querySelectorAll('tr:first-child td');
24

  
25
      this.stickyHeader = this.headTarget.cloneNode(true);
26
      this.stickyHeader.removeAttribute(`data-${this.identifier}-target`)
27
      this.stickyTarget.appendChild(this.stickyHeader);
28
      this.stickyHeaderColumns = this.stickyHeader.querySelectorAll('tr th');
29
      this.prepared = true
30
    }
31
 }
32

  
33
  disconnect() {
34
    this.intersectionObserver?.disconnect();
35
    this.resizeObserver?.disconnect();
36
  }
37

  
38
  observe() {
39
    this.intersectionObserver = new IntersectionObserver(entries => {
40
      entries.forEach(entry => {
41
        if (!entry.isIntersecting && !this.isHeaderOverflowX) {
42
          this.stickyValue = true;
43
        }
44
        if (entry.isIntersecting && !this.isHeaderOverflowX) {
45
          this.stickyValue = false;
46
        }
47
      })
48
    });
49

  
50
    this.resizeObserver = new ResizeObserver(() => {
51
      if (!this.isIntersecting && this.isHeaderOverflowX) {
52
        this.stickyValue = false;
53
      }
54
      if (!this.isIntersecting && !this.isHeaderOverflowX) {
55
        this.stickyValue = true;
56
      }
57
      this.syncWidth();
58
    });
59

  
60
    this.intersectionObserver.observe(this.headTarget);
61
    this.resizeObserver.observe(this.headTarget);
62
  }
63

  
64
  syncWidth(e) {
65
    this.stickyHeader.style.width = window.getComputedStyle(this.headTarget).width;
66
    this.bodyColumns.forEach((col, i) => {
67
      const style = window.getComputedStyle(col);
68
      if (!col.classList.contains('id') && !col.classList.contains('checkbox')) {
69
        col.style.width = style.width;
70
        this.stickyHeaderColumns[i].style.width = style.width;
71
        this.stickyHeaderColumns[i].style.padding = style.padding;
72
      }
73
    });
74
  }
75

  
76
  stickyValueChanged(value) {
77
    this.prepare()
78
    if (value) {
79
      this.headTarget.style.visibility = 'hidden';
80
      this.stickyTarget.style.display  = '';
81
      this.syncWidth();
82
    } else {
83
      this.headTarget.style.visibility = 'visible';
84
      this.stickyTarget.style.display  = 'none';
85
    }
86
  }
87

  
88
  get isIntersecting() {
89
    return this.headTarget.getBoundingClientRect().top > 0;
90
  }
91

  
92
  get isHeaderOverflowX() {
93
    return this.element.scrollWidth > this.element.clientWidth;
94
  }
95
}
app/views/issues/_list.html.erb
1
<% sticky_header = false unless defined?(sticky_header) %><%# Disable sticky table headers in the mypage block.  %>
1 2
<% query_options = nil unless defined?(query_options) %>
2 3
<% query_options ||= {} %>
3 4

  
4 5
<%= form_tag({}, :data => {:cm_url => issues_context_menu_path}) do -%>
5 6
<%= hidden_field_tag 'back_url', url_for(:params => request.query_parameters), :id => nil %>
6 7
<%= query_columns_hidden_tags(query) %>
7
<div class="autoscroll">
8

  
9
<%= tag.div class: 'autoscroll', data: sticky_header && {controller: 'sticky-table-header', sticky_table_header_sticky_value: 'false'} do %>
10
<table class="list sticky" style="display:none" data-sticky-table-header-target="sticky">
11
</table>
8 12
<table class="list issues odd-even <%= query.css_classes %>">
9
  <thead>
13
  <thead data-sticky-table-header-target="head">
10 14
    <tr>
11 15
      <th class="checkbox hide-when-print">
12 16
        <%= check_box_tag 'check_all', '', false, :class => 'toggle-selection',
......
18 22
      <th class="buttons hide-when-print"></th>
19 23
    </tr>
20 24
  </thead>
21
  <tbody>
25
  <tbody data-sticky-table-header-target="body">
22 26
  <% grouped_issue_list(issues, query) do |issue, level, group_name, group_count, group_totals| -%>
23 27
  <% if group_name %>
24 28
    <% reset_cycle %>
......
53 57
  <% end -%>
54 58
  </tbody>
55 59
</table>
56
</div>
60
<% end %>
57 61
<% end -%>
app/views/issues/index.html.erb
30 30
<p class="nodata"><%= l(:label_no_data) %></p>
31 31
<% else %>
32 32
<%= render_query_totals(@query) %>
33
<%= render :partial => 'issues/list', :locals => {:issues => @issues, :query => @query} %>
33
<%= render 'issues/list', issues: @issues, query: @query, sticky_header: true %>
34 34
<span class="pagination"><%= pagination_links_full @issue_pages, @issue_count %></span>
35 35
<% end %>
36 36

  
(2-2/2)