diff --git a/core/misc/tabledrag.es6.js b/core/misc/tabledrag.es6.js
index 76643c3a478c70fd3ea61e6d421296b9c75fb7dc..67fc2b2ee71cfd71e6f2b5df2349780f5c27aef7 100644
--- a/core/misc/tabledrag.es6.js
+++ b/core/misc/tabledrag.es6.js
@@ -558,10 +558,14 @@
         case 38:
         // Safari up arrow.
         case 63232: {
-          let $previousRow = $(self.rowObject.element).prev('tr:first-of-type');
+          let $previousRow = $(self.rowObject.element)
+            .prev('tr')
+            .eq(0);
           let previousRow = $previousRow.get(0);
           while (previousRow && $previousRow.is(':hidden')) {
-            $previousRow = $(previousRow).prev('tr:first-of-type');
+            $previousRow = $(previousRow)
+              .prev('tr')
+              .eq(0);
             previousRow = $previousRow.get(0);
           }
           if (previousRow) {
@@ -577,7 +581,9 @@
                 previousRow &&
                 $previousRow.find('.js-indentation').length
               ) {
-                $previousRow = $(previousRow).prev('tr:first-of-type');
+                $previousRow = $(previousRow)
+                  .prev('tr')
+                  .eq(0);
                 previousRow = $previousRow.get(0);
                 groupHeight += $previousRow.is(':hidden')
                   ? 0
@@ -618,10 +624,13 @@
         case 63233: {
           let $nextRow = $(self.rowObject.group)
             .eq(-1)
-            .next('tr:first-of-type');
+            .next('tr')
+            .eq(0);
           let nextRow = $nextRow.get(0);
           while (nextRow && $nextRow.is(':hidden')) {
-            $nextRow = $(nextRow).next('tr:first-of-type');
+            $nextRow = $(nextRow)
+              .next('tr')
+              .eq(0);
             nextRow = $nextRow.get(0);
           }
           if (nextRow) {
diff --git a/core/misc/tabledrag.js b/core/misc/tabledrag.js
index 97ead1c69ab475afa70a20c33c0fb741897fc681..db926660930337c23b4ba53483f074fa82c8089f 100644
--- a/core/misc/tabledrag.js
+++ b/core/misc/tabledrag.js
@@ -285,10 +285,10 @@ var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol
         case 38:
         case 63232:
           {
-            var $previousRow = $(self.rowObject.element).prev('tr:first-of-type');
+            var $previousRow = $(self.rowObject.element).prev('tr').eq(0);
             var previousRow = $previousRow.get(0);
             while (previousRow && $previousRow.is(':hidden')) {
-              $previousRow = $(previousRow).prev('tr:first-of-type');
+              $previousRow = $(previousRow).prev('tr').eq(0);
               previousRow = $previousRow.get(0);
             }
             if (previousRow) {
@@ -299,7 +299,7 @@ var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol
               if ($(item).is('.tabledrag-root')) {
                 groupHeight = 0;
                 while (previousRow && $previousRow.find('.js-indentation').length) {
-                  $previousRow = $(previousRow).prev('tr:first-of-type');
+                  $previousRow = $(previousRow).prev('tr').eq(0);
                   previousRow = $previousRow.get(0);
                   groupHeight += $previousRow.is(':hidden') ? 0 : previousRow.offsetHeight;
                 }
@@ -329,10 +329,10 @@ var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol
         case 40:
         case 63233:
           {
-            var $nextRow = $(self.rowObject.group).eq(-1).next('tr:first-of-type');
+            var $nextRow = $(self.rowObject.group).eq(-1).next('tr').eq(0);
             var nextRow = $nextRow.get(0);
             while (nextRow && $nextRow.is(':hidden')) {
-              $nextRow = $(nextRow).next('tr:first-of-type');
+              $nextRow = $(nextRow).next('tr').eq(0);
               nextRow = $nextRow.get(0);
             }
             if (nextRow) {
diff --git a/core/modules/system/tests/modules/tabledrag_test/js/tabledrag_test.es6.js b/core/modules/system/tests/modules/tabledrag_test/js/tabledrag_test.es6.js
new file mode 100644
index 0000000000000000000000000000000000000000..b3a3af7db91516338c00f9894c1b1e331a94da2c
--- /dev/null
+++ b/core/modules/system/tests/modules/tabledrag_test/js/tabledrag_test.es6.js
@@ -0,0 +1,22 @@
+/**
+ * @file
+ * Testing behaviors for tabledrag library.
+ */
+(function($, Drupal) {
+  /**
+   * @type {Drupal~behavior}
+   *
+   * @prop {Drupal~behaviorAttach} attach
+   *   Removes a test class from the handle elements to allow verifying that
+   *   dragging operations have been executed.
+   */
+  Drupal.behaviors.tableDragTest = {
+    attach(context) {
+      $('.tabledrag-handle', context)
+        .once('tabledrag-test')
+        .on('keydown.tabledrag-test', event => {
+          $(event.currentTarget).removeClass('tabledrag-test-dragging');
+        });
+    },
+  };
+})(jQuery, Drupal);
diff --git a/core/modules/system/tests/modules/tabledrag_test/js/tabledrag_test.js b/core/modules/system/tests/modules/tabledrag_test/js/tabledrag_test.js
new file mode 100644
index 0000000000000000000000000000000000000000..8e0d8108fe35a32ad7ca9ffb5723d443110886c6
--- /dev/null
+++ b/core/modules/system/tests/modules/tabledrag_test/js/tabledrag_test.js
@@ -0,0 +1,16 @@
+/**
+* DO NOT EDIT THIS FILE.
+* See the following change record for more information,
+* https://www.drupal.org/node/2815083
+* @preserve
+**/
+
+(function ($, Drupal) {
+  Drupal.behaviors.tableDragTest = {
+    attach: function attach(context) {
+      $('.tabledrag-handle', context).once('tabledrag-test').on('keydown.tabledrag-test', function (event) {
+        $(event.currentTarget).removeClass('tabledrag-test-dragging');
+      });
+    }
+  };
+})(jQuery, Drupal);
\ No newline at end of file
diff --git a/core/modules/system/tests/modules/tabledrag_test/src/Form/TableDragTestForm.php b/core/modules/system/tests/modules/tabledrag_test/src/Form/TableDragTestForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..a5f1f4630a2d4acb3b8a88cfd1aeb693f63327a7
--- /dev/null
+++ b/core/modules/system/tests/modules/tabledrag_test/src/Form/TableDragTestForm.php
@@ -0,0 +1,157 @@
+<?php
+
+namespace Drupal\tabledrag_test\Form;
+
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\State\StateInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Provides a form for draggable table testing.
+ */
+class TableDragTestForm extends FormBase {
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * Constructs a TableDragTestForm object.
+   *
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state service.
+   */
+  public function __construct(StateInterface $state) {
+    $this->state = $state;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static($container->get('state'));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'tabledrag_test_form';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $form['table'] = [
+      '#type' => 'table',
+      '#header' => [
+        [
+          'data' => $this->t('Text'),
+          'colspan' => 4,
+        ],
+        $this->t('Weight'),
+      ],
+      '#tabledrag' => [
+        [
+          'action' => 'order',
+          'relationship' => 'sibling',
+          'group' => 'tabledrag-test-weight',
+        ],
+        [
+          'action' => 'match',
+          'relationship' => 'parent',
+          'group' => 'tabledrag-test-parent',
+          'subgroup' => 'tabledrag-test-parent',
+          'source' => 'tabledrag-test-id',
+          'hidden' => TRUE,
+          'limit' => 2,
+        ],
+        [
+          'action' => 'depth',
+          'relationship' => 'group',
+          'group' => 'tabledrag-test-depth',
+          'hidden' => TRUE,
+        ],
+      ],
+      '#attributes' => ['id' => 'tabledrag-test-table'],
+      '#attached' => ['library' => ['tabledrag_test/tabledrag']],
+    ];
+
+    // Provide a default set of five rows.
+    $rows = $this->state->get('tabledrag_test_table', array_flip(range(1, 5)));
+    foreach ($rows as $id => $row) {
+      if (!is_array($row)) {
+        $row = [];
+      }
+
+      $row += [
+        'parent' => '',
+        'weight' => 0,
+        'depth' => 0,
+        'classes' => [],
+        'draggable' => TRUE,
+      ];
+
+      if (!empty($row['draggable'])) {
+        $row['classes'][] = 'draggable';
+      }
+
+      $form['table'][$id] = [
+        'title' => [
+          'indentation' => [
+            '#theme' => 'indentation',
+            '#size' => $row['depth'],
+          ],
+          '#plain_text' => "Row with id $id",
+        ],
+        'id' => [
+          '#type' => 'hidden',
+          '#value' => $id,
+          '#attributes' => ['class' => ['tabledrag-test-id']],
+        ],
+        'parent' => [
+          '#type' => 'hidden',
+          '#default_value' => $row['parent'],
+          '#parents' => ['table', $id, 'parent'],
+          '#attributes' => ['class' => ['tabledrag-test-parent']],
+        ],
+        'depth' => [
+          '#type' => 'hidden',
+          '#default_value' => $row['depth'],
+          '#attributes' => ['class' => ['tabledrag-test-depth']],
+        ],
+        'weight' => [
+          '#type' => 'weight',
+          '#default_value' => $row['weight'],
+          '#attributes' => ['class' => ['tabledrag-test-weight']],
+        ],
+        '#attributes' => ['class' => $row['classes']],
+      ];
+    }
+
+    $form['save'] = [
+      '#type' => 'submit',
+      '#value' => $this->t('Save'),
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $test_table = [];
+    foreach ($form_state->getValue('table') as $row) {
+      $test_table[$row['id']] = $row;
+    }
+
+    $this->state->set('tabledrag_test_table', $test_table);
+  }
+
+}
diff --git a/core/modules/system/tests/modules/tabledrag_test/tabledrag_test.info.yml b/core/modules/system/tests/modules/tabledrag_test/tabledrag_test.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..98d6bb4b96ba09d8f1024052c94753fd499d6af5
--- /dev/null
+++ b/core/modules/system/tests/modules/tabledrag_test/tabledrag_test.info.yml
@@ -0,0 +1,6 @@
+type: module
+name: 'TableDrag test'
+description: 'Draggable table test module.'
+core: 8.x
+package: Testing
+version: VERSION
diff --git a/core/modules/system/tests/modules/tabledrag_test/tabledrag_test.libraries.yml b/core/modules/system/tests/modules/tabledrag_test/tabledrag_test.libraries.yml
new file mode 100644
index 0000000000000000000000000000000000000000..87876c3c2b8cf79d103f0dbfa2960beaa6175bd0
--- /dev/null
+++ b/core/modules/system/tests/modules/tabledrag_test/tabledrag_test.libraries.yml
@@ -0,0 +1,6 @@
+tabledrag:
+  version: VERSION
+  js:
+    js/tabledrag_test.js: {}
+  dependencies:
+    - core/drupal.tabledrag
diff --git a/core/modules/system/tests/modules/tabledrag_test/tabledrag_test.routing.yml b/core/modules/system/tests/modules/tabledrag_test/tabledrag_test.routing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1bb88ff12bc629d3ae20ecd3221aab4340b83eb9
--- /dev/null
+++ b/core/modules/system/tests/modules/tabledrag_test/tabledrag_test.routing.yml
@@ -0,0 +1,7 @@
+tabledrag_test.test_form:
+  path: '/tabledrag_test'
+  defaults:
+    _form: '\Drupal\tabledrag_test\Form\TableDragTestForm'
+    _title: 'Draggable table test'
+  requirements:
+    _access: 'TRUE'
diff --git a/core/tests/Drupal/FunctionalJavascriptTests/TableDrag/TableDragTest.php b/core/tests/Drupal/FunctionalJavascriptTests/TableDrag/TableDragTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..ee7fe96c2ba37a4f4bf10ee03d2f0091c5ce11c3
--- /dev/null
+++ b/core/tests/Drupal/FunctionalJavascriptTests/TableDrag/TableDragTest.php
@@ -0,0 +1,345 @@
+<?php
+
+namespace Drupal\FunctionalJavascriptTests\TableDrag;
+
+use Behat\Mink\Element\NodeElement;
+use Drupal\FunctionalJavascriptTests\WebDriverTestBase;
+
+/**
+ * Tests draggable table.
+ *
+ * @group javascript
+ */
+class TableDragTest extends WebDriverTestBase {
+
+  /**
+   * Class used to verify that dragging operations are in execution.
+   */
+  const DRAGGING_CSS_CLASS = 'tabledrag-test-dragging';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['tabledrag_test'];
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->state = $this->container->get('state');
+  }
+
+  /**
+   * Tests accessibility through keyboard of the tabledrag functionality.
+   */
+  public function testKeyboardAccessibility() {
+    $this->state->set('tabledrag_test_table', array_flip(range(1, 5)));
+
+    $expected_table = [
+      ['id' => 1, 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
+      ['id' => 2, 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
+      ['id' => 3, 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
+      ['id' => 4, 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
+      ['id' => 5, 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
+    ];
+    $this->drupalGet('tabledrag_test');
+    $this->assertDraggableTable($expected_table);
+
+    // Nest the row with id 2 as child of row 1.
+    $this->moveRowWithKeyboard($this->findRowById(2), 'right');
+    $expected_table[1] = ['id' => 2, 'weight' => -10, 'parent' => 1, 'indentation' => 1, 'changed' => TRUE];
+    $this->assertDraggableTable($expected_table);
+
+    // Nest the row with id 3 as child of row 1.
+    $this->moveRowWithKeyboard($this->findRowById(3), 'right');
+    $expected_table[2] = ['id' => 3, 'weight' => -9, 'parent' => 1, 'indentation' => 1, 'changed' => TRUE];
+    $this->assertDraggableTable($expected_table);
+
+    // Nest the row with id 3 as child of row 2.
+    $this->moveRowWithKeyboard($this->findRowById(3), 'right');
+    $expected_table[2] = ['id' => 3, 'weight' => -10, 'parent' => 2, 'indentation' => 2, 'changed' => TRUE];
+    $this->assertDraggableTable($expected_table);
+
+    // Nesting should be allowed to maximum level 2.
+    $this->moveRowWithKeyboard($this->findRowById(4), 'right', 4);
+    $expected_table[3] = ['id' => 4, 'weight' => -9, 'parent' => 2, 'indentation' => 2, 'changed' => TRUE];
+    $this->assertDraggableTable($expected_table);
+
+    // Re-order children of row 1.
+    $this->moveRowWithKeyboard($this->findRowById(4), 'up');
+    $expected_table[2] = ['id' => 4, 'weight' => -10, 'parent' => 2, 'indentation' => 2, 'changed' => TRUE];
+    $expected_table[3] = ['id' => 3, 'weight' => -9, 'parent' => 2, 'indentation' => 2, 'changed' => TRUE];
+    $this->assertDraggableTable($expected_table);
+
+    // Move back the row 3 to the 1st level.
+    $this->moveRowWithKeyboard($this->findRowById(3), 'left');
+    $expected_table[3] = ['id' => 3, 'weight' => -9, 'parent' => 1, 'indentation' => 1, 'changed' => TRUE];
+    $this->assertDraggableTable($expected_table);
+
+    $this->moveRowWithKeyboard($this->findRowById(3), 'left');
+    $expected_table[0] = ['id' => 1, 'weight' => -10, 'parent' => '', 'indentation' => 0, 'changed' => FALSE];
+    $expected_table[3] = ['id' => 3, 'weight' => -9, 'parent' => '', 'indentation' => 0, 'changed' => TRUE];
+    $expected_table[4] = ['id' => 5, 'weight' => -8, 'parent' => '', 'indentation' => 0, 'changed' => FALSE];
+    $this->assertDraggableTable($expected_table);
+
+    // Move row 3 to the last position.
+    $this->moveRowWithKeyboard($this->findRowById(3), 'down');
+    $expected_table[3] = ['id' => 5, 'weight' => -9, 'parent' => '', 'indentation' => 0, 'changed' => FALSE];
+    $expected_table[4] = ['id' => 3, 'weight' => -8, 'parent' => '', 'indentation' => 0, 'changed' => TRUE];
+    $this->assertDraggableTable($expected_table);
+
+    // Nothing happens when trying to move the last row further down.
+    $this->moveRowWithKeyboard($this->findRowById(3), 'down');
+    $this->assertDraggableTable($expected_table);
+
+    // Nest row 3 under 5. The max depth allowed should be 1.
+    $this->moveRowWithKeyboard($this->findRowById(3), 'right', 3);
+    $expected_table[4] = ['id' => 3, 'weight' => -10, 'parent' => 5, 'indentation' => 1, 'changed' => TRUE];
+    $this->assertDraggableTable($expected_table);
+
+    // The first row of the table cannot be nested.
+    $this->moveRowWithKeyboard($this->findRowById(1), 'right');
+    $this->assertDraggableTable($expected_table);
+
+    // Move a row which has nested children. The children should move with it,
+    // with nesting preserved. Swap the order of the top-level rows by moving
+    // row 1 to after row 3.
+    $this->moveRowWithKeyboard($this->findRowById(1), 'down', 2);
+    $expected_table[0] = ['id' => 5, 'weight' => -10, 'parent' => '', 'indentation' => 0, 'changed' => FALSE];
+    $expected_table[3] = $expected_table[1];
+    $expected_table[1] = $expected_table[4];
+    $expected_table[4] = $expected_table[2];
+    $expected_table[2] = ['id' => 1, 'weight' => -9, 'parent' => '', 'indentation' => 0, 'changed' => TRUE];
+    $this->assertDraggableTable($expected_table);
+  }
+
+  /**
+   * Tests the root and leaf behaviors for rows.
+   */
+  public function testRootLeafDraggableRowsWithKeyboard() {
+    $this->state->set('tabledrag_test_table', [
+      1 => [],
+      2 => ['parent' => 1, 'depth' => 1, 'classes' => ['tabledrag-leaf']],
+      3 => ['parent' => 1, 'depth' => 1],
+      4 => [],
+      5 => ['classes' => ['tabledrag-root']],
+    ]);
+
+    $this->drupalGet('tabledrag_test');
+    $expected_table = [
+      ['id' => 1, 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
+      ['id' => 2, 'weight' => 0, 'parent' => 1, 'indentation' => 1, 'changed' => FALSE],
+      ['id' => 3, 'weight' => 0, 'parent' => 1, 'indentation' => 1, 'changed' => FALSE],
+      ['id' => 4, 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
+      ['id' => 5, 'weight' => 0, 'parent' => '', 'indentation' => 0, 'changed' => FALSE],
+    ];
+    $this->assertDraggableTable($expected_table);
+
+    // Rows marked as root cannot be moved as children of another row.
+    $this->moveRowWithKeyboard($this->findRowById(5), 'right');
+    $this->assertDraggableTable($expected_table);
+
+    // Rows marked as leaf cannot have children. Trying to move the row #3
+    // as child of #2 should have no results.
+    $this->moveRowWithKeyboard($this->findRowById(3), 'right');
+    $this->assertDraggableTable($expected_table);
+
+    // Leaf can be still swapped and moved to first level.
+    $this->moveRowWithKeyboard($this->findRowById(2), 'down');
+    $this->moveRowWithKeyboard($this->findRowById(2), 'left');
+    $expected_table[0]['weight'] = -10;
+    $expected_table[1]['id'] = 3;
+    $expected_table[1]['weight'] = -10;
+    $expected_table[2] = ['id' => 2, 'weight' => -9, 'parent' => '', 'indentation' => 0, 'changed' => TRUE];
+    $expected_table[3]['weight'] = -8;
+    $expected_table[4]['weight'] = -7;
+    $this->assertDraggableTable($expected_table);
+
+    // Root rows can have children.
+    $this->moveRowWithKeyboard($this->findRowById(4), 'down');
+    $this->moveRowWithKeyboard($this->findRowById(4), 'right');
+    $expected_table[3]['id'] = 5;
+    $expected_table[4] = ['id' => 4, 'weight' => -10, 'parent' => 5, 'indentation' => 1, 'changed' => TRUE];
+    $this->assertDraggableTable($expected_table);
+  }
+
+  /**
+   * Tests the warning that appears upon making changes to a tabledrag table.
+   */
+  public function testTableDragChangedWarning() {
+    $this->drupalGet('tabledrag_test');
+
+    // By default no text is visible.
+    $this->assertSession()->pageTextNotContains('You have unsaved changes.');
+    // Try to make a non-allowed action, like moving further down the last row.
+    // No changes happen, so no message should be shown.
+    $this->moveRowWithKeyboard($this->findRowById(5), 'down');
+    $this->assertSession()->pageTextNotContains('You have unsaved changes.');
+
+    // Make a change. The message will appear.
+    $this->moveRowWithKeyboard($this->findRowById(5), 'right');
+    $this->assertSession()->pageTextContainsOnce('You have unsaved changes.');
+
+    // Make another change, the text will stay visible and appear only once.
+    $this->moveRowWithKeyboard($this->findRowById(2), 'up');
+    $this->assertSession()->pageTextContainsOnce('You have unsaved changes.');
+  }
+
+  /**
+   * Asserts the whole structure of the draggable test table.
+   *
+   * @param array $structure
+   *   The table structure. Each entry represents a row and consists of:
+   *   - id: the expected value for the ID hidden field.
+   *   - weight: the expected row weight.
+   *   - parent: the expected parent ID for the row.
+   *   - indentation: how many indents the row should have.
+   *   - changed: whether or not the row should have been marked as changed.
+   */
+  protected function assertDraggableTable(array $structure) {
+    $rows = $this->getSession()->getPage()->findAll('xpath', '//table[@id="tabledrag-test-table"]/tbody/tr');
+    $this->assertSession()->elementsCount('xpath', '//table[@id="tabledrag-test-table"]/tbody/tr', count($structure));
+
+    foreach ($structure as $delta => $expected) {
+      $this->assertTableRow($rows[$delta], $expected['id'], $expected['weight'], $expected['parent'], $expected['indentation'], $expected['changed']);
+    }
+  }
+
+  /**
+   * Asserts the values of a draggable row.
+   *
+   * @param \Behat\Mink\Element\NodeElement $row
+   *   The row element to assert.
+   * @param string $id
+   *   The expected value for the ID hidden input of the row.
+   * @param int $weight
+   *   The expected weight of the row.
+   * @param string $parent
+   *   The expected parent ID.
+   * @param int $indentation
+   *   The expected indentation of the row.
+   * @param bool $changed
+   *   Whether or not the row should have been marked as changed.
+   */
+  protected function assertTableRow(NodeElement $row, $id, $weight, $parent = '', $indentation = 0, $changed = FALSE) {
+    // Assert that the row position is correct by checking that the id
+    // corresponds.
+    $this->assertSession()->hiddenFieldValueEquals("table[$id][id]", $id, $row);
+    $this->assertSession()->hiddenFieldValueEquals("table[$id][parent]", $parent, $row);
+    $this->assertSession()->fieldValueEquals("table[$id][weight]", $weight, $row);
+    $this->assertSession()->elementsCount('css', '.js-indentation.indentation', $indentation, $row);
+    // A row is marked as changed when the related markup is present.
+    $this->assertSession()->elementsCount('css', 'abbr.tabledrag-changed', (int) $changed, $row);
+  }
+
+  /**
+   * Finds a row in the test table by the row ID.
+   *
+   * @param string $id
+   *   The ID of the row.
+   *
+   * @return \Behat\Mink\Element\NodeElement
+   *   The row element.
+   */
+  protected function findRowById($id) {
+    $xpath = "//table[@id='tabledrag-test-table']/tbody/tr[.//input[@name='table[$id][id]']]";
+    $row = $this->getSession()->getPage()->find('xpath', $xpath);
+    $this->assertNotEmpty($row);
+    return $row;
+  }
+
+  /**
+   * Moves a row through the keyboard.
+   *
+   * @param \Behat\Mink\Element\NodeElement $row
+   *   The row to move.
+   * @param string $arrow
+   *   The arrow button to use to move the row. Either one of 'left', 'right',
+   *   'up' or 'down'.
+   * @param int $repeat
+   *   (optional) How many times to press the arrow button. Defaults to 1.
+   */
+  protected function moveRowWithKeyboard(NodeElement $row, $arrow, $repeat = 1) {
+    $keys = [
+      'left' => 37,
+      'right' => 39,
+      'up' => 38,
+      'down' => 40,
+    ];
+    if (!isset($keys[$arrow])) {
+      throw new \InvalidArgumentException('The arrow parameter must be one of "left", "right", "up" or "down".');
+    }
+
+    $key = $keys[$arrow];
+
+    $handle = $row->find('css', 'a.tabledrag-handle');
+    $handle->focus();
+
+    for ($i = 0; $i < $repeat; $i++) {
+      $this->markRowHandleForDragging($handle);
+      $handle->keyDown($key);
+      $handle->keyUp($key);
+      $this->waitUntilDraggingCompleted($handle);
+    }
+
+    $handle->blur();
+  }
+
+  /**
+   * Marks a row handle for dragging.
+   *
+   * The handle is marked by adding a css class that is removed by an helper
+   * js library once the dragging is over.
+   *
+   * @param \Behat\Mink\Element\NodeElement $handle
+   *   The draggable row handle element.
+   *
+   * @throws \Exception
+   *   Thrown when the class is not added successfully to the handle.
+   */
+  protected function markRowHandleForDragging(NodeElement $handle) {
+    $class = self::DRAGGING_CSS_CLASS;
+    $script = <<<JS
+document.evaluate("{$handle->getXpath()}", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null)
+  .singleNodeValue.classList.add('{$class}');
+JS;
+
+    $this->getSession()->executeScript($script);
+    $has_class = $this->getSession()->getPage()->waitFor(1, function () use ($handle, $class) {
+      return $handle->hasClass($class);
+    });
+
+    if (!$has_class) {
+      throw new \Exception(sprintf('Dragging css class was not added on handle "%s".', $handle->getXpath()));
+    }
+  }
+
+  /**
+   * Waits until the dragging operations are finished on a row handle.
+   *
+   * @param \Behat\Mink\Element\NodeElement $handle
+   *   The draggable row handle element.
+   *
+   * @throws \Exception
+   *   Thrown when the dragging operations are not completed on time.
+   */
+  protected function waitUntilDraggingCompleted(NodeElement $handle) {
+    $class_removed = $this->getSession()->getPage()->waitFor(1, function () use ($handle) {
+      return !$handle->hasClass($this::DRAGGING_CSS_CLASS);
+    });
+
+    if (!$class_removed) {
+      throw new \Exception(sprintf('Dragging operations did not complete on time on handle %s', $handle->getXpath()));
+    }
+  }
+
+}