From 9f77b46e055dcc491b0fa84eacbc6e9f2475d2ce Mon Sep 17 00:00:00 2001
From: tohosaku <tohosaku@cosmichorror.org>
Date: Thu, 16 Apr 2026 12:50:54 +0000
Subject: [PATCH 2/3] Add tab buttons controller

---
 app/assets/javascripts/application-legacy.js  |  67 ------------
 app/assets/stylesheets/application.css        |  10 +-
 .../controllers/tab_buttons_controller.js     | 102 ++++++++++++++++++
 app/views/common/_tabs.html.erb               |  10 +-
 app/views/layouts/base.html.erb               |   8 +-
 lib/redmine/menu_manager.rb                   |   6 +-
 6 files changed, 118 insertions(+), 85 deletions(-)
 create mode 100644 app/javascript/controllers/tab_buttons_controller.js

diff --git a/app/assets/javascripts/application-legacy.js b/app/assets/javascripts/application-legacy.js
index bf83ac54994..531a6f6e0a7 100644
--- a/app/assets/javascripts/application-legacy.js
+++ b/app/assets/javascripts/application-legacy.js
@@ -487,65 +487,6 @@ function replaceInHistory(url) {
   }
 }
 
-function moveTabRight(el) {
-  var lis = $(el).parents('div.tabs').first().find('ul').children();
-  var bw = $(el).parents('div.tabs-buttons').outerWidth(true);
-  var tabsWidth = 0;
-  var i = 0;
-  lis.each(function() {
-    if ($(this).is(':visible')) {
-      tabsWidth += $(this).outerWidth(true);
-    }
-  });
-  if (tabsWidth < $(el).parents('div.tabs').first().width() - bw) { return; }
-  $(el).siblings('.tab-left').removeClass('disabled');
-  while (i<lis.length && !lis.eq(i).is(':visible')) { i++; }
-  var w = lis.eq(i).width();
-  lis.eq(i).hide();
-  if (tabsWidth - w < $(el).parents('div.tabs').first().width() - bw) {
-    $(el).addClass('disabled');
-  }
-}
-
-function moveTabLeft(el) {
-  var lis = $(el).parents('div.tabs').first().find('ul').children();
-  var i = 0;
-  while (i < lis.length && !lis.eq(i).is(':visible')) { i++; }
-  if (i > 0) {
-    lis.eq(i-1).show();
-    $(el).siblings('.tab-right').removeClass('disabled');
-  }
-  if (i <= 1) {
-    $(el).addClass('disabled');
-  }
-}
-
-function displayTabsButtons() {
-  var lis;
-  var tabsWidth;
-  var el;
-  var numHidden;
-  $('div.tabs').each(function() {
-    el = $(this);
-    lis = el.find('ul').children();
-    tabsWidth = 0;
-    numHidden = 0;
-    lis.each(function(){
-      if ($(this).is(':visible')) {
-        tabsWidth += $(this).outerWidth(true);
-      } else {
-        numHidden++;
-      }
-    });
-    var bw = $(el).find('div.tabs-buttons').outerWidth(true);
-    if ((tabsWidth < el.width() - bw) && (lis.length === 0 || lis.first().is(':visible'))) {
-      el.find('div.tabs-buttons').hide();
-    } else {
-      el.find('div.tabs-buttons').show().children('button.tab-left').toggleClass('disabled', numHidden == 0);
-    }
-  });
-}
-
 function setPredecessorFieldsVisibility() {
   var relationType = $('#relation_relation_type');
   if (relationType.val() == "precedes" || relationType.val() == "follows") {
@@ -1082,13 +1023,6 @@ function setupAjaxIndicator() {
   });
 }
 
