Skip to main content

Creating a layout plugin with dynamic regions

Layout plugins in Drupal are typically declared via a YML file, with the regions nominated ahead of time. But what happens if you need your regions to be dynamic, such as an arbitrary number of tabs in a tabset? 

by lee.rowlands /

Let's explore how to declare a layout plugin with dynamic regions.

A typical layout plugin

A typical layout plugin is defined in YOURMODULE.layouts.yml with a fixed set of regions.

Here's an example from core's layout_builder.layouts.yml

layout_twocol_section:
  label: 'Two column'
  path: layouts/twocol_section
  template: layout--twocol-section
  library: layout_builder/twocol_section
  class: '\Drupal\layout_builder\Plugin\Layout\TwoColumnLayout'
  category: 'Columns: 2'
  default_region: first
  icon_map:
    - [first, second]
  regions:
    first:
      label: First
    second:
      label: Second

In this example, there are two regions, first and second.

One thing worth pointing out here is the class entry in that definition.

If you omit class, core falls back to using \Drupal\Core\Layout\LayoutDefault, the default class.

Looking at this class, we can see it has a getPluginDefinition method and this returns an instance of \Drupal\Core\Layout\LayoutDefinition. And we can then see that this has a method ::getRegions.

So we want to be able to provide dynamic regions, and this is the extension point.

First things first

Let's start with a plugin definition, and a class entry

layout_tabs:
  label: Tab-set
  template: layouts/layout-tabs
  icon_map:
    - [title]
    - [tab1, tab2, tab3]
    - [content]
    - [content]
    - [content]
  regions:
    title:
      label: Title
    tab1:
      label: Tab 1
    tab2:
      label: Tab 2
  class: Drupal\yourmodule\Tabset

So what we've got here is a custom class Drupal\yourmodule\Tabset. We need somewhere to store our region mapping, and this is where the plugin configuration comes in.

/**
 * {@inheritdoc}
 */
public function defaultConfiguration() {
  $parent = parent::defaultConfiguration();
  $parent['title'] = '';
  if (empty($this->configuration['tabs'])) {
    return $parent + [
      'tabs' => [
        ['detail' => ['icon' => '', 'label' => 'Tab 1'], 'weight' => 1],
        ['detail' => ['icon' => '', 'label' => 'Tab 2'], 'weight' => 2],
      ],
    ];
  }
  return $parent;
}

Here we're defining a default of two tabs with the corresponding weight, label and a place for the icon.

So let's put together a configuration form allowing the content-editor to add as many tabs as they like

/**
 * {@inheritdoc}
 */
public function buildConfigurationForm(
  array $form,
  FormStateInterface $form_state
) {
  /** @var \Drupal\Core\Form\SubformStateInterface $form_state */
  $build = parent::buildConfigurationForm($form, $form_state);
  $config = $this->getConfiguration();
  $build['title'] = [
    '#type' => 'textfield',
    '#default_value' => $this->configuration['title'],
    '#title' => $this->t('Title'),
    '#description' => $this->t('Provide an optional title for this section'),
  ];
  $tabs = $form_state->getCompleteFormState()->getValue(['layout_settings', 'tabs'], $config['tabs'] ?: [
    ['detail' => ['icon' => '', 'label' => 'Tab 1'], 'weight' => 1],
    ['detail' => ['icon' => '', 'label' => 'Tab 2'], 'weight' => 2],
  ]);
  $build['tabs'] = [
    '#prefix' => '<div id="tabs-add-more">',
    '#suffix' => '</div>',
    '#type' => 'table',
    '#header' => [
      $this->t('Tabs'),
      $this->t('Weight'),
    ],
  ];
  $build['tabs']['#tabledrag'][] = [
    'action' => 'order',
    'relationship' => 'sibling',
    'group' => 'lb-tabs-weight',
  ];
  $icons = $this->iconManager->getIconList();
  foreach ($tabs as $ix => $tab) {
    $detail = $tab['detail'];
    $build['tabs'][$ix] = [
      '#weight' => $tab['weight'] ?? 50,
      'detail' => [
        '#type' => 'container',
        'label' => [
          '#title_display' => 'invisible',
          '#type' => 'textfield',
          '#required' => TRUE,
          '#title' => $this->t('Label for tab %ix', ['%ix' => $ix + 1]),
          '#default_value' => $detail['label'],
        ],
        'icon' => [
          '#title_display' => 'invisible',
          '#type' => 'select',
          'empty_value' => '',
          '#required' => FALSE,
          'empty_option' => $this->t('None'),
          '#title' => $this->t('Icon for tab %ix', ['%ix' => $ix + 1]),
          '#default_value' => $detail['icon'],
          '#options' => $icons,
        ],
        'remove' => [
          '#type' => 'submit',
          '#value' => $this->t('Remove %label', ['%label' => $detail['label']]),
          '#submit' => [[static::class, 'removeTabSubmit']],
          '#ajax' => [
            'callback' => [static::class, 'updateForm'],
            'wrapper' => 'tabs-add-more',
            'effect' => 'fade',
            'method' => 'replaceWith',
          ],
        ],
      ],
      'weight' => [
        '#type' => 'weight',
        '#title' => $this->t('Weight for tab %ix', ['%ix' => $ix + 1]),
        '#title_display' => 'invisible',
        '#delta' => 50,
        '#default_value' => $tab['weight'] ?? 50,
        '#attributes' => [
          'class' => ['lb-tabs-weight'],
        ],
      ],
      '#attributes' => [
        'class' => ['draggable', 'js-form-wrapper'],
      ],
    ];
  }
  uasort($build['tabs'], ['Drupal\Component\Utility\SortArray', 'sortByWeightProperty']);
  $build['add_another'] = [
    '#type' => 'submit',
    '#value' => $this->t('Add another tab'),
    '#submit' => [[static::class, 'addMoreSubmit']],
    '#ajax' => [
      'callback' => [static::class, 'updateForm'],
      'wrapper' => 'tabs-add-more',
      'effect' => 'fade',
      'method' => 'replaceWith',
    ],
  ];
  return $build;
}
/**
 * Submission handler for the "Add another tab" button.
 */
