Skip to content
Snippets Groups Projects
Code owners
Assign users and groups as approvers for specific file changes. Learn more.
Datalist.php 6.25 KiB
<?php

namespace Drupal\datalist\Element;

use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Element;
use Drupal\Core\Render\Element\RenderElement;
use Drupal\Core\Render\Element\Textfield;

/**
 * Provides a render element for a datalist input.
 *
 * It will render a textfield connected to a datalist.
 * Currently multivalue and grouping is not supported.
 * By default we hide the keys when rendering.
 *
 * Be sure to fill in autocomplete for the fallback to work.
 *
 * Usage example:
 *
 * @code
 * $form['author']['name'] = array(
 *   '#type' => 'datalist',
 *   '#options' => [],
 *   '#title' => '',
 *   '#required' => TRUE|FALSE,
 *   '#placeholder' => 'a placeholder for the input field',
 *   '#default_value' => ''
 *   '#clear_button' => FALSE|"string for the clear button label" (defaults to
 *   'X')
 *   '#clear_button_description' => FALSE|"string for describing the clear
 *   button" (defaults to 'Clear field')
 *   '#use_keys' => TRUE|FALSE (default=FALSE)
 *   '#autocomplete_route_name' => NULL, OPTIONAL
 *   '#autocomplete_route_parameters' => [], OPTIONAL
 * );
 * @endcode
 *
 * @FormElement("datalist")
 */
class Datalist extends Textfield {

  /**
   * Switch back to a basic textfield.
   *
   * @param array $element
   *   The element.
   *
   * @return array
   *   The element modified to a simple textfield.
   */
  public static function fallbackTextfield($element): array {
    // Reset to base information.
    $route = $element['#autocomplete_route_name'];
    $element = array_merge($element, $element['#parent_textfield']);
    $element['#autocomplete_route_name'] = $route;

    $element['#type'] = 'textfield';

    return $element;
  }

  /**
   * {@inheritdoc}
   */
  public function getInfo() {
    $class = static::class;
    return [
        '#input' => TRUE,
        '#process' => [
          [$class, 'processDatalist'],
          [$class, 'processAutocomplete'],
          [$class, 'processAjaxForm'],
          [$class, 'processPattern'],
          [$class, 'processGroup'],
        ],
        '#pre_render' => [
          [$class, 'preRenderDatalist'],
          [$class, 'preRenderGroup'],
        ],
        '#theme' => 'input__datalist',
        '#options' => [],
        '#clear_button' => self::getClearButton(),
        '#down_button' => self::getDownButton(),
        '#clear_button_description' => self::getClearButtonDescription(),
        '#use_keys' => FALSE,
        '#autocomplete_route_name' => NULL,
        '#autocomplete_route_parameters' => [],
        '#parent_textfield' => parent::getInfo(),
      ] + parent::getInfo();
  }

  public static function getClearButtonDescription() {
    return t('Clear field');
  }

  public static function getClearButton() {
    return '✖';
  }

  public static function getDownButton() {
    return '▼';
  }

  /**
   * {@inheritdoc}
   */
  public static function processDatalist(&$element, FormStateInterface $form_state, &$complete_form) {
    $element['#cache']['contexts'][] = 'headers';

    self::fallbackTextfield($element);

    $element['#list'] = "{$element['#id']}-datalist";

    if (!empty($element['#clear_button'])) {
      $element['#attached']['library'][] = 'datalist/datalist';
    }

    // Let validation on empty value also work by adding the empty value.
    // Since we add #options, core will perform a required validation on our
    // submitted value. Since datalist is basically an enhanced  textfield, when
    // the form is submitted without interacting with the field, the validation
    // method will receive an empty string. In order for this string to be
    // considered legal, we always add it to our options list. When a field is
    // required, it will now get a required error and not an illegal choice.
    // In preprocess we unset this empty choice if the field is required.
    $element['#options'] = ['' => $element['#placeholder'] ?? ''] + $element['#options'];

    return $element;
  }

  /**
   * {@inheritdoc}
   */
  public static function valueCallback(&$element, $input, FormStateInterface $form_state) {
    if ($input !== FALSE && $input !== NULL) {
      // We might just have submitted without selecting or typing anything.
      // In that case an empty string is used as input.
      if ($input === '' || $element['#use_keys']) {
        return $input;
      }
      else {
        // If the input is coming from the textinput, and we don't #use_keys as
        // values, we are getting the label as a value, so lookup the key for
        // this label.
        $lookup = array_search($input, $element['#options'], TRUE);

        if ($lookup !== FALSE) {
          return $lookup;
        }

        // Setting the input in code will result in the input being the key
        // already, so in case we don't find it just return what was given.
        if (array_key_exists($input, $element['#options'])) {
          return $input;
        }
      }
    }

    return NULL;
  }

  /**
   * Prepares a datalist render element.
   */
  public static function preRenderDatalist($element) {
    // Make sure we keep the datalist type class, even if we extend this class.
    $element['#wrapper_attributes']['class'][] = 'form-type--datalist';

    if (!($element['#required'] ?? FALSE)) {
      // Don't show the empty value, it's shown by the placeholder.
      unset($element['#options']['']);
    }

    if (!$element['#use_keys'] && $element['#value'] !== '') {
      // Transform the actual field value which is a key into a label
      // for displaying.
      $element['#value'] = $element['#options'][$element['#value']] ?? '';
    }

    $element['#attributes']['class'][] = 'form-element';
    $element['#attributes']['type'] = 'text';
    $element['#attributes']['autocomplete'] = 'off';

    Element::setAttributes($element, [
      'id',
      'name',
      'value',
      'size',
      'maxlength',
      'placeholder',
      'list',
      'autocomplete',
    ]);
    RenderElement::setAttributes($element, ['form-text', 'form-datalist']);

    return $element;
  }

  /**
   * {@inheritdoc}
   */
  public static function preRenderAjaxForm($element) {
    if (
      !empty($element['#ajax'])
      && !isset($element['#ajax_processed'])
      && !isset($element['#ajax']['event'])
    ) {
      $element['#ajax']['event'] = 'change';
    }
    return parent::preRenderAjaxForm($element);
  }

}