Module: LcpRuby::Display::CardHelper
- Defined in:
- app/helpers/lcp_ruby/display/card_helper.rb
Overview
Shared zone helpers for card-based layouts (tiles, kanban, future).
Each helper resolves one zone end-to-end: field resolution, permission check, renderer invocation, empty-value placeholder, a11y attributes, HTML escaping. Helpers return either an HTML-safe String (non-blank) or “” (zone suppressed — permission, blank value, or zone not configured).
Per-layout body partials (‘_tile_card_body.html.erb`, `_kanban_card_body.html.erb`) compose these helpers in the order and wrapping HTML each layout needs. Zone resolution is shared; zone arrangement stays with each layout.
Constant Summary collapse
- ACCENT_TOKENS =
{ "success" => "var(--lcp-success)", "danger" => "var(--lcp-danger)", "warning" => "var(--lcp-warning)", "info" => "var(--lcp-info, var(--lcp-text-muted))", "muted" => "var(--lcp-text-muted)", "primary" => "var(--lcp-primary, var(--lcp-link))" }.freeze
Instance Method Summary collapse
-
#card_accent_style(record, card_config) ⇒ Object
Returns: inline style string “–lcp-card-accent: var(–lcp-<token>);” or “”.
-
#card_actions(record, action_set, card_config) ⇒ Object
Returns: <div class=“lcp-card-actions”>…</div> or “”.
-
#card_aria_attributes(record, card_config, presenter) ⇒ Object
Returns: Hash of ARIA attributes for the card root element.
-
#card_avatar(record, card_config) ⇒ Object
Returns: <div class=“lcp-card-avatar”><img …/></div> or “”.
-
#card_description(record, card_config) ⇒ Object
Returns: <div class=“lcp-card-description”>…</div> or “” when suppressed.
-
#card_field_list(record, card_config, column_set:) ⇒ Object
Returns: <div class=“lcp-card-fields”>…</div> with 2-column label/value grid, or “” when no fields visible after permission filtering.
-
#card_image(record, card_config, max_height: nil) ⇒ Object
Returns: <div class=“lcp-card-image”><img …/></div> or “”.
-
#card_subtitle(record, card_config) ⇒ Object
Returns: rendered subtitle div or “” when not configured / unauthorized / blank.
-
#card_title(record, card_config, row_click: nil, title_id: nil) ⇒ Object
Returns: <a> link (when row_click configured + title readable) or <span> (plain) or empty_value_placeholder (unauthorized/blank).
-
#card_title_id(record, presenter_name) ⇒ Object
Deterministic DOM id for the title element (used by aria-labelledby).
Instance Method Details
#card_accent_style(record, card_config) ⇒ Object
Returns: inline style string “–lcp-card-accent: var(–lcp-<token>);” or “”. Value matched against whitelist; raw field values never interpolated.
193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 |
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 193 def card_accent_style(record, card_config) field = card_config["color_field"] return "" if field.blank? return "" unless @column_set.readable_named_slot?(field) raw = @field_resolver.resolve(record, field, fk_map: @fk_map) return "" if raw.blank? token = card_config.dig("color_map", raw.to_s) || workflow_state_color(record, field) || raw.to_s css_var = ACCENT_TOKENS[token.to_s] unless css_var Rails.logger&.warn( "[LcpRuby] card.color_field='#{field}' value '#{raw}' is not a whitelisted palette token; no accent emitted" ) return "" end "--lcp-card-accent: #{css_var};" end |
#card_actions(record, action_set, card_config) ⇒ Object
Returns: <div class=“lcp-card-actions”>…</div> or “”. Respects actions: dropdown|inline|none.
167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 |
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 167 def card_actions(record, action_set, card_config) mode = card_config["actions"] || "dropdown" return "" if mode == "none" actions = action_set.single_actions(record) return "" if actions.empty? body = if mode == "dropdown" content_tag(:div, class: "lcp-actions-dropdown") do concat(content_tag(:button, "…".html_safe, type: "button", class: "btn lcp-dropdown-toggle")) concat( content_tag(:div, class: "lcp-dropdown-menu") do safe_join(actions.map { |a| render(partial: "lcp_ruby/resources/action_button", locals: { action: a, record: record, css_class: "lcp-dropdown-item" }) }) end ) end else safe_join(actions.map { |a| render(partial: "lcp_ruby/resources/action_button", locals: { action: a, record: record, css_class: "lcp-tile-action-btn" }) }) end content_tag(:div, body, class: "lcp-card-actions") end |
#card_aria_attributes(record, card_config, presenter) ⇒ Object
Returns: Hash of ARIA attributes for the card root element. Always includes role: “article” plus exactly one of aria-labelledby (when title is readable + non-blank) or aria-label.
219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 |
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 219 def card_aria_attributes(record, card_config, presenter) attrs = { role: "article" } title_field = card_config["title_field"] title_value = title_field.present? ? resolve_title_value(record, title_field) : nil if title_value.present? attrs["aria-labelledby"] = card_title_id(record, presenter.name) elsif record.respond_to?(:to_label) && record.to_label.present? attrs["aria-label"] = record.to_label else attrs["aria-label"] = I18n.t("lcp_ruby.card.record_fallback", id: record.id, default: "Record ##{record.id}") end attrs end |
#card_avatar(record, card_config) ⇒ Object
Returns: <div class=“lcp-card-avatar”><img …/></div> or “”. Avatar alt defaults to “” (decorative) unless avatar_alt_field is set.
114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 |
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 114 def card_avatar(record, card_config) field = card_config["avatar_field"] return "" if field.blank? return "" unless @column_set.readable_named_slot?(field) value = @field_resolver.resolve(record, field, fk_map: @fk_map) return "" if value.blank? unless image_renderable?(value) if Rails.logger Rails.logger.warn( "[LcpRuby] card.avatar_field='#{field}' value is not a renderable image; zone omitted" ) end return "" end alt = card_avatar_alt(record, card_config) || "" content_tag(:div, image_tag(value, alt: alt), class: "lcp-card-avatar") end |
#card_description(record, card_config) ⇒ Object
Returns: <div class=“lcp-card-description”>…</div> or “” when suppressed.
72 73 74 75 76 77 78 79 80 81 82 83 84 |
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 72 def card_description(record, card_config) field = card_config["description_field"] return "" if field.blank? return "" unless @column_set.readable_named_slot?(field) value = @field_resolver.resolve(record, field, fk_map: @fk_map) return "" if value.blank? max_lines = card_config["description_max_lines"] style = ("--lcp-card-desc-lines: #{max_lines.to_i}" if max_lines) content_tag(:div, value, class: "lcp-card-description", style: style) end |
#card_field_list(record, card_config, column_set:) ⇒ Object
Returns: <div class=“lcp-card-fields”>…</div> with 2-column label/value grid, or “” when no fields visible after permission filtering.
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 |
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 137 def card_field_list(record, card_config, column_set:) fields = column_set.visible_card_fields(card_config) return "" if fields.empty? items = fields.map do |fc| value = @field_resolver.resolve(record, fc["field"], fk_map: @fk_map) field_def = card_field_def(fc["field"]) label = field_label_for(fc, field_def: field_def, model_name: current_model_name) rendered_value = if fc["partial"].present? render_custom_partial( partial: fc["partial"], locals: { value: value, record: record, options: fc["options"] || {} }, context: "card.fields[].partial '#{fc['partial']}' for record_id=#{record.id}" ) elsif fc["renderer"].present? render_display_value(value, fc["renderer"], fc["options"] || {}, field_def, record: record) else empty_value_placeholder(format_enum_display(value, field_def, current_model_name), current_presenter) end content_tag(:div, label, class: "lcp-card-field-label") + content_tag(:div, rendered_value, class: "lcp-card-field-value") end content_tag(:div, safe_join(items), class: "lcp-card-fields") end |
#card_image(record, card_config, max_height: nil) ⇒ Object
Returns: <div class=“lcp-card-image”><img …/></div> or “”. Alt-text fallback chain: image_alt_field → title_field → “”.
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 |
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 88 def card_image(record, card_config, max_height: nil) field = card_config["image_field"] return "" if field.blank? return "" unless @column_set.readable_named_slot?(field) value = @field_resolver.resolve(record, field, fk_map: @fk_map) return "" if value.blank? unless image_renderable?(value) if Rails.logger Rails.logger.warn( "[LcpRuby] card.image_field='#{field}' value is not a renderable image " \ "(not Active Storage attached and not http(s) URL); zone omitted" ) end return "" end alt = card_image_alt(record, card_config) || "" style = ("max-height: #{max_height}" if max_height) content_tag(:div, image_tag(value, alt: alt), class: "lcp-card-image", style: style) end |
#card_subtitle(record, card_config) ⇒ Object
Returns: rendered subtitle div or “” when not configured / unauthorized / blank.
52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 |
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 52 def card_subtitle(record, card_config) field = card_config["subtitle_field"] return "" if field.blank? return "" unless @column_set.readable_named_slot?(field) value = @field_resolver.resolve(record, field, fk_map: @fk_map) field_def = card_field_def(field) rendered = if (renderer_key = card_config["subtitle_renderer"]).present? render_display_value(value, renderer_key, card_config["subtitle_options"] || {}, field_def, record: record) else # Parity with table column rendering — humanize enum values before placeholder fallback. empty_value_placeholder(format_enum_display(value, field_def, current_model_name), current_presenter) end content_tag(:div, rendered, class: "lcp-card-subtitle") end |
#card_title(record, card_config, row_click: nil, title_id: nil) ⇒ Object
Returns: <a> link (when row_click configured + title readable) or <span> (plain) or empty_value_placeholder (unauthorized/blank). Always HTML-safe.
26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 26 def card_title(record, card_config, row_click: nil, title_id: nil) field = card_config["title_field"] return "" if field.blank? value = resolve_title_value(record, field) rendered = empty_value_placeholder(value, current_presenter) # Blank/unauthorized title is the placeholder "—" — never a nav target. return rendered if value.blank? case row_click when "show" link_to(rendered, resource_path(record), data: { turbo_frame: "_top" }) when Hash if row_click["mode"] == "filter" && record.respond_to?(:group_values) url = grouped_row_click_url(record, current_presenter) url ? link_to(rendered, url, data: { turbo_frame: "_top" }) : rendered else rendered end else rendered end end |
#card_title_id(record, presenter_name) ⇒ Object
Deterministic DOM id for the title element (used by aria-labelledby). Keyed by presenter name (not model) so multiple presenters of the same model on one page (e.g., a composite dashboard) produce unique ids.
238 239 240 |
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 238 def card_title_id(record, presenter_name) "card-#{presenter_name}-#{record.id}-title" end |