Project

General

Profile

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

Takashi Kato, 2026-03-25 16:54

View differences:

app/assets/stylesheets/application.css
416 416
  margin-block-end: 4px;
417 417
  overflow: hidden;
418 418
}
419
table.list.sticky thead {
420
  position: fixed;
421
  top: 0;
422
  z-index: 1;
423
}
419 424
table.list th, .table-list-header { background-color: var(--oc-gray-2); padding: 4px; white-space: nowrap; font-weight: bold; border-block-end: 2px solid var(--oc-gray-4); }
420 425
table.list th.whitespace-normal {white-space: normal;}
421 426
table.list td {text-align: center; vertical-align: middle; padding-block: 3px; padding-inline-end: 10px; border-block-start: 1px solid var(--oc-gray-4);}
......
428 433
table.list td.attachments span.attachment-filename a.icon-download {visibility: hidden;}
429 434
table.list td.attachments span.attachment-filename:hover a.icon-download {visibility: visible;}
430 435
table.list td.tick {inline-size: 15%}
431
table.list td.checkbox { inline-size: 15px; padding-block: 2px 0; padding-inline: 0;}
436
table.list td.checkbox { inline-size: 15px; padding-block: 4px; padding-inline: 0;}
432 437
table.list .checkbox input {padding: 0; block-size: initial;}
433 438
table.list td.buttons, div.buttons { white-space:nowrap; text-align: end; }
434 439
table.list td.buttons a, div.buttons a, table.list td.buttons span.icon-only { margin-inline-end: 0.6em; }
app/assets/stylesheets/responsive.css
867 867
    inset-block-start: 64px;
868 868
  }
869 869

  
870
  /* Offset sticky issue list headers below the fixed mobile header. */
871
  table.list.sticky thead {
872
    inset-block-start: 64px;
873
  }
874

  
870 875
  /* Prevent content from being hidden behind #sticky-issue-header and project-jump when scrolling via anchor links. */
871 876
  .controller-issues.action-show div.wiki a[name],
872 877
  .controller-issues.action-show #history div[id^="note-"],
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', 'source', '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 = Array.from(this.sourceTarget.children);
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
      rootMargin: `-${this.stickyTopOffset}px 0px 0px 0px`
50
    });
51

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

  
62
    this.intersectionObserver.observe(this.headTarget);
63
    this.resizeObserver.observe(this.headTarget);
64
  }
65

  
66
  syncWidth(e) {
67
    const headRect = this.headTarget.getBoundingClientRect()
68

  
69
    this.stickyHeader.style.insetInlineStart = `${headRect.left}px`
70
    this.stickyHeader.style.width = window.getComputedStyle(this.headTarget).width;
71
    this.bodyColumns.forEach((col, i) => {
72
      const style = window.getComputedStyle(col);
73
      if (!col.classList.contains('id') && !col.classList.contains('checkbox')) {
74
        col.style.width = style.width;
75
        this.stickyHeaderColumns[i].style.width = style.width;
76
        this.stickyHeaderColumns[i].style.padding = style.padding;
77
      }
78
    });
79
  }
80

  
81
  stickyValueChanged(value) {
82
    this.prepare()
83
    if (value) {
84
      this.headTarget.style.visibility = 'hidden';
85
      this.stickyTarget.style.display  = '';
86
      this.syncWidth();
87
    } else {
88
      this.headTarget.style.visibility = 'visible';
89
      this.stickyTarget.style.display  = 'none';
90
    }
91
  }
92

  
93
  get isIntersecting() {
94
    return this.headTarget.getBoundingClientRect().top > this.stickyTopOffset
95
  }
96

  
97
  get isHeaderOverflowX() {
98
    return this.element.scrollWidth > this.element.clientWidth;
99
  }
100

  
101
  get stickyTopOffset() {
102
    this.prepare()
103

  
104
    const top = window.getComputedStyle(this.stickyHeader).top
105
    return top === "auto" ? 0 : parseFloat(top)
106
  }
107
}
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
<% source_target = 'source' %>
10
<%= tag.div class: 'autoscroll', data: sticky_header && {controller: 'sticky-table-header', sticky_table_header_sticky_value: 'false'} do %>
11
<table class="list sticky" style="display:none" data-sticky-table-header-target="sticky">
12
</table>
8 13
<table class="list issues odd-even <%= query.css_classes %>">
9
  <thead>
14
  <thead data-sticky-table-header-target="head">
10 15
    <tr>
11 16
      <th class="checkbox hide-when-print">
12 17
        <%= check_box_tag 'check_all', '', false, :class => 'toggle-selection',
......
18 23
      <th class="buttons hide-when-print"></th>
19 24
    </tr>
20 25
  </thead>
21
  <tbody>
26
  <tbody data-sticky-table-header-target="body">
22 27
  <% grouped_issue_list(issues, query) do |issue, level, group_name, group_count, group_totals| -%>
23 28
  <% if group_name %>
24 29
    <% reset_cycle %>
......
35 40
      </td>
36 41
    </tr>
37 42
  <% end %>
38
  <tr id="issue-<%= issue.id %>" class="hascontextmenu <%= cycle('odd', 'even') %> <%= issue.css_classes %> <%= level > 0 ? "idnt idnt-#{level}" : nil %>">
43
  <tr <%= tag.attributes id: "issue-#{issue.id}",
44
                         class: ['hascontextmenu', cycle('odd', 'even'), issue.css_classes, "idnt idnt-#{level}": (level > 0)],
45
                         data: {sticky_table_header_target: source_target}%>>
39 46
    <td class="checkbox hide-when-print"><%= check_box_tag("ids[]", issue.id, false, :id => nil) %></td>
40 47
    <% query.inline_columns.each do |column| %>
41 48
    <%= content_tag('td', column_content(column, issue), :class => column.css_classes) %>
42 49
    <% end %>
43 50
    <td class="buttons hide-when-print"><%= link_to_context_menu %></td>
44 51
  </tr>
52
  <% source_target = nil %>
45 53
  <% query.block_columns.each do |column|
46 54
       if (text = column_content(column, issue)) && text.present? -%>
47 55
  <tr class="<%= current_cycle %>">
......
57 65
  <% end -%>
58 66
  </tbody>
59 67
</table>
60
</div>
68
<% end %>
61 69
<% 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

  
(5-5/5)