Project

General

Profile

Patch #43976 » 0002-Add-tab-buttons-controller.patch

Takashi Kato, 2026-04-19 05:34

View differences:

app/assets/javascripts/application-legacy.js
487 487
  }
488 488
}
489 489

  
490
function moveTabRight(el) {
491
  var lis = $(el).parents('div.tabs').first().find('ul').children();
492
  var bw = $(el).parents('div.tabs-buttons').outerWidth(true);
493
  var tabsWidth = 0;
494
  var i = 0;
495
  lis.each(function() {
496
    if ($(this).is(':visible')) {
497
      tabsWidth += $(this).outerWidth(true);
498
    }
499
  });
500
  if (tabsWidth < $(el).parents('div.tabs').first().width() - bw) { return; }
501
  $(el).siblings('.tab-left').removeClass('disabled');
502
  while (i<lis.length && !lis.eq(i).is(':visible')) { i++; }
503
  var w = lis.eq(i).width();
504
  lis.eq(i).hide();
505
  if (tabsWidth - w < $(el).parents('div.tabs').first().width() - bw) {
506
    $(el).addClass('disabled');
507
  }
508
}
509

  
510
function moveTabLeft(el) {
511
  var lis = $(el).parents('div.tabs').first().find('ul').children();
512
  var i = 0;
513
  while (i < lis.length && !lis.eq(i).is(':visible')) { i++; }
514
  if (i > 0) {
515
    lis.eq(i-1).show();
516
    $(el).siblings('.tab-right').removeClass('disabled');
517
  }
518
  if (i <= 1) {
519
    $(el).addClass('disabled');
520
  }
521
}
522

  
523
function displayTabsButtons() {
524
  var lis;
525
  var tabsWidth;
526
  var el;
527
  var numHidden;
528
  $('div.tabs').each(function() {
529
    el = $(this);
530
    lis = el.find('ul').children();
531
    tabsWidth = 0;
532
    numHidden = 0;
533
    lis.each(function(){
534
      if ($(this).is(':visible')) {
535
        tabsWidth += $(this).outerWidth(true);
536
      } else {
537
        numHidden++;
538
      }
539
    });
540
    var bw = $(el).find('div.tabs-buttons').outerWidth(true);
541
    if ((tabsWidth < el.width() - bw) && (lis.length === 0 || lis.first().is(':visible'))) {
542
      el.find('div.tabs-buttons').hide();
543
    } else {
544
      el.find('div.tabs-buttons').show().children('button.tab-left').toggleClass('disabled', numHidden == 0);
545
    }
546
  });
547
}
548

  
549 490
function setPredecessorFieldsVisibility() {
550 491
  var relationType = $('#relation_relation_type');
551 492
  if (relationType.val() == "precedes" || relationType.val() == "follows") {
......
1082 1023
  });
