Patch #43641 » 0001-Make-the-issues-table-header-sticky.patch
| 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 | |
- « Previous
- 1
- 2
- Next »