From b8004c7c37182a9de2fb657884ffccbcfe03b75f Mon Sep 17 00:00:00 2001
From: Takashi Kato <tohosaku@cosmichorror.org>
Date: Sun, 9 Feb 2025 10:19:53 +0000
Subject: [PATCH 1/3] Convert responsive.js to stimulus controller

---
 app/assets/javascripts/application-legacy.js  | 21 +++++
 app/assets/javascripts/responsive.js          | 89 -------------------
 app/helpers/application_helper.rb             |  2 +-
 .../controllers/command_controller.js         | 15 ++++
 .../controllers/relay_controller.js           | 19 ++++
 .../controllers/responsive_controller.js      | 88 ++++++++++++++++++
 app/views/layouts/base.html.erb               |  6 +-
 7 files changed, 147 insertions(+), 93 deletions(-)
 delete mode 100644 app/assets/javascripts/responsive.js
 create mode 100644 app/javascript/controllers/command_controller.js
 create mode 100644 app/javascript/controllers/relay_controller.js
 create mode 100644 app/javascript/controllers/responsive_controller.js

diff --git a/app/assets/javascripts/application-legacy.js b/app/assets/javascripts/application-legacy.js
index 211dd3e0a5d..bf83ac54994 100644
--- a/app/assets/javascripts/application-legacy.js
+++ b/app/assets/javascripts/application-legacy.js
@@ -890,6 +890,27 @@ function observeSearchfield(fieldId, targetId, url, options) {
   });
 }
 
