|
| 1 | +module Layered |
| 2 | + module Ui |
| 3 | + module TagHelper |
| 4 | + # Renders an interactive tag - e.g. an email recipient or an active |
| 5 | + # filter. Visually related to +l-ui-badge+, but a distinct control: a |
| 6 | + # badge is a static styled span, while a tag is a container whose |
| 7 | + # segments (label, remove) are separately interactive. |
| 8 | + # |
| 9 | + # <%= l_ui_tag do |t| %> |
| 10 | + # <% t.text "alice@example.com" %> |
| 11 | + # <% t.remove aria: { label: "Remove alice@example.com" } %> |
| 12 | + # <% end %> |
| 13 | + # |
| 14 | + # A tag may carry a popover. The whole tag becomes the placement target |
| 15 | + # (so the popover aligns with the tag rather than the segment inside |
| 16 | + # it), and button segments open it via +popovertarget+: |
| 17 | + # |
| 18 | + # <%= l_ui_tag do |t| %> |
| 19 | + # <% t.button(aria: { label: "Edit status filter" }) do %> |
| 20 | + # Status: Active |
| 21 | + # <% end %> |
| 22 | + # <% t.remove remove_filter_path, aria: { label: "Remove status filter" } %> |
| 23 | + # <% t.popover do %> |
| 24 | + # <p>Filter controls.</p> |
| 25 | + # <% end %> |
| 26 | + # <% end %> |
| 27 | + # |
| 28 | + # Note: use +<% t.button %>+ (without the equals sign) so segment content |
| 29 | + # is captured by the builder rather than written to the body buffer. |
| 30 | + # |
| 31 | + # Segments render in the order they are declared; the popover element is |
| 32 | + # always appended last. Give icon-only segments (such as +t.remove+) an |
| 33 | + # +aria-label+ so they have an accessible name. |
| 34 | + # |
| 35 | + # Options: |
| 36 | + # rounded: (Boolean) Pill shape (matching +l-ui-badge--rounded+); defaults to false for the subtle badge shape. |
| 37 | + # container: (Hash) Extra HTML attributes for the wrapping <div>. |
| 38 | + # |
| 39 | + # Builder methods: |
| 40 | + # t.text(content = nil, **options, &block) Static label segment (<span>). |
| 41 | + # t.button(**options, &block) Button segment; opens the tag's popover when one is declared. |
| 42 | + # t.link(url, **options, &block) Link segment, styled like a button segment. |
| 43 | + # t.remove(url = nil, **options, &block) Trailing remove segment: a link when +url+ is given, otherwise a |
| 44 | + # button. Renders a ✕ icon unless the block supplies custom content. |
| 45 | + # t.popover(id: nil, placement: :bottom, align: :start, &block) |
| 46 | + # Attaches a popover to the tag; the block is the popover body. |
| 47 | + # Options match +l_ui_popover+. |
| 48 | + def l_ui_tag(rounded: false, container: {}, &block) |
| 49 | + builder = TagBuilder.new(self) |
| 50 | + capture { block.call(builder) } |
| 51 | + |
| 52 | + container_attrs = container.deep_dup |
| 53 | + container_attrs[:class] = class_names("l-ui-tag", ("l-ui-tag--rounded" if rounded), container_attrs[:class]) |
| 54 | + |
| 55 | + if builder.popover? |
| 56 | + container_data = container_attrs[:data] || {} |
| 57 | + existing_controller = container_data.delete(:controller) || container_data.delete("controller") |
| 58 | + container_data[:controller] = [existing_controller, "l-ui--popover"].compact.reject(&:empty?).join(" ") |
| 59 | + container_data[:"l-ui--popover-target"] = "trigger" |
| 60 | + container_data[:"l-ui--popover-placement-value"] = builder.popover_placement |
| 61 | + container_data[:"l-ui--popover-align-value"] = builder.popover_align |
| 62 | + container_attrs[:data] = container_data |
| 63 | + end |
| 64 | + |
| 65 | + tag.div(**container_attrs) do |
| 66 | + parts = builder.segments.map { |segment| l_ui_tag_segment(builder, segment) } |
| 67 | + parts << l_ui_tag_popover(builder) if builder.popover? |
| 68 | + safe_join(parts) |
| 69 | + end |
| 70 | + end |
| 71 | + |
| 72 | + class TagBuilder |
| 73 | + attr_reader :segments, :popover_id, :popover_placement, :popover_align, :popover_body |
| 74 | + |
| 75 | + def initialize(view) |
| 76 | + @view = view |
| 77 | + @segments = [] |
| 78 | + end |
| 79 | + |
| 80 | + def text(content = nil, **options, &block) |
| 81 | + content = @view.capture(&block) if block |
| 82 | + @segments << { kind: :text, content: content, options: options } |
| 83 | + nil |
| 84 | + end |
| 85 | + |
| 86 | + def button(**options, &block) |
| 87 | + content = block ? @view.capture(&block) : nil |
| 88 | + @segments << { kind: :button, content: content, options: options } |
| 89 | + nil |
| 90 | + end |
| 91 | + |
| 92 | + def link(url, **options, &block) |
| 93 | + content = block ? @view.capture(&block) : nil |
| 94 | + @segments << { kind: :link, url: url, content: content, options: options } |
| 95 | + nil |
| 96 | + end |
| 97 | + |
| 98 | + def remove(url = nil, **options, &block) |
| 99 | + content = block ? @view.capture(&block) : nil |
| 100 | + @segments << { kind: :remove, url: url, content: content, options: options } |
| 101 | + nil |
| 102 | + end |
| 103 | + |
| 104 | + def popover(id: nil, placement: :bottom, align: :start, &block) |
| 105 | + @popover_id = id || "l-ui-tag-popover-#{SecureRandom.hex(4)}" |
| 106 | + @popover_placement = placement |
| 107 | + @popover_align = align |
| 108 | + @popover_body = @view.capture(&block) |
| 109 | + nil |
| 110 | + end |
| 111 | + |
| 112 | + def popover? |
| 113 | + !@popover_id.nil? |
| 114 | + end |
| 115 | + end |
| 116 | + |
| 117 | + private |
| 118 | + |
| 119 | + def l_ui_tag_segment(builder, segment) |
| 120 | + options = segment[:options].deep_dup |
| 121 | + |
| 122 | + case segment[:kind] |
| 123 | + when :text |
| 124 | + options[:class] = class_names("l-ui-tag__text", options[:class]) |
| 125 | + tag.span(segment[:content], **options) |
| 126 | + when :button |
| 127 | + options[:class] = class_names("l-ui-tag__button", options[:class]) |
| 128 | + options[:type] ||= "button" |
| 129 | + options[:popovertarget] ||= builder.popover_id if builder.popover? |
| 130 | + tag.button(segment[:content], **options) |
| 131 | + when :link |
| 132 | + options[:class] = class_names("l-ui-tag__button", options[:class]) |
| 133 | + link_to(segment[:content], segment[:url], options) |
| 134 | + when :remove |
| 135 | + options[:class] = class_names("l-ui-tag__remove", options[:class]) |
| 136 | + content = segment[:content] || l_ui_tag_remove_icon |
| 137 | + if segment[:url] |
| 138 | + link_to(content, segment[:url], options) |
| 139 | + else |
| 140 | + options[:type] ||= "button" |
| 141 | + tag.button(content, **options) |
| 142 | + end |
| 143 | + end |
| 144 | + end |
| 145 | + |
| 146 | + # Filled x-circle, matching the bundled +icon_close_circle.svg+ asset. |
| 147 | + # Rendered inline (rather than via image_tag) so it inherits the tag's |
| 148 | + # muted +currentColor+; an <img>-loaded SVG cannot. |
| 149 | + def l_ui_tag_remove_icon |
| 150 | + tag.svg( |
| 151 | + tag.path("fill-rule" => "evenodd", "clip-rule" => "evenodd", |
| 152 | + "d" => "M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25Zm-1.72 6.97a.75.75 0 0 0-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 1 0 1.06 1.06L12 13.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L13.06 12l1.72-1.72a.75.75 0 1 0-1.06-1.06L12 10.94l-1.72-1.72Z"), |
| 153 | + class: "l-ui-icon--sm", |
| 154 | + fill: "currentColor", |
| 155 | + "viewBox" => "0 0 24 24", |
| 156 | + "aria-hidden" => "true" |
| 157 | + ) |
| 158 | + end |
| 159 | + |
| 160 | + def l_ui_tag_popover(builder) |
| 161 | + tag.div( |
| 162 | + builder.popover_body, |
| 163 | + id: builder.popover_id, |
| 164 | + popover: "auto", |
| 165 | + class: "l-ui-popover", |
| 166 | + data: { "l-ui--popover-target" => "popover" } |
| 167 | + ) |
| 168 | + end |
| 169 | + end |
| 170 | + end |
| 171 | +end |
0 commit comments