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