Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 282
0.00% covered (danger)
0.00%
0 / 106
0.00% covered (danger)
0.00%
0 / 228
0.00% covered (danger)
0.00%
0 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
ProfileViewBuilder
0.00% covered (danger)
0.00%
0 / 282
0.00% covered (danger)
0.00%
0 / 106
0.00% covered (danger)
0.00%
0 / 228
0.00% covered (danger)
0.00%
0 / 16
2756
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 createInstance
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 view
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
12
 buildSlots
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 120
0.00% covered (danger)
0.00%
0 / 1
90
 buildFlatLibraryPanels
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 buildButtons
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
20
 prepareViewIslands
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
42
 buildContextualIslands
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 buildPanes
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 buildDeferrableAttribute
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 buildFloatingControlsRegion
0.00% covered (danger)
0.00%
0 / 39
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
56
 buildStartButtons
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 buildDynamicTabs
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 resolvePanelDisplay
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 buildMenuWrapper
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getIslandsEnableSorted
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\display_builder;
6
7use Drupal\Component\Utility\Html;
8use Drupal\Core\Entity\EntityDisplayRepositoryInterface;
9use Drupal\Core\Entity\EntityInterface;
10use Drupal\Core\Entity\EntityRepositoryInterface;
11use Drupal\Core\Entity\EntityTypeInterface;
12use Drupal\Core\Entity\EntityTypeManagerInterface;
13use Drupal\Core\Entity\EntityViewBuilder;
14use Drupal\Core\Language\LanguageManagerInterface;
15use Drupal\Core\Security\TrustedCallbackInterface;
16use Drupal\Core\Theme\Registry;
17use Drupal\display_builder\Entity\ProfileInterface;
18use Drupal\display_builder\Island\IslandInterface;
19use Drupal\display_builder\Island\IslandPluginManagerInterface;
20use Drupal\display_builder\Island\IslandType;
21use Symfony\Component\DependencyInjection\ContainerInterface;
22
23/**
24 * View builder handler for display builder profiles.
25 */
26class ProfileViewBuilder extends EntityViewBuilder implements TrustedCallbackInterface {
27
28  use RenderableBuilderTrait;
29
30  /**
31   * The entity we are building the view for.
32   */
33  protected ProfileInterface $entity;
34
35  /**
36   * {@inheritdoc}
37   */
38  public function __construct(
39    EntityTypeInterface $entity_type,
40    EntityRepositoryInterface $entity_repository,
41    LanguageManagerInterface $language_manager,
42    Registry $theme_registry,
43    EntityDisplayRepositoryInterface $entity_display_repository,
44    protected EntityTypeManagerInterface $entityTypeManager,
45    protected IslandPluginManagerInterface $islandPluginManager,
46  ) {
47    parent::__construct($entity_type, $entity_repository, $language_manager, $theme_registry, $entity_display_repository);
48  }
49
50  /**
51   * {@inheritdoc}
52   */
53  public static function createInstance(ContainerInterface $container, EntityTypeInterface $entity_type): static {
54    return new static(
55      $entity_type,
56      $container->get('entity.repository'),
57      $container->get('language_manager'),
58      $container->get('theme.registry'),
59      $container->get('entity_display.repository'),
60      $container->get('entity_type.manager'),
61      $container->get('plugin.manager.db_island'),
62    );
63  }
64
65  /**
66   * {@inheritdoc}
67   */
68  public function view(EntityInterface $entity, $view_mode = 'full', $langcode = NULL): array {
69    // We have 'hacked' the interface by using $view_mode as a way of passing
70    // the Instance entity ID.
71    $builder_id = $view_mode;
72
73    /** @var \Drupal\display_builder\Entity\ProfileInterface $entity */
74    $entity = $entity;
75    $this->entity = $entity;
76
77    /** @var \Drupal\display_builder\InstanceInterface $builder */
78    $builder = $this->entityTypeManager->getStorage('display_builder_instance')->load($builder_id);
79    $buildable_id = $builder->get('buildable')->first()->get('plugin_id')->getValue() ?? '';
80    $contexts = $builder->getAvailableContexts() ?? [];
81
82    $islands_enabled_sorted = $this->getIslandsEnableSorted($contexts);
83    $build = [
84      '#type' => 'component',
85      '#component' => 'display_builder:display_builder',
86      '#props' => [
87        'builder_id' => $builder_id,
88        'buildable_id' => Html::getClass($buildable_id),
89        'hash' => (string) $builder->getHash(),
90      ],
91      '#slots' => $this->buildSlots($builder, $islands_enabled_sorted),
92      '#attached' => [],
93      '#cache' => [
94        'tags' => \array_merge(
95          $builder->getCacheTags(),
96          $entity->getCacheTags()
97        ),
98      ],
99    ];
100
101    foreach ($islands_enabled_sorted as $islands) {
102      foreach ($islands as $island) {
103        $build = $island->alterRenderable($builder, $build);
104      }
105    }
106
107    return $build;
108  }
109
110  /**
111   * Builds and returns the value of each slot.
112   *
113   * @param \Drupal\display_builder\InstanceInterface $builder
114   *   Display builder instance.
115   * @param array $islands_enabled_sorted
116   *   An array of enabled islands.
117   *
118   * @return array
119   *   An associative array with the value of each slot.
120   */
121  private function buildSlots(InstanceInterface $builder, array $islands_enabled_sorted): array {
122    $builder_data = $builder->getCurrentState();
123
124    $button_islands = $islands_enabled_sorted[IslandType::Button->value] ?? [];
125    $library_islands = $islands_enabled_sorted[IslandType::Library->value] ?? [];
126    $contextual_islands = $islands_enabled_sorted[IslandType::Contextual->value] ?? [];
127    $menu_islands = $islands_enabled_sorted[IslandType::Menu->value] ?? [];
128    $view_islands = $islands_enabled_sorted[IslandType::View->value] ?? [];
129    $floating_islands = $islands_enabled_sorted[IslandType::Floating->value] ?? [];
130
131    if (!empty($menu_islands)) {
132      $menu_islands = $this->buildMenuWrapper($builder, $menu_islands);
133    }
134
135    if (!empty($library_islands)) {
136      $library_islands = $this->entity->isLibraryFlat()
137        ? $this->buildFlatLibraryPanels($builder, $library_islands, $builder_data)
138        : [
139          $this->buildDynamicTabs($builder, $library_islands, TRUE, $this->entity->getLibraryTabsDisplay()),
140          $this->buildPanes($builder, $library_islands, $builder_data),
141        ];
142    }
143
144    $view_islands_data = $this->prepareViewIslands($builder, $view_islands, $floating_islands);
145    $view_sidebar = $view_islands_data['view_sidebar'];
146    $view_main = $view_islands_data['view_main'];
147
148    // Library content can be in main or sidebar.
149    // @todo Move the logic to LibrariesPanel::build().
150    // @see https://www.drupal.org/project/display_builder/issues/3542866
151    if (isset($view_sidebar['library']) && !empty($library_islands)) {
152      $view_sidebar['library']['content'] = $library_islands;
153    }
154    elseif (isset($view_main['library']) && !empty($library_islands)) {
155      $view_main['library']['content'] = $library_islands;
156    }
157
158    if (!empty($contextual_islands)) {
159      $contextual_islands = $this->buildContextualIslands($builder, $islands_enabled_sorted);
160    }
161
162    return [
163      'view_sidebar_buttons' => $view_islands_data['view_sidebar_buttons'],
164      'view_sidebar' => $view_sidebar,
165      'view_main_tabs' => $view_islands_data['view_main_tabs'],
166      'view_main' => $view_main,
167      'view_floating_controls' => $view_islands_data['view_floating_controls'],
168      'start_buttons' => $this->buildButtons($builder, $button_islands, 'start'),
169      'end_buttons' => $this->buildButtons($builder, $button_islands),
170      'contextual_islands' => $contextual_islands,
171      'menu_islands' => $menu_islands,
172    ];
173  }
174
175  /**
176   * Build library panels merged into a single flat list with one search box.
177   *
178   * @param \Drupal\display_builder\InstanceInterface $builder
179   *   The builder instance.
180   * @param \Drupal\display_builder\Island\IslandInterface[] $library_islands
181   *   The enabled library islands.
182   * @param array $builder_data
183   *   The builder current state.
184   *
185   * @return array
186   *   A two-element render array: the shared search input, and the panes
187   *   wrapped in a container the search input targets.
188   */
189  private function buildFlatLibraryPanels(InstanceInterface $builder, array $library_islands, array $builder_data): array {
190    $container_id = 'db-library-flat-' . $builder->id();
191
192    $search = [
193      '#type' => 'component',
194      '#component' => 'display_builder:input',
195      '#props' => [
196        'variant' => 'search',
197        // Names the field for assistive tech, which a placeholder cannot do.
198        // Visually hidden, @see components/library_panel/search.css.
199        'label' => $this->t('Search the library'),
200        'placeholder' => $this->t('Search'),
201        'size' => 'medium',
202        'autocomplete_off' => TRUE,
203        'clearable' => TRUE,
204        'icon' => 'search',
205      ],
206      '#attributes' => [
207        'class' => ['db-search-library'],
208        'data-search-container-id' => $container_id,
209        'data-elements-selector' => '.db-placeholder',
210        'autofocus' => TRUE,
211      ],
212    ];
213
214    $content = [
215      '#type' => 'html_tag',
216      '#tag' => 'div',
217      '#attributes' => [
218        'id' => $container_id,
219        'class' => ['db-library-flat'],
220      ],
221      'panes' => $this->buildPanes($builder, $library_islands, $builder_data),
222    ];
223
224    return [$search, $content];
225  }
226
227  /**
228   * Build buttons for a region.
229   *
230   * @param \Drupal\display_builder\InstanceInterface $builder
231   *   The builder instance.
232   * @param \Drupal\display_builder\Island\IslandInterface[] $buttonIslands
233   *   The button islands.
234   * @param string $region
235   *   The button region.
236   *
237   * @return array
238   *   The buttons.
239   */
240  private function buildButtons(InstanceInterface $builder, array $buttonIslands, string $region = 'end'): array {
241    $islands = [];
242
243    foreach ($buttonIslands as $id => $island) {
244      $islandRegion = $island->getConfiguration()['region'] ?? 'end';
245
246      if ($islandRegion === $region) {
247        $islands[$id] = $island;
248      }
249    }
250
251    $buttons = [];
252
253    if (!empty($islands)) {
254      $buttons = $this->buildPanes($builder, $islands, [], [], 'span');
255    }
256
257    return $buttons;
258  }
259
260  /**
261   * Prepares view islands data.
262   *
263   * @param \Drupal\display_builder\InstanceInterface $builder
264   *   Display builder instance.
265   * @param array $islands
266   *   The sorted, enabled View islands.
267   * @param array $floating_islands
268   *   The sorted, enabled Floating islands, to attach to their target main
269   *   View island(s), @see buildFloatingControlsRegion().
270   *
271   * @return array
272   *   The prepared view islands data.
273   */
274  private function prepareViewIslands(InstanceInterface $builder, array $islands, array $floating_islands = []): array {
275    $view_islands_sidebar = [];
276    $view_islands_main = [];
277    $view_sidebar_buttons = [];
278    $view_main_tabs = [];
279
280    foreach ($islands as $id => $island) {
281      if ($island->getTypeId() !== IslandType::View->value) {
282        continue;
283      }
284
285      $configuration = $island->getConfiguration();
286
287      if ($configuration['region'] === 'sidebar') {
288        $view_islands_sidebar[$id] = $islands[$id];
289        $view_sidebar_buttons[$id] = $islands[$id];
290      }
291      else {
292        $view_islands_main[$id] = $islands[$id];
293        $view_main_tabs[$id] = $islands[$id];
294      }
295    }
296
297    $view_panels_display = $this->entity->getViewPanelsDisplay();
298
299    if (!empty($view_sidebar_buttons)) {
300      $view_sidebar_buttons = $this->buildStartButtons($builder, $view_sidebar_buttons, $view_panels_display);
301    }
302
303    if (!empty($view_main_tabs)) {
304      $view_main_tabs = $this->buildDynamicTabs($builder, $view_main_tabs, FALSE, $view_panels_display);
305    }
306
307    $builder_data = $builder->getCurrentState();
308    $view_sidebar = $this->buildPanes($builder, $view_islands_sidebar, $builder_data);
309    // Default hidden.
310    $view_main = $this->buildPanes($builder, $view_islands_main, $builder_data, ['shoelace-tabs__tab--hidden']);
311    $view_floating_controls = $this->buildFloatingControlsRegion($builder, $view_islands_main, $floating_islands);
312
313    return [
314      'view_sidebar_buttons' => $view_sidebar_buttons,
315      'view_main_tabs' => $view_main_tabs,
316      'view_sidebar' => $view_sidebar,
317      'view_main' => $view_main,
318      'view_floating_controls' => $view_floating_controls,
319    ];
320  }
321
322  /**
323   * Build contextual islands which are tabbed sub islands.
324   *
325   * @param \Drupal\display_builder\InstanceInterface $builder
326   *   Display builder instance.
327   * @param array $islands_enabled_sorted
328   *   The islands enabled sorted.
329   *
330   * @return array
331   *   The contextual islands render array.
332   */
333  private function buildContextualIslands(InstanceInterface $builder, array $islands_enabled_sorted): array {
334    $contextual_islands = $islands_enabled_sorted[IslandType::Contextual->value] ?? [];
335
336    if (empty($contextual_islands)) {
337      return [];
338    }
339
340    $filter = $this->buildInput((string) $builder->id(), '', 'search', 'medium', 'off', $this->t('Search'), TRUE, 'search');
341    // @see components/library_panel/search.js
342    $filter['#attributes']['class'] = ['db-search-instance'];
343
344    return [
345      '#type' => 'html_tag',
346      '#tag' => 'div',
347      // Used for custom styling in components/display_builder/form.css.
348      '#attributes' => [
349        'id' => \sprintf('%s-contextual', $builder->id()),
350        'class' => ['db-form'],
351      ],
352      'tabs' => $this->buildDynamicTabs($builder, $contextual_islands, FALSE, $this->entity->getContextualTabsDisplay()),
353      'filter' => $filter,
354      'panes' => $this->buildPanes($builder, $contextual_islands, $builder->getCurrentState()),
355    ];
356  }
357
358  /**
359   * Builds panes.
360   *
361   * @param \Drupal\display_builder\InstanceInterface $builder
362   *   Display builder instance.
363   * @param \Drupal\display_builder\Island\IslandInterface[] $islands
364   *   The islands to build tabs for.
365   * @param array $data
366   *   (Optional) The data to pass to the islands.
367   * @param array $classes
368   *   (Optional) The HTML classes to start with.
369   * @param string $tag
370   *   (Optional) The HTML tag, defaults to 'div'.
371   *
372   * @return array
373   *   The tabs render array.
374   */
375  private function buildPanes(InstanceInterface $builder, array $islands, array $data = [], array $classes = [], string $tag = 'div'): array {
376    $panes = [];
377
378    foreach ($islands as $island_id => $island) {
379      $island_classes = \array_merge($classes, [
380        'db-island',
381        \sprintf('db-island-%s', $island->getTypeId()),
382        \sprintf('db-island-%s', $island->getPluginId()),
383      ]);
384
385      $panes[$island_id] = [
386        '#type' => 'html_tag',
387        '#tag' => $tag,
388        'children' => $island->build($builder, $data),
389        '#attributes' => [
390          // `id` attribute is used by HTMX OOB swap.
391          'id' => $island->getHtmlId((string) $builder->id()),
392          // `sse-swap` attribute is used by HTMX SSE swap.
393          'sse-swap' => $island->getHtmlId((string) $builder->id()),
394          'class' => $island_classes,
395          'data-testid' => \sprintf('%s_%s', $island->getTypeId(), $island->getPluginId()),
396        ] + $this->buildDeferrableAttribute($island),
397      ];
398    }
399
400    return $panes;
401  }
402
403  /**
404   * Marks a pane the client may leave stale while it is off screen.
405   *
406   * The server owns this decision, so the client does not have to re-derive it
407   * from island types and stay in sync with them - it just looks for the
408   * attribute.
409   *
410   * @param \Drupal\display_builder\Island\IslandInterface $island
411   *   The island the pane belongs to.
412   *
413   * @return array
414   *   The attribute to merge in, or an empty array if the island always
415   *   renders.
416   *
417   * @see components/display_builder/js/deferred_islands.js
418   * @see \Drupal\display_builder\Island\IslandInterface::isDeferrable()
419   */
420  private function buildDeferrableAttribute(IslandInterface $island): array {
421    return $island->isDeferrable() ? ['data-db-deferrable' => 'true'] : [];
422  }
423
424  /**
425   * Builds the floating controls region for the main-region View islands.
426   *
427   * Each Floating island is rendered exactly once, as a flex child of a
428   * single `.db-island-floating-controls` box, no matter how many View panes
429   * it attaches to. It carries a comma-separated `data-attached-to` listing
430   * every attached pane present in this profile, and stays visible while ANY
431   * of them is the active tab (@see components/shoelace/tabs/tabs.js
432   * syncPanes()). Rendering per Floating island rather than per View pane is
433   * what keeps its `id` unique - a pane that attached the same Floating island
434   * twice (e.g. Highlight on both Canvas and Scaffold) used to emit duplicate
435   * ids, an invalid-HTML/axe failure, and it also keeps the reload/OOB target
436   * (@see IslandPluginBase::reloadWithGlobalData(), which swaps `#{getHtmlId}`)
437   * pointing at a single element.
438   *
439   * @param \Drupal\display_builder\InstanceInterface $builder
440   *   Display builder instance.
441   * @param \Drupal\display_builder\Island\IslandInterface[] $view_islands
442   *   The enabled main-region View islands, keyed by plugin ID.
443   * @param \Drupal\display_builder\Island\IslandInterface[] $floating_islands
444   *   The sorted, enabled Floating islands.
445   *
446   * @return array
447   *   A single wrapper render array holding one child per Floating island, or
448   *   an empty array when none attach to a present pane.
449   */
450  private function buildFloatingControlsRegion(InstanceInterface $builder, array $view_islands, array $floating_islands): array {
451    $data = $builder->getCurrentState();
452    $children = [];
453
454    foreach ($floating_islands as $floating_island) {
455      $definition = $floating_island->getPluginDefinition();
456      $attach_to = \is_array($definition) ? ($definition['attach_to'] ?? []) : [];
457
458      // The selectors of the panes this island attaches to that are actually
459      // present in this profile. The tabs system already uses the same
460      // selector for a pane's data-target, so syncPanes() shows/hides this
461      // control in step with those panes.
462      $targets = [];
463
464      foreach ($attach_to as $view_island_id) {
465        if (isset($view_islands[$view_island_id])) {
466          $targets[] = '#' . $view_islands[$view_island_id]->getHtmlId((string) $builder->id());
467        }
468      }
469
470      if (empty($targets)) {
471        continue;
472      }
473
474      $floating_island_id = $floating_island->getPluginId();
475      // Same db-island-{type}/db-island-{plugin_id} classing as buildPanes()
476      // - other JS (e.g. assets/js/viewport_switcher.js) selects on it.
477      $children[$floating_island_id] = [
478        '#type' => 'html_tag',
479        '#tag' => 'div',
480        '#attributes' => [
481          'id' => $floating_island->getHtmlId((string) $builder->id()),
482          // `sse-swap` attribute is used by HTMX SSE swap.
483          'sse-swap' => $floating_island->getHtmlId((string) $builder->id()),
484          'class' => [
485            'db-island',
486            \sprintf('db-island-%s', $floating_island->getTypeId()),
487            \sprintf('db-island-%s', $floating_island_id),
488            // Starts hidden like the panes do; the initial syncPanes() call on
489            // page load corrects it.
490            'shoelace-tabs__tab--hidden',
491          ],
492          'data-testid' => \sprintf('%s_%s', $floating_island->getTypeId(), $floating_island_id),
493          'data-attached-to' => \implode(',', $targets),
494        ] + $this->buildDeferrableAttribute($floating_island),
495        'children' => $floating_island->build($builder, $data),
496      ];
497    }
498
499    if (empty($children)) {
500      return [];
501    }
502
503    return [
504      '#type' => 'html_tag',
505      '#tag' => 'div',
506      '#attributes' => [
507        'class' => ['db-island-floating-controls'],
508      ],
509      'children' => $children,
510    ];
511  }
512
513  /**
514   * Build the buttons to hide/show the drawer.
515   *
516   * @param \Drupal\display_builder\InstanceInterface $builder
517   *   Display builder instance.
518   * @param \Drupal\display_builder\Island\IslandInterface[] $islands
519   *   An array of island objects for which buttons will be created.
520   * @param string $display
521   *   (Optional) How to show each island: 'label', 'icon' or 'icon_label'.
522   *   Default 'icon_label'.
523   *
524   * @return array
525   *   An array of render arrays for the drawer buttons.
526   */
527  private function buildStartButtons(InstanceInterface $builder, array $islands, string $display = 'icon_label'): array {
528    $build = [];
529    ['icon' => $show_icon, 'label' => $show_label] = self::resolvePanelDisplay($display);
530
531    foreach ($islands as $island) {
532      $island_id = $island->getPluginId();
533
534      $build[$island_id] = [
535        '#type' => 'component',
536        '#component' => 'display_builder:button',
537        '#props' => [
538          'id' => \sprintf('start-btn-%s-%s', $builder->id(), $island_id),
539          'label' => $show_label ? (string) $island->label() : '',
540          'icon' => $show_icon ? $island->getIcon() : NULL,
541          'tooltip' => $show_label ? NULL : (string) $island->label(),
542          'attributes' => [
543            'data-open-first-drawer' => TRUE,
544            'data-target' => $island_id,
545          ],
546        ],
547      ];
548
549      if ($keyboard = $island::keyboardShortcuts()) {
550        $build[$island_id]['#attributes']['data-keyboard-key'] = $keyboard['key'] ?? '';
551        $build[$island_id]['#attributes']['data-keyboard-help'] = $keyboard['help'] ?? '';
552        $build[$island_id]['#attributes']['aria-keyshortcuts'] = $keyboard['key'] ?? '';
553      }
554    }
555
556    return $build;
557  }
558
559  /**
560   * Builds tabs.
561   *
562   * @param \Drupal\display_builder\InstanceInterface $builder
563   *   Display builder instance.
564   * @param \Drupal\display_builder\Island\IslandInterface[] $islands
565   *   The islands to build tabs for.
566   * @param bool $contextual
567   *   (Optional) Is the tabs contextual? See component for details. Default no.
568   * @param string $display
569   *   (Optional) How to show each island: 'label', 'icon' or 'icon_label'.
570   *   Default 'label'.
571   *
572   * @return array
573   *   The tabs render array.
574   */
575  private function buildDynamicTabs(InstanceInterface $builder, array $islands, bool $contextual = FALSE, string $display = 'label'): array {
576    // Global id is based on last island.
577    $id = '';
578    $tabs = [];
579    ['icon' => $show_icon, 'label' => $show_label] = self::resolvePanelDisplay($display);
580
581    foreach ($islands as $island) {
582      $id = $island_id = $island->getHtmlId((string) $builder->id());
583      $attributes = [
584        'data-testid' => \sprintf('tab_%s_%s', $island->getTypeId(), $island->getPluginId()),
585      ];
586
587      if ($keyboard = $island::keyboardShortcuts()) {
588        $attributes['data-keyboard-key'] = $keyboard['key'] ?? '';
589        $attributes['data-keyboard-help'] = $keyboard['help'] ?? '';
590        $attributes['aria-keyshortcuts'] = $keyboard['key'] ?? '';
591      }
592
593      $tabs[] = [
594        'title' => $island->label(),
595        'url' => '#' . $island_id,
596        'attributes' => $attributes,
597        'icon' => $show_icon ? $island->getIcon() : NULL,
598        'show_label' => $show_label,
599      ];
600    }
601
602    // Id is needed for storage tabs state, @see component tabs.js file.
603    return $this->buildTabs($id, $tabs, $contextual);
604  }
605
606  /**
607   * Resolves a 'label'/'icon'/'icon_label' display mode into show flags.
608   *
609   * @param string $display
610   *   One of 'label', 'icon' or 'icon_label'.
611   *
612   * @return array
613   *   An associative array with 'icon' and 'label' boolean flags.
614   */
615  private static function resolvePanelDisplay(string $display): array {
616    return [
617      'icon' => \in_array($display, ['icon', 'icon_label'], TRUE),
618      'label' => \in_array($display, ['label', 'icon_label'], TRUE),
619    ];
620  }
621
622  /**
623   * Builds menu with islands as entries.
624   *
625   * @param \Drupal\display_builder\InstanceInterface $builder
626   *   Display builder instance.
627   * @param \Drupal\display_builder\Island\IslandInterface[] $islands
628   *   The islands to build tabs for.
629   *
630   * @return array
631   *   The islands render array.
632   *
633   * @see components/contextual_menu/contextual_menu.js
634   */
635  private function buildMenuWrapper(InstanceInterface $builder, array $islands): array {
636    $build = [
637      '#type' => 'component',
638      '#component' => 'display_builder:contextual_menu',
639      '#slots' => [
640        'label' => $this->t('Select an action'),
641      ],
642      '#attributes' => [
643        'class' => ['db-background', 'db-menu'],
644        // Require for JavaScript.
645        // @see components/contextual_menu/contextual_menu.js
646        'data-db-id' => (string) $builder->id(),
647      ],
648    ];
649
650    $items = [];
651
652    foreach ($islands as $island) {
653      $items = \array_merge($items, $island->build($builder, $builder->getCurrentState()));
654    }
655    $build['#slots']['items'] = $items;
656
657    return $build;
658  }
659
660  /**
661   * Get enabled panes sorted by weight.
662   *
663   * @param \Drupal\Core\Plugin\Context\ContextInterface[] $contexts
664   *   An array of contexts, keyed by context name.
665   *
666   * @return array
667   *   The list of enabled islands sorted.
668   *
669   * @todo just key by weight and default weight in Island?
670   */
671  private function getIslandsEnableSorted(array $contexts): array {
672    // Set island by weight.
673    // @todo just key by weight and default weight in Island?
674    $islands_enable_by_weight = $this->entity->getEnabledIslands();
675
676    return $this->islandPluginManager->getIslandsByTypes($contexts, $this->entity->getIslandConfigurations(), $islands_enable_by_weight);
677  }
678
679}