Skip to content

Lesson 6 — Accordion and Tabs

Components::Accordion

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
# app/components/accordion.rb
class Components::Accordion < Components::Base
  prop :multiple, _Boolean, default: -> { false }

  def after_initialize
    @panels = []
  end

  def view_template(&)
    vanish(&)

    div(
      class: "divide-y divide-border rounded-lg border border-border",
      data:  { controller: "accordion", accordion_multiple_value: @multiple }
    ) do
      @panels.each_with_index { |panel, index| render_panel(panel, index) }
    end
  end

  def panel(title:, open: false, &content)
    @panels << { title:, open:, content: capture(&) }
    nil
  end

  private

  def render_panel(panel, index)
    div(data: { accordion_target: "panel" }) do
      button(
        type:  "button",
        class: "flex w-full items-center justify-between px-4 py-3 " \
               "text-left text-sm font-medium text-text hover:bg-surface-alt",
        data:  {
          action:           "click->accordion#toggle",
          accordion_target: "trigger",
        },
        aria:  { expanded: panel[:open].to_s }
      ) do
        span { panel[:title] }
        span(
          class: "transition-transform duration-200",
          data:  { accordion_target: "icon" }
        ) { "▾" }
      end

      div(
        class:  "overflow-hidden transition-all duration-200",
        hidden: !panel[:open],
        data:   { accordion_target: "content" }
      ) do
        div(class: "px-4 py-3 text-sm text-text-muted") { raw safe(panel[:content]) }
      end
    end
  end
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// app/javascript/controllers/accordion_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["panel", "trigger", "content", "icon"]
  static values  = { multiple: Boolean }

  toggle(event) {
    const trigger = event.currentTarget
    const panel   = trigger.closest("[data-accordion-target='panel']")
    const content = panel.querySelector("[data-accordion-target='content']")
    const icon    = panel.querySelector("[data-accordion-target='icon']")
    const isOpen  = !content.hidden

    if (!this.multipleValue) this.closeAll()

    content.hidden = isOpen
    trigger.setAttribute("aria-expanded", (!isOpen).toString())
    icon.style.transform = isOpen ? "" : "rotate(180deg)"
  }

  closeAll() {
    this.panelTargets.forEach(panel => {
      panel.querySelector("[data-accordion-target='content']").hidden = true
      panel.querySelector("[data-accordion-target='trigger']")
           .setAttribute("aria-expanded", "false")
      panel.querySelector("[data-accordion-target='icon']").style.transform = ""
    })
  }
}

Components::Tabs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
# app/components/tabs.rb
class Components::Tabs < Components::Base
  def after_initialize
    @tabs = []
  end

  def view_template(&)
    vanish(&)
    div(data: { controller: "tabs" }) do
      render_tab_list
      render_panels
    end
  end

  def tab(label, &content)
    @tabs << { label:, content: capture(&) }
    nil
  end

  private

  def render_tab_list
    div(role: "tablist", class: "flex border-b border-border") do
      @tabs.each_with_index do |tab, index|
        button(
          type:  "button",
          role:  "tab",
          id:    "tab-#{index}",
          class: tab_button_classes(index),
          data:  {
            action:      "click->tabs#select",
            tabs_target: "trigger",
            index:       index
          },
          aria:  { selected: (index == 0).to_s, controls: "panel-#{index}" }
        ) { tab[:label] }
      end
    end
  end

  def render_panels
    @tabs.each_with_index do |tab, index|
      div(
        id:     "panel-#{index}",
        role:   "tabpanel",
        hidden: index != 0,
        class:  "py-4",
        data:   { tabs_target: "panel" },
        aria:   { labelledby: "tab-#{index}" }
      ) { raw safe(tab[:content]) }
    end
  end

  def tab_button_classes(index)
    base = "px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors"
    index == 0 ? "#{base} border-primary text-primary"
               : "#{base} border-transparent text-text-muted hover:text-text"
  end
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// app/javascript/controllers/tabs_controller.js
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["trigger", "panel"]

  select(event) {
    const index = parseInt(event.currentTarget.dataset.index)
    this.triggerTargets.forEach((trigger, i) => {
      const isSelected = i === index
      trigger.setAttribute("aria-selected", isSelected.toString())
      trigger.classList.toggle("border-primary",    isSelected)
      trigger.classList.toggle("text-primary",       isSelected)
      trigger.classList.toggle("border-transparent", !isSelected)
      trigger.classList.toggle("text-text-muted",    !isSelected)
    })
    this.panelTargets.forEach((panel, i) => { panel.hidden = i !== index })
  }
}

Usage

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Accordion(multiple: false) do |a|
  a.panel(title: "What is KanbanFlow?", open: true) do
    p { "A multi-user Kanban board built with Phlex and Rails 8." }
  end
  a.panel(title: "How do I create a board?") do
    p { "Click '+ New Board' from the boards index." }
  end
  a.panel(title: "Can I invite team members?") do
    p { "Yes — board membership is covered in Module 11." }
  end
end
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
Tabs() do |t|
  t.tab "Overview" do
    p { "Board overview content." }
  end
  t.tab "Members" do
    p { "Member list content." }
  end
  t.tab "Settings" do
    p { "Board settings content." }
  end
end