Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4477b8c
[#75274] Unify truncation controllers
myabc May 22, 2026
17dec9f
[#75274] Compose from AttributeComponent
myabc May 22, 2026
da299b9
[#75274] Add and document ExpandableText preview
myabc May 22, 2026
7f03c9c
[#75274] Extract VerticalTruncateComponent
myabc May 30, 2026
b08309d
Privatise expandable-text controller; rename API
myabc May 30, 2026
78e0b9e
Deep-dup expander_arguments to protect caller hashes
myabc May 30, 2026
ba70cd6
Add VerticalTruncateComponent Lookbook preview
myabc May 30, 2026
d06192e
Merge branch 'dev' into code-maintenance/75274-document-expandable-text
myabc Jun 2, 2026
8a016f5
Own the dialog slot in ExpandableText
myabc Jun 16, 2026
ec5558b
[DREAM-693] Rework the ExpandableText docs
myabc Jun 16, 2026
0f8f839
Merge branch 'dev' into code-maintenance/75274-document-expandable-text
myabc Jun 16, 2026
6a89622
Expose dialog ARIA on the ExpandableText expander
myabc Jun 16, 2026
7034c07
Disable preview cop for VerticalTruncateComponent
myabc Jun 16, 2026
1a92b29
Use accessible selectors in ExpandableText spec
myabc Jun 16, 2026
0915bc7
Increase height of vertical preview
myabc Jun 17, 2026
09e58d3
Extract ExpandableText lines default to a constant
myabc Jun 17, 2026
2bd9d11
Fix broken Lookbook preview link in ExpandableText docs
myabc Jun 17, 2026
9b47949
Rename ExpandableText direction to truncate
myabc Jun 17, 2026
a957c67
Bump ExpandableText line clamp limit to 8
myabc Jun 17, 2026
852b075
Floor multi_line truncation at 2 lines
myabc Jun 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/components/_index.sass
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
@import "op_primer/full_page_prompt_component"
@import "op_primer/form_helpers"
@import "op_primer/inline_macro_component"
@import "open_project/common/attribute_component"
@import "op_primer/vertical_truncate_component"
@import "open_project/common/attribute_help_text_component"
@import "open_project/common/attribute_help_text_caption_component"
@import "open_project/common/attribute_label_component"
Expand Down
23 changes: 13 additions & 10 deletions app/components/op_primer/expandable_text_component.html.erb
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
<%= render(Primer::BaseComponent.new(**@system_arguments)) do %>
<%= render Primer::Beta::Truncate.new(
data: { truncation_target: "truncate" },
flex: 1
) do %>
<%= render(@truncate_component) do %>
<%= content %>
<% end %>

<%= render Primer::Alpha::HiddenTextExpander.new(
hidden: true,
mt: 1,
aria: { label: t(:"js.label_expand_text") },
data: { truncation_target: "expander" }
) %>
<%= render Primer::Alpha::HiddenTextExpander.new(**@expander_arguments) %>
<% end %>

<% if @expansion.dialog? %>
<% unless dialog? %>
<% with_dialog(title: I18n.t("js.label_expand_text"), visually_hide_title: true) do |dialog| %>
Comment thread
myabc marked this conversation as resolved.
<% dialog.with_header %>
<% dialog.with_body_content(content) %>
<% end %>
<% end %>

<%= dialog %>
<% end %>
112 changes: 108 additions & 4 deletions app/components/op_primer/expandable_text_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -29,22 +29,126 @@
#++
#
module OpPrimer
class ExpandableTextComponent < Primer::Component # rubocop:disable OpenProject/AddPreviewForViewComponent
def initialize(**system_arguments)
# Truncates block content and exposes an expander to reveal the full text.
#
# Two truncation directions are supported:
#
# - `:horizontal` clips a single line with `Primer::Beta::Truncate`.
# - `:vertical` clamps to `lines` rows with `OpPrimer::VerticalTruncateComponent`.
#
# The companion `expandable-text` Stimulus controller toggles the expanded state.
# With `expansion: :inline` the expander reveals the text in place; with
# `expansion: :dialog` the expander opens the component's `dialog` slot and the
# controller only manages the expander's visibility.
class ExpandableTextComponent < Primer::Component
DIRECTION_OPTIONS = %i[horizontal vertical].freeze
DIRECTION_DEFAULT = :horizontal

EXPANSION_OPTIONS = %i[inline dialog].freeze
EXPANSION_DEFAULT = :inline

attr_reader :direction, :expansion

# The dialog revealed when `expansion: :dialog`. The component owns the
# dialog's `id` and wires the expander button to open it, so callers only
# configure the dialog's own content (title, header, body). The slot is
# optional: in `:dialog` mode without it, the component renders a default
# dialog showing the full content.
renders_one :dialog, lambda { |**system_arguments|
Primer::Alpha::Dialog.new(**system_arguments, id: @dialog_id)
}

# @param direction [Symbol] truncation direction. `:horizontal` clips a
# single line; `:vertical` clamps to `lines` rows.
# @param lines [Integer] number of visible rows in `:vertical` mode, clamped to `1..6`.
# @param expansion [Symbol] `:inline` reveals the text in place; `:dialog`
# opens the component's `dialog` slot (the expander button is wired to it
# automatically) and the controller only manages the expander's visibility.
# @param dialog_id [String] `id` for the dialog in `:dialog` mode; defaults to
# a generated value. The expander button is wired to this id automatically.
# @param expander_arguments [Hash] system arguments forwarded to the
# `Primer::Alpha::HiddenTextExpander`.
# @param system_arguments [Hash] forwarded to the wrapping
# `Primer::BaseComponent`.
# rubocop:disable Metrics/AbcSize
def initialize(
direction: DIRECTION_DEFAULT,
lines: 3,
Comment thread
myabc marked this conversation as resolved.
Outdated
expansion: EXPANSION_DEFAULT,
dialog_id: "expandable-text-dialog-#{SecureRandom.hex(4)}",
expander_arguments: {},
**system_arguments
)
super()

@direction = ActiveSupport::StringInquirer.new(
fetch_or_fallback(DIRECTION_OPTIONS, direction, DIRECTION_DEFAULT).to_s
)
@expansion = ActiveSupport::StringInquirer.new(
fetch_or_fallback(EXPANSION_OPTIONS, expansion, EXPANSION_DEFAULT).to_s
)
@dialog_id = dialog_id

@system_arguments = deny_tag_argument(**system_arguments)
@system_arguments[:tag] = :div
@system_arguments[:display] = :flex
@system_arguments[:align_items] = :baseline
@system_arguments[:align_items] = @direction.vertical? ? :flex_end : :baseline
@system_arguments[:data] = merge_data(
@system_arguments,
data: { controller: "truncation" }
data: {
controller: "expandable-text",
expandable_text_mode_value: @direction,
expandable_text_inline_value: @expansion.inline?
}
)
@system_arguments[:classes] = class_names(
@system_arguments[:classes],
"gap-1 min-width-0"
)

truncate_arguments = { flex: 1, data: { expandable_text_target: "truncate" } }
@truncate_component =
if @direction.vertical?
OpPrimer::VerticalTruncateComponent.new(lines:, **truncate_arguments)
else
Primer::Beta::Truncate.new(**truncate_arguments)
end

set_expander_arguments!(expander_arguments)
end
# rubocop:enable Metrics/AbcSize

private

def set_expander_arguments!(expander_arguments)
@expander_arguments = expander_arguments.deep_dup
@expander_arguments[:hidden] = true unless @expander_arguments.key?(:hidden)
Comment thread
myabc marked this conversation as resolved.
@expander_arguments[:mt] ||= 1
@expander_arguments[:aria] = merge_aria(
{ aria: { label: I18n.t("js.label_expand_text") } },
@expander_arguments
)
@expander_arguments[:data] = merge_data(
{ data: { expandable_text_target: "expander" } },
@expander_arguments
)
Comment thread
myabc marked this conversation as resolved.

wire_expander_to_dialog! if @expansion.dialog?
end

# In dialog mode the expander button opens the component-owned dialog, so the
# caller never sets `show_dialog_id` themselves. The button also advertises
# the dialog it controls to assistive technology.
def wire_expander_to_dialog!
button_arguments = (@expander_arguments[:button_arguments] ||= {})
button_arguments[:data] = merge_data(
button_arguments,
data: { show_dialog_id: @dialog_id }
)
button_arguments[:aria] = merge_aria(
button_arguments,
aria: { haspopup: "dialog", controls: @dialog_id }
)
end
end
end
64 changes: 64 additions & 0 deletions app/components/op_primer/vertical_truncate_component.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# frozen_string_literal: true

#-- copyright
# OpenProject is an open source project management software.
# Copyright (C) the OpenProject GmbH
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See COPYRIGHT and LICENSE files for more details.
#++

module OpPrimer
# Clamps block content to a fixed number of lines via CSS `-webkit-line-clamp`,
# styled through the `.op-vertical-truncate` class hierarchy.
#
# The vertical counterpart to `Primer::Beta::Truncate` (which clips a single
# line horizontally). Like `Truncate`, it wraps whatever block content it is
# given; callers pass system arguments (e.g. `flex:`, `data:`) through.
class VerticalTruncateComponent < Primer::Component # rubocop:disable OpenProject/AddPreviewForViewComponent
LINES_RANGE = (1..6)
LINES_DEFAULT = 3

# @param lines [Integer] number of visible rows, clamped to `1..6`.
# @param tag [Symbol] wrapping element; defaults to `:div` (safe for block
# content). Overridable, mirroring `Primer::Beta::Truncate`.
# @param system_arguments [Hash] forwarded to the wrapping `Primer::BaseComponent`.
def initialize(lines: LINES_DEFAULT, **system_arguments)
super()

@system_arguments = system_arguments
@system_arguments[:tag] ||= :div

lines = lines.to_i.clamp(LINES_RANGE)
@system_arguments[:classes] = class_names(
@system_arguments[:classes],
"op-vertical-truncate",
"op-vertical-truncate--lines-#{lines}"
)
end

def call
render(Primer::BaseComponent.new(**@system_arguments)) { content }
end
end
end
15 changes: 15 additions & 0 deletions app/components/op_primer/vertical_truncate_component.sass
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Vertical (multi-line) truncation, the counterpart to Primer's `.Truncate`.
// `OpPrimer::VerticalTruncateComponent` clamps content to `--lines-N` rows; the
// `truncation` Stimulus controller toggles `--expanded` for inline expansion.
.op-vertical-truncate
min-width: 0

@for $i from 1 through 6
&--lines-#{$i}
@include line-clamp($i)

// Defined after the line-clamp modifiers so it wins on equal specificity.
&--expanded
display: block
overflow: visible
-webkit-line-clamp: unset
76 changes: 22 additions & 54 deletions app/components/open_project/common/attribute_component.html.erb
Original file line number Diff line number Diff line change
@@ -1,54 +1,22 @@
<div
data-controller="attribute"
data-attribute-background-reference-id-value="<%= background_reference_id %>"
class="op-long-text-attribute">

<%= render(
Primer::Beta::Text.new(
tag: :div,
classes: ["op-long-text-attribute--text", PARAGRAPH_CSS_CLASS],
color: text_color,
style: "max-height: #{max_height};",
data: {
"attribute-target": "descriptionText"
}
)
) { short_text } %>

<%= render(
Primer::Beta::Text.new(
tag: :div,
display: display_expand_button_value,
classes: "op-long-text-attribute--text-hider",
data: { "attribute-target": "textHider" }
)
) %>

<%= render(
Primer::Alpha::HiddenTextExpander.new(
inline: false,
"aria-label": I18n.t("label_attribute_expand_text", attribute: name),
display: display_expand_button_value,
data: {
"attribute-target": "expandButton",
"test-selector": "expand-button"
},
button_arguments: { "data-show-dialog-id": id },
classes: "op-long-text-attribute--text-expander"
)
) %>

<%= render(
Primer::Alpha::Dialog.new(
id: id,
data: {
"test-selector": "attribute-dialog"
},
title: name,
size: :large
)
) do |component|
component.with_body { full_text }
component.with_header(variant: :large)
end %>
</div>
<%= render(
OpPrimer::ExpandableTextComponent.new(
direction: truncation_direction,
lines:,
expansion: :dialog,
dialog_id: id,
expander_arguments: {
hidden: !show_expander?,
aria: { label: I18n.t("label_attribute_expand_text", attribute: name) },
test_selector: "expand-button"
}
)
) do |component|
component.with_dialog(title: name, size: :large, test_selector: "attribute-dialog") do |dialog|
dialog.with_header(variant: :large)
dialog.with_body_content(full_text)
end
render(
Primer::Beta::Text.new(classes: PARAGRAPH_CSS_CLASS, color: text_color)
.with_content(short_text)
)
end %>
18 changes: 9 additions & 9 deletions app/components/open_project/common/attribute_component.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,22 +36,26 @@ class AttributeComponent < Primer::Component
:name,
:description,
:lines,
:background_reference_id,
:format

PARAGRAPH_CSS_CLASS = "op-uc-p"

def initialize(id, name, description, lines: 1, background_reference_id: "content", format: true, **args)
def initialize(id, name, description, lines: 1, format: true, **args)
super()
@id = id
@name = name
@description = description
@system_arguments = args
@lines = lines
@background_reference_id = background_reference_id
@format = format
end

# `lines` only constrains height in vertical mode; a single line is best
# served by horizontal (single-line ellipsis) truncation.
def truncation_direction
lines > 1 ? :vertical : :horizontal
end

def short_text
if multi_type?
I18n.t(:label_preview_not_available)
Expand All @@ -64,18 +68,14 @@ def full_text
@full_text ||= format ? helpers.format_text(description) : description
end

def display_expand_button_value
multi_type? || body_children.length > 1 ? :block : :none
def show_expander?
multi_type? || body_children.length > 1
end

def text_color
:muted if multi_type?
end

def max_height
"#{lines * 1.6}em"
end

private

# rubocop:disable Rails/OutputSafety
Expand Down
17 changes: 0 additions & 17 deletions app/components/open_project/common/attribute_component.sass

This file was deleted.

Loading
Loading