From be4ef3360df405e97d21de5682a648c87bc0e309 Mon Sep 17 00:00:00 2001 From: Alexander Brandon Coles Date: Sat, 16 May 2026 22:03:04 +0200 Subject: [PATCH] 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. --- .../common/border_box_list_component.rb | 18 ++++++-- .../border_box_list_component/header.html.erb | 46 +++++++++++++------ .../border_box_list_component/header.rb | 13 +++++- .../border_box_list_component_preview.rb | 46 ++++++++++++------- .../work_package_card_list_component.rb | 6 --- .../work_package_card_list_component_spec.rb | 26 ----------- .../common/border_box_list_component_spec.rb | 38 +++++++++++++-- 7 files changed, 121 insertions(+), 72 deletions(-) diff --git a/app/components/open_project/common/border_box_list_component.rb b/app/components/open_project/common/border_box_list_component.rb index af9f1c8c674..7dbf1e2fbba 100644 --- a/app/components/open_project/common/border_box_list_component.rb +++ b/app/components/open_project/common/border_box_list_component.rb @@ -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 diff --git a/app/components/open_project/common/border_box_list_component/header.html.erb b/app/components/open_project/common/border_box_list_component/header.html.erb index b8a3fcd402a..9bc37cafcc0 100644 --- a/app/components/open_project/common/border_box_list_component/header.html.erb +++ b/app/components/open_project/common/border_box_list_component/header.html.erb @@ -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 %> diff --git a/app/components/open_project/common/border_box_list_component/header.rb b/app/components/open_project/common/border_box_list_component/header.rb index def4041f1cb..6d5a516b77b 100644 --- a/app/components/open_project/common/border_box_list_component/header.rb +++ b/app/components/open_project/common/border_box_list_component/header.rb @@ -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. diff --git a/lookbook/previews/open_project/common/border_box_list_component_preview.rb b/lookbook/previews/open_project/common/border_box_list_component_preview.rb index 0714463cfe6..4a406b87055 100644 --- a/lookbook/previews/open_project/common/border_box_list_component_preview.rb +++ b/lookbook/previews/open_project/common/border_box_list_component_preview.rb @@ -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 diff --git a/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb b/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb index 0daf4ee3277..5122de033ed 100644 --- a/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb +++ b/modules/backlogs/app/components/backlogs/work_package_card_list_component.rb @@ -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 diff --git a/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb b/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb index f51b2f0090b..d9246595ff8 100644 --- a/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb +++ b/modules/backlogs/spec/components/backlogs/work_package_card_list_component_spec.rb @@ -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 diff --git a/spec/components/open_project/common/border_box_list_component_spec.rb b/spec/components/open_project/common/border_box_list_component_spec.rb index 0b6075f87c9..c072dc586cf 100644 --- a/spec/components/open_project/common/border_box_list_component_spec.rb +++ b/spec/components/open_project/common/border_box_list_component_spec.rb @@ -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