Module: LcpRuby::LayoutHelper

Defined in:
app/helpers/lcp_ruby/layout_helper.rb

Constant Summary collapse

PROVIDER_RETURN_ALLOWED_KEYS =

MenuItem-shaped keys allowed in provider returns. ‘provider:` and `panel_provider:` are stripped (chains are banned — each YAML position resolves to one source, never a chain).

%w[
  type label label_key icon url method aria_label aria_label_key
  badge children visible_when disable_when presenter alias action
  defaults render render_panel widget position options
].freeze

Instance Method Summary collapse

Instance Method Details

#apply_path_template(text) ⇒ Object

Substitute ‘LcpRuby::LayoutHelper.namespacenamespace.member` tokens in a label/aria string at render time, mirroring `menu_item_url`’s template handling. nil in → nil out. Runtime errors degrade safely in production: the untemplated raw string falls through so the item still renders.



568
569
570
571
572
573
574
575
576
577
578
579
580
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 568

def apply_path_template(text)
  return text unless text.is_a?(String) && text.include?("{")

  LcpRuby::Metadata::PathTemplate.resolve(
    text,
    current_user: LcpRuby::Current.user,
    request: (request if respond_to?(:request))
  )
rescue LcpRuby::MenuRenderError => e
  raise unless Rails.env.production?
  LcpRuby.record_error(e, subsystem: "menu_path_template", text: text)
  text
end

#build_menu_request_contextObject

Builds the ‘request_context:` hash passed to provider classes. Provider may run outside a controller-driven request (cache warm-up, mailer, background job that builds an HTML preview); `path: nil` and `params: {}` are documented fallbacks.



309
310
311
312
313
314
315
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 309

def build_menu_request_context
  if respond_to?(:request) && request
    { path: request.path, params: request.filtered_parameters }
  else
    { path: nil, params: {} }
  end
end

#current_density_defaultObject



772
773
774
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 772

def current_density_default
  _preference_default("density", %w[comfortable compact], :density, :default)
end

#current_radius_defaultObject



776
777
778
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 776

def current_radius_default
  _preference_default("border_radius", %w[rounded sharp], :border_radius, :default)
end

#current_theme_defaultObject

— Theme helpers —



768
769
770
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 768

def current_theme_default
  _preference_default("theme", %w[auto light dark], :default)
end

#expand_menu_provider(item, user:, request_context:, depth:) ⇒ Object

Calls a ‘provider:` class and expands its return into the walker’s current depth. Returns an array of MenuItem instances (siblings to be flat_mapped in by the walker). nil/[] returns produce silent collapse.

Provider returns are validated through MenuItem.from_hash_at_depth with the same destination mutex + structural rules as YAML —invalid shapes raise in dev/test, record_error + drop in prod. Depth is threaded so providers can’t sneak past MAX_NESTING_DEPTH.



221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 221

def expand_menu_provider(item, user:, request_context:, depth:)
  provider_class = Services::Registry.lookup("menu_items", item.provider_name)
  unless provider_class
    raise LcpRuby::MetadataError, "unknown menu_items provider: #{item.provider_name}"
  end

  result = provider_class.call(
    options: item.provider_options || {},
    current_user: user,
    request_context: request_context
  )
  return [] if result.nil? || result == []

  # Strip every destination + provider-specific kwarg from the
  # original item's snapshot — only modifiers (label, icon,
  # position, etc.) carry over to the expanded children. Provider
  # supplies the destination via its returned hash, so leaving
  # `children: []` (constructor default) in base_kwargs would
  # collide with a provider-returned `url:` and trip the
  # destination mutex check at from_hash_at_depth.
  base_modifiers = {
    "label" => item.label.is_a?(String) ? item.label : nil,
    "label_key" => item.label_key,
    "icon" => item.icon,
    "aria_label" => item.aria_label,
    "aria_label_key" => item.aria_label_key,
    "position" => item.position,
    "badge" => item.badge,
    "visible_when" => (item.visible_when.empty? ? nil : item.visible_when),
    "disable_when" => (item.disable_when.empty? ? nil : item.disable_when),
    "render" => item.render_partial
  }.compact

  Array.wrap(result).flat_map do |hash|
    sanitized = filter_provider_keys(hash, item.provider_name)
    # Provider keys win on conflict — provider's label: overrides
    # YAML's label: per spec.
    merged = base_modifiers.merge(sanitized)

    constructed = LcpRuby::Metadata::MenuItem.from_hash_at_depth(merged, depth)

    # Provider may have returned a :group with children — recurse.
    if constructed.children.any?
      [ constructed.with(children: visible_menu_items(constructed.children, depth: depth + 1)) ]
    else
      [ constructed ]
    end
  end
