Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save secretpray/454ede82bf7eeef969d32564f6fe2e9d to your computer and use it in GitHub Desktop.
Save secretpray/454ede82bf7eeef969d32564f6fe2e9d to your computer and use it in GitHub Desktop.
Adding touchscreen gesture support for Bootstrap 5 Tab switching in a Ruby on Rails 6 with Turbo Hotwire/Stimulus application

Stimulus controller (name: 'content-swipe-tab' )

import { Controller } from '@hotwired/stimulus'

export default class extends Controller {
  static targets = ["tabs", 'tabsPane', "swipeArea"]
  //[TODO] перенести у static values
  static swipeThreshold = 100 // Пороговое значение для определения свайпа (чтобы мелкие движения не учитывались)
  startX = null
  startY = null

  showTab(event) {
    event.preventDefault()
    const tab = event.target.closest("[data-bs-toggle='tab']") // Находим вкладку, на которую кликнули
    if (tab && !tab.classList.contains("active")) {
      this.activateTab(tab) // Активируем вкладку, если она не активна
    }
  }

  swipeLeft() {
    const currentTab = this.getCurrentTab()
    const nextTab = currentTab.closest('li').nextElementSibling // Получаем следующую вкладку (стандарт -> currentTab.nextElementSibling)
    if (nextTab) {
      this.activateTab(nextTab.querySelector('button')) // Активируем следующую вкладку (стандарт -> nextTab)
    }
  }

  swipeRight() {
    const currentTab = this.getCurrentTab()
    const prevTab = currentTab.closest('li').previousElementSibling // Получаем предыдущую вкладку (стандарт -> currentTab.nextElementSibling)
    if (prevTab) {
      this.activateTab(prevTab.querySelector('button')) // Активируем предыдущую вкладку (стандарт -> nextTab)
    }
  }

  connect() {
    // Добавляем обработчик события touchstart
    this.swipeAreaTarget.addEventListener("touchstart", (event) => {
      this.startX = event.touches[0].clientX
      this.startY = event.touches[0].clientY
    })

   // Добавляем обработчик события touchend
    this.swipeAreaTarget.addEventListener("touchend", (event) => {
      const endX = event.changedTouches[0].clientX 
      const endY = event.changedTouches[0].clientY

      const diffX = this.startX - endX
      const diffY = this.startY - endY

      if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > this.constructor.swipeThreshold) { // Если движение по X больше движения по Y и больше порогового значения
        if (diffX > 0) {
          this.swipeLeft() // предыдущая вкладка
        } else {
          this.swipeRight() // следующая вкладка
        }
      }
    })
  }

  getCurrentTab() {
    return this.tabsTargets.find((tab) => tab.classList.contains("active")) // Находим активную вкладку
  }

  activateTab(tab) {
    this.tabsTargets.forEach((t) => t.classList.remove("active")) // Удаляем класс "active" со всех вкладок
    tab.classList.add("active") // Добавляем класс "active" к текущей вкладке

    this.tabsPaneTargets.forEach((pane) => pane.classList.remove("show", "active")) // Удаляем класс active из всех элементов, которые соответствуют вкладкам
    const target = document.querySelector(tab.dataset.bsTarget) // Получаем элемент, соответствующий вкладке
    if (target) target.classList.add("show", "active") / Добавляем класс 'show active' к элементу, который соответствует вкладке
  }
}

sample.htm.erb (with Bootstrap 5 Tab)

