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