rescue LcpRuby::MetadataError
  raise # depth overflow / structural — never silently swallow
rescue StandardError => e
  raise unless Rails.env.production?
  LcpRuby.record_error(
    e, subsystem: "menu_item_provider", provider: item.provider_name
  )
  []
end

#field_label_for(col_or_field, field_def: nil, model_name: nil) ⇒ Object

Resolves a column or field label using the same fallback chain as FieldDefinition#resolved_label, with one extra step for synthetic fields (timestamps, FK columns, dotted association paths) that don’t have a FieldDefinition object.

1. col_or_field["label"] — explicit override in presenter YAML
2. field_def.resolved_label(model_name:) — model-scoped + global i18n
3. I18n.t("lcp_ruby.fields.<basename>") — global, when no field_def
4. basename.humanize


12
13
14
15
16
17
18
19
20
21
22
23
24
25
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 12

def field_label_for(col_or_field, field_def: nil, model_name: nil)
  explicit = col_or_field.is_a?(Hash) ? (col_or_field["label"] || col_or_field[:label]) : nil
  return explicit if explicit.present?

  field_name = (col_or_field.is_a?(Hash) ? (col_or_field["field"] || col_or_field[:field]) : col_or_field).to_s
  basename = field_name.split(".").last

  if field_def
    from_field = field_def.resolved_label(model_name: model_name)
    return from_field if from_field.present?
  end

  I18n.t("lcp_ruby.fields.#{basename}", default: basename.humanize)
end

#filter_provider_keys(hash, provider_name) ⇒ Object

Filters a provider-returned hash to the whitelisted MenuItem keys. Drops ‘provider:` and `panel_provider:` (chains are banned). Drops anything else with the canonical idiom — raise in dev/test, record_error in production.



283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 283

def filter_provider_keys(hash, provider_name)
  stringified = hash.transform_keys(&:to_s)
  sanitized = {}

  stringified.each do |k, v|
    if PROVIDER_RETURN_ALLOWED_KEYS.include?(k)
      sanitized[k] = v
    else
      err = LcpRuby::MetadataError.new(
        "menu_items provider '#{provider_name}' returned forbidden key " \
        "#{k.inspect}; allowed keys: #{PROVIDER_RETURN_ALLOWED_KEYS.join(', ')}"
      )
      raise err unless Rails.env.production?
      LcpRuby.record_error(
        err, subsystem: "menu_item_provider", provider: provider_name, key: k
      )
    end
  end

  sanitized
end

#hidden_on_classes(config) ⇒ Object



52
53
54
55
56
57
58
59
60
61
62
63
64
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 52

def hidden_on_classes(config)
  return "" unless config.is_a?(Hash)
  classes = []
  hidden_on = config["hidden_on"]
  if hidden_on.is_a?(Array)
    hidden_on.each do |breakpoint|
      classes << "lcp-hidden-#{breakpoint}"
    end
  elsif hidden_on.is_a?(String)
    classes << "lcp-hidden-#{hidden_on}"
  end
  classes.join(" ")
end

#inject_sidebar_toggle_if_needed(items, menu_def) ⇒ Object

Internal helper; views should call ‘visible_top_menu_items` instead. Lives at the helper layer (not inside `visible_menu_items`) so the synth has access to the resolved responsive policy and the caller context (top vs sidebar menu).



715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 715

