From ef64066cccbb01c963baaff28948ef3491a8fc30 Mon Sep 17 00:00:00 2001
From: Florent Torregrosa
 <14238-florenttorregrosa@users.noreply.drupalcode.org>
Date: Wed, 22 Mar 2023 19:19:47 +0100
Subject: [PATCH] Issue #3292515 by Grimreaper: Bootstrap 5 : Forms > Floating
 labels

---
 doc/Forms.md                                  | 139 ++++++++++++++++++
 src/Element/ElementProcessInputGroup.php      |  15 +-
 src/HookHandler/ElementInfoAlter.php          |  40 +++++
 src/HookHandler/PreprocessFormElement.php     |  33 ++++-
 src/HookHandler/PreprocessInput.php           |  24 +++
 src/HookHandler/PreprocessTextarea.php        |  39 +++++
 .../overrides/input/form-element.html.twig    |   9 ++
 ui_suite_bootstrap.theme                      |  11 ++
 8 files changed, 297 insertions(+), 13 deletions(-)
 create mode 100644 src/HookHandler/PreprocessTextarea.php

diff --git a/doc/Forms.md b/doc/Forms.md
index e7fa120a..217bf28a 100644
--- a/doc/Forms.md
+++ b/doc/Forms.md
@@ -401,3 +401,142 @@ $form['input_group_file'] = [
   '#field_prefix' => $this->t('Upload'),
 ];
 ```
+
+## Floating labels
+
+https://getbootstrap.com/docs/5.2/forms/floating-labels.
+
+We handle a new value for `#title_display`: `floating`.
+
+UI Suite Bootstrap introduces a new property: `#floating_label`.
+
+When this property is set to `TRUE`, it has the same behavior as setting
+`#title_display` to `floating`.
+
+This is useful for example in Webform UI, which let you set `#title_display` but
+as there won't be the `floating` option, you can enter `#floating_label` in the
+YAML of its advanced options.
+
+Also if no placeholder attributes is set. UI Suite Bootstrap will fallback to
+the label itself.
+
+### Example
+
+https://getbootstrap.com/docs/5.2/forms/floating-labels/#example:
+
+```php
+$form['floating_label_property'] = [
+  '#type' => 'textfield',
+  '#title' => $this->t('With #floating_label property'),
+  '#floating_label' => TRUE,
+];
+
+$form['floating_label_email'] = [
+  '#type' => 'email',
+  '#title' => $this->t('Email address'),
+  '#title_display' => 'floating',
+  '#attributes' => [
+    'placeholder' => $this->t('name@example.com'),
+  ],
+];
+
+$form['floating_label_password'] = [
+  '#type' => 'password',
+  '#title' => $this->t('Password'),
+  '#title_display' => 'floating',
+  '#attributes' => [
+    'placeholder' => $this->t('Password'),
+  ],
+];
+
+$form['floating_label_value'] = [
+  '#type' => 'email',
+  '#title' => $this->t('Input with value'),
+  '#title_display' => 'floating',
+  '#default_value' => 'test@example.com',
+  '#attributes' => [
+    'placeholder' => $this->t('name@example.com'),
+  ],
+];
+```
+
+### Textareas
+
+https://getbootstrap.com/docs/5.2/forms/floating-labels/#textareas:
+
+```php
+$form['floating_label_textarea'] = [
+  '#type' => 'textarea',
+  '#title' => $this->t('Comments'),
+  '#title_display' => 'floating',
+  '#attributes' => [
+    'placeholder' => $this->t('Leave a comment here'),
+  ],
+];
+```
+
+### Selects
+
+https://getbootstrap.com/docs/5.2/forms/floating-labels/#selects:
+
+```php
+$form['floating_label_select'] = [
+  '#type' => 'select',
+  '#title' => $this->t('Works with selects'),
+  '#title_display' => 'floating',
+  '#options' => [
+    'option_1' => $this->t('Option 1'),
+    'option_2' => $this->t('Option 2'),
+    'option_3' => $this->t('Option 3'),
+  ],
+];
+```
+
+### Readonly plaintext
+
+https://getbootstrap.com/docs/5.2/forms/floating-labels/#readonly-plaintext:
+
+```php
+$form['floating_label_readonly'] = [
+  '#type' => 'email',
+  '#title' => $this->t('Empty input'),
+  '#title_display' => 'floating',
+  '#attributes' => [
+    'placeholder' => $this->t('name@example.com'),
+    'class' => [
+      'form-control-plaintext',
+    ],
+    'readonly' => TRUE,
+  ],
+];
+
+$form['floating_label_readonly_value'] = [
+  '#type' => 'email',
+  '#title' => $this->t('Input with value'),
+  '#title_display' => 'floating',
+  '#default_value' => 'name@example.com',
+  '#attributes' => [
+    'placeholder' => $this->t('name@example.com'),
+    'class' => [
+      'form-control-plaintext',
+    ],
+    'readonly' => TRUE,
+  ],
+];
+```
+
+### Input groups
+
+https://getbootstrap.com/docs/5.2/forms/floating-labels/#input-groups:
+
+```php
+$form['floating_label_input_group'] = [
+  '#type' => 'textfield',
+  '#title' => $this->t('Username'),
+  '#title_display' => 'floating',
+  '#attributes' => [
+    'placeholder' => $this->t('Username'),
+  ],
+  '#field_prefix' => '@',
+];
+```
diff --git a/src/Element/ElementProcessInputGroup.php b/src/Element/ElementProcessInputGroup.php
index 8af53cac..131cffe0 100644
--- a/src/Element/ElementProcessInputGroup.php
+++ b/src/Element/ElementProcessInputGroup.php
@@ -20,13 +20,13 @@ class ElementProcessInputGroup {
   public static function processInputGroup(array &$element, FormStateInterface $form_state, array &$complete_form): array {
     $element_object = Element::create($element);
     if (
-      $element_object->getProperty('input') &&
-      (
-        $element_object->getProperty('field_prefix') ||
-        $element_object->getProperty('field_suffix') ||
-        $element_object->getProperty('input_group_after') ||
-        $element_object->getProperty('input_group_before') ||
-        $element_object->getProperty('input_group_button')
+      $element_object->getProperty('input')
+      && (
+        $element_object->getProperty('field_prefix')
+        || $element_object->getProperty('field_suffix')
+        || $element_object->getProperty('input_group_after')
+        || $element_object->getProperty('input_group_before')
+        || $element_object->getProperty('input_group_button')
       )
     ) {
       static::processAddon($element_object, 'field_prefix', 'input_group_before');
@@ -75,6 +75,7 @@ class ElementProcessInputGroup {
     if (empty($addons)) {
       return;
     }
+    $processed_addons = [];
     foreach ($addons as $addon) {
       // Allow to inject renderable array for advanced implementations.
       if (\is_array($addon)) {
diff --git a/src/HookHandler/ElementInfoAlter.php b/src/HookHandler/ElementInfoAlter.php
index 4400ca89..017db9cf 100644
--- a/src/HookHandler/ElementInfoAlter.php
+++ b/src/HookHandler/ElementInfoAlter.php
@@ -65,6 +65,35 @@ class ElementInfoAlter {
     'weight',
   ];
 
+  /**
+   * List of additional properties for floating label feature.
+   */
+  public const FLOATING_LABEL_PROPERTIES = [
+    'floating_label' => FALSE,
+  ];
+
+  /**
+   * List of form elements supporting floating label.
+   */
+  public const FLOATING_LABEL_ELEMENTS = [
+    'date',
+    'email',
+    'entity_autocomplete',
+    'language_select',
+    'machine_name',
+    'number',
+    'password',
+    'password_confirm',
+    'search',
+    'select',
+    'tel',
+    'text_format',
+    'textarea',
+    'textfield',
+    'url',
+    'weight',
+  ];
+
   /**
    * Alter form element info.
    *
@@ -116,6 +145,17 @@ class ElementInfoAlter {
         'processInputGroup',
       ];
     }
+
+    // Floating label.
+    foreach (static::FLOATING_LABEL_ELEMENTS as $form_element_id) {
+      if (!isset($info[$form_element_id])) {
+        continue;
+      }
+
+      foreach (static::FLOATING_LABEL_PROPERTIES as $property => $property_default_value) {
+        $info[$form_element_id]["#{$property}"] = $property_default_value;
+      }
+    }
   }
 
 }
diff --git a/src/HookHandler/PreprocessFormElement.php b/src/HookHandler/PreprocessFormElement.php
index 8d31ec74..cc42dce6 100644
--- a/src/HookHandler/PreprocessFormElement.php
+++ b/src/HookHandler/PreprocessFormElement.php
@@ -4,6 +4,7 @@ declare(strict_types = 1);
 
 namespace Drupal\ui_suite_bootstrap\HookHandler;
 
+use Drupal\Core\Template\Attribute;
 use Drupal\ui_suite_bootstrap\Utility\Element;
 use Drupal\ui_suite_bootstrap\Utility\Variables;
 
@@ -22,9 +23,9 @@ class PreprocessFormElement {
   /**
    * An element object provided in the variables array, may not be set.
    *
-   * @var \Drupal\ui_suite_bootstrap\Utility\Element
+   * @var \Drupal\ui_suite_bootstrap\Utility\Element|false
    */
-  protected Element $element;
+  protected $element;
 
   /**
    * Preprocess form element.
@@ -39,6 +40,9 @@ class PreprocessFormElement {
 
     $this->variables = Variables::create($variables);
     $this->element = $this->variables->element;
+    if (!$this->element) {
+      return;
+    }
     $label = Element::create($variables['label']);
 
     // See https://getbootstrap.com/docs/5.2/forms/checks-radios
@@ -65,9 +69,10 @@ class PreprocessFormElement {
 
     // Input group.
     // Create variables for input_group flags.
-    $this->variables->offsetSet('input_group',
-      $this->element->getProperty('input_group_after') ||
-      $this->element->getProperty('input_group_before')
+    $this->variables->offsetSet(
+      'input_group',
+      $this->element->getProperty('input_group_after')
+      || $this->element->getProperty('input_group_before')
     );
     // Get input group attributes.
     // Cannot use map directly because of the attributes' management.
@@ -79,7 +84,23 @@ class PreprocessFormElement {
       'input_group_before' => 'input_group_before',
     ]);
 
-
+    // Floating label.
+    // Override title_display if using #floating_label.
+    if ($this->element->hasProperty('floating_label') && $this->element->getProperty('floating_label')) {
+      $this->element->setProperty('title_display', 'floating');
+      $this->variables->map([
+        'title_display' => 'title_display',
+      ]);
+      $this->variables->map([
+        'title_display' => 'label_display',
+      ]);
+    }
+    $this->variables->offsetSet('floating_label_attributes', new Attribute([
+      'class' => [
+        'form-floating',
+        ($this->variables->offsetGet('input_group') && $this->element->getProperty('errors')) ? 'is-invalid' : '',
+      ],
+    ]));
   }
 
 }
diff --git a/src/HookHandler/PreprocessInput.php b/src/HookHandler/PreprocessInput.php
index fbb6678d..8c2596a6 100644
--- a/src/HookHandler/PreprocessInput.php
+++ b/src/HookHandler/PreprocessInput.php
@@ -88,10 +88,34 @@ class PreprocessInput {
       $this->variables->offsetSet('label', $this->element->getProperty('value'));
     }
 
+    $this->floatingLabel();
+
     // Map the element properties.
     $this->variables->map([
       'attributes' => 'attributes',
     ]);
   }
 
+  /**
+   * Ensure the element has a placeholder. Otherwise, fallback to the label.
+   */
+  protected function floatingLabel(): void {
+    if (!$this->element) {
+      return;
+    }
+
+    if (
+      (
+        (
+          $this->element->hasProperty('floating_label')
+          && $this->element->getProperty('floating_label')
+        )
+        || $this->element->getProperty('title_display') == 'floating'
+      )
+      && !$this->element->hasAttribute('placeholder')
+    ) {
+      $this->element->setAttribute('placeholder', $this->element->getProperty('title'));
+    }
+  }
+
 }
diff --git a/src/HookHandler/PreprocessTextarea.php b/src/HookHandler/PreprocessTextarea.php
new file mode 100644
index 00000000..1e53be5b
--- /dev/null
+++ b/src/HookHandler/PreprocessTextarea.php
@@ -0,0 +1,39 @@
+<?php
+
+declare(strict_types = 1);
+
+namespace Drupal\ui_suite_bootstrap\HookHandler;
+
+use Drupal\ui_suite_bootstrap\Utility\Variables;
+
+/**
+ * Pre-processes variables for the "textarea" theme hook.
+ */
+class PreprocessTextarea extends PreprocessInput {
+
+  /**
+   * Prepare variables.
+   *
+   * @param array $variables
+   *   The hook preprocess variables.
+   */
+  public function preprocess(array &$variables): void {
+    if (!isset($variables['element'])) {
+      return;
+    }
+
+    $this->variables = Variables::create($variables);
+    $this->element = $this->variables->element;
+    if (!$this->element) {
+      return;
+    }
+
+    $this->floatingLabel();
+
+    // Map the element properties.
+    $this->variables->map([
+      'attributes' => 'attributes',
+    ]);
+  }
+
+}
diff --git a/templates/overrides/input/form-element.html.twig b/templates/overrides/input/form-element.html.twig
index c4df1a3f..edc97d1b 100644
--- a/templates/overrides/input/form-element.html.twig
+++ b/templates/overrides/input/form-element.html.twig
@@ -90,8 +90,17 @@
     {{ input_group_before }}
   {% endif %}
 
+  {% if label_display == 'floating' %}
+    <div{{ floating_label_attributes }}>
+  {% endif %}
+
   {{ children }}
 
+  {% if label_display == 'floating' %}
+      {{ label }}
+    </div>
+  {% endif %}
+
   {% if input_group_after %}
     {{ input_group_after }}
   {% endif %}
diff --git a/ui_suite_bootstrap.theme b/ui_suite_bootstrap.theme
index 6b7a021e..85b513e3 100644
--- a/ui_suite_bootstrap.theme
+++ b/ui_suite_bootstrap.theme
@@ -26,6 +26,7 @@ use Drupal\ui_suite_bootstrap\HookHandler\PreprocessPatternNavbar;
 use Drupal\ui_suite_bootstrap\HookHandler\PreprocessPatternNavbarNav;
 use Drupal\ui_suite_bootstrap\HookHandler\PreprocessPatternNavItem;
 use Drupal\ui_suite_bootstrap\HookHandler\PreprocessPatternPagination;
+use Drupal\ui_suite_bootstrap\HookHandler\PreprocessTextarea;
 use Drupal\ui_suite_bootstrap\HookHandler\ThemeSuggestionsAlter;
 
 /**
@@ -188,6 +189,16 @@ function ui_suite_bootstrap_preprocess_pattern_pagination(array &$variables): vo
   $instance->preprocess($variables);
 }
 
+/**
+ * Implements hook_preprocess_HOOK() for 'textarea'.
+ */
+function ui_suite_bootstrap_preprocess_textarea(array &$variables): void {
+  /** @var \Drupal\ui_suite_bootstrap\HookHandler\PreprocessTextarea $instance */
+  $instance = \Drupal::service('class_resolver')
+    ->getInstanceFromDefinition(PreprocessTextarea::class);
+  $instance->preprocess($variables);
+}
+
 /**
  * Implements hook_theme_suggestions_HOOK_alter() for 'input'.
  */
-- 
GitLab