Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 163
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 75
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
ViewportSwitcher
0.00% covered (danger)
0.00%
0 / 156
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 75
0.00% covered (danger)
0.00%
0 / 9
756
0.00% covered (danger)
0.00%
0 / 1
 create
0.00% covered (danger)
0.00%
0 / 5
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
 defaultConfiguration
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
 buildConfigurationForm
0.00% covered (danger)
0.00%
0 / 18
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
 configurationSummary
0.00% covered (danger)
0.00%
0 / 10
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
 build
0.00% covered (danger)
0.00%
0 / 81
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 48
0.00% covered (danger)
0.00%
0 / 1
56
 getDefinitions
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
30
 getMaxWidthValueFromMediaQuery
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 getProvidersOptions
0.00% covered (danger)
0.00%
0 / 11
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
 getProviders
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\display_builder\Plugin\display_builder\Island;
6
7use Drupal\breakpoint\BreakpointManager;
8use Drupal\Core\Extension\ModuleExtensionList;
9use Drupal\Core\Extension\ThemeExtensionList;
10use Drupal\Core\Form\FormStateInterface;
11use Drupal\Core\StringTranslation\TranslatableMarkup;
12use Drupal\display_builder\Attribute\Island;
13use Drupal\display_builder\InstanceInterface;
14use Drupal\display_builder\Island\IslandConfigurationFormInterface;
15use Drupal\display_builder\Island\IslandConfigurationFormTrait;
16use Drupal\display_builder\Island\IslandPluginBase;
17use Drupal\display_builder\Island\IslandType;
18use Symfony\Component\DependencyInjection\ContainerInterface;
19
20/**
21 * Island plugin implementation.
22 */
23#[Island(
24  id: 'viewport',
25  label: new TranslatableMarkup('Responsive width'),
26  description: new TranslatableMarkup('Change main region width according to breakpoints for canvas and preview panels.'),
27  type: IslandType::Floating,
28  modules: ['breakpoint'],
29  attach_to: ['builder', 'preview'],
30)]
31class ViewportSwitcher extends IslandPluginBase implements IslandConfigurationFormInterface {
32
33  use IslandConfigurationFormTrait;
34
35  private const HIDE_PROVIDER = ['toolbar', 'stark'];
36
37  /**
38   * The module list extension service.
39   */
40  protected ThemeExtensionList $themeList;
41
42  /**
43   * The module list extension service.
44   */
45  protected ModuleExtensionList $moduleList;
46
47  /**
48   * The breakpoint manager.
49   */
50  protected BreakpointManager $breakpointManager;
51
52  /**
53   * {@inheritdoc}
54   */
55  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition): static {
56    $instance = parent::create($container, $configuration, $plugin_id, $plugin_definition);
57    $instance->themeList = $container->get('extension.list.theme');
58    $instance->moduleList = $container->get('extension.list.module');
59    $instance->breakpointManager = $container->get('breakpoint.manager');
60
61    return $instance;
62  }
63
64  /**
65   * {@inheritdoc}
66   */
67  public function defaultConfiguration(): array {
68    return [
69      'exclude' => [],
70      'format' => 'compact',
71    ];
72  }
73
74  /**
75   * {@inheritdoc}
76   */
77  public function buildConfigurationForm(array $form, FormStateInterface $form_state): array {
78    $configuration = $this->getConfiguration();
79
80    $form['format'] = [
81      '#type' => 'select',
82      '#title' => $this->t('Selector format'),
83      '#description' => $this->t('Choose the appearance of the selector. Normal select or a compact dropdown menu.'),
84      '#options' => [
85        'default' => $this->t('Default'),
86        'compact' => $this->t('Compact'),
87      ],
88      '#default_value' => $configuration['format'],
89    ];
90
91    $form['exclude'] = [
92      '#type' => 'checkboxes',
93      '#title' => $this->t('Exclude providers'),
94      '#options' => $this->getProvidersOptions($this->t('breakpoint'), $this->t('breakpoints')),
95      '#default_value' => $configuration['exclude'],
96    ];
97
98    return $form;
99  }
100
101  /**
102   * {@inheritdoc}
103   */
104  public function configurationSummary(): array {
105    $configuration = $this->getConfiguration();
106    $summary = [];
107
108    $exclude = \array_filter($configuration['exclude'] ?? []);
109
110    $summary[] = $this->t('Excluded providers: @exclude', [
111      '@exclude' => ($exclude = \array_filter($configuration['exclude'] ?? [])) ? \implode(', ', $exclude) : $this->t('None'),
112    ]);
113
114    $summary[] = $this->t('Format: @format', [
115      '@format' => $configuration['format'] ?? 'default',
116    ]);
117
118    return $summary;
119  }
120
121  /**
122   * {@inheritdoc}
123   */
124  public function build(InstanceInterface $builder, array $data = [], array $options = []): array {
125    $configuration = $this->getConfiguration();
126    $definitions = $this->getDefinitions();
127
128    $groups = [];
129
130    foreach ($definitions as $definition) {
131      if (!isset($definition['group'])) {
132        continue;
133      }
134      $groups[$definition['group']] = $definition['group'];
135    }
136
137    $options = $data = [];
138    $items = [
139      [
140        'title' => $this->t('Fluid (Current viewport)'),
141        'class' => 'active',
142      ],
143      [
144        'divider' => TRUE,
145      ],
146    ];
147
148    foreach ($groups as $group_id => $label) {
149      $points = $this->breakpointManager->getBreakpointsByGroup($group_id);
150
151      foreach ($points as $point_id => $point) {
152        $query = $point->getMediaQuery();
153        $width = $this->getMaxWidthValueFromMediaQuery($query);
154
155        if (!$width) {
156          continue;
157        }
158        $point_label = $point->getLabel();
159        $options[$label][$point_id] = $point_label;
160        $items[] = [
161          'title' => $point_label,
162          'value' => $point_id,
163        ];
164        $data[$point_id] = $width;
165      }
166    }
167
168    $select = [
169      '#type' => 'component',
170      '#component' => 'display_builder:select',
171      '#attached' => [
172        'library' => ['display_builder/viewport_switcher'],
173      ],
174      '#props' => [
175        'options' => $options,
176        'icon' => 'window',
177        'empty_option' => $this->t('Fluid (Current viewport)'),
178      ],
179      '#attributes' => [
180        'style' => 'display: inline-block;',
181        'data-points' => \json_encode($data),
182        'data-island-action' => 'viewport',
183      ],
184    ];
185
186    if ($configuration['format'] !== 'compact') {
187      return $select;
188    }
189
190    unset($select['#attributes']['style']);
191    $select['#props']['icon'] = NULL;
192
193    // A plain title attribute, not the dropdown's own #props.tooltip (which
194    // wraps the trigger in a Shoelace <sl-tooltip>): Floating UI computes a
195    // permanently broken position for a tooltip that's part of the
196    // server-rendered HTML inside a floating controls cluster.
197    $button = $this->buildButton('', NULL, 'display');
198    $button['#attributes']['class'] = ['switch-viewport-btn'];
199    $button['#attributes']['size'] = 'small';
200    $button['#attributes']['title'] = $this->t('Switch viewport of this display');
201
202    return [
203      '#type' => 'component',
204      '#component' => 'display_builder:dropdown',
205      '#slots' => [
206        'button' => $button,
207        'content' => [
208          '#type' => 'component',
209          '#component' => 'display_builder:menu',
210          '#props' => [
211            'items' => $items,
212          ],
213          '#attributes' => [
214            'class' => ['viewport-menu', 'db-background'],
215            'data-points' => \json_encode($data),
216          ],
217        ],
218      ],
219      '#attributes' => [
220        // db-background: visual surface now that this floats over the
221        // pane's own content, instead of sitting in the toolbar's chrome.
222        'class' => ['switch-viewport', 'db-background'],
223        'data-island-action' => 'viewport',
224      ],
225      '#attached' => [
226        'library' => ['display_builder/viewport_switcher'],
227      ],
228    ];
229  }
230
231  /**
232   * Get the definitions list which is used by a few methods.
233   *
234   * @return array
235   *   Breakpoints plugin definitions as associative arrays.
236   */
237  public function getDefinitions(): array {
238    $definitions = $this->breakpointManager->getDefinitions();
239
240    foreach ($definitions as $definition_id => $definition) {
241      if (isset($definition['provider']) && \in_array($definition['provider'], self::HIDE_PROVIDER, TRUE)) {
242        unset($definitions[$definition_id]);
243      }
244
245      // Exclude definitions with not supported media queries.
246      if (!$this->getMaxWidthValueFromMediaQuery($definition['mediaQuery'])) {
247        unset($definitions[$definition_id]);
248      }
249    }
250
251    return $definitions;
252  }
253
254  /**
255   * Get width and max width from media query.
256   *
257   * @param string $query
258   *   The media query from the breakpoint definition.
259   *
260   * @return ?string
261   *   The width with its unit (120px, 13em, 100vh...). Null is no width found.
262   */
263  protected function getMaxWidthValueFromMediaQuery(string $query): ?string {
264    if (\str_contains($query, 'not ')) {
265      // Queries with negated expression(s) are not supported.
266      return NULL;
267    }
268    // Look for max-width: 1250px or max-width: 1250 px.
269    \preg_match('/max-width:\s*([0-9]+)\s*([A-Za-z]+)/', $query, $matches);
270
271    if (\count($matches) > 2) {
272      return $matches[1] . $matches[2];
273    }
274    // Looking for width <= 1250px or <= 1250 px.
275    \preg_match('/width\s*<=\s*([0-9]+)\s*([A-Za-z]+)/', $query, $matches);
276
277    if (\count($matches) > 1) {
278      return $matches[1];
279    }
280
281    // @todo Currently only supports queries with max-width or width <= using
282    // px, em, vh units.
283    // Does not support min-width, percentage units, or complex/combined media
284    // queries.
285    return NULL;
286  }
287
288  /**
289   * Get providers options for select input.
290   *
291   * @param string|TranslatableMarkup $singular
292   *   Singular label of the plugins.
293   * @param string|TranslatableMarkup $plural
294   *   Plural label of the plugins.
295   *
296   * @return array
297   *   An associative array with extension ID as key and extension description
298   *   as value.
299   */
300  protected function getProvidersOptions(string|TranslatableMarkup $singular = 'definition', string|TranslatableMarkup $plural = 'definitions'): array {
301    $options = [];
302
303    foreach ($this->getProviders($this->getDefinitions()) as $provider_id => $provider) {
304      $params = [
305        '@name' => $provider['name'],
306        '@type' => $provider['type'],
307        '@count' => $provider['count'],
308        '@singular' => $singular,
309        '@plural' => $plural,
310      ];
311      $options[$provider_id] = $this->formatPlural($provider['count'], '@name (@type, @count @singular)', '@name (@type, @count @plural)', $params);
312    }
313
314    return $options;
315  }
316
317  /**
318   * Get all providers.
319   *
320   * @param array $definitions
321   *   Plugin definitions.
322   *
323   * @return array
324   *   Drupal extension definitions, keyed by extension ID
325   */
326  protected function getProviders(array $definitions): array {
327    $themes = $this->themeList->getAllInstalledInfo();
328    $modules = $this->moduleList->getAllInstalledInfo();
329    $providers = [];
330
331    foreach ($definitions as $definition) {
332      $provider_id = $definition['provider'];
333      $provider = $themes[$provider_id] ?? $modules[$provider_id] ?? NULL;
334
335      if (!$provider) {
336        continue;
337      }
338      $provider['count'] = isset($providers[$provider_id]) ? ($providers[$provider_id]['count']) + 1 : 1;
339      $providers[$provider_id] = $provider;
340    }
341
342    return $providers;
343  }
344
345}