Repository: primer/view_components (Ruby)

Issue:
`Primer::Beta::Link` is missing support for trainling and leading visuals
# Steps to reproduce

* Go to the specification of the Link component and look at the anatomy: https://primer.style/components/link#anatomy 
* There is a support for trailing and leading visuals defined

<img width="400" alt="Bildschirmfoto 2024-07-30 um 07 48 27" src="https://github.com/user-attachments/assets/b94d0543-da2a-422d-b706-c064686280ce">

# Current behavior

* The Link component supports neither a trailing nor a leading visual

# Expected behavior

* There are slots for one leading and one trailing visual defined in the `Primer::Beta::Link` component


Discussion:
Thanks so much for contributing the PR! Primer engineers have been tagged for review so we'll try to take a look soon.



Patch:
diff --git a/.changeset/odd-dots-laugh.md b/.changeset/odd-dots-laugh.md
new file mode 100644
index 0000000000..8667f57324
--- /dev/null
+++ b/.changeset/odd-dots-laugh.md
@@ -0,0 +1,5 @@
+---
+'@primer/view-components': minor
+---
+
+Support leading and trailing icons for Links
diff --git a/app/components/primer/base_component.rb b/app/components/primer/base_component.rb
index 061f6a921d..4b0ea3f136 100644
--- a/app/components/primer/base_component.rb
+++ b/app/components/primer/base_component.rb
@@ -151,6 +151,7 @@ class BaseComponent < Primer::Component
     # | :- | :- | :- |
     # | classes | String | CSS class name value to be concatenated with generated Primer CSS classes. |
     # | test_selector | String | Adds `data-test-selector='given value'` in non-Production environments for testing purposes. |
+    # | trim | Boolean | Calls `strip` on the content to remove trailing and leading white spaces. |
     def initialize(tag:, classes: nil, **system_arguments)
       @tag = tag
 
@@ -158,6 +159,8 @@ def initialize(tag:, classes: nil, **system_arguments)
 
       @result = Primer::Classify.call(**@system_arguments.merge(classes: classes))
 
+      @trim = !!@system_arguments.delete(:trim)
+
       @system_arguments[:"data-view-component"] = true
       # Filter out Primer keys so they don't get assigned as HTML attributes
       @content_tag_args = add_test_selector(@system_arguments).except(*Primer::Classify::Utilities::UTILITIES.keys)
@@ -167,7 +170,7 @@ def call
       if SELF_CLOSING_TAGS.include?(@tag)
         tag(@tag, @content_tag_args.merge(@result))
       else
-        content_tag(@tag, content, @content_tag_args.merge(@result))
+        content_tag(@tag, @trim ? trimmed_content : content, @content_tag_args.merge(@result))
       end
     end
   end
diff --git a/app/components/primer/beta/link.html.erb b/app/components/primer/beta/link.html.erb
new file mode 100644
index 0000000000..c6abde2bf6
--- /dev/null
+++ b/app/components/primer/beta/link.html.erb
@@ -0,0 +1,16 @@
+<%= render Primer::ConditionalWrapper.new(condition: tooltip?, trim: true, tag: :span, position: :relative) do %>
+  <%= render(Primer::BaseComponent.new(trim: true, **@system_arguments)) do %>
+    <% if leading_visual %>
+      <span>
+        <%= leading_visual %>
+      </span>
+    <% end %>
+    <%= content %>
+    <% if trailing_visual %>
+      <span>
+        <%= trailing_visual %>
+      </span>
+    <% end %>
+  <% end %>
+  <%= tooltip if tooltip? %>
+<% end %>
diff --git a/app/components/primer/beta/link.rb b/app/components/primer/beta/link.rb
index 91462e6ae7..1fdfd0442a 100644
--- a/app/components/primer/beta/link.rb
+++ b/app/components/primer/beta/link.rb
@@ -30,6 +30,32 @@ class Link < Primer::Component
         Primer::Alpha::Tooltip.new(**system_arguments)
       }
 
+      # Leading visuals appear to the left of the link text.
+      #
+      # Use:
+      #
+      # - `leading_visual_icon` which accepts the arguments accepted by <%= link_to_component(Primer::Beta::Octicon) %>.
+      #
+      # @param system_arguments [Hash] Same arguments as <%= link_to_component(Primer::Beta::Octicon) %>.
+      renders_one :leading_visual, types: {
+        icon: lambda { |**system_arguments|
+          Primer::Beta::Octicon.new(**system_arguments)
+        }
+      }
+
+      # Trailing visuals appear to the right of the link text.
+      #
+      # Use:
+      #
+      # - `trailing_visual_icon` which accepts the arguments accepted by <%= link_to_component(Primer::Beta::Octicon) %>.
+      #
+      # @param system_arguments [Hash] Same arguments as <%= link_to_component(Primer::Beta::Octicon) %>.
+      renders_one :trailing_visual, types: {
+        icon: lambda { |**system_arguments|
+          Primer::Beta::Octicon.new(**system_arguments)
+        }
+      }
+
       # @param href [String] URL to be used for the Link. Required. If the requirements are not met an error will be raised in non production environments. In production, an empty link element will be rendered.
       # @param scheme [Symbol] <%= one_of(Primer::Beta::Link::SCHEME_MAPPINGS.keys) %>
       # @param muted [Boolean] Uses light gray for Link color, and blue on hover.
@@ -54,20 +80,6 @@ def initialize(href: nil, scheme: DEFAULT_SCHEME, muted: false, underline: false
       def before_render
         raise ArgumentError, "href is required" if @system_arguments[:href].nil? && !Rails.env.production?
       end
-
-      def call
-        if tooltip.present?
-          render Primer::BaseComponent.new(tag: :span, position: :relative) do
-            render(Primer::BaseComponent.new(**@system_arguments)) do
-              content
-            end.to_s + tooltip.to_s
-          end
-        else
-          render(Primer::BaseComponent.new(**@system_arguments)) do
-            content
-          end
-        end
-      end
     end
   end
 end
diff --git a/app/components/primer/component.rb b/app/components/primer/component.rb
index 79c416954f..abc7a16f07 100644
--- a/app/components/primer/component.rb
+++ b/app/components/primer/component.rb
@@ -144,5 +144,12 @@ def should_raise_error?
     def should_raise_aria_error?
       !Rails.env.production? && raise_on_invalid_aria? && !ENV["PRIMER_WARNINGS_DISABLED"]
     end
+
+    def trimmed_content
+      return content unless content.present?
+
+      # strip unsets `html_safe`, so we have to set it back again to guarantee that HTML blocks won't break
+      content.html_safe? ? content.strip.html_safe : content.strip # rubocop:disable Rails/OutputSafety
+    end
   end
 end
diff --git a/app/components/primer/conditional_wrapper.rb b/app/components/primer/conditional_wrapper.rb
index 57c61e93b7..27fcc46368 100644
--- a/app/components/primer/conditional_wrapper.rb
+++ b/app/components/primer/conditional_wrapper.rb
@@ -10,12 +10,15 @@ class ConditionalWrapper < Primer::Component
     def initialize(condition:, **base_component_arguments)
       @condition = condition
       @base_component_arguments = base_component_arguments
+      @trim = !!@base_component_arguments.delete(:trim)
     end
 
     def call
-      return content unless @condition
+      unless @condition
+        return @trim ? trimmed_content : content
+      end
 
-      BaseComponent.new(**@base_component_arguments).render_in(self) { content }
+      BaseComponent.new(trim: @trim, **@base_component_arguments).render_in(self) { content }
     end
   end
 end
diff --git a/previews/primer/beta/link_preview.rb b/previews/primer/beta/link_preview.rb
index b8e0734342..0a76c35cd4 100644
--- a/previews/primer/beta/link_preview.rb
+++ b/previews/primer/beta/link_preview.rb
@@ -9,8 +9,14 @@ class LinkPreview < ViewComponent::Preview
       # @param underline [Boolean]
       # @param muted [Boolean]
       # @param scheme [Symbol] select [default, primary, secondary]
-      def playground(scheme: :default, muted: false, underline: true)
-        render(Primer::Beta::Link.new(href: "#", scheme: scheme, muted: muted, underline: underline)) { "This is a link!" }
+      # @param leading_visual_icon [Symbol] octicon
+      # @param trailing_visual_icon [Symbol] octicon
+      def playground(scheme: :default, muted: false, underline: true, leading_visual_icon: nil, trailing_visual_icon: nil)
+        render(Primer::Beta::Link.new(href: "#", scheme: scheme, muted: muted, underline: underline)) do |link|
+          link.with_leading_visual_icon(icon: leading_visual_icon) if leading_visual_icon && leading_visual_icon != :none
+          link.with_trailing_visual_icon(icon: trailing_visual_icon) if trailing_visual_icon && trailing_visual_icon != :none
+          "This is a link!"
+        end
       end
 
       # @label Default Options
@@ -66,6 +72,22 @@ def color_scheme_secondary_muted
         render(Primer::Beta::Link.new(href: "#", scheme: :secondary, muted: true)) { "This is a muted secondary link color." }
       end
       # @!endgroup
+
+      # @label With leading icon
+      def with_leading_icon
+        render(Primer::Beta::Link.new(href: "#")) do |component|
+          component.with_leading_visual_icon(icon: :"mark-github")
+          "Link with leading icon"
+        end
+      end
+
+      # @label With trailing icon
+      def with_trailing_icon
+        render(Primer::Beta::Link.new(href: "#")) do |component|
+          component.with_trailing_visual_icon(icon: :"link-external")
+          "Link with trailing icon"
+        end
+      end
     end
   end
 end


Test patch:
diff --git a/test/components/beta/link_test.rb b/test/components/beta/link_test.rb
index dbfbd7e94c..b8d5471763 100644
--- a/test/components/beta/link_test.rb
+++ b/test/components/beta/link_test.rb
@@ -92,4 +92,26 @@ def test_renders_with_tooltip_sibling
 
     assert_selector("a[href='http://google.com'] + tool-tip", text: "Tooltip text", visible: false)
   end
+
+  def test_renders_leading_visual_icon
+    render_inline(Primer::Beta::Link.new(href: "http://google.com")) do |component|
+      component.with_leading_visual_icon(icon: "plus")
+      "content"
+    end
+
+    assert_selector("a[href='http://google.com']")
+    assert_selector(".octicon-plus")
+  end
+
+  def test_renders_trailing_visual_icon
+    render_inline(Primer::Beta::Link.new(href: "http://google.com")) do |component|
+      component.with_leading_visual_icon(icon: "plus")
+      component.with_trailing_visual_icon(icon: "alert")
+      "content"
+    end
+
+    assert_selector("a[href='http://google.com']")
+    assert_selector("a span:first-child .octicon-plus")
+    assert_selector("a span:nth-child(2) .octicon-alert")
+  end
 end