def inject_sidebar_toggle_if_needed(items, menu_def)
  policy = menu_def&.responsive
  return items unless policy
  return items unless menu_def&.has_sidebar_menu?
  return items unless policy.sidebar_below_breakpoint == "off_canvas"
  return items unless policy.sidebar_auto_inject_toggle?
  return items if items.any?(&:sidebar_toggle?)

  # Positional argument (not kwarg splat) — Ruby 3 would otherwise
  # reinterpret a string-keyed hash as kwargs and raise ArgumentError.
  synthetic = LcpRuby::Metadata::MenuItem.from_hash({
    "sidebar_toggle"  => true,
    "icon"            => "menu",
    "aria_label_key"  => "lcp_ruby.nav.open_menu",
    "position"        => "left"
  })

  [ synthetic ] + items
end

#lcp_page_title_tagObject



760
761
762
763
764
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 760

def lcp_page_title_tag
  default_title = Rails.application.class.module_parent_name.humanize
  app_title = I18n.t("lcp_ruby.app.title", default: default_title).presence || default_title
  (:title, [ content_for(:title).presence, app_title ].compact.join(" \u2014 "))
end


638
639
640
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 638

def menu_bottom_items(items)
  items.select(&:bottom?)
end

— Menu system helpers —

Returns:

  • (Boolean)


101
102
103
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 101

def menu_defined?
  LcpRuby.loader.menu_defined?
end


105
106
107
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 105

def menu_definition
  LcpRuby.loader.menu_definition
end

Check if a menu item is active for the current slug

Returns:

  • (Boolean)


488
489
490
491
492
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 488

def menu_item_active?(item, current_slug)
  return false if current_slug.blank?

  item.contains_slug?(current_slug, LcpRuby.loader)
end

Render badge HTML for a menu item, or nil if no badge / provider returns nil



614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 614

def menu_item_badge(item)
  return nil unless item.has_badge?

  provider = Services::Registry.lookup("data_providers", item.badge_provider)
  unless provider
    Rails.logger.debug("[LcpRuby::Menu] Data provider '#{item.badge_provider}' not registered")
    return nil
  end

  data = provider.call(user: LcpRuby::Current.user)
  return nil if data.nil?

  render_menu_badge(item, data)
rescue => e
  raise unless Rails.env.production?
  LcpRuby.record_error(e, subsystem: "menu_badge", badge_provider: item.badge_provider)
  nil
end

Render the icon placeholder for a menu item. Emits an ‘<i data-lucide=“…”>` element which Lucide replaces with an inline SVG (see `app/assets/javascripts/lcp_ruby/lucide_init.js`). Always carries `aria-hidden=“true”` so the icon contributes nothing to the surrounding link/button’s accessible name —see the W3C accessible-name algorithm. Returns nil when the item has no resolved icon, so callers can use ‘<%= menu_item_icon_html(item) %>` unconditionally.



590
591
592
593
594
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 590

def menu_item_icon_html(item)
  icon = item.resolved_icon(LcpRuby.loader)
  return nil if icon.blank?
  (:i, "", "data-lucide" => icon, class: "lcp-menu-icon", "aria-hidden" => "true")
end

True when the item renders nothing visible besides an icon —used by the layout partials to attach the ‘–icon-only` class to the surrounding `<li>` for compact spacing.

Items with ‘render:` (custom trigger renderer like lcp_ruby/user_menu) are NOT icon-only — the renderer owns full inner content including any label/avatar/caret it chooses. The framework can’t introspect what the partial draws, so we conservatively assume the renderer supplies a visible name and skip icon-only spacing rules. This keeps render-based items aligned with regular group headers (icon column ~16px) instead of being centered like real icon-only items (notifications bell, help link, etc.).

Returns:

  • (Boolean)


608
609
610
611
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 608

def menu_item_icon_only?(item)
  return false if item.render_partial.present?
  menu_item_label(item).blank?
end

