Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
124 / 124
100.00% covered (success)
100.00%
14 / 14
CRAP
100.00% covered (success)
100.00%
1 / 1
ComponentBuilder
100.00% covered (success)
100.00%
124 / 124
100.00% covered (success)
100.00%
14 / 14
47
100.00% covered (success)
100.00%
1 / 1
 renderComponent
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
4
 buildElementAttributes
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
6
 normalizeSelectOptions
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
6
 applyComponentInput
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 applySelectInput
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 resolveRequiredFlags
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
7
 applyTitleDisplay
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 applyTitleDisplayAsTitle
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 applyTitleDisplayAsPlaceholder
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 applyTitleDisplayAsNone
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 applyTitleDisplayAsAttribute
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 applyTitleDisplayAsDescription
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 setRequiredWhenFlag
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 appendRequiredSuffix
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Drupal\name\Utility;
6
7use Drupal\Core\Render\Element;
8use Drupal\name\Element\Name;
9
10/**
11 * Builds component sub-elements for the name Form API element.
12 *
13 * @internal
14 */
15final class ComponentBuilder {
16
17  /**
18   * Builds a single component sub-element for process().
19   *
20   * @param array $components
21   *   Core properties for all components.
22   * @param string $component_key
23   *   The component key of the component that is being rendered.
24   * @param array $base_element
25   *   Base FAPI element that makes up a name element.
26   * @param bool $core
27   *   Whether the component is required as part of a valid name.
28   *
29   * @return array
30   *   The constructed component FAPI structure for a name element.
31   */
32  public static function renderComponent(
33    array $components,
34    string $component_key,
35    array $base_element,
36    bool $core,
37  ): array {
38    $component = $components[$component_key];
39    $element = self::buildElementAttributes($component);
40    $element['#attributes']['class'][] = 'name-' . $component_key;
41
42    if ($core) {
43      $element['#attributes']['class'][] = 'name-core-component';
44    }
45
46    foreach (['type', 'title', 'size', 'maxlength'] as $key) {
47      $element['#' . $key] = $component[$key];
48    }
49
50    if (isset($base_element['#value'][$component_key])) {
51      $element['#default_value'] = $base_element['#value'][$component_key];
52    }
53    $element = self::applyComponentInput($element, $component);
54
55    ['show_marker' => $required_marker, 'flag_required' => $flag_required_input]
56      = self::resolveRequiredFlags($core, $base_element);
57
58    return self::applyTitleDisplay(
59      $element,
60      $component['title_display'] ?? 'description',
61      $required_marker,
62      $flag_required_input,
63    );
64  }
65
66  /**
67   * Builds the base render array and merged attributes for a component.
68   *
69   * @param array $component
70   *   Component definition.
71   *
72   * @return array
73   *   Render array skeleton for the component.
74   */
75  private static function buildElementAttributes(array $component): array {
76    $element = [];
77    foreach (Element::properties($component) as $key) {
78      $element[$key] = $component[$key];
79    }
80    $element['#attributes']['class'][] = 'name-element';
81
82    if (isset($component['attributes'])) {
83      foreach ($component['attributes'] as $key => $attribute) {
84        if (!isset($element['#attributes'][$key])) {
85          $element['#attributes'][$key] = $attribute;
86          continue;
87        }
88        if (is_array($attribute)) {
89          $element['#attributes'][$key] = array_merge(
90            $element['#attributes'][$key],
91            $attribute,
92          );
93          continue;
94        }
95        $element['#attributes'][$key] .= ' ' . $attribute;
96      }
97    }
98
99    return $element;
100  }
101
102  /**
103   * Normalizes select options and extracts the placeholder label.
104   *
105   * @param array $options
106   *   Select options.
107   *
108   * @return array
109   *   Two-item array with normalized options and empty label.
110   */
111  private static function normalizeSelectOptions(array $options): array {
112    $empty_label = NULL;
113    if (array_key_exists('_none', $options)) {
114      $empty_label = (string) $options['_none'];
115      unset($options['_none']);
116    }
117
118    $clean_options = [];
119    foreach ($options as $label) {
120      $label = (string) $label;
121      $is_default_placeholder = ($empty_label === NULL && str_starts_with($label, '--'));
122      if ($is_default_placeholder) {
123        $empty_label = trim(substr($label, 2));
124        continue;
125      }
126      $clean_options[] = $label;
127    }
128
129    $normalized = [];
130    foreach ($clean_options as $label) {
131      $normalized[$label] = $label;
132    }
133
134    return [$normalized, $empty_label];
135  }
136
137  /**
138   * Applies input-specific properties for a component element.
139   *
140   * @param array $element
141   *   Component render array.
142   * @param array $component
143   *   Component definition.
144   *
145   * @return array
146   *   Updated render array.
147   */
148  private static function applyComponentInput(array $element, array $component): array {
149    if ($component['type'] == 'select') {
150      return self::applySelectInput($element, $component);
151    }
152
153    if (!empty($component['autocomplete'])) {
154      $element += $component['autocomplete'];
155    }
156
157    return $element;
158  }
159
160  /**
161   * Applies select-specific properties for a component element.
162   *
163   * @param array $element
164   *   Component render array.
165   * @param array $component
166   *   Component definition.
167   *
168   * @return array
169   *   Updated render array.
170   */
171  private static function applySelectInput(array $element, array $component): array {
172    $element['#options'] = $component['options'];
173    $element['#size'] = 1;
174
175    $has_select_options = !empty($element['#options']) && is_array($element['#options']);
176    if (!$has_select_options) {
177      return $element;
178    }
179
180    [$normalized, $empty_label] = self::normalizeSelectOptions($element['#options']);
181    if ($empty_label !== NULL) {
182      $element['#empty_value'] = '_none';
183      $element['#empty_option'] = $empty_label !== '' ? $empty_label : '--';
184    }
185    $element['#options'] = $normalized;
186
187    return $element;
188  }
189
190  /**
191   * Resolves required marker and required input flags.
192   *
193   * @param bool $core
194   *   Whether the component is a required core part.
195   * @param array $base_element
196   *   Base form element.
197   *
198   * @return array
199   *   Marker and required flags.
200   */
201  private static function resolveRequiredFlags(bool $core, array $base_element): array {
202    $has_field_parents = isset($base_element['#field_parents'])
203      && is_array($base_element['#field_parents'])
204      && !in_array('default_value_input', $base_element['#field_parents'], TRUE);
205    $required_context = $core
206      && !empty($base_element['#required'])
207      && $has_field_parents;
208
209    return [
210      'show_marker' => $required_context
211      && !empty($base_element['#show_component_required_marker']),
212      'flag_required' => $required_context
213      && !empty($base_element['#flag_required_input']),
214    ];
215  }
216
217  /**
218   * Applies title display configuration and required metadata.
219   *
220   * @param array $element
221   *   Component render array.
222   * @param string $title_display
223   *   Title display mode.
224   * @param bool $required_marker
225   *   Whether to show required marker styling.
226   * @param bool $flag_required_input
227   *   Whether to mark the field as required.
228   *
229   * @return array
230   *   Updated render array.
231   */
232  private static function applyTitleDisplay(
233    array $element,
234    string $title_display,
235    bool $required_marker,
236    bool $flag_required_input,
237  ): array {
238    return match ($title_display) {
239      'title' => self::applyTitleDisplayAsTitle($element, $required_marker, $flag_required_input),
240      'placeholder' => self::applyTitleDisplayAsPlaceholder($element, $required_marker, $flag_required_input),
241      'none' => self::applyTitleDisplayAsNone($element, $flag_required_input),
242      'attribute' => self::applyTitleDisplayAsAttribute($element, $required_marker),
243      default => self::applyTitleDisplayAsDescription($element, $required_marker, $flag_required_input),
244    };
245  }
246
247  /**
248   * Applies "title" display behavior.
249   */
250  private static function applyTitleDisplayAsTitle(
251    array $element,
252    bool $required_marker,
253    bool $flag_required_input,
254  ): array {
255    $element['#title_display'] = 'before';
256    $element = self::setRequiredWhenFlag($element, $flag_required_input);
257
258    if ($required_marker) {
259      $element['#label_attributes']['class'][] = 'js-form-required';
260      $element['#label_attributes']['class'][] = 'form-required';
261    }
262
263    return $element;
264  }
265
266  /**
267   * Applies "placeholder" display behavior.
268   */
269  private static function applyTitleDisplayAsPlaceholder(
270    array $element,
271    bool $required_marker,
272    bool $flag_required_input,
273  ): array {
274    $element['#attributes']['placeholder'] = self::appendRequiredSuffix(
275      (string) $element['#title'],
276      $required_marker,
277    );
278    $element = self::setRequiredWhenFlag($element, $flag_required_input);
279    $element['#title_display'] = 'invisible';
280
281    return $element;
282  }
283
284  /**
285   * Applies "none" display behavior.
286   */
287  private static function applyTitleDisplayAsNone(
288    array $element,
289    bool $flag_required_input,
290  ): array {
291    $element['#title_display'] = 'invisible';
292    $element = self::setRequiredWhenFlag($element, $flag_required_input);
293
294    return $element;
295  }
296
297  /**
298   * Applies "attribute" display behavior.
299   */
300  private static function applyTitleDisplayAsAttribute(
301    array $element,
302    bool $required_marker,
303  ): array {
304    $element['#title_display'] = 'attribute';
305    $element['#attributes']['title'] = self::appendRequiredSuffix(
306      (string) $element['#title'],
307      $required_marker,
308    );
309
310    return $element;
311  }
312
313  /**
314   * Applies default "description" display behavior.
315   */
316  private static function applyTitleDisplayAsDescription(
317    array $element,
318    bool $required_marker,
319    bool $flag_required_input,
320  ): array {
321    $element['#title_display'] = 'invisible';
322    $element['#required'] = $flag_required_input;
323    $element['#description'] = [
324      '#theme' => 'form_element_label',
325      '#title' => $element['#title'],
326      '#required' => $required_marker,
327      '#title_display' => 'before',
328    ];
329    // Keep the callback target on Name for backwards compatibility.
330    $element['#after_build'][] = [Name::class, 'componentDescriptionAfterBuildLabelAlter'];
331
332    return $element;
333  }
334
335  /**
336   * Sets #required when required-input flag is enabled.
337   */
338  private static function setRequiredWhenFlag(array $element, bool $flag_required_input): array {
339    if ($flag_required_input) {
340      $element['#required'] = TRUE;
341    }
342
343    return $element;
344  }
345
346  /**
347   * Appends the required marker text when needed.
348   */
349  private static function appendRequiredSuffix(string $label, bool $required_marker): string {
350    if ($required_marker) {
351      return $label . ' (' . t('Required') . ')';
352    }
353
354    return $label;
355  }
356
357}