ui_icons-1.0.x-dev/src/Element/IconAutocomplete.php
src/Element/IconAutocomplete.php
<?php
declare(strict_types=1);
namespace Drupal\ui_icons\Element;
// cspell:ignore autocompleteclose
use Drupal\Component\Utility\Html;
use Drupal\Component\Utility\NestedArray;
use Drupal\Core\Ajax\AjaxResponse;
use Drupal\Core\Ajax\ReplaceCommand;
use Drupal\Core\Form\FormElementHelper;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Render\Attribute\FormElement;
use Drupal\Core\Render\Element;
use Drupal\Core\Render\Element\FormElementBase;
use Drupal\Core\StringTranslation\TranslatableMarkup;
use Drupal\Core\Theme\Icon\IconDefinition;
use Drupal\Core\Theme\Icon\IconDefinitionInterface;
use Drupal\Core\Theme\Icon\Plugin\IconPackManagerInterface;
use Drupal\ui_icons\IconSearch;
use Symfony\Component\HttpFoundation\Request;
/**
* Provides a form element to select an icon.
*
* Properties:
* - #default_value: (string) Icon value as pack_id:icon_id.
* - #show_settings: (bool) Enable extractor settings, default FALSE.
* - #default_settings: (array) Settings for the extractor settings.
* - #settings_title: (string) Extractor settings details title.
* - #allowed_icon_pack: (array) Icon pack to limit the selection.
* - #result_format: (string) autocomplete search format, can be 'grid' or
* anything else for list.
* - #max_result: (int) search results.
* - #return_id: (bool) Form return icon id instead of icon object as default.
*
* Some base properties from FormElementBase.
* - #description: (string) Help or description text for the input element.
* - #placeholder: (string) Placeholder text for the input, default to
* 'Start typing icon name'.
* - #required: (bool) Whether or not input is required on the element.
* - #size: (int): Textfield size, default 55.
*
* Global properties applied to the parent element:
* - #attributes: (array) Attributes to the global element.
*
* @see web/core/lib/Drupal/Core/Render/Element/FormElementBase.php
*
* Usage example:
* @code
* $form['icon'] = [
* '#type' => 'icon_autocomplete',
* '#title' => $this->t('Select icon'),
* '#default_value' => 'my_icon_pack:my_default_icon',
* '#allowed_icon_pack' => [
* 'my_icon_pack',
* 'other_icon_pack',
* ],
* '#show_settings' => TRUE,
* '#result_format' => 'grid',
* ];
* @endcode
*/
#[FormElement('icon_autocomplete')]
class IconAutocomplete extends FormElementBase {
/**
* {@inheritdoc}
*/
public function getInfo(): array {
$class = static::class;
return [
'#input' => TRUE,
'#element_validate' => [
[$class, 'validateIcon'],
],
'#process' => [
[$class, 'processIcon'],
[$class, 'processIconAjaxForm'],
[$class, 'processAjaxForm'],
[$class, 'processGroup'],
],
'#pre_render' => [
[$class, 'preRenderGroup'],
],
'#theme' => 'icon_selector',
'#theme_wrappers' => ['form_element'],
'#allowed_icon_pack' => [],
'#result_format' => 'list',
'#max_result' => IconSearch::SEARCH_RESULT,
'#show_settings' => FALSE,
'#default_settings' => [],
'#settings_title' => new TranslatableMarkup('Settings'),
'#return_id' => FALSE,
];
}
/**
* {@inheritdoc}
*/
public static function valueCallback(mixed &$element, mixed $input, FormStateInterface $form_state): mixed {
$icon = NULL;
if ($input !== FALSE) {
if (empty($input['icon_id'])) {
// In case of default value we need to be able to delete.
unset($element['#default_value']);
return [];
}
$return = $input;
// @todo avoid calling getIcon, perhaps get rid of object return.
/** @var \Drupal\Core\Theme\Icon\IconDefinitionInterface $icon */
$icon = self::iconPack()->getIcon($input['icon_id']);
if (NULL === $icon) {
return $return;
}
// Settings filtered to store only the current icon values. Keep indexed
// with the icon pack id to match the forms default settings parameter.
$pack_id = $icon->getPackId();
if (isset($input['icon_settings'][$pack_id])) {
$return['icon_settings'] = [$pack_id => $input['icon_settings'][$pack_id]];
}
}
else {
if (!empty($element['#default_value']) && is_string($element['#default_value'])) {
/** @var \Drupal\Core\Theme\Icon\IconDefinitionInterface $icon */
$icon = self::iconPack()->getIcon($element['#default_value']);
}
}
if ($icon) {
$return['object'] = $icon;
return $return;
}
return $input;
}
/**
* Ajax callback for icon_autocomplete forms.
*
* @param array $form
* The build form.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state.
* @param \Symfony\Component\HttpFoundation\Request $request
* The current request.
*
* @return \Drupal\Core\Ajax\AjaxResponse
* The ajax response of the ajax icon.
*/
public static function buildAjaxCallback(array &$form, FormStateInterface &$form_state, Request $request): AjaxResponse {
/** @var \Drupal\Core\Render\RendererInterface $renderer */
$renderer = \Drupal::service('renderer');
$form_parents = explode('/', (string) $request->query->get('element_parents'));
// Sanitize form parents before using them.
$form_parents = array_filter($form_parents, [Element::class, 'child']);
// Retrieve the element to be rendered.
$form = NestedArray::getValue($form, $form_parents);
$status_messages = ['#type' => 'status_messages'];
$form['#prefix'] .= $renderer->renderRoot($status_messages);
$output = $renderer->renderRoot($form);
$response = new AjaxResponse();
$response->setAttachments($form['#attached']);
return $response->addCommand(new ReplaceCommand(NULL, $output));
}
/**
* Callback for creating form sub element icon_id.
*
* @param array $element
* An associative array containing the properties and children of the
* generic input element.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param array $complete_form
* The complete form structure.
*
* @return array
* The processed element with icon_id element.
*/
public static function processIcon(array &$element, FormStateInterface $form_state, array &$complete_form): array {
$element['#tree'] = TRUE;
// @todo find where this error can occur.
if (isset($element['#value']) && $element['#value'] instanceof \Stringable) {
$element['#value'] = [];
}
$element['icon_id'] = [
// Search type allow clear feature on some browser.
'#type' => 'search',
// This #title will not actually be used. Instead the parent element's
// #title is used as the label (see below).
'#title' => new TranslatableMarkup('Icon'),
'#title_display' => 'invisible',
'#placeholder' => $element['#placeholder'] ?? '',
'#autocomplete_route_name' => 'ui_icons.autocomplete',
'#required' => $element['#required'] ?? FALSE,
'#size' => $element['#size'] ?? 55,
'#maxlength' => 128,
'#value' => $element['#value']['icon_id'] ?? $element['#default_value'] ?? '',
// Ensure the ::validateIcon run.
'#limit_validation_errors' => [$element['#parents']],
'#description' => $element['#description'] ?? new TranslatableMarkup('Start typing the icon name. Icon availability depends on the selected icon packs.'),
];
// Clean unwanted values on parent.
unset($element['#size'], $element['#placeholder']);
if (!empty($element['#allowed_icon_pack'])) {
$element['icon_id']['#autocomplete_query_parameters']['allowed_icon_pack'] = implode('+', $element['#allowed_icon_pack']);
}
if (!empty($element['#max_result'])) {
$element['icon_id']['#autocomplete_query_parameters']['max_result'] = $element['#max_result'];
}
if (!empty($element['#result_format'])) {
$element['icon_id']['#autocomplete_query_parameters']['result_format'] = $element['#result_format'];
}
return $element;
}
/**
* Callback for #ajax and settings form element.
*
* @param array $element
* An associative array containing the properties and children of the
* generic input element.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The current state of the form.
* @param array $complete_form
* The complete form structure.
*
* @return array
* The processed element with added #ajax and extractor setting forms.
*/
public static function processIconAjaxForm(array &$element, FormStateInterface $form_state, array &$complete_form): array {
// Generate a unique wrapper HTML ID.
$ajax_wrapper_id = Html::getUniqueId('ajax-wrapper');
// Prefix and suffix used for Ajax replacement.
$element['#prefix'] = '<div id="' . $ajax_wrapper_id . '">';
$element['#suffix'] = '</div>';
$parents_prefix = implode('_', $element['#parents']);
$element['icon_id']['#attributes']['data-wrapper-id'] = $ajax_wrapper_id;
$element['icon_id']['#ajax'] = [
'callback' => [static::class, 'buildAjaxCallback'],
'options' => [
'query' => [
'element_parents' => implode('/', $element['#array_parents']),
],
],
'disable-refocus' => TRUE,
'wrapper' => $ajax_wrapper_id,
'effect' => 'none',
// As we used autocomplete we want matching events.
// @todo check for too early errors related to change.
'event' => 'autocompleteclose change',
];
// ProcessIcon will handle #value or #default_value.
$icon_full_id = $element['#value']['icon_id'] ?? $element['icon_id']['#value'] ?? NULL;
if (!$icon_full_id || !is_string($icon_full_id) || FALSE === strpos($icon_full_id, IconDefinition::ICON_SEPARATOR) || NULL === self::iconPack()->getIcon($icon_full_id)) {
// If a default value based on a disabled icon pack exist, clear it.
unset($element['icon_id']['#value']);
return $element;
}
// If no settings or no value found.
if (FALSE === (bool) $element['#show_settings']) {
return $element;
}
$element['icon_settings'] = [
'#type' => 'details',
'#name' => 'icon[' . $parents_prefix . ']',
'#title' => $element['#settings_title'],
];
if (!$icon_data = IconDefinition::getIconDataFromId($icon_full_id)) {
return $element;
}
$pack_id = $icon_data['pack_id'];
if (!empty($element['#allowed_icon_pack'])) {
if (!in_array($pack_id, $element['#allowed_icon_pack'])) {
unset($element['icon_settings']);
return $element;
}
}
// Track the array size before adding forms, if no change it means we have
// no extractor form.
$settings_empty_count = count($element['icon_settings']);
self::iconPack()->getExtractorPluginForms(
$element['icon_settings'],
$form_state,
$element['#default_settings'] ?? [],
[$pack_id => $pack_id],
);
// Remove if no extractor form is found.
if ($settings_empty_count === count($element['icon_settings'])) {
unset($element['icon_settings']);
}
return $element;
}
/**
* Form element validation extractor for icon_autocomplete elements.
*
* @param array $element
* The element to validate.
* @param \Drupal\Core\Form\FormStateInterface $form_state
* The form state object.
* @param array $complete_form
* The complete form array.
*/
public static function validateIcon(array &$element, FormStateInterface $form_state, array &$complete_form): void {
$input_exists = FALSE;
$values = $form_state->getValues();
if (!$values) {
return;
}
$input = NestedArray::getValue($values, $element['#parents'], $input_exists);
if (!$input_exists) {
return;
}
if (empty($input['icon_id']) && !$element['#required']) {
$form_state->setValueForElement($element, NULL);
return;
}
/** @var \Drupal\Core\Theme\Icon\IconDefinitionInterface $icon */
$icon = self::iconPack()->getIcon($input['icon_id']);
if (NULL === $icon || !$icon instanceof IconDefinitionInterface) {
$form_state->setError($element['icon_id'], new TranslatableMarkup('Icon for %title is invalid: %icon.<br>Please search again and select a result in the list.', [
'%title' => FormElementHelper::getElementTitle($element),
'%icon' => $input['icon_id'],
]));
return;
}
$pack_id = $icon->getPackId();
if (!empty($element['#allowed_icon_pack']) && !in_array($pack_id, $element['#allowed_icon_pack'])) {
$form_state->setError($element['icon_id'], new TranslatableMarkup('Icon for %title is not valid anymore because it is part of icon pack: %pack_id. This field limit icon pack to: %limit.', [
'%title' => FormElementHelper::getElementTitle($element),
'%pack_id' => $pack_id,
'%limit' => implode(', ', $element['#allowed_icon_pack']),
]));
return;
}
$settings = [];
if (isset($input['icon_settings'][$pack_id])) {
$settings[$pack_id] = $input['icon_settings'][$pack_id];
// @todo validateConfigurationForm from extractor plugin?
}
if (isset($element['#return_id']) && TRUE === $element['#return_id']) {
$form_state->setValueForElement($element, ['target_id' => $icon->getId(), 'settings' => $settings]);
return;
}
$form_state->setValueForElement($element, ['icon' => $icon, 'settings' => $settings]);
}
/**
* Wraps the icon pack service.
*
* @return \Drupal\Core\Theme\Icon\Plugin\IconPackManagerInterface
* The icon pack manager service.
*/
protected static function iconPack(): IconPackManagerInterface {
return \Drupal::service('plugin.manager.icon_pack');
}
}