-function setupTabs() {
-  if($('.tabs').length > 0) {
-    displayTabsButtons();
-    $(window).resize(displayTabsButtons);
-  }
-}
-
 function setupFilePreviewNavigation() {
   // only bind arrow keys when preview navigation is present
   const element = $('.pagination.filepreview').first();
@@ -1521,7 +1455,6 @@ $(document).ready(hideOnLoad);
 $(document).ready(addFormObserversForDoubleSubmit);
 $(document).ready(defaultFocus);
 $(document).ready(setupAttachmentDetail);
-$(document).ready(setupTabs);
 $(document).ready(setupFilePreviewNavigation);
 $(document).on('focus', '[data-auto-complete=true]', function(event) {
   inlineAutoComplete(event.target);
diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css
index 5c9a61e5d72..b31775fda0e 100644
--- a/app/assets/stylesheets/application.css
+++ b/app/assets/stylesheets/application.css
@@ -195,7 +195,7 @@ body.has-main-menu #header {
 #main-menu ul {
   margin: 0;
   padding: 0;
-  inline-size: 100%;
+  width: max-content;
   min-block-size: 28px;
   white-space: nowrap;
   display: flex;
@@ -1716,15 +1716,13 @@ p.progress-info {clear: inline-start; font-size: 86%; margin-block-start: -4px;
 .version-overview table.progress td { block-size: 1.2em; }
 
 /***** Tabs *****/
-#content .tabs {block-size: 2.6em; margin-block-end: 1.2em; position: relative; overflow: hidden;}
+#content .tabs {block-size: 2.6em; margin-block-end: 1.2em; position: relative; overflow: hidden; border-block-end: 1px solid var(--oc-gray-4)}
 #content .tabs ul {
   margin: 0;
   position: absolute;
-  inset-block-end:0;
+  inset-block-end: 0;
   padding-inline-start: 0.5em;
-  min-inline-size: 2000px;
-  inline-size: 100%;
-  border-block-end: 1px solid var(--oc-gray-4);
+  width: max-content;
 }
 #content .tabs ul li {
   float:inline-start;
diff --git a/app/javascript/controllers/tab_buttons_controller.js b/app/javascript/controllers/tab_buttons_controller.js
new file mode 100644
index 00000000000..96684ffaea2
--- /dev/null
+++ b/app/javascript/controllers/tab_buttons_controller.js
@@ -0,0 +1,102 @@
+import { Controller } from "@hotwired/stimulus"
+
+// Connects to data-controller="tab-buttons"
+export default class extends Controller {
+  static targets = ['list', 'buttons']
+  static values  = {showButton: Boolean}
+
+  connect() {
+    this.intersectionObserver = new IntersectionObserver(entries => {
+      entries.forEach(entry => {
+        if (entry.isIntersecting && this.showButtonValue) {
+          this.showButtonValue = false
+        }
+        if (!entry.isIntersecting && !this.showButtonValue) {
+          this.showButtonValue = true
+        }
+      })
+    }, {
+      root: this.element,
+      threshold: 0.99
+    });
+    this.intersectionObserver.observe(this.listTarget);
+  }
+
+  disconnect() {
+    this.intersectionObserver?.disconnect();
+  }
+
+  right(e) {
+    const el = e.currentTarget;
+    const func = (total, el) => isVisible(el) ? total + outerWidth(el)
+                                              : total;
+    const visibleTabsWidth = this.items.reduce(func, 0)
+    const availableWidth   = this.element.getBoundingClientRect().width - outerWidth(this.buttonsTarget);
+
+    if (visibleTabsWidth < availableWidth) return;
+
+    this.enableButton(el, '.tab-left')
+
+    const i = this.firstVisibleIndex;
+    if (i === -1) return;
+
+    const w = outerWidth(this.items[i]);
+    this.items[i].style.display = 'none'
+
+    if (visibleTabsWidth - w < availableWidth) {
+      el.classList.add('disabled');
+    }
+  }
+
+  left(e) {
+    const el = e.currentTarget;
+    const i = this.firstVisibleIndex;
+
+    if (i > 0) {
+      this.items[i - 1].style.display = ''
+      this.enableButton(el, '.tab-right')
+    }
+
+    if (i <= 1) {
+      el.classList.add('disabled');
+    }
+  }
+
+  #items;
+
+  get items() {
+    if (typeof this.#items === 'undefined') {
+      this.#items = Array.from(this.listTarget.children);
+    }
+    return this.#items
+  }
+
+  showButtonValueChanged(value) {
+    if (value) {
+      this.buttonsTarget.style.display = ''
+    } else {
+      if (this.items.every(e => e.style.display === '')) {
+        this.buttonsTarget.style.display = 'none'
+      }
+    }
+  }
+
+  enableButton(el, selector) {
+    const siblings = [...el.parentNode.children].filter((child) => child.matches(selector) && child !== el);
+    siblings.forEach(el => el.classList.remove('disabled'))
+  }
+
+  get firstVisibleIndex() {
+    return this.items.findIndex(item => isVisible(item))
+  }
+}
+
+function outerWidth(el) {
+  const style = getComputedStyle(el);
+
+  return (
+    el.getBoundingClientRect().width +
+    parseFloat(style.marginLeft) +
+    parseFloat(style.marginRight)
+  );
+}
diff --git a/app/views/common/_tabs.html.erb b/app/views/common/_tabs.html.erb
index 3f3dd278e2b..4141ef73920 100644
--- a/app/views/common/_tabs.html.erb
+++ b/app/views/common/_tabs.html.erb
@@ -1,7 +1,7 @@
 <% default_action = false %>
 
-<div class="tabs">
-  <ul>
+<div class="tabs" data-controller="tab-buttons">
+  <ul data-tab-buttons-target="list">
   <% tabs.each do |tab| -%>
     <% action = get_tab_action(tab) %>
     <li><%= link_to l(tab[:label]), (tab[:url] || { :tab => tab[:name] }),
@@ -11,11 +11,11 @@
     <% default_action = action if tab[:name] == selected_tab %>
   <% end -%>
   </ul>
-  <div class="tabs-buttons" style="display:none;">
-    <button class="tab-left icon-only" type="button" onclick="moveTabLeft(this);">
+  <div class="tabs-buttons" style="display:none;" data-tab-buttons-target="buttons">
+    <button class="tab-left icon-only" type="button" data-action="tab-buttons#left">
       <%= sprite_icon("angle-left", rtl: true) %>
     </button>
-    <button class="tab-right icon-only" type="button" onclick="moveTabRight(this);">
+    <button class="tab-right icon-only" type="button" data-action="tab-buttons#right">
       <%= sprite_icon("angle-right", rtl: true) %>
     </button>
   </div>
diff --git a/app/views/layouts/base.html.erb b/app/views/layouts/base.html.erb
index 344441a9935..68981a41d71 100644
--- a/app/views/layouts/base.html.erb
+++ b/app/views/layouts/base.html.erb
@@ -89,13 +89,13 @@
     <h1><%= page_header_title %></h1>
 
     <% if display_main_menu?(@project) %>
-    <div id="main-menu" class="tabs">
+    <div id="main-menu" class="tabs" data-controller="tab-buttons">
         <%= render_main_menu(@project) %>
-        <div class="tabs-buttons" style="display:none;">
-            <button class="tab-left icon-only" onclick="moveTabLeft(this); return false;">
+        <div class="tabs-buttons" style="display:none;" data-tab-buttons-target="buttons">
+            <button class="tab-left icon-only" type="button" data-action="tab-buttons#left">
               <%= sprite_icon("angle-left", rtl: true) %>
             </button>
-            <button class="tab-right icon-only" onclick="moveTabRight(this); return false;">
+            <button class="tab-right icon-only" type="button" data-action="tab-buttons#right">
               <%= sprite_icon("angle-right", rtl: true) %>
             </button>
         </div>
diff --git a/lib/redmine/menu_manager.rb b/lib/redmine/menu_manager.rb
index 5d14fc1f414..610ced3eb52 100644
--- a/lib/redmine/menu_manager.rb
+++ b/lib/redmine/menu_manager.rb
@@ -101,7 +101,7 @@ module Redmine
       # Renders the application main menu
       def render_main_menu(project)
         if menu_name = controller.current_menu(project)
-          render_menu(menu_name, project)
+          render_menu(menu_name, project, data: {tab_buttons_target: 'list'})
         end
       end
 
@@ -110,12 +110,12 @@ module Redmine
         menu_name.present? && Redmine::MenuManager.items(menu_name).children.present?
       end
 
-      def render_menu(menu, project=nil)
+      def render_menu(menu, project=nil, options={})
         links = []
         menu_items_for(menu, project) do |node|
           links << render_menu_node(node, project)
         end
-        links.empty? ? nil : content_tag('ul', links.join.html_safe)
+        links.empty? ? nil : content_tag('ul', links.join.html_safe, options)
       end
 
       def render_menu_node(node, project=nil)
-- 
2.47.3

