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.
208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 |
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 208 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.
182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 |
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 182 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.
234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 |
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 234 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.
128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 |
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 128 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.
80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 |
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 80 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? # Humanize when description_field points to an enum (uncommon but # supported). Non-enum fields pass through unchanged; has_many Arrays # render comma-joined via the collection renderer. fd, fd_model = (field) display = card_display_value(value, fd, fd_model || current_model_name) max_lines = card_config["description_max_lines"] style = ("--lcp-card-desc-lines: #{max_lines.to_i}" if max_lines) content_tag(:div, display, 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.
151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 |
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 151 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, fd_model = (fc["field"]) fd_model ||= current_model_name label = field_label_for(fc, field_def: field_def, model_name: fd_model) 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, model_name: fd_model) else empty_value_placeholder(card_display_value(value, field_def, fd_model), 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 → “”.
102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 |
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 102 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.
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 58 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, fd_model = (field) fd_model ||= current_model_name rendered = if (renderer_key = card_config["subtitle_renderer"]).present? render_display_value(value, renderer_key, card_config["subtitle_options"] || {}, field_def, record: record, model_name: fd_model) else # Parity with table column rendering — humanize enum values # (scalar or has_many Array) before placeholder fallback. empty_value_placeholder(card_display_value(value, field_def, fd_model), 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 50 51 52 53 54 55 |
# 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) # Humanize when title_field points to an enum (issue #18 — resolver # now returns raw enum keys). Non-enum fields pass through unchanged; # a has_many dot-path Array renders comma-joined via the collection # renderer (card_display_value), same as subtitle/description — a bare # format_enum_display would print the Ruby array literal. fd, model_name = (field) rendered = empty_value_placeholder(card_display_value(value, fd, model_name || current_model_name), 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.
253 254 255 |
# File 'app/helpers/lcp_ruby/display/card_helper.rb', line 253 def card_title_id(record, presenter_name) "card-#{presenter_name}-#{record.id}-title" end |