1

Motivation

I am trying to build a "table of contents" / "In-page navigation" block, that should extract labels and ids from block render elements within a theme region.

E.g. a theme region might contain a content block with a node, and then some additional blocks with views or other things. The in-page nav would have links to specific node fields or field groups, and to the additional blocks.

This means there will be a theme region with a render element where in some nested levels we find elements that want to add entries to the in-page nav. Ideally we want to collect all these entries from the render tree.

Question

In Drupal 10, how can I collect data from nested child render elements to populate the in-page navigation?

Investigation and ideas so far

In Drupal 7 this was possible with hook_page_build() or hook_page_alter().

In Drupal 8+ these hooks were removed: https://www.drupal.org/node/2357755.
I understand these hooks would potentially cause difficulties with caching, but it should be possible to add all the cache metadata that is needed for a case like this.

We can use tricks to add a '#pre_render' or '#post_render' callback in the page or region render element, or use a theme preprocess hook. However, if the children are loaded from cache, we won't have the full tree available.

I was also wondering if I could let the elements that want an in-page nav item set something in '#attached' that will bubble up to the parent element where the in-page nav should be created. I could invent a new key in '#attached', e.g. ['#attached']['inpage_nav_items']. But this is risky, because in HtmlResponseAttachmentsProcessor::processAttachments() any "custom" attachment key results in a LogicException with "You are not allowed to use %s in #attached.". So I would need a way to remove that key from '#attached' before it bubbles up to the root. Or I could put it into ['#attached']['drupalSettings']. But I don't really like that option, it feels like a hack.

Another problem with '#attached': At the time of '#post_render', the '#attached' on the parent element is still not set, instead, the info needs to be extracted from the child elements. It is possible, but not comfortable.

3
  • Another limitation with the '#attached' trick would be if some of the label-providing elements are added as placeholders. Such labels would not make it into '#attached' in time.
    – donquixote
    Commented Apr 5 at 23:08
  • So.. I find that the '#attached' is a dead end due to PlaceholderingRenderCache. If the element is replaced by a placeholder for caching purposes, the '#attached' from child elements won't be available to the parent elements, it will only be available on root level.
    – donquixote
    Commented Apr 13 at 14:16
  • We can call renderRoot() at the level where the in-page navigation is built, to force all placeholders to be resolved. But then, the links could end up in the wrong order, if they were defined through '#attached'. To reliably get the correct order, the links could be defined as tokens in the rendered markup.
    – donquixote
    Commented Apr 14 at 22:49

1 Answer 1

0

I'm not sure to answer your questions correctly, but I once needed to generate one page menu for a website, with a menu linking to each block, acting like a one page content.

I didn't wanted to have link to nothing else than blocks that where included in a particular region. Then when you said "The in-page nav would have links to specific node fields or field groups, and to the additional blocks.", I understand it will not be easy or possible.

What I used is only possible if it is managed by the theme and include blocks (and only blocks) of a specific region.

I never found a way to get the list of blocks loaded on a page for a particular region. Else I would have try to make a block for it.

Here is the solution

  1. Add a region one_page on your theme.

  2. Add a preprocess for this region

     use Drupal\block\Entity\Block;
    
     /**
      * Implements hook_preprocess_region().
      */
     function mytheme_preprocess_region(&$variables) {
       if ($variables['region'] == 'one_page') {
         $blocs_menu = [];
         // look for each elements of the region.
         foreach ($variables['elements'] as $key => $values) {
           // Except properties.
           if (substr($key, 0, 1) == '#') {
             continue;
           }
           $bloc_object = Block::load($key);
           if (is_object($bloc_object)) {
             $bloc_settings = $bloc_object->get('settings');
             // Add block to inline menu only if title is displayed.
             if ($bloc_settings['label_display']) {
               $blocs_menu[] = [
                 'id' => $key,
                 'label' => $bloc_settings['label'],
               ];
             }
           }
         }
         if (count($blocs_menu)) {
           $variables['region_menu'] = $blocs_menu;
         }
       }
     }
    

3 Add a twig template for this region region--one-page.html.twig, to manage the menu

{%- if content -%}
  {%- if region_menu -%}
  <nav class="page-nav-anchor">
    <ul>
      {%- for menu in region_menu -%}
      <li><a rel="scroll" href="#block-{{ menu['id'] | clean_class }}">{{ menu['label'] }}</a></li>
      {%- endfor -%}
    </ul>
  </nav>
  {%- endif -%}
  {{- content -}}
{%- endif -%}
1
  • Thank you @TytooF. This solution still has some limitations: It does not detect labels in nested render elements (e.g. from field groups or fields), only from blocks. And it requires that we load the block entities whenever we build this, even when most other elements are coming from render cache.
    – donquixote
    Commented Apr 5 at 23:04

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.