Add BorderBoxList collapsible header option

Render plain BorderBoxList headers by default while keeping the
CollapsibleHeader toggle available behind an explicit option. Align the
plain header branch with CollapsibleHeader markup so later Backlogs
header restyling can reuse the same structure.
This commit is contained in:
Alexander Brandon Coles
2026-05-16 22:03:04 +02:00
parent 4c9dd6cc23
commit be4ef3360d
7 changed files with 121 additions and 72 deletions
@@ -38,7 +38,9 @@ module OpenProject
class BorderBoxListComponent < ApplicationComponent
include OpPrimer::ComponentHelpers
attr_reader :container, :current_user, :header_id, :footer_id
attr_reader :container, :collapsible, :current_user, :header_id, :footer_id
alias_method :collapsible?, :collapsible
# Optional header row.
#
@@ -55,6 +57,7 @@ module OpenProject
system_arguments[:id] = header_id
system_arguments[:list_id] = list_id
system_arguments[:interactive] = interactive?
system_arguments[:collapsible] = collapsible?
Header.new(**system_arguments)
}
@@ -157,14 +160,23 @@ module OpenProject
# `dom_target` to derive DOM ids for the list and related controls.
# @param interactive [Boolean] whether dynamic list updates should be
# announced politely to assistive technology.
# @param collapsible [Boolean] whether the header renders a collapsible
# toggle. Defaults to `false`.
# @param current_user [User] user context passed to work-package items.
# @param system_arguments [Hash] forwarded to `Primer::Beta::BorderBox`.
# Pass `id:` to set the box id; related ids are derived from it.
def initialize(container:, interactive: false, current_user: User.current, **system_arguments)
def initialize(
container:,
interactive: false,
collapsible: false,
current_user: User.current,
**system_arguments
)
super()
@container = container
@interactive = interactive
@collapsible = collapsible
@current_user = current_user
@system_arguments = system_arguments.except(:list_id)
@@ -193,7 +205,7 @@ module OpenProject
return unless header?
header.resolve_count!(items.size)
return unless footer?
return unless collapsible? && footer?
header.collapsible_id = [list_id, footer_id].compact.join(" ")
end
@@ -15,22 +15,38 @@ See COPYRIGHT and LICENSE files for more details.
<%= grid_layout("op-border-box-list-header", tag: :div) do |grid| %>
<% grid.with_area(:collapsible) do %>
<%=
render(
Primer::OpenProject::BorderBox::CollapsibleHeader.new(
collapsible_id:,
collapsed:,
multi_line: true
)
) do |collapsible|
%>
<% collapsible.with_title(tag: title_tag) { title } %>
<% if render_count? %>
<% collapsible.with_count(**counter_arguments) %>
<% if collapsible? %>
<%=
render(
Primer::OpenProject::BorderBox::CollapsibleHeader.new(
collapsible_id:,
collapsed:,
multi_line: true
)
) do |collapsible|
%>
<% collapsible.with_title(tag: title_tag) { title } %>
<% if render_count? %>
<% collapsible.with_count(**counter_arguments) %>
<% end %>
<% if description? %>
<% collapsible.with_description do %>
<%= description %>
<% end %>
<% end %>
<% end %>
<% if description? %>
<% collapsible.with_description do %>
<%= description %>
<% else %>
<%= render(Primer::BaseComponent.new(tag: :div, classes: "CollapsibleHeader CollapsibleHeader--multi-line")) do %>
<%= render(Primer::BaseComponent.new(tag: :div, classes: "CollapsibleHeader-title-line")) do %>
<%= render(Primer::Beta::Truncate.new(tag: title_tag, classes: "CollapsibleHeader-title Box-title")) { title } %>
<% if render_count? %>
<%= render(Primer::Beta::Counter.new(**counter_arguments, classes: class_names(counter_arguments[:classes], "CollapsibleHeader-count"))) %>
<% end %>
<% end %>
<% if description? %>
<%= render(Primer::Beta::Text.new(color: :subtle, trim: true, classes: "CollapsibleHeader-description")) do %>
<%= description %>
<% end %>
<% end %>
<% end %>
<% end %>
@@ -81,7 +81,8 @@ module OpenProject
:title_tag,
:list_id,
:interactive,
:collapsed
:collapsed,
:collapsible
attr_writer :collapsible_id
@@ -99,6 +100,9 @@ module OpenProject
# @param interactive [Boolean] whether counter updates should be
# announced politely to assistive technology.
# @param collapsed [Boolean] whether the collapsible header starts closed.
# @param collapsible [Boolean] whether the header renders a collapsible
# toggle. Defaults to `true`. Pass `false` to render a plain title
# without a toggle button.
# @param system_arguments [Hash] forwarded to `Primer::Beta::BorderBox#with_header`.
def initialize(
title:,
@@ -109,6 +113,7 @@ module OpenProject
list_id: nil,
interactive: false,
collapsed: false,
collapsible: true,
**system_arguments
)
super()
@@ -122,9 +127,15 @@ module OpenProject
@interactive = interactive
@collapsible_id = list_id
@collapsed = collapsed
@collapsible = collapsible
@system_arguments = system_arguments
end
# @return [Boolean] whether a collapsible toggle should be rendered.
def collapsible?
collapsible
end
# Resolves inferred counts after the list slots have been captured.
#
# @param item_count [Integer] number of rendered item slots.
@@ -34,10 +34,12 @@ module OpenProject
class BorderBoxListComponentPreview < ViewComponent::Preview
# @label Default
# @param interactive toggle
def default(interactive: false)
# @param collapsible toggle
def default(interactive: false, collapsible: false)
render OpenProject::Common::BorderBoxListComponent.new(
container: "border-box-list-preview",
interactive: boolean_preview_param(interactive)
interactive: boolean_preview_param(interactive),
collapsible: boolean_preview_param(collapsible)
) do |list|
list.with_header(title: "Things we're building", count: true) do |header|
header.with_description { "There's lots to look forward to" }
@@ -61,35 +63,30 @@ module OpenProject
# @label With work package items
# @param interactive toggle
def with_work_package_items(interactive: false)
# @param collapsible toggle
def with_work_package_items(interactive: false, collapsible: false)
work_packages = WorkPackage.includes(:project).limit(2).to_a
return preview_message("No work packages in the database.") if work_packages.empty?
render OpenProject::Common::BorderBoxListComponent.new(
container: "border-box-list-work-package-preview",
interactive: boolean_preview_param(interactive)
interactive: boolean_preview_param(interactive),
collapsible: boolean_preview_param(collapsible)
) do |list|
list.with_header(title: "Work packages", count: true)
work_packages.each do |work_package|
list.with_work_package_item(work_package:) do |item|
item.with_menu(button_aria_label: "Work package actions") do |menu|
menu.with_item(label: "Open", href: "/work_packages/#{work_package.id}") do |menu_item|
menu_item.with_leading_visual_icon(icon: :link)
end
end
end
end
render_work_package_items(list, work_packages)
end
end
# @label Playground
# @param collapsible toggle
# @param title_tag [Symbol] select [h2, h3, h4, h5]
# @param count [Symbol] select [inferred, hidden, explicit, zero]
# @param count_scheme [Symbol] select [primary, secondary]
# @param hide_zero_count toggle
# @param interactive toggle
def playground(
collapsible: false,
title_tag: :h4,
count: :inferred,
count_scheme: :primary,
@@ -98,7 +95,8 @@ module OpenProject
)
render OpenProject::Common::BorderBoxListComponent.new(
container: "border-box-list-playground-preview",
interactive: boolean_preview_param(interactive)
interactive: boolean_preview_param(interactive),
collapsible: boolean_preview_param(collapsible)
) do |list|
list.with_header(
title: "Playground list",
@@ -122,10 +120,12 @@ module OpenProject
# @label Empty state
# List with a header and an empty state (Blankslate), no items.
# @param interactive toggle
def empty_state(interactive: false)
# @param collapsible toggle
def empty_state(interactive: false, collapsible: false)
render OpenProject::Common::BorderBoxListComponent.new(
container: "border-box-list-empty-preview",
interactive: boolean_preview_param(interactive)
interactive: boolean_preview_param(interactive),
collapsible: boolean_preview_param(collapsible)
) do |list|
list.with_header(title: "Empty list", count: 0)
list.with_empty_state(
@@ -159,6 +159,18 @@ module OpenProject
blankslate.with_heading(tag: :h4).with_content(text)
end
end
def render_work_package_items(list, work_packages)
work_packages.each do |work_package|
list.with_work_package_item(work_package:) do |item|
item.with_menu(button_aria_label: "Work package actions") do |menu|
menu.with_item(label: "Open", href: "/work_packages/#{work_package.id}") do |menu_item|
menu_item.with_leading_visual_icon(icon: :link)
end
end
end
end
end
end
end
end
@@ -76,7 +76,6 @@ module Backlogs
title:,
count: work_packages.size,
count_label: default_count_label(count),
collapsed: folded?,
**system_arguments,
&
)
@@ -84,7 +83,6 @@ module Backlogs
title:,
count:,
count_label:,
collapsed:,
**system_arguments,
&
)
@@ -120,10 +118,6 @@ module Backlogs
}
end
def folded?
current_user.pref[:backlogs_versions_default_fold_state] == "closed"
end
def default_count_label(count)
return unless count
@@ -151,32 +151,6 @@ RSpec.describe Backlogs::WorkPackageCardListComponent, type: :component do
)
end
context "when the user prefers closed folds" do
before do
user.pref[:backlogs_versions_default_fold_state] = "closed"
end
it "renders the header as collapsed" do
expect(rendered_component).to have_css(
".CollapsibleHeader-triggerArea",
aria: { expanded: "false" }
)
end
end
context "when the user prefers open folds" do
before do
user.pref[:backlogs_versions_default_fold_state] = "open"
end
it "renders the header as expanded" do
expect(rendered_component).to have_css(
".CollapsibleHeader-triggerArea",
aria: { expanded: "true" }
)
end
end
context "with work packages" do
let(:header_arguments) { { title: "Sprint 1" } }
let(:work_packages) do
@@ -396,7 +396,7 @@ RSpec.describe OpenProject::Common::BorderBoxListComponent, type: :component do
describe "header collapsible behavior" do
it "sets collapsible_id from list and footer ids" do
rendered = render_inline(
described_class.new(container: "collapse-test")
described_class.new(container: "collapse-test", collapsible: true)
) do |list|
list.with_header(title: "Collapsible")
list.with_item { "row" }
@@ -413,7 +413,7 @@ RSpec.describe OpenProject::Common::BorderBoxListComponent, type: :component do
it "sets collapsible_id from list id only when no footer" do
rendered = render_inline(
described_class.new(container: "collapse-no-footer")
described_class.new(container: "collapse-no-footer", collapsible: true)
) do |list|
list.with_header(title: "No footer")
list.with_item { "row" }
@@ -671,7 +671,7 @@ RSpec.describe OpenProject::Common::BorderBoxListComponent, type: :component do
it "derives the header ids when explicit slot ids are provided" do
rendered = render_inline(
described_class.new(container: "ignored", id: "explicit-box")
described_class.new(container: "ignored", id: "explicit-box", collapsible: true)
) do |list|
list.with_header(title: "Header", id: "explicit-header", list_id: "explicit-list")
list.with_item { "row" }
@@ -697,7 +697,7 @@ RSpec.describe OpenProject::Common::BorderBoxListComponent, type: :component do
it "derives the footer id from the explicit box id" do
rendered = render_inline(
described_class.new(container: "ignored", id: "explicit-box")
described_class.new(container: "ignored", id: "explicit-box", collapsible: true)
) do |list|
list.with_header(title: "Header")
list.with_item { "row" }
@@ -737,4 +737,34 @@ RSpec.describe OpenProject::Common::BorderBoxListComponent, type: :component do
expect { described_class.new }.to raise_error(ArgumentError)
end
end
describe "collapsible" do
it "renders a non-collapsible header by default" do
rendered = render_inline(
described_class.new(container: "no-collapse")
) do |list|
list.with_header(title: "Non-collapsible header", count: 3) do |header|
header.with_description { "Description text" }
end
list.with_item { "row" }
end
expect(rendered).to have_heading("Non-collapsible header", level: 4)
expect(rendered).to have_css(".Counter", text: "3")
expect(rendered).to have_text("Description text")
expect(rendered).to have_no_css("collapsible-header")
expect(rendered).to have_no_css("[aria-controls]")
end
it "renders a collapsible header when collapsible is true" do
rendered = render_inline(
described_class.new(container: "explicit-collapse", collapsible: true)
) do |list|
list.with_header(title: "Collapsible header")
list.with_item { "row" }
end
expect(rendered).to have_css("collapsible-header")
end
end
end