Resolve the visible display label for a menu item. Returns nil when the item renders icon-only (no ‘label`, or `label: false`, or a view_group whose presenter exposes no resolvable label — the validator catches the misconfiguration form at boot).



550
551
552
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 550

def menu_item_label(item)
  apply_path_template(item.resolved_label(LcpRuby.loader))
end

Resolve the accessible name for a menu item — used by templates to set ‘aria-label` on the link/button when no visible label would render. When a visible label IS rendered, returns nil so the link’s accessible name comes from the visible text instead (preventing screen-reader duplication or aria-label shadowing).



559
560
561
562
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 559

def menu_item_link_aria_label(item)
  return nil if menu_item_label(item).present?
  apply_path_template(item.resolved_aria_label(LcpRuby.loader))
end

Generate the path for a menu item. Kept for backwards-compat with any external caller; the wrappers below now use the richer ‘menu_item_url` which also returns the HTTP method (so `link_to` vs `button_to` dispatch is possible).



498
499
500
501
502
503
504
505
506
507
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 498

def menu_item_path(item)
  if item.view_group?
    slug = item.resolved_slug(LcpRuby.loader)
    return nil unless slug

    lcp_ruby.resources_path(lcp_slug: slug)
  elsif item.link?
    item.url
  end
end

Resolves a menu item to a ‘{ url:, method: }` hash for rendering. The method dictates `link_to` (GET) vs `button_to` (non-GET) in the navigation partials. Returns nil when the URL cannot be resolved (unknown view_group page, unregistered presenter, or path-template runtime error in production —`record_error` fires once and the item silently drops).

Centralizes the path-template runtime rescue here so the layout partials don’t need their own ‘rescue` branches; any MenuRenderError surfaces in dev/test (raised) but is swallowed in production with the canonical idiom (record_error + return nil → guard at `if link_data` skips the wrapper).



521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 521

def menu_item_url(item)
  if item.view_group?
    slug = item.resolved_slug(LcpRuby.loader)
    return nil unless slug
    { url: lcp_ruby.resources_path(lcp_slug: slug), method: :get }
  elsif item.presenter_slug.present?
    LcpRuby::Metadata::MenuItemResolver.resolve_presenter(item, loader: LcpRuby.loader)
  elsif item.url.present?
    resolved = LcpRuby::Metadata::PathTemplate.resolve(
      item.url,
      current_user: LcpRuby::Current.user,
      request: (request if respond_to?(:request))
    )
    { url: resolved, method: item.http_method || :get }
  end
rescue LcpRuby::MenuRenderError => e
  raise unless Rails.env.production?
  LcpRuby.record_error(
    e,
    subsystem: "menu_path_template",
    item: item.label || item.url || item.presenter_slug || "?"
  )
  nil
end

Check if a menu item should be visible to the current user. ‘helper:` is forwarded into MenuItem#meets_visibility? so the per-render permission-evaluator cache lives at the helper level (one cache per render pass, naturally scoped by view-context lifetime).

Returns:

  • (Boolean)


173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 173

def menu_item_visible?(item, helper: self)
  return true if item.separator?

  # Conditional visibility — full ConditionEvaluator surface
  # (service:, all/any/not) PLUS presenter-permission gate for
  # `presenter:` items (can_access_presenter? + can?/can_execute_action?).
  # Empty visible_when + no presenter_slug → always visible.
  return false unless item.meets_visibility?(LcpRuby::Current.user, helper)

  # View group items: check presenter accessibility
  if item.view_group?
    vg = LcpRuby.loader.view_group_definitions[item.view_group_name]
    return false unless vg

    page = LcpRuby.loader.page_definitions[vg.primary_page]
    return false unless page&.routable?

    # Standalone pages (dashboards) have no presenter — skip presenter check,
    # visibility is controlled by visible_when in menu.yml
    presenter = LcpRuby.loader.presenter_definitions[page.main_presenter_name]

    # Standalone pages (dashboards) may have no presenter — skip check
    if presenter
      return false unless presenter_accessible?(presenter)
    end
  end

  true
end

Returns “top”, “sidebar”, or “both”



110
111
112
113
114
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 110

def menu_layout
  return "top" unless menu_defined?

  menu_definition.layout_mode
end

Split top_menu items into left (default) and right-aligned groups



643
644
645
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 643

def menu_left_top_items(items)
  items.reject(&:right?)
end

Split items into main (non-bottom) and bottom items for sidebar



634
635
636
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 634

def menu_main_items(items)
  items.reject(&:bottom?)
end


647
648
649
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 647

def menu_right_top_items(items)
  items.select(&:right?)
end


66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 66

def navigable_entries
  LcpRuby.loader.navigable_view_groups.filter_map do |vg|
    # Skip infrastructure view groups that opt out of auto-listing.
    # Same gate as Loader#auto_append_unreferenced_view_groups! — keeps both
    # auto-mode paths (with and without menu.yml) consistent.
    next unless vg.auto_append?

    page = LcpRuby.loader.page_definitions[vg.primary_page]
    next unless page&.routable?

    presenter = LcpRuby.loader.presenter_definitions[page.main_presenter_name]

    # Standalone pages (dashboards) have no presenter — skip presenter check,
    # visibility is controlled by visible_when in menu.yml or view group config
    if presenter
      next unless presenter_accessible?(presenter)
    end

    all_slugs = vg.page_names.filter_map do |name|
      LcpRuby.loader.page_definitions[name]&.slug
    end

    {
      presenter: presenter,
      label: presenter&.resolved_label || page.title,
      slug: page.slug,
      icon: presenter&.icon,
      navigation: vg.navigation_config,
      all_slugs: all_slugs
    }
  end.sort_by { |entry| entry[:navigation].is_a?(Hash) ? (entry[:navigation]["position"] || 99) : 99 }
end

#panel_items_for(item) ⇒ Object

Returns the resolved panel children for a ‘:group + render_panel:` item — `static_children + panel_provider_items`. Static children are already filtered by the walker (item.children is the post- `visible_menu_items` set). panel_provider_items are computed lazily and memoized per render pass.



322
323
324
325
326
327
328
329
330
331
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 322

def panel_items_for(item)
  static_part = item.children
  provider_part =
    if item.panel_provider_name.present?
      panel_provider_items_for(item)
    else
      []
    end
  static_part + provider_part
end

#panel_provider_items_for(item) ⇒ Object

Lazy resolver for ‘panel_provider:` items. Memoizes the resolved children on `@panel_provider_cache` keyed by `item.object_id` so the trigger render and the panel render don’t double-call the provider class (typical pattern: render: partial calls counts / current-state methods that the panel_provider also reads — a provider author wraps the body in ‘Rails.cache.fetch` to make both safe; the helper-side cache adds dedup within a single render pass).



341
342
343
344
345
346
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 341

def panel_provider_items_for(item)
  return [] unless item.panel_provider_name.present?

  @panel_provider_cache ||= {}
  @panel_provider_cache[item.object_id] ||= resolve_panel_provider_items(item)
end

#permission_evaluator_for(presenter_def, user) ⇒ Object

Per-render PermissionEvaluator cache for ‘presenter:` menu items. The view context (one per render pass — request, mailer, or any explicit `render_to_string` call) gets a fresh `@permission_cache`, so two consecutive renders never share state. Cache key includes `is_impersonated` (LcpRuby::ImpersonatedUser is a SimpleDelegator whose `id` returns the real user’s id; without this flag an admin who starts impersonating a subordinate would still see admin menu items) and the resolved role list (so role changes within a single user mid-render — rare, but possible — invalidate correctly).



469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 469

def permission_evaluator_for(presenter_def, user)
  @permission_cache ||= {}
  is_impersonated = user.is_a?(LcpRuby::ImpersonatedUser)
  cache_key = [
    presenter_def.model,
    user&.id,
    is_impersonated,
    LcpRuby.user_roles(user).sort
  ]
  # Singular accessor handles _default fallback; the plural hash returns
  # nil for unconfigured models and crashes the evaluator.
  @permission_cache[cache_key] ||= LcpRuby::Authorization::PermissionEvaluator.new(
    LcpRuby.loader.permission_definition(presenter_def.model),
    user,
    presenter_def.model
  )
end

#presenter_description(presenter, view:) ⇒ Object

Resolves a presenter view description with i18n override path ‘lcp_ruby.presenters.<name>.<view>.description`. Returns nil when the presenter doesn’t define a description.



30
31
32
33
34
35
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 30

def presenter_description(presenter, view:)
  raw = presenter&.public_send("#{view}_config")&.dig("description")
  return nil if raw.blank?

  I18n.t("lcp_ruby.presenters.#{presenter.name}.#{view}.description", default: raw)
end

#render_custom_partial(partial:, locals:, context: "custom partial") ⇒ Object



37
38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 37

def render_custom_partial(partial:, locals:, context: "custom partial")
  render partial: partial, locals: locals
rescue ActionView::MissingTemplate
  raise
rescue => e
  raise unless Rails.env.production?

  Rails.logger.error(
    "[LcpRuby] Custom partial error in #{context}: #{e.class}: #{e.message} " \
    "(partial=#{partial})"
  )
  render partial: "lcp_ruby/shared/custom_partial_error",
         locals: { partial_path: partial }
end

#render_menu_item(item, context: :panel) ⇒ Object

Renders a MenuItem in one of two contexts. The default ‘:panel` context wraps the item in `<li class=“lcp-panel-item”>` with `role=“menuitem”` on the inner wrapper — used by `render_panel:` partials to render their `items:` local. The `:bare` context emits the inner wrapper only (no `<li>`) — used by widgets that iterate menu items inside their own list semantics (autocomplete results in a search widget, suggestion list inside a custom dropdown).

‘:top` and `:sidebar` contexts are reserved for internal use —`_top.html.erb` / `_sidebar.html.erb` render via their own partials, not via this helper.



438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 438

def render_menu_item(item, context: :panel)
  case context
  when :panel
    render "lcp_ruby/navigation/panel_item", item: item
  when :bare
    if item.widget?
      render item.render_partial_path,
             item: item,
             disabled: !item.meets_enabled?(LcpRuby::Current.user),
             bare: true
    else
      render "lcp_ruby/navigation/link_or_button",
             item: item,
             klass: ""
    end
  else
    raise ArgumentError, "render_menu_item: unknown context #{context.inspect} " \
                         "(supported: :panel, :bare)"
  end
end

#resolve_section_title(section, presenter_name = nil) ⇒ Object

Resolve section title via i18n with fallback to the YAML value. Lookup: lcp_ruby.presenters.<presenter>.sections.<section_key>

When the section declares ‘section_key:` explicitly (DSL `key:` keyword), use it verbatim — `section_key: “details”` → looks up `…sections.details`. Without an explicit key, fall back to the legacy behaviour: parameterize the literal title (`“List Details”` → `list_details`). The explicit form lets configurators decouple the i18n key from the human-readable label and keeps non-English literals out of the en locale.



744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 744

def resolve_section_title(section, presenter_name = nil)
  raw_title = section["section"] || section["title"]
  explicit_key = section["section_key"]
  return raw_title if presenter_name.blank?

  if explicit_key.present?
    I18n.t(
      "lcp_ruby.presenters.#{presenter_name}.sections.#{explicit_key}",
      default: raw_title || explicit_key.to_s.humanize
    )
  elsif raw_title.present?
    section_key = raw_title.to_s.parameterize(separator: "_")
    I18n.t("lcp_ruby.presenters.#{presenter_name}.sections.#{section_key}", default: raw_title)
  end
end

#responsive_top_nav_data_attrs(menu_def) ⇒ Object

data-* attributes for the top <nav> element. ‘data-measuring` is the FOUC mask removed by the controller after first measurement (or by the 100ms safety timeout); for non-overflow_more modes the controller removes it on connect immediately.



686
687
688
689
690
691
692
693
694
695
696
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 686

def responsive_top_nav_data_attrs(menu_def)
  policy = (menu_def&.responsive) || Metadata::ResponsivePolicy.new(nil)
  {
    controller: "lcp-responsive-top-nav",
    responsive_mode: policy.top_menu_mode,
    breakpoint: policy.top_menu_breakpoint.to_s,
    more_label: I18n.t(policy.top_menu_more_label_key, default: "More"),
    more_icon: policy.top_menu_more_icon,
    measuring: ""
  }
end

Read the current rail state from the server-set cookie (with policy fallback). Server-side read for FOUC-free SSR — JS writes the cookie on user toggle.



666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 666

def sidebar_initial_rail_state(menu_def)
  # Read via request.cookies (raw Rack hash) — avoids depending on
  # the controller's `cookies` jar being exposed to the view context.
  cookie_value =
    if respond_to?(:request) && request
      request.cookies["lcp_sidebar"].to_s
    else
      ""
    end

  return cookie_value if cookie_value == "expanded" || cookie_value == "collapsed"

  # `auto` falls back to expanded on first paint; JS may toggle on connect.
  menu_def&.responsive&.sidebar_initial == "collapsed" ? "collapsed" : "expanded"
end

#theme_switching_allowed?Boolean

Returns:

  • (Boolean)


780
781
782
783
784
785
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 780

def theme_switching_allowed?
  theme_config = LcpRuby.configuration.theme
  return true unless theme_config.is_a?(Hash)

  theme_config.fetch(:allow_user_switch, true)
end

#top_menu_item_responsive_data_attrs(item, index:) ⇒ Object

Per-item data-* attributes for top_menu items. ‘index:` is the original 0-based position within the left zone; right-zone items never overflow and pass `index: nil`.



701
702
703
704
705
706
707
708
709
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 701

def top_menu_item_responsive_data_attrs(item, index:)
  {
    original_index: index&.to_s,
    priority: item.responsive_priority&.to_s,
    pin: item.pin,
    hide_below: item.hide_below&.to_s,
    show_below: item.show_below&.to_s
  }.compact
end

#visible_menu_items(items, depth: 0) ⇒ Object

Filter menu items by visibility (role + presenter access), AND expand ‘provider:` items via the menu_items registry. The walker has two destination branches:

1. `provider:` items — call the provider class once, replace
   the YAML position with its returned hash(es).
2. `:group` items — recurse into static children. Drop the
   group when filtered children are empty AND no
   `render_panel:` is set (legacy compat: empty dropdowns
   should not render). Groups with `render_panel:` always
   survive — the panel partial decides what to render.

‘panel_provider:` items are NOT resolved here. They’re computed lazily at render time via ‘panel_provider_items_for(item)` (see C19) so the trigger renders even when the provider is slow.

Passes ‘self` as `helper:` so MenuItem#meets_visibility? can share the per-render @permission_cache (multiple siblings with the same presenter pay PermissionEvaluator construction once).



135
136
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
165
166
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 135

def visible_menu_items(items, depth: 0)
  return [] if items.nil?

  user = LcpRuby::Current.user
  request_context = build_menu_request_context

  items.flat_map do |item|
    next [] unless menu_item_visible?(item, helper: self)

    if item.provider_name.present?
      expand_menu_provider(item, user: user, request_context: request_context, depth: depth)
    elsif item.children.any?
      expanded = visible_menu_items(item.children, depth: depth + 1)
      # Drop a :group whose filtered children are empty AND that
      # has no render_panel: to fill the panel (preserves the
      # legacy `next if visible_children.empty?` behaviour).
      if expanded.empty? && item.render_panel_partial.blank?
        []
      else
        [ item.with(children: expanded) ]
      end
    elsif item.group?
      # children: [] — only valid when render_panel: is set
      # (validator enforces this). Always kept; the rendering
      # branch handles content via panel_provider or the custom
      # render_panel: partial.
      [ item ]
    else
      [ item ]
    end
  end
end

#visible_top_menu_items(menu_def) ⇒ Object

Wrapper around ‘visible_menu_items` for the TOP menu specifically, auto-injecting a synthesized `sidebar_toggle:` item when the responsive policy enables off-canvas mode and no explicit toggle is authored. `_sidebar.html.erb` continues to call `visible_menu_items` directly (no synthesis there). Synth runs after visibility filtering so the platform item never competes with configurator items hidden by Pundit.



658
659
660
661
# File 'app/helpers/lcp_ruby/layout_helper.rb', line 658

def visible_top_menu_items(menu_def)
  items = visible_menu_items(menu_def.top_menu)
  inject_sidebar_toggle_if_needed(items, menu_def)
end