+function isMobile() {
+  const element = document.getElementsByClassName('.js-flyout-menu-toggle-button')[0];
+  if (typeof element !== 'undefined') {
+      return isVisible(element);
+  } else {
+      return false;
+  }
+}
+
+function isVisible(element) {
+  try {
+    if (typeof element.checkVisibility === 'function') {
+      return element.checkVisibility();
+    }
+    return !!( element.offsetWidth || element.offsetHeight || element.getClientRects().length );
+  } catch (error) {
+    console.error('Error checking visibility:', error);
+    return false;
+  }
+}
+
 $(document).ready(function(){
   $(".drdn .autocomplete").val('');
 
diff --git a/app/assets/javascripts/responsive.js b/app/assets/javascripts/responsive.js
deleted file mode 100644
index da51903aede..00000000000
--- a/app/assets/javascripts/responsive.js
+++ /dev/null
@@ -1,89 +0,0 @@
-/**
- * Redmine - project management software
- * Copyright (C) 2006-  Jean-Philippe Lang
- * This code is released under the GNU General Public License.
- */
-
-// generic layout specific responsive stuff goes here
-
-function openFlyout() {
-  $('html').addClass('flyout-is-active');
-  $('#main, #header').on('click.close-flyout', function(e){
-    e.preventDefault();
-    e.stopPropagation();
-    closeFlyout();
-  });
-}
-
-function closeFlyout() {
-  $('html').removeClass('flyout-is-active');
-  $('#main, #header').off('click.close-flyout');
-}
-
-
-function isMobile() {
-  return $('.js-flyout-menu-toggle-button').is(":visible");
-}
-
-function setupFlyout() {
-  var mobileInit = false,
-    desktopInit = false;
-
-  /* click handler for mobile menu toggle */
-  $('.js-flyout-menu-toggle-button').on('click', function(e) {
-    e.preventDefault();
-    e.stopPropagation();
-    if($('html').hasClass('flyout-is-active')) {
-      closeFlyout();
-    } else {
-      openFlyout();
-    }
-  });
-
-  /* bind resize handler */
-  $(window).resize(function() {
-    initMenu();
-  })
-
-  /* menu init function for dom detaching and appending on mobile / desktop view */
-  function initMenu() {
-
-    var _initMobileMenu = function() {
-      /* only init mobile menu, if it hasn't been done yet */
-      if(!mobileInit) {
-
-        $('#main-menu > ul').detach().appendTo('.js-project-menu');
-        $('#top-menu > ul').detach().appendTo('.js-general-menu');
-        $('#sidebar > *').detach().appendTo('.js-sidebar');
-        $('#account > ul').detach().appendTo('.js-profile-menu');
-
-        mobileInit = true;
-        desktopInit = false;
-      }
-    }
-
-    var _initDesktopMenu = function() {
-      if(!desktopInit) {
-
-        $('.js-project-menu > ul').detach().appendTo('#main-menu');
-        $('.js-general-menu > ul').detach().appendTo('#top-menu');
-        $('.js-sidebar > *').detach().appendTo('#sidebar');
-        $('.js-profile-menu > ul').detach().appendTo('#account');
-
-        desktopInit = true;
-        mobileInit = false;
-      }
-    }
-
-    if(isMobile()) {
-      _initMobileMenu();
-    } else {
-      _initDesktopMenu();
-    }
-  }
-
-  // init menu on page load
-  initMenu();
-}
-
-$(document).ready(setupFlyout);
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index 7fe9d4d9dcb..7791e469aac 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -1768,7 +1768,7 @@ module ApplicationHelper
       'rails-ujs',
       'tribute-5.1.3.min'
     )
-    tags << javascript_include_tag('application-legacy', 'responsive')
+    tags << javascript_include_tag('application-legacy')
     unless User.current.pref.warn_on_leaving_unsaved == '0'
       warn_text = escape_javascript(l(:text_warn_on_leaving_unsaved))
       tags <<
diff --git a/app/javascript/controllers/command_controller.js b/app/javascript/controllers/command_controller.js
new file mode 100644
index 00000000000..ee41d2071be
--- /dev/null
+++ b/app/javascript/controllers/command_controller.js
@@ -0,0 +1,15 @@
+/**
+ * Redmine - project management software
+ * Copyright (C) 2006-  Jean-Philippe Lang
+ * This code is released under the GNU General Public License.
+ */
+
+import { Controller } from "@hotwired/stimulus"
+
+// Connects to data-controller="command"
+export default class extends Controller {
+  execute(e) {
+    e.preventDefault();
+    this.dispatch(e.params.name);
+  }
+}
diff --git a/app/javascript/controllers/relay_controller.js b/app/javascript/controllers/relay_controller.js
new file mode 100644
index 00000000000..75da9057142
--- /dev/null
+++ b/app/javascript/controllers/relay_controller.js
@@ -0,0 +1,19 @@
+/**
+ * Copyright (C) 2024- Justin Searls
+ * copied from https://justin.searls.co/posts/a-decoupled-approach-to-relaying-events-between-stimulus-controllers/
+ */
+
+import { Controller } from '@hotwired/stimulus'
+
+export default class RelayController extends Controller {
+  forward (e) {
+    const subscribers = this.element.querySelectorAll(`[data-relay-events*='${e.type}']`)
+
+    subscribers.forEach(el => {
+      el.dispatchEvent(new CustomEvent(e.type, {
+        detail: e.detail,
+        params: e.params
+      }))
+    })
+  }
+}
diff --git a/app/javascript/controllers/responsive_controller.js b/app/javascript/controllers/responsive_controller.js
new file mode 100644
index 00000000000..019571e694b
--- /dev/null
+++ b/app/javascript/controllers/responsive_controller.js
@@ -0,0 +1,88 @@
+/**
+ * Redmine - project management software
+ * Copyright (C) 2006-  Jean-Philippe Lang
+ * This code is released under the GNU General Public License.
+ */
+import { Controller } from "@hotwired/stimulus"
+
+// generic layout specific responsive stuff goes here
+// Connects to data-controller="responsive"
+export default class extends Controller {
+  static values  = { isMobile: Boolean }
+
+  initialize() {
+    this.mediaQueryList = window.matchMedia('screen and (max-width: 899px)');
+    this.mediaQueryList.addEventListener('change', (e) => {
+      this.isMobileValue = e.matches
+    })
+  }
+
+  connect() {
+    this.handlers = []
+    this.html     = document.querySelector('html')
+
+    // init menu on page load
+    this.isMobileValue = this.mediaQueryList.matches
+  }
+
+  /* click handler for mobile menu toggle */
+  toggle(e) {
+    e.preventDefault();
+    e.stopPropagation();
+    if(this.html.classList.contains(this.isFlyoutActive)) {
+      this.closeFlyout();
+    } else {
+      this.openFlyout();
+    }
+  }
+
+  isMobileValueChanged(value) {
+    if (value) {
+      this.replace('#main-menu > ul', '.js-project-menu')
+      this.replace('#top-menu  > ul', '.js-general-menu')
+      this.replace('#sidebar   > *' , '.js-sidebar')
+      this.replace('#account   > ul', '.js-profile-menu')
+    } else {
+      this.replace('.js-project-menu > ul', '#main-menu')
+      this.replace('.js-general-menu > ul', '#top-menu')
+      this.replace('.js-sidebar      > *' , '#sidebar')
+      this.replace('.js-profile-menu > ul', '#account')
+    }
+  }
+
+  replace(from, to) {
+    const fromElem = document.querySelectorAll(from)
+    const toElem   = document.querySelector(to)
+
+    if (toElem !== null) {
+      const toChildren = toElem.children
+      toElem.replaceChildren(...toChildren, ...fromElem);
+    }
+  }
+
+  openFlyout() {
+    this.html.classList.add(this.isFlyoutActive);
+    this.handlers = ['#main', '#header'].map((selector) => {
+      return addHandler(document, 'click', (e) => {
+        if (e.target.matches('.js-flyout-menu-toggle-button')) return;
+        if (e.target.closest(selector) === null) return;
+
+        this.closeFlyout()
+      })
+    });
+  }
+
+  closeFlyout() {
+    this.html.classList.remove(this.isFlyoutActive);
+    this.handlers.forEach(handler => handler());
+  }
+
+  get isFlyoutActive() {
+    return 'flyout-is-active';
+  }
+}
+
+function addHandler(element, ...args) {
+  element.addEventListener(...args);
+  return () => element.removeEventListener(...args);
+}
diff --git a/app/views/layouts/base.html.erb b/app/views/layouts/base.html.erb
index 2b9e95cfe1b..344441a9935 100644
--- a/app/views/layouts/base.html.erb
+++ b/app/views/layouts/base.html.erb
@@ -20,9 +20,9 @@
 </head>
 <body class="<%= body_css_classes %>" data-text-formatting="<%= Setting.text_formatting %>">
 <%= call_hook :view_layouts_base_body_top %>
-<div id="wrapper">
+<div id="wrapper" data-controller="relay">
 
-<div class="flyout-menu js-flyout-menu">
+<div class="flyout-menu js-flyout-menu" data-controller="responsive" data-action="command:toggle->responsive#toggle" data-relay-events="command:toggle">
 
     <% if User.current.logged? || !Setting.login_required? %>
         <div class="flyout-menu__search">
@@ -67,7 +67,7 @@
 
 <div id="header">
 
-    <a href="#" class="mobile-toggle-button js-flyout-menu-toggle-button"></a>
+    <a href="#" class="mobile-toggle-button js-flyout-menu-toggle-button" data-controller="command" data-action="command#execute command:toggle->relay#forward" data-command-name-param="toggle"></a>
 
     <% if User.current.logged? || !Setting.login_required? %>
     <div id="quick-search">
-- 
2.47.3