1083 1024
}
1084 1025

  
1085
function setupTabs() {
1086
  if($('.tabs').length > 0) {
1087
    displayTabsButtons();
1088
    $(window).resize(displayTabsButtons);
1089
  }
1090
}
1091

  
1092 1026
function setupFilePreviewNavigation() {
1093 1027
  // only bind arrow keys when preview navigation is present
1094 1028
  const element = $('.pagination.filepreview').first();
......
1521 1455
$(document).ready(addFormObserversForDoubleSubmit);
1522 1456
$(document).ready(defaultFocus);
1523 1457
$(document).ready(setupAttachmentDetail);
1524
$(document).ready(setupTabs);
1525 1458
$(document).ready(setupFilePreviewNavigation);
1526 1459
$(document).on('focus', '[data-auto-complete=true]', function(event) {
1527 1460
  inlineAutoComplete(event.target);
app/assets/stylesheets/application.css
195 195
#main-menu ul {
196 196
  margin: 0;
197 197
  padding: 0;
198
  inline-size: 100%;
198
  width: max-content;
199 199
  min-block-size: 28px;
200 200
  white-space: nowrap;
201 201
  display: flex;
......
1716 1716
.version-overview table.progress td { block-size: 1.2em; }
1717 1717

  
1718 1718
/***** Tabs *****/
1719
#content .tabs {block-size: 2.6em; margin-block-end: 1.2em; position: relative; overflow: hidden;}
1719
#content .tabs {block-size: 2.6em; margin-block-end: 1.2em; position: relative; overflow: hidden; border-block-end: 1px solid var(--oc-gray-4)}
1720 1720
#content .tabs ul {
1721 1721
  margin: 0;
1722 1722
  position: absolute;
1723
  inset-block-end:0;
1723
  inset-block-end: 0;
1724 1724
  padding-inline-start: 0.5em;
1725
  min-inline-size: 2000px;
1726
  inline-size: 100%;
1727
  border-block-end: 1px solid var(--oc-gray-4);
1725
  width: max-content;
1728 1726
}
1729 1727
#content .tabs ul li {
1730 1728
  float:inline-start;
app/javascript/controllers/tab_buttons_controller.js
1
import { Controller } from "@hotwired/stimulus"
2

  
3
// Connects to data-controller="tab-buttons"
4
export default class extends Controller {
5
  static targets = ['list', 'buttons']
6
  static values  = {showButton: Boolean}
7

  
8
  connect() {
9
    this.intersectionObserver = new IntersectionObserver(entries => {
10
      entries.forEach(entry => {
11
        if (entry.isIntersecting && this.showButtonValue) {
12
          this.showButtonValue = false
13
        }
14
        if (!entry.isIntersecting && !this.showButtonValue) {
15
          this.showButtonValue = true
16
        }
17
      })
18
    }, {
19
      root: this.element,
20
      threshold: 0.99
21
    });
22
    this.intersectionObserver.observe(this.listTarget);
23
  }
24

  
25
  disconnect() {
26
    this.intersectionObserver?.disconnect();
27
  }
28

  
29
  right(e) {
30
    const el = e.currentTarget;
31
    const func = (total, el) => isVisible(el) ? total + outerWidth(el)
32
                                              : total;
33
    const visibleTabsWidth = this.items.reduce(func, 0)
34
    const availableWidth   = this.element.getBoundingClientRect().width - outerWidth(this.buttonsTarget);
35

  
36
    if (visibleTabsWidth < availableWidth) return;
37

  
38
    this.enableButton(el, '.tab-left')
39

  
40
    const i = this.firstVisibleIndex;
41
    if (i === -1) return;
42

  
43
    const w = outerWidth(this.items[i]);
44
    this.items[i].style.display = 'none'
45

  
46
    if (visibleTabsWidth - w < availableWidth) {
47
      el.classList.add('disabled');
48
    }
49
  }
50

  
51
  left(e) {
52
    const el = e.currentTarget;
53
    const i = this.firstVisibleIndex;
54

  
55
    if (i > 0) {
56
      this.items[i - 1].style.display = ''
57
      this.enableButton(el, '.tab-right')
58
    }
59

  
60
    if (i <= 1) {
61
      el.classList.add('disabled');
62
    }
63
  }
64

  
65
  #items;
66

  
67
  get items() {
68
    if (typeof this.#items === 'undefined') {
69
      this.#items = Array.from(this.listTarget.children);
70
    }
71
    return this.#items
72
  }
73

  
74
  showButtonValueChanged(value) {
75
    if (value) {
76
      this.buttonsTarget.style.display = ''
77
    } else {
78
      if (this.items.every(e => e.style.display === '')) {
79
        this.buttonsTarget.style.display = 'none'
80
      }
81
    }
82
  }
83

  
84
  enableButton(el, selector) {
85
    const siblings = [...el.parentNode.children].filter((child) => child.matches(selector) && child !== el);
86
    siblings.forEach(el => el.classList.remove('disabled'))
87
  }
88

  
89
  get firstVisibleIndex() {
90
    return this.items.findIndex(item => isVisible(item))
91
  }
92
}
93

  
94
function outerWidth(el) {
95
  const style = getComputedStyle(el);
96

  
97
  return (
98
    el.getBoundingClientRect().width +
99
    parseFloat(style.marginLeft) +
100
    parseFloat(style.marginRight)
101
  );
102
}
app/views/common/_tabs.html.erb
1 1
<% default_action = false %>
2 2

  
3
<div class="tabs">
4
  <ul>
3
<div class="tabs" data-controller="tab-buttons">
4
  <ul data-tab-buttons-target="list">
5 5
  <% tabs.each do |tab| -%>
6 6
    <% action = get_tab_action(tab) %>
7 7
    <li><%= link_to l(tab[:label]), (tab[:url] || { :tab => tab[:name] }),
......
11 11
    <% default_action = action if tab[:name] == selected_tab %>
12 12
  <% end -%>
13 13
  </ul>
14
  <div class="tabs-buttons" style="display:none;">
15
    <button class="tab-left icon-only" type="button" onclick="moveTabLeft(this);">
14
  <div class="tabs-buttons" style="display:none;" data-tab-buttons-target="buttons">
15
    <button class="tab-left icon-only" type="button" data-action="tab-buttons#left">
16 16
      <%= sprite_icon("angle-left", rtl: true) %>
17 17
    </button>
18
    <button class="tab-right icon-only" type="button" onclick="moveTabRight(this);">
18
    <button class="tab-right icon-only" type="button" data-action="tab-buttons#right">
19 19
      <%= sprite_icon("angle-right", rtl: true) %>
20 20
    </button>
21 21
  </div>
app/views/layouts/base.html.erb
89 89
    <h1><%= page_header_title %></h1>
90 90

  
91 91
    <% if display_main_menu?(@project) %>
92
    <div id="main-menu" class="tabs">
92
    <div id="main-menu" class="tabs" data-controller="tab-buttons">
93 93
        <%= render_main_menu(@project) %>
94
        <div class="tabs-buttons" style="display:none;">
95
            <button class="tab-left icon-only" onclick="moveTabLeft(this); return false;">
94
        <div class="tabs-buttons" style="display:none;" data-tab-buttons-target="buttons">
95
            <button class="tab-left icon-only" type="button" data-action="tab-buttons#left">
96 96
              <%= sprite_icon("angle-left", rtl: true) %>
97 97
            </button>
98
            <button class="tab-right icon-only" onclick="moveTabRight(this); return false;">
98
            <button class="tab-right icon-only" type="button" data-action="tab-buttons#right">
99 99
              <%= sprite_icon("angle-right", rtl: true) %>
100 100
            </button>
101 101
        </div>
lib/redmine/menu_manager.rb
101 101
      # Renders the application main menu
102 102
      def render_main_menu(project)
103 103
        if menu_name = controller.current_menu(project)
104
          render_menu(menu_name, project)
104
          render_menu(menu_name, project, data: {tab_buttons_target: 'list'})
105 105
        end
106 106
      end
107 107

  
......
110 110
        menu_name.present? && Redmine::MenuManager.items(menu_name).children.present?
111 111
      end
112 112

  
113
      def render_menu(menu, project=nil)
113
      def render_menu(menu, project=nil, options={})
114 114
        links = []
115 115
        menu_items_for(menu, project) do |node|
116 116
          links << render_menu_node(node, project)
117 117
        end
118
        links.empty? ? nil : content_tag('ul', links.join.html_safe)
118
        links.empty? ? nil : content_tag('ul', links.join.html_safe, options)
119 119
      end
120 120

  
121 121
      def render_menu_node(node, project=nil)
(2-2/3)