<div class="container-fluid" id='main-page-container' data-controller="content-swipe-tab"> <%# Stimulus контроллер %>
  <h2 class="title text-center mb-5"><%= t('pages.components.index.title') %></h2>
  <ul class="nav nav-tabs justify-content-center mb-3"
      id="sectionTab" 
      role="tablist">
    <li class="nav-item px-3" role="presentation">
      <button class="nav-link active" 
              id="network-tab" 
              data-bs-toggle="tab" 
              data-bs-target="#network-tab-pane" 
              type="button" 
              role="tab" 
              aria-controls="network-tab-pane" 
              aria-selected="true"
              data-content-swipe-tab-target='tabs'> <%# Stimulus targets 'tabs' %>
        <img class="svg-icon me-2" src="/icons/hero-pen.svg"/>
        <%= t('pages.components.index.tabs.title.posts') %>
      </button>
    </li>
    <li class="nav-item px-3" role="presentation">
      <button class="nav-link" 
              id="store-tab" 
              data-bs-toggle="tab" 
              data-bs-target="#store-tab-pane" 
              type="button" 
              role="tab" 
              aria-controls="store-tab-pane" 
              aria-selected="false"
              data-content-swipe-tab-target='tabs'> <%# Stimulus targets 'tabs' %>
        <img class="svg-icon me-2" src="/icons/hero-cart.svg"/>
        <%= t('pages.components.index.tabs.title.products') %>
      </button>
    </li>
    <li class="nav-item px-3" role="presentation">
      <button class="nav-link" 
              id="services-tab" 
              data-bs-toggle="tab" 
              data-bs-target="#services-tab-pane" 
              type="button" 
              role="tab" 
              aria-controls="services-tab-pane" 
              aria-selected="false"
              data-content-swipe-tab-target='tabs'> <%# Stimulus targets 'tabs' %>
        <img class="svg-icon me-2" src="/icons/hero-heart.svg"/>
        <%= t('pages.components.index.tabs.title.services') %>
      </button>
    </li>
  </ul>
  <div class="tab-content" id="nav-tabContent" data-content-swipe-tab-target="swipeArea"> <%# Stimulus targets 'swipeArea' %>
    <div class="tab-pane fade show active" 
          id="network-tab-pane" 
          role="tabpanel" 
          aria-labelledby="network-tab" 
          tabindex="0"
          data-content-swipe-tab-target='tabsPane'> <%# Stimulus targets 'tabPane' %>
      <div class="indexbeg">
        <div class="container content d-flex justify-content-evenly">
          <div>
            <img class="tab-image" src="/images/hero-network.png" />
          </div>
          <div class="hero-text m-0">
            <h3 class="tab-title"><%= t('pages.components.index.section.posts.title') %></h3>
            <h4 class="tab-subtitle my-2"><%= t('pages.components.index.section.posts.subtitle') %></h4>
            <div class='d-flex'>
              <%= link_to posts_path, class: 'btn btn-new-outline mt-4' do %>
                <%= t('pages.components.index.section.goto') %>
                <img class="arrow" src="/Arrow1.svg" />
              <% end %>
            </div>
          </div>
        </div>
      </div>
    </div>
    <div class="tab-pane fade" 
          id="store-tab-pane" 
          role="tabpanel" 
          aria-labelledby="store-tab" 
          tabindex="1"
          data-content-swipe-tab-target='tabsPane'> <%# Stimulus targets 'tabPane' %>

      <div class="indexbeg">
        <div class="container content d-flex justify-content-evenly">
          <div>
            <img class="tab-image" src="/images/hero-announ.png" />
          </div>
          <div class="hero-text m-0">
            <h3 class="tab-title"><%= t('pages.components.index.section.products.title') %></h3>
            <h4 class="tab-subtitle my-2"><%= t('pages.components.index.section.products.subtitle') %></h4>
            <div class='d-flex'>
                <%= link_to category_path('ihrashky'), class: 'btn btn-new-outline mt-4' do %>
                  <%= t('pages.components.index.section.goto') %>
                  <img class="arrow" src="/Arrow1.svg" />
                <% end %>
            </div>
          </div>
        </div> 
      </div>
    </div>
    <div class="tab-pane fade" 
          id="services-tab-pane" 
          role="tabpanel" 
          aria-labelledby="services-tab" 
          tabindex="2"
          data-content-swipe-tab-target='tabsPane'> <%# Stimulus targets 'tabPane' %>
      <div class="indexbeg">
        <div class="container content d-flex justify-content-evenly">
          <div>
            <img class="tab-image" src="/images/hero-services.png" />
          </div>
          <div class="hero-text m-0">
            <h3 class="tab-title"><%= t('pages.components.index.section.services.title') %></h3>
            <h4 class="tab-subtitle my-2"><%= t('pages.components.index.section.services.subtitle') %></h4>
            <%= render 'pages/shared/link_alert' %>
          </div>
        </div> 
      </div>
    
    </div>
  </div>
</div>
@secretpray
Copy link
Author

Цей варіант передбачає що при обранні однієї з вкладок, в іншому компоненті вкладок буде одночасно активована інша, відповідно контексту, вкладка

import { Controller } from '@hotwired/stimulus'

