Бывает так, что при создании форм разработчику не хватает всех предопределенных типов полей, которые есть в Form API. В таком случае можно написать свой элемент формы, основываясь на классе Drupal 8 FormElement.
Рассмотрим разработку такого поля на основе поля для ввода времени. А потом на основе нового поля создадим поле, которое позволяет вводить интервал времени в пределах одних суток.
В HTML5 есть тип поля ввода time, который позволяет вводить часы, минуты и секунды:
<input type="time" step="900" />
Параметр step указывает на инкремент минут (в данном случае 15), если он меньше 60, то будет возможность вводить также и секунды.
Примечание: сделать функционал поля с вводом времени можно с помощью встроенного в Drupal типа datetime (нужно проверить, включён ли модуль Datetime).
$form['time'] = [
'#type' => 'datetime',
'#date_date_element' => 'none',
'#date_time_element' => 'time'
];
Но мы хотим больше контроля над нашим полем.
Итак, создаем модуль, который будет выводить форму. В папке src/Element создадим файл Time.php, который и будет содержать поле времени.
Нам необходимо указать название нашего типа (time) — это то, что будет писаться в '#type' при построении формы и объявить метод getInfo, который описывает параметры элемента, определяет методы для валидации, рендеренга.
namespace Drupal\settings\Element;
use Drupal\Core\Render\Element;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Core\Datetime\Entity\DateFormat;
use Drupal\Component\Utility\NestedArray;
/**
* Provides a time element.
*
* @FormElement("time")
*/
class Time extends Element\FormElement {
public function getInfo() {
$time_format = '';
if (!defined('MAINTENANCE_MODE')) {
if ($time_format_entity = DateFormat::load('html_time')) {
$time_format = $time_format_entity->getPattern();
}
}
$class = get_class($this);
return [
'#input' => TRUE,
'#element_validate' => [
[$class, 'validateTime'],
],
'#process' => [
[$class, 'processTime'],
[$class, 'processGroup'],
],
'#pre_render' => [
[$class, 'preRenderTime'],
[$class, 'preRenderGroup'],
],
'#theme' => 'input__textfield',
'#theme_wrappers' => ['form_element'],
'#time_format' => $time_format,
'#time_callbacks' => [],
'#step' => 60 * 15,
];
}
}
В секции '#process' мы определили метод processTime. В этом методе мы можем задать полю какие-то параметры по умолчанию, провести обработку параметров элемента, которые получаем из описания поля, когда создаем форму.
public static function processTime(&$element, FormStateInterface $form_state, &$complete_form) {
$element['time'] = [
'#name' => $element['#name'],
'#title' => t('Time'),
'#title_display' => 'invisible',
'#default_value' => $element['#default_value'],
'#attributes' => $element['#attributes'],
'#required' => $element['#required'],
'#size' => 12,
'#error_no_message' => TRUE,
];
return $element;
}
Также нам надо определить внешний вид нашего поля. В getInfo есть два параметра для этого:
'#theme' => 'input__textfield',
'#theme_wrappers' => ['form_element'],
Тема для вывода самого поля и его обвеса (врапперы, сообщения об ошибках, примечания и т. д.). Мы можем создать свою тему (сделаем это для второго поля — интервала), тут же мы используем стандартный шаблон для текстового поля и стандартный враппер для элемента формы. Все они определены в ядре.
Чтобы задать атрибуты поля ввода (так необходимый нам тип поля time), определяем '#pre_render' метод preRenderTime:
public static function preRenderTime($element) {
$element['#attributes']['type'] = 'time';
Element::setAttributes($element, ['id', 'name', 'value', 'size', 'step']);
// Sets the necessary attributes, such as the error class for validation.
// Without this line the field will not be hightlighted, if an error occurred
static::setAttributes($element, ['form-text']);
return $element;
}
Для элемента надо определить, как будет формироваться значение по умолчанию и как вообще значение будет присваиваться элементу. Для этого нужен метод valueCallback:
public static function valueCallback(&$element, $input, FormStateInterface $form_state) {
if ($input !== FALSE) {
$format = isset($element['#time_format']) && $element['#time_format'] ? $element['#time_format'] : 'html_time';
$time_format = DateFormat::load($format)->getPattern();
try {
DrupalDateTime::createFromFormat($time_format, $input, NULL);
}
catch (\Exception $e) {
$input = NULL;
}
}
else {
$input = $element['#default_value'];
}
return $input;
}
Здесь мы по умолчанию присваиваем переданное дефолтное значение, а, если значение уже есть в поле, то проверяем его на соответствие формату времени, очищая поле, если формат неверный.
И последнее, что надо сделать, это написать метод для валидации, заданный в getInfo в секции '#element_validate':
public static function validateTime(&$element, FormStateInterface $form_state, &$complete_form) {
$format = isset($element['#time_format']) && $element['#time_format'] ? $element['#time_format'] : 'html_time';
$time_format = DateFormat::load($format)->getPattern();
$title = !empty($element['#title']) ? $element['#title'] : '';
$input_exists = FALSE;
$input = NestedArray::getValue($form_state->getValues(), $element['#parents'], $input_exists);
if ($input_exists) {
if (empty($input) && !$element['#required']) {
$form_state->setValueForElement($element, NULL);
}
elseif (empty($input) && $element['#required']) {
$form_state->setError($element, t('The %field is required. Please enter time in the format %format.', ['%field' => $title, '%format' => $time_format]));
}
else {
try {
DrupalDateTime::createFromFormat($time_format, $input, NULL);
$form_state->setValueForElement($element, $input);
}
catch (\Exception $e) {
$form_state->setError($element, t('The %field is required. Please enter time in the format %format.', ['%field' => $title, '%format' => $time_format]));
}
}
}
}
Элемент готов! Теперь мы можем задать этот поле в форме таким образом:
$form['time'] = [
'#type' => 'time',
'#name' => 'time',
'#title' => t('Time'),
'#step' => 60 * 15,
'#default_value' => '11:00'
];
Теперь на основе созданного элемента создадим поле, позволяющее вводить интервал времени. Тут все аналогично, на этом примере видно, как делать составные элементы, а также, как использовать свои шаблоны для вывода поля.
Создадим элемент Timerange:
/**
* Provides a time range element.
*
* @FormElement("timerange")
*/
class Timerange extends Element\FormElement {
public function getInfo() {
$time_format = '';
if (!defined('MAINTENANCE_MODE')) {
if ($time_format_entity = DateFormat::load('html_time')) {
$time_format = $time_format_entity->getPattern();
}
}
$class = get_class($this);
return [
'#input' => TRUE,
'#element_validate' => [
[$class, 'validateTimerange'],
],
'#process' => [
[$class, 'processRange'],
[$class, 'processGroup'],
],
'#pre_render' => [
[$class, 'preRenderGroup'],
],
'#theme' => 'timerange_form',
'#theme_wrappers' => ['timerange_wrapper'],
'#time_format' => $time_format,
'#time_callbacks' => [],
'#step' => 60 * 15,
];
}
public static function processRange(&$element, FormStateInterface $form_state, &$complete_form) {
$element['#tree'] = TRUE;
$element['start'] = [
'#type' => 'time',
'#name' => $element['#name'].'[start]',
'#time_format' => $element['#time_format'],
'#step' => 60 * 15,
'#default_value' => $element['#default_value']['start']
];
$element['end'] = [
'#type' => 'time',
'#name' => $element['#name'].'[end]',
'#time_format' => $element['#time_format'],
'#step' => 60 * 15,
'#default_value' => $element['#default_value']['end']
];
return $element;
}
public static function valueCallback(&$element, $input, FormStateInterface $form_state) {
if ($input !== FALSE) {
$format = isset($element['#time_format']) && $element['#time_format'] ? $element['#time_format'] : 'html_time';
$time_format = DateFormat::load($format)->getPattern();
try {
DrupalDateTime::createFromFormat($time_format, $input['start'], NULL);
}
catch (\Exception $e) {
$input['start'] = NULL;
}
try {
DrupalDateTime::createFromFormat($time_format, $input['end'], NULL);
}
catch (\Exception $e) {
$input['end'] = NULL;
}
}
else {
$input = [
'start' => $element['#default_value']['start'],
'end' => $element['#default_value']['end'],
];
}
return $input;
}
}
Как видно в методе processRange, элемент теперь содержит два поля: start и end, оба типа time. При присвоении значения полю, мы теперь имеем ассоциативный массив. С валидацией уже справится несложно, главное не забывать, что значение поля является массивом с ключами start и end.
В качестве шаблонов для вывода поля мы указали timerange_form и timerange_wrapper. Объявим эти шаблоны в модуле таким образом (код хуков взят из ядра):
function settings_theme() {
return [
// ...
'timerange_form' => [
'render element' => 'element',
],
'timerange_wrapper' => [
'render element' => 'element',
]
];
}
// ******
function template_preprocess_timerange_form(&$variables) {
$element = $variables['element'];
$variables['attributes'] = [];
if (isset($element['#id'])) {
$variables['attributes']['id'] = $element['#id'];
}
if (!empty($element['#attributes']['class'])) {
$variables['attributes']['class'] = (array) $element['#attributes']['class'];
}
$variables['content'] = $element;
}
function template_preprocess_timerange_wrapper(&$variables) {
$element = $variables['element'];
if (!empty($element['#title'])) {
$variables['title'] = $element['#title'];
}
// Suppress error messages.
$variables['errors'] = NULL;
$variables['description'] = NULL;
if (!empty($element['#description'])) {
$description_attributes = [];
if (!empty($element['#id'])) {
$description_attributes['id'] = $element['#id'] . '--description';
}
$variables['description'] = $element['#description'];
$variables['description_attributes'] = new Attribute($description_attributes);
}
$variables['required'] = FALSE;
// For required datetime fields 'form-required' & 'js-form-required' classes
// are appended to the label attributes.
if (!empty($element['#required'])) {
$variables['required'] = TRUE;
}
$variables['content'] = $element['#children'];
}
timerage-form.html.twig:
<div{{ attributes }} class="timerange-fields">
{{ content }}
</div>
timerange-wrapper.html.twig:
<div class="timerange">
{%
set title_classes = [
required ? 'js-form-required',
required ? 'form-required',
'label'
]
%}
{% if title %}
<h4{{ title_attributes.addClass(title_classes) }}>{{ title }}</h4>
{% endif %}
{{ content }}
{% if errors %}
<div>
{{ errors }}
</div>
{% endif %}
{% if description %}
<div{{ description_attributes }}>
{{ description }}
</div>
{% endif %}
</div>
И немного CSS, чтобы эти два поля стояли рядом:
.timerange-fields {
display: flex;
justify-content: flex-start;
}
.timerange-fields > div {
margin-left: 20px;
}
.timerange-fields > div:first-child {
margin-left: 0;
}
.timerange .form-item {
margin-top: 0;
}
Теперь можно задать поле таким образом:
$form['working_hours'] = [
'#tree' => TRUE,
'#type' => 'timerange',
'#title' => $this->t('Working hours'),
'#time_format' => 'working_time',
'#default_value' => ['start' => '11:00', 'end' => '17:00'],
];
Результат:
Посмотреть исходники элементов ядра можно в core/lib/Drupal/Core/Render/Element.