public static function addMoreSubmit(array $form, FormStateInterface $form_state) {
  $tabs = $form_state->getValue(['layout_settings', 'tabs'], []);
  $count = count($tabs) + 1;
  $tabs[] = [
    'detail' => ['icon' => '', 'label' => 'Tab ' . $count],
    'weight' => $count,
  ];
  $form_state->setValue(['layout_settings', 'tabs'], $tabs);
  $form_state->setRebuild();
}
/**
 * Submission handler for the "Remove tab" button.
 */
public static function removeTabSubmit(array $form, FormStateInterface $form_state) {
  $button = $form_state->getTriggeringElement();
  $tabs = $form_state->getValue(['layout_settings', 'tabs'], []);
  $parents = $button['#parents'];
  // Remove the button.
  array_pop($parents);
  // Remove the detail container.
  array_pop($parents);
  unset($tabs[end($parents)]);
  $form_state->setValue(['layout_settings', 'tabs'], $tabs);
  $form_state->setRebuild();
}
/**
 * Ajax callback for the "Add another tab" button.
 */
public function updateForm(array $form, FormStateInterface $form_state) {
  return $form['layout_settings']['tabs'] ?? [];
}
/**
 * {@inheritdoc}
 */
public function submitConfigurationForm(
  array &$form,
  FormStateInterface $form_state
) {
  parent::submitConfigurationForm($form, $form_state);
  $this->configuration['tabs'] = $form_state->getValue(['tabs'], []);
  $this->configuration['title'] = $form_state->getValue(['title'], []);
}

/**
 * {@inheritdoc}
 */
public function validateConfigurationForm(
  array &$form,
  FormStateInterface $form_state
) {
  parent::validateConfigurationForm($form, $form_state);
  $tabs = $form_state->getValue(['tabs'], []);
  $labels = array_map(function (array $item) {
    return $item['detail']['label'];
  }, $tabs);
  if (count($labels) > count(array_unique($labels))) {
    $form_state->setErrorByName('layout_settings][tabs][0][detail][label', $this->t('Each tab name must be unique'));
  }
}

This gives us a nice draggable table of tabs, with a place for the user to add the title, and select an icon (the details of the icon manager are outside the scope of this post).

Now when the user saves, all the tab info is stored in the component configuration. We can now use this to build the regions in our layout plugin

/**
 * Builds regions from tab configuration.
 *
 * @param array $tabs
 *   Tabs.
 *
 * @return array
 *   Regions.
 */
protected function buildRegions(array $tabs) : array {
  $regions = [];
  foreach (array_values($tabs) as $ix => $tab) {
    $regions['tab' . ($ix + 1)] = [
      'label' => $tab['detail']['label'] ?? 'Tab ' . ($ix + 1),
    ];
  }
  return $regions;
}

And the final step, is we need our plugin definition to return the correct regions.

Luckily, there's also a setRegions method, so we can do this in our constructor:

/**
 * {@inheritdoc}
 */
public function __construct(
  array $configuration,
  $plugin_id,
  $plugin_definition
) {
  parent::__construct($configuration, $plugin_id, $plugin_definition);
  $this->pluginDefinition->setRegions($this->buildRegions($this->getConfiguration()['tabs']));
}

And that just leaves us with the task of theming the output.

We can do that in preprocessing - the theme hook will match that of the template defined above in our YML - layout_tabs

use Drupal\Core\Render\Element;
/**
 * Implements hook_preprocess_hook().
 */
function yourmodule_preprocess_layout_tabs(&$vars) {
  $tabs = [];
  $labels = $vars['layout']->getRegions();
  $settings = $vars['settings'];
  foreach (Element::children($vars['content']) as $ix => $region) {
    $tabs[] = [
      'id' => preg_replace('/[^a-z0-9_]+/', '_', mb_strtolower($labels[$region]['label'])),
      'title' => $labels[$region]['label'],
      'content' => $vars['content'][$region],
      'icon_class' => $settings['tabs'][$ix]['detail']['icon'] ?? '',
      'region' => $region,
    ];
  }
  $vars['tabs'] = $tabs;
  $vars['title'] = $settings['title'];
  $vars['#attached']['library'][] = 'yourmodule/layout_tabs';
}

This also takes care of adding any CSS/Javascript via a library.

So whilst it takes a bit of code to achieve the ajax/configuration form, the actual dynamic regions part of the code is pretty straight-forward.

Let us know in the comments if you have any tips on how to improve this, or you can think of other use-cases where dynamic regions would be useful.

Whilst it doesn't have the same approach, there is actually a contrib project for a tabbed layout.