export default class extends Controller {
  static targets = [
    "tabs",
    "tabPane",
    "tabsContent",
    "tabsFaq", 
    'tabsContentPane', 
    'tabsFaqPane',
    "swipeContentArea",
    "swipeFaqArea" 
  ]
  
  swipeThreshold = 100 // Порогове значення для визначення свайпа (щоб дрібні рухи не враховувалися)
  startX = null
  startY = null

  tabPanes = {
    "posts-tab-pane": "posts-faq-tab",
    "products-tab-pane": "products-faq-tab",
    "services-tab-pane": "services-faq-tab",
    "posts-faq-tab-pane": "posts-tab",
    "products-faq-tab-pane": "products-tab",
    "services-faq-tab-pane": "services-tab",
  };

  swipeLeft(nameTarget) {
    const currentTab = this.getCurrentTab(nameTarget)
    const nextTab = currentTab.closest('li').nextElementSibling // Отримуємо наступну вкладку (стандарт -> currentTab.nextElementSibling)
    if (nextTab) {
      const tab = nextTab.querySelector('button')
      this.activateTab(tab, nameTarget) // Активуємо наступну вкладку (стандарт -> nextTab)
      this.selectSecondTab(tab.getAttribute('aria-controls'));
    }
  }

  swipeRight(nameTarget) {
    const currentTab = this.getCurrentTab(nameTarget)
    const prevTab = currentTab.closest('li').previousElementSibling // Отримуємо попередню вкладку (стандарт -> currentTab.nextElementSibling)
    if (prevTab) {
      const tab = prevTab.querySelector('button')
      this.activateTab(tab, nameTarget) // Активуємо попередню вкладку (стандарт -> nextTab)
      this.selectSecondTab(tab.getAttribute('aria-controls'));
    }
  }

  touchstartHandler(element) {
    element.addEventListener("touchstart", (event) => {
      this.startX = event.touches[0].clientX
      this.startY = event.touches[0].clientY
    })
  }

  touchendHandler(element) {
    const nameTarget = element.dataset.nameValue

    element.addEventListener("touchend", (event) => {
      const endX = event.changedTouches[0].clientX
      const endY = event.changedTouches[0].clientY

      const diffX = this.startX - endX
      const diffY = this.startY - endY

      if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > this.swipeThreshold) { // Якщо рух по X більший за рух по Y і більший за порогове значення
        if (diffX > 0) {
          this.swipeLeft(nameTarget) // попередя вкладка
        } else {
          this.swipeRight(nameTarget) // наступна вкладка

        }
      }
    })
  }

  connect() {
    // Додаємо обробник події touchstart
    this.touchstartHandler(this.swipeContentAreaTarget)
    this.touchstartHandler(this.swipeFaqAreaTarget)

    // Додаємо обробник події touchend
    this.touchendHandler(this.swipeContentAreaTarget)
    this.touchendHandler(this.swipeFaqAreaTarget)
  }

  getCurrentTab(nameTarget) {
    const elements = this[nameTarget + "Targets"]
    return elements.find((tab) => tab.classList.contains("active")) // Знаходимо активну вкладку
  }

  clickActivate(event) {
    const activatedTabPane = event.target
    const activatedTabPaneId = activatedTabPane.getAttribute("aria-controls");

    this.selectSecondTab(activatedTabPaneId);
  }

  selectSecondTab(tabPaneId) {
    if (this.tabPanes.hasOwnProperty(tabPaneId)) this.activateTabPane(this.tabPanes[tabPaneId])
  }

  activateTab(tab, nameTarget) {
    const tabsElements = this[nameTarget + "Targets"];
    tabsElements.forEach((t) => t.classList.remove("active")) // Видаляємо клас active з усіх вкладок
    tab.classList.add("active") // Додаємо клас active до поточної вкладки

    const panesElements =  this[nameTarget + "PaneTargets"]
    panesElements.forEach((pane) => pane.classList.remove("show", "active")) // Видаляємо клас active з усіх елементів, які відповідають вкладкам
    const target = document.querySelector(tab.dataset.bsTarget) // Отримуємо елемент, який відповідає вкладці
    if (target) target.classList.add("show", "active") // Додаємо клас 'show active' до елемента, який відповідає вкладці
  }

  activateTabPane(tabName) {
    const tab = this.element.querySelector(`#${tabName}`);
    const nameTarget = tab.dataset.nameValue
    
    this.activateTab(tab, nameTarget);
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment