Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -27,28 +27,43 @@ See COPYRIGHT and LICENSE files for more details.

++# %>

<%= render(Primer::Beta::BorderBox.new(**@system_arguments)) do |border_box| %>
<% if header? %>
<% border_box.with_header(**header.row_args) do %>
<%= header %>
<%=
render(
Primer::ConditionalWrapper.new(
condition: empty_state_behavior.dynamic?,
tag: :div,
data: { controller: "border-box-list" }
)
) do
%>
<%= render(Primer::Beta::BorderBox.new(**@system_arguments)) do |border_box| %>
<% if header? %>
<% border_box.with_header(**header.row_args) do %>
<%= header %>
<% end %>
<% end %>
<% end %>

<% items.each do |item| %>
<% border_box.with_row(**item.row_args) do %>
<%= item %>
<% if items.any? %>
<% items.each do |item| %>
<% border_box.with_row(**item.row_args) do %>
<%= item %>
<% end %>
<% end %>
<% elsif empty_state? && !empty_state_behavior.none? %>
<% border_box.with_row(data: { empty_list_item: true }) do %>
<%= empty_state %>
<% end %>
<% end %>
<% end %>

<% if empty_state? %>
<% border_box.with_row(data: { empty_list_item: true }) do %>
<%= empty_state %>
<% if footer? %>
<% border_box.with_footer(**footer.footer_args) do %>
<%= footer %>
<% end %>
<% end %>
<% end %>

<% if footer? %>
<% border_box.with_footer(**footer.footer_args) do %>
<%= footer %>
<% end %>
<% if empty_state_behavior.dynamic? %>
<template data-border-box-list-target="emptyStateTemplate">
<li class="Box-row" data-empty-list-item="true"><%= empty_state %></li>
</template>
<% end %>
<% end %>
65 changes: 54 additions & 11 deletions app/components/open_project/common/border_box_list_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,11 @@ class BorderBoxListComponent < ApplicationComponent
SCHEME_OPTIONS = [SCHEME_DEFAULT, :transparent].freeze
HEADER_PADDING_DEFAULT = :inherit
HEADER_PADDING_OPTIONS = [HEADER_PADDING_DEFAULT, :condensed, :default, :spacious].freeze
EMPTY_STATE_BEHAVIOR_DEFAULT = :static
EMPTY_STATE_BEHAVIOR_OPTIONS = [EMPTY_STATE_BEHAVIOR_DEFAULT, :none, :dynamic].freeze

attr_reader :container, :scheme, :header_padding, :collapsible, :current_user, :header_id, :footer_id, :list_id
attr_reader :container, :scheme, :header_padding, :empty_state_behavior, :collapsible, :current_user,
:header_id, :footer_id, :list_id

alias_method :collapsible?, :collapsible

Expand Down Expand Up @@ -134,12 +137,10 @@ class BorderBoxListComponent < ApplicationComponent
}

# Optional empty-state content rendered when no items are present.
# When omitted, the component renders a generic default empty state.
#
# @!parse
# # Adds empty-state content.
# #
# # Interactive lists announce this empty state only when the slot is
# # configured explicitly.
# # Adds custom empty-state content.
# #
# # @param title [String] empty-state title.
# # @param description [String, nil] optional supporting text.
Expand All @@ -148,17 +149,28 @@ class BorderBoxListComponent < ApplicationComponent
# # drop-zone overlay with this label. The overlay becomes visible
# # while a sortable item hovers the surrounding
# # `[data-drop-container="active"]` list.
# # @param action_label [String, nil] optional call-to-action rendered
# # as the blankslate's primary action.
# # @param action_icon [Symbol, nil] optional leading icon for the
# # call-to-action.
# # @param action_arguments [Hash] forwarded to the primary-action
# # button (e.g. `href:`, `scheme:`, `data:`).
# # @param system_arguments [Hash] forwarded to `Primer::Beta::Blankslate`.
# # @return [ViewComponent::Slot]
# def with_empty_state(title:, description: nil, icon: nil, drop_target_label: nil, **system_arguments)
# def with_empty_state(title:, description: nil, icon: nil, drop_target_label: nil,
# action_label: nil, action_icon: nil, action_arguments: {}, **system_arguments)
# end
renders_one :empty_state, ->(title:, description: nil, icon: nil, drop_target_label: nil, **system_arguments) {
renders_one :empty_state, ->(title:, description: nil, icon: nil, drop_target_label: nil,
action_label: nil, action_icon: nil, action_arguments: {}, **system_arguments) {
EmptyState.new(
title:,
description:,
icon:,
interactive: interactive?,
drop_target_label:,
action_label:,
action_icon:,
action_arguments:,
**system_arguments
)
}
Expand Down Expand Up @@ -190,10 +202,15 @@ class BorderBoxListComponent < ApplicationComponent
# the header. `:inherit` keeps Primer's padding from the underlying
# BorderBox. `:condensed`, `:default`, and `:spacious` override only
# the header's block padding.
# @param empty_state_behavior [Symbol] policy for the empty state shown
# when the list has no items. `:static` (default) renders the generic
# empty state unless a custom one is declared via `with_empty_state`.
# `:none` suppresses the empty state entirely, including any declared
# slot. `:dynamic` reserves client-side lifecycle handling for
# sortable and filtered lists; no markup is added by this param yet.
# @param interactive [Boolean] whether dynamic list updates should be
# announced politely to assistive technology. This affects the counter
# and an explicitly configured empty state; it does not create default
# empty-state content for manually composed lists.
# and empty-state content.
# @param collapsible [Boolean] whether the header renders a collapsible
# toggle. Defaults to `false`.
# @param current_user [User] user context passed to work-package items.
Expand All @@ -203,6 +220,7 @@ def initialize( # rubocop:disable Metrics/AbcSize
container:,
scheme: SCHEME_DEFAULT,
header_padding: HEADER_PADDING_DEFAULT,
empty_state_behavior: EMPTY_STATE_BEHAVIOR_DEFAULT,
interactive: false,
collapsible: false,
current_user: User.current,
Expand All @@ -217,14 +235,22 @@ def initialize( # rubocop:disable Metrics/AbcSize
@header_padding = ActiveSupport::StringInquirer.new(
fetch_or_fallback(HEADER_PADDING_OPTIONS, header_padding, HEADER_PADDING_DEFAULT).to_s
)
@empty_state_behavior = ActiveSupport::StringInquirer.new(
fetch_or_fallback(EMPTY_STATE_BEHAVIOR_OPTIONS, empty_state_behavior, EMPTY_STATE_BEHAVIOR_DEFAULT).to_s
)
@interactive = interactive
@collapsible = collapsible
@current_user = current_user
@system_arguments = system_arguments.except(:list_id, :list_arguments)

@system_arguments[:id] ||= dom_target(container)
@list_id = dom_target(@system_arguments[:id], :list)
@system_arguments[:list_arguments] = { id: @list_id }
@system_arguments[:list_arguments] =
if @empty_state_behavior.dynamic?
{ id: @list_id, data: { "border-box-list-target": "list" } }
else
{ id: @list_id }
end
@system_arguments[:classes] = class_names(
@system_arguments[:classes],
"op-border-box-list",
Expand All @@ -240,11 +266,14 @@ def initialize( # rubocop:disable Metrics/AbcSize

def before_render
content
configure_empty_state!
configure_header!
end

def render?
header? || items.any? || empty_state? || footer?
# rubocop:disable Style/InverseMethods -- `none?` is StringInquirer#none?, not Enumerable#none?
header? || items.any? || (empty_state? && !empty_state_behavior.none?) || footer?
# rubocop:enable Style/InverseMethods
end

private
Expand All @@ -261,6 +290,20 @@ def configure_header!

header.collapsible_id = [list_id, footer_id].compact.join(" ")
end

def configure_empty_state!
Comment thread
myabc marked this conversation as resolved.
return unless empty_state_behavior.static? || empty_state_behavior.dynamic?
return if empty_state?
# :dynamic lists always need prototype content for the parked template,
# even when currently populated, so a client-side drain to zero rows has
# a real blankslate to clone instead of a contentless placeholder.
return if empty_state_behavior.static? && items.any?

with_empty_state(
title: I18n.t(:label_nothing_display),
description: I18n.t(:no_results_title_text)
)
end
end
end
end
21 changes: 5 additions & 16 deletions app/components/open_project/common/border_box_list_component.sass
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,8 @@

& + .Box-row
border-top-color: $color
// Display the selected state margin for the last child, or the penultimate child,
// if the last one is the hidden blankslate.
&:is(:last-child, :has(+ [data-empty-list-item]))

&:last-child
margin-bottom: calc(var(--borderWidth-thin) * -1)
border-bottom: var(--borderWidth-thin) solid $color

Expand Down Expand Up @@ -100,19 +99,6 @@
&[data-drop-container="refused"]
outline-color: var(--borderColor-danger-muted)

// The empty-state row only makes sense while it is alone in the list. Hiding
// it as soon as a sibling row appears (e.g. dropped by drag and drop) avoids
// a flicker between the drop and the server render removing it.
> .Box-list
> .Box-row
&[data-empty-list-item]:not(:only-child)
display: none
// Display rounded corners for the penultimate child, if the last child is the
// hidden blankslate.
&:has(+ [data-empty-list-item])
border-bottom-right-radius: var(--borderRadius-medium)
border-bottom-left-radius: var(--borderRadius-medium)

.op-border-box-list-header
display: grid
grid-template-columns: minmax(0, 1fr) auto auto auto
Expand Down Expand Up @@ -168,6 +154,9 @@
display: flex
align-items: center
gap: var(--stack-gap-condensed)
// Reserve the height of a medium control so headers whose action
// buttons toggle (e.g. hidden while filtering) keep a stable height.
min-height: var(--control-medium-size)

.Box-title
min-width: 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,22 @@ class EmptyState < ApplicationComponent
# overlay with this label. The overlay becomes visible while a
# sortable item hovers the surrounding `[data-drop-container="active"]`
# list.
# @param action_label [String, nil] optional call-to-action rendered as
# the blankslate's primary action.
# @param action_icon [Symbol, nil] optional leading icon for the
# call-to-action.
# @param action_arguments [Hash] forwarded to the primary-action button
# (e.g. `href:`, `scheme:`, `data:`).
# @param system_arguments [Hash] forwarded to `Primer::Beta::Blankslate`.
def initialize(
title:,
description: nil,
icon: nil,
interactive: false,
drop_target_label: nil,
action_label: nil,
action_icon: nil,
action_arguments: {},
**system_arguments
)
super()
Expand All @@ -64,6 +73,9 @@ def initialize(
@description = description
@icon = icon
@drop_target_label = drop_target_label
@action_label = action_label
@action_icon = action_icon
@action_arguments = action_arguments.deep_dup

@system_arguments = system_arguments
return unless interactive
Expand All @@ -85,6 +97,12 @@ def blankslate
blankslate.with_description_content(@description) if @description
blankslate.with_visual_icon(icon: @icon) if @icon

if @action_label.present?
action = blankslate.with_primary_action(**@action_arguments)
action.with_leading_visual_icon(icon: @action_icon) if @action_icon
action.with_content(@action_label)
end

blankslate
end
end
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,39 +40,49 @@ module HasMenu
# @!parse
# # Adds a trailing action menu.
# #
# # By default the menu renders the standard invisible
# # kebab-horizontal show button. Pass `button_arguments:` to
# # customize that default trigger, or call `with_show_button` in
# # the slot block to provide a custom trigger.
# #
# # @param menu_id [String, nil] id prefix for the Primer action menu.
# # @param button_aria_label [String, nil] accessible label for the
# # menu button.
# # @param button_aria_label [String, nil] compatibility shortcut
# # for the default menu button accessible label. Prefer
# # `button_arguments: { aria: { label: ... } }`.
# # @param button_arguments [Hash] forwarded to the default
# # `with_show_button` call.
# # @param system_arguments [Hash] forwarded to
# # `Primer::Alpha::ActionMenu`.
# # @return [ViewComponent::Slot]
# def with_menu(menu_id: nil, button_aria_label: nil, **system_arguments, &block)
# def with_menu(menu_id: nil, button_aria_label: nil, button_arguments: {}, **system_arguments, &block)
# end
renders_one :menu, ->(menu_id: nil, button_aria_label: nil, **system_arguments) do
build_menu(menu_id:, button_aria_label:, **system_arguments)
renders_one :menu, ->(menu_id: nil, button_aria_label: nil, button_arguments: {}, **system_arguments) do
build_menu(menu_id:, button_aria_label:, button_arguments:, **system_arguments)
end
end

private

def build_menu(menu_id: nil, button_aria_label: nil, **system_arguments)
def build_menu(menu_id: nil, button_aria_label: nil, button_arguments: {}, **system_arguments)
system_arguments[:classes] = class_names(
system_arguments[:classes],
"hide-when-print"
)

menu = Primer::Alpha::ActionMenu.new(
if button_aria_label.present?
button_arguments = button_arguments.deep_dup
button_arguments[:aria] = merge_aria(
{ aria: { label: button_aria_label } },
button_arguments
)
end

Menu.new(
menu_id: menu_id || default_menu_id,
button_arguments:,
anchor_align: :end,
**system_arguments
)
menu.with_show_button(
scheme: :invisible,
icon: :"kebab-horizontal",
"aria-label": button_aria_label || I18n.t(:label_actions),
tooltip_direction: :se
)
menu
end

def default_menu_id
Expand Down
Loading
Loading