Issue #3370113 by Utkarsh_33, lauriii, omkar.podey, tedbow, bnjmnm, ckrina, smustgrave, longwave, hooroomoo, srishtiiee, yoroy: Make it easier to enter multiple values to fields allowing unlimited values
parent
eaca861b38
commit
ded815fbe0
|
@ -953,3 +953,11 @@ js-cookie:
|
|||
js:
|
||||
assets/vendor/js-cookie/js.cookie.min.js: {}
|
||||
deprecated: The %library_id% asset library is deprecated in Drupal 10.1.0 and will be removed in Drupal 11.0.0. There is no replacement. See https://www.drupal.org/node/3322720
|
||||
|
||||
drupal.fieldListKeyboardNavigation:
|
||||
version: VERSION
|
||||
js:
|
||||
misc/field-list-keyboard-navigation.js: {}
|
||||
dependencies:
|
||||
- core/drupal
|
||||
- core/tabbable
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* @file
|
||||
* Attaches behaviors for Drupal's field list keyboard navigation.
|
||||
*/
|
||||
(function (Drupal, { isFocusable }) {
|
||||
/**
|
||||
* Attaches the focus shifting functionality.
|
||||
*
|
||||
* @type {Drupal~behavior}
|
||||
*
|
||||
* @prop {Drupal~behaviorAttach} attach
|
||||
* Attaches behaviors.
|
||||
*/
|
||||
Drupal.behaviors.fieldListKeyboardNavigation = {
|
||||
attach() {
|
||||
once(
|
||||
'keyboardNavigation',
|
||||
'input[type="text"], input[type="number"]',
|
||||
document.querySelector('[data-field-list-table]'),
|
||||
).forEach((element) =>
|
||||
element.addEventListener('keypress', (event) => {
|
||||
if (event.key !== 'Enter') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const currentElement = event.target;
|
||||
|
||||
// Function to find the next focusable element.
|
||||
const findNextFocusableElement = (element) => {
|
||||
const currentRow = element.closest('tr');
|
||||
const inputElements = currentRow.querySelectorAll(
|
||||
'input[type="text"], input[type="number"]',
|
||||
);
|
||||
const afterIndex = [...inputElements].indexOf(element) + 1;
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const inputElement of [...inputElements].slice(afterIndex)) {
|
||||
if (isFocusable(inputElement)) {
|
||||
return inputElement;
|
||||
}
|
||||
}
|
||||
const nextRow = currentRow.nextElementSibling;
|
||||
if (nextRow) {
|
||||
return findNextFocusableElement(nextRow);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const nextFocusableElement = findNextFocusableElement(currentElement);
|
||||
|
||||
// If a focusable element is found, move focus there.
|
||||
if (nextFocusableElement) {
|
||||
nextFocusableElement.focus();
|
||||
// Move cursor to the end of the input.
|
||||
const value = nextFocusableElement.value;
|
||||
nextFocusableElement.value = '';
|
||||
nextFocusableElement.value = value;
|
||||
return;
|
||||
}
|
||||
// If no focusable element is found, add another item to the list.
|
||||
event.target
|
||||
.closest('[data-field-list-table]')
|
||||
.parentNode.querySelector('[data-field-list-button]')
|
||||
.dispatchEvent(new Event('mousedown'));
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
})(Drupal, window.tabbable);
|
|
@ -207,7 +207,21 @@
|
|||
'<span class="admin-link"><button type="button" class="link" aria-label="'
|
||||
.concat(Drupal.t('Edit machine name'), '">')
|
||||
.concat(Drupal.t('Edit'), '</button></span>'),
|
||||
).on('click', eventData, clickEditHandler);
|
||||
)
|
||||
.on('click', eventData, clickEditHandler)
|
||||
.on('keyup', (e) => {
|
||||
// Avoid propagating a keyup event from the machine name input.
|
||||
if (e.key === 'Enter' || eventData.code === 'Space') {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
e.target.click();
|
||||
}
|
||||
})
|
||||
.on('keydown', (e) => {
|
||||
if (e.key === 'Enter' || eventData.code === 'Space') {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
$suffix.append($link);
|
||||
|
||||
// Preview the machine name in realtime when the human-readable name
|
||||
|
|
|
@ -4,6 +4,9 @@ namespace Drupal\options\Plugin\Field\FieldType;
|
|||
|
||||
use Drupal\Component\Utility\Html;
|
||||
use Drupal\Component\Utility\NestedArray;
|
||||
use Drupal\Core\Ajax\AjaxResponse;
|
||||
use Drupal\Core\Ajax\FocusFirstCommand;
|
||||
use Drupal\Core\Ajax\InsertCommand;
|
||||
use Drupal\Core\Field\FieldDefinitionInterface;
|
||||
use Drupal\Core\Field\FieldItemBase;
|
||||
use Drupal\Core\Form\FormStateInterface;
|
||||
|
@ -118,6 +121,7 @@ abstract class ListItemBase extends FieldItemBase implements OptionsProviderInte
|
|||
],
|
||||
'#attributes' => [
|
||||
'id' => 'allowed-values-order',
|
||||
'data-field-list-table' => TRUE,
|
||||
],
|
||||
'#tabledrag' => [
|
||||
[
|
||||
|
@ -126,6 +130,9 @@ abstract class ListItemBase extends FieldItemBase implements OptionsProviderInte
|
|||
'group' => 'weight',
|
||||
],
|
||||
],
|
||||
'#attached' => [
|
||||
'library' => ['core/drupal.fieldListKeyboardNavigation'],
|
||||
],
|
||||
];
|
||||
|
||||
$max = $form_state->get('items_count');
|
||||
|
@ -196,12 +203,14 @@ abstract class ListItemBase extends FieldItemBase implements OptionsProviderInte
|
|||
}
|
||||
}
|
||||
$element['allowed_values']['table']['#max_delta'] = $max;
|
||||
|
||||
$element['allowed_values']['add_more_allowed_values'] = [
|
||||
'#type' => 'submit',
|
||||
'#name' => 'add_more_allowed_values',
|
||||
'#value' => $this->t('Add another item'),
|
||||
'#attributes' => ['class' => ['field-add-more-submit']],
|
||||
'#attributes' => [
|
||||
'class' => ['field-add-more-submit'],
|
||||
'data-field-list-button' => TRUE,
|
||||
],
|
||||
// Allow users to add another row without requiring existing rows to have
|
||||
// values.
|
||||
'#limit_validation_errors' => [],
|
||||
|
@ -210,6 +219,10 @@ abstract class ListItemBase extends FieldItemBase implements OptionsProviderInte
|
|||
'callback' => [static::class, 'addMoreAjax'],
|
||||
'wrapper' => $wrapper_id,
|
||||
'effect' => 'fade',
|
||||
'progress' => [
|
||||
'type' => 'throbber',
|
||||
'message' => $this->t('Adding a new item...'),
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
|
@ -246,10 +259,14 @@ abstract class ListItemBase extends FieldItemBase implements OptionsProviderInte
|
|||
// Go one level up in the form.
|
||||
$element = NestedArray::getValue($form, array_slice($button['#array_parents'], 0, -1));
|
||||
$delta = $element['table']['#max_delta'];
|
||||
$element['table'][$delta]['item']['#prefix'] = '<div class="ajax-new-content">' . ($element['table'][$delta]['item']['#prefix'] ?? '');
|
||||
$element['table'][$delta]['item']['#prefix'] = '<div class="ajax-new-content" data-drupal-selector="field-list-add-more-focus-target">' . ($element['table'][$delta]['item']['#prefix'] ?? '');
|
||||
$element['table'][$delta]['item']['#suffix'] = ($element['table'][$delta]['item']['#suffix'] ?? '') . '</div>';
|
||||
|
||||
return $element;
|
||||
$response = new AjaxResponse();
|
||||
$response->addCommand(new InsertCommand(NULL, $element));
|
||||
$response->addCommand(new FocusFirstCommand('[data-drupal-selector="field-list-add-more-focus-target"]'));
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -79,7 +79,8 @@ class OptionsFieldUITest extends WebDriverTestBase {
|
|||
*
|
||||
* @dataProvider providerTestOptionsAllowedValues
|
||||
*/
|
||||
public function testOptionsAllowedValues($option_type, $options, $is_string_option) {
|
||||
public function testOptionsAllowedValues($option_type, $options, $is_string_option, string $add_row_method) {
|
||||
$assert = $this->assertSession();
|
||||
$this->fieldName = 'field_options_text';
|
||||
$this->createOptionsField($option_type);
|
||||
$page = $this->getSession()->getPage();
|
||||
|
@ -87,15 +88,79 @@ class OptionsFieldUITest extends WebDriverTestBase {
|
|||
$this->drupalGet($this->adminPath);
|
||||
|
||||
$i = 0;
|
||||
$expected_rows = 1;
|
||||
$this->assertAllowValuesRowCount(1);
|
||||
foreach ($options as $option_key => $option_label) {
|
||||
$page->fillField("settings[allowed_values][table][$i][item][label]", $option_label);
|
||||
$enter_element_name = $label_element_name = "settings[allowed_values][table][$i][item][label]";
|
||||
$page->fillField($label_element_name, $option_label);
|
||||
$key_element_name = "settings[allowed_values][table][$i][item][key]";
|
||||
|
||||
// Add keys if not string option list.
|
||||
if (!$is_string_option) {
|
||||
$page->fillField("settings[allowed_values][table][$i][item][key]", $option_key);
|
||||
$this->pressEnterOnElement("[name=\"$label_element_name\"]");
|
||||
// Assert that pressing enter on label field does not create the new
|
||||
// row if the key field is visible.
|
||||
$this->assertAllowValuesRowCount($expected_rows);
|
||||
$enter_element_name = $key_element_name;
|
||||
$this->assertHasFocusByAttribute('name', $key_element_name);
|
||||
$page->fillField($key_element_name, $option_key);
|
||||
}
|
||||
$page->pressButton('Add another item');
|
||||
else {
|
||||
$this->assertFalse($assert->fieldExists($key_element_name)->isVisible());
|
||||
}
|
||||
switch ($add_row_method) {
|
||||
case 'Press button':
|
||||
$page->pressButton('Add another item');
|
||||
break;
|
||||
|
||||
case 'Enter button':
|
||||
$button = $assert->buttonExists('Add another item');
|
||||
$this->pressEnterOnElement('[data-drupal-selector="' . $button->getAttribute('data-drupal-selector') . '"]');
|
||||
break;
|
||||
|
||||
case 'Enter element':
|
||||
// If testing using the "enter" key while focused on element there a
|
||||
// few different scenarios to test.
|
||||
switch ($i) {
|
||||
case 0:
|
||||
// For string options the machine name input can be exposed which
|
||||
// will mean the label input will no longer create the next row.
|
||||
if ($is_string_option) {
|
||||
$this->exposeOptionMachineName($expected_rows);
|
||||
$this->pressEnterOnElement("[name=\"$enter_element_name\"]");
|
||||
$this->assertHasFocusByAttribute('name', $key_element_name);
|
||||
// Ensure that pressing enter while focused on the label input
|
||||
// did not create a new row if the machine name field is
|
||||
// visible.
|
||||
$this->assertAllowValuesRowCount($expected_rows);
|
||||
$enter_element_name = $key_element_name;
|
||||
}
|
||||
break;
|
||||
}
|
||||
$this->pressEnterOnElement("[name=\"$enter_element_name\"]");
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new \UnexpectedValueException("Unknown method $add_row_method");
|
||||
}
|
||||
|
||||
$i++;
|
||||
$expected_rows++;
|
||||
$this->assertSession()->waitForElementVisible('css', "[name='settings[allowed_values][table][$i][item][label]']");
|
||||
$this->assertHasFocusByAttribute('name', "settings[allowed_values][table][$i][item][label]");
|
||||
$this->assertAllowValuesRowCount($expected_rows);
|
||||
|
||||
if ($is_string_option) {
|
||||
// Expose the key input for string options for the previous row to test
|
||||
// shifting focus from the label to key inputs on the previous row by
|
||||
// pressing enter.
|
||||
$this->exposeOptionMachineName($expected_rows - 1);
|
||||
}
|
||||
// Test that pressing enter on the label input on previous row will shift
|
||||
// focus to key input of that row.
|
||||
$this->pressEnterOnElement("[name=\"$label_element_name\"]");
|
||||
$this->assertHasFocusByAttribute('name', $key_element_name);
|
||||
$this->assertAllowValuesRowCount($expected_rows);
|
||||
}
|
||||
$page->pressButton('Save field settings');
|
||||
|
||||
|
@ -200,6 +265,21 @@ class OptionsFieldUITest extends WebDriverTestBase {
|
|||
$this->adminPath = 'admin/structure/types/manage/' . $this->type . '/fields/node.' . $this->type . '.' . $this->fieldName . '/storage';
|
||||
}
|
||||
|
||||
/**
|
||||
* Presses "Enter" on the specified element.
|
||||
*
|
||||
* @param string $selector
|
||||
* Current element having focus.
|
||||
*/
|
||||
private function pressEnterOnElement(string $selector): void {
|
||||
$javascript = <<<JS
|
||||
const element = document.querySelector('$selector');
|
||||
const event = new KeyboardEvent('keypress', { key: 'Enter', keyCode: 13, bubbles: true });
|
||||
element.dispatchEvent(event);
|
||||
JS;
|
||||
$this->getSession()->executeScript($javascript);
|
||||
}
|
||||
|
||||
/**
|
||||
* Data provider for testOptionsAllowedValues().
|
||||
*
|
||||
|
@ -208,9 +288,11 @@ class OptionsFieldUITest extends WebDriverTestBase {
|
|||
* - Option type.
|
||||
* - Array of option type values.
|
||||
* - Whether option type is string type or not.
|
||||
* - The method which should be used to add another row to the table. The
|
||||
* possible values are 'Press button', 'Enter button' or 'Enter element'.
|
||||
*/
|
||||
public function providerTestOptionsAllowedValues() {
|
||||
return [
|
||||
$type_cases = [
|
||||
'List integer' => [
|
||||
'list_integer',
|
||||
[1 => 'First', 2 => 'Second', 3 => 'Third'],
|
||||
|
@ -227,6 +309,62 @@ class OptionsFieldUITest extends WebDriverTestBase {
|
|||
TRUE,
|
||||
],
|
||||
];
|
||||
// Test adding options for each option field type using several possible
|
||||
// methods that could be used for navigating the options list:
|
||||
// - Press button: add a new item by pressing the 'Add another item'
|
||||
// button using mouse.
|
||||
// - Enter button: add a new item by pressing the 'Add another item'
|
||||
// button using enter key on the keyboard.
|
||||
// - Enter element: add a new item by pressing enter on the last text
|
||||
// field inside the table.
|
||||
$test_cases = [];
|
||||
foreach ($type_cases as $key => $type_case) {
|
||||
foreach (['Press button', 'Enter button', 'Enter element'] as $add_more_method) {
|
||||
$test_cases["$key: $add_more_method"] = array_merge($type_case, [$add_more_method]);
|
||||
}
|
||||
}
|
||||
return $test_cases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the count of the allowed values rows.
|
||||
*
|
||||
* @param int $expected_count
|
||||
* The expected row count.
|
||||
*/
|
||||
private function assertAllowValuesRowCount(int $expected_count): void {
|
||||
$this->assertCount(
|
||||
$expected_count,
|
||||
$this->getSession()->getPage()->findAll('css', '#allowed-values-order tr.draggable')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts an element specified by an attribute value has focus.
|
||||
*
|
||||
* @param string $name
|
||||
* The attribute name.
|
||||
* @param string $value
|
||||
* The attribute value.
|
||||
*
|
||||
* @todo Replace with assertHasFocus() in https://drupal.org/i/3041768.
|
||||
*/
|
||||
private function assertHasFocusByAttribute(string $name, string $value): void {
|
||||
$active_element = $this->getSession()->evaluateScript('document.activeElement');
|
||||
$this->assertSame($value, $active_element->getAttribute($name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes the machine name input for a row.
|
||||
*
|
||||
* @param int $row
|
||||
* The row number.
|
||||
*/
|
||||
private function exposeOptionMachineName(int $row): void {
|
||||
$index = $row - 1;
|
||||
$rows = $this->getSession()->getPage()->findAll('css', '#allowed-values-order tr.draggable');
|
||||
$this->assertSession()->buttonExists('Edit', $rows[$index])->click();
|
||||
$this->assertSession()->waitForElementVisible('css', "[name='settings[allowed_values][table][$index][item][key]']");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue