Repository: wagtail/wagtail

Issue:
ModelAdmin IndexView not keyboard navigable
Found a bug? Please fill out the sections below. 👍

### Issue Summary

The default ModelAdmin IndexView isn't keyboard navigable. Without using a mouse, there's no way to use the edit or delete (or, presumably, other added buttons of that view) of a particular item. This also affects screen reader users.

### Steps to Reproduce

1. Use ModelAdmin to register a menu item. 
2. Try to tab to an item's edit or delete buttons. 
3. Alternatively, in the bakery demo, navigate to anything under Bread Categories or Bread Misc, and then edit something via keyboard. 

Any other relevant information. For example, why do you consider this a bug and what did you expect to happen instead?

At first glance, changing these to operate in the same manner as the page and snippets indexview works seems appropriate, where each item is a link that can be targetted, thus triggering the `.focus-within` of the `td` or `tr`.  

* I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: yes

### Technical details

* Wagtail version: 2.13.3
* Browser version: Chrome, Safari, FirefoxESR (all Mac OS). I presume IE and Edge, as well. 



Discussion:
Thank you @jessemenn, re-reading this I remember testing it as well after you had mentioned it and finding out the same thing.

I believe a potential workaround we had discussed was to follow the same pattern as page listings – have the title of the item be a link to the edit view, so focus can be moved within each row. Then, we can use `:focus-within` on the row to reveal the buttons that are currently revealed on hover only.

Another option would be to have a focusable trigger element for this sole purpose – but it feels like it’d be simpler and more useful to all to make the title a link instead.
Marking this as a good first issue as I believe this would be possible for someone to pick up with minimal Wagtail knowledge – the problem can be replicated directly in our bakerydemo development environment.

It looks like we would need to change this line to wrap `item` in an anchor tag: https://github.com/wagtail/wagtail/blob/952264a2abffd1c01e08611bae26ceaf8d958d43/wagtail/contrib/modeladmin/templates/modeladmin/includes/result_row_value.html#L2

Here is the code which currently generates the URL for the Edit buttons: https://github.com/wagtail/wagtail/blob/952264a2abffd1c01e08611bae26ceaf8d958d43/wagtail/contrib/modeladmin/views.py#L186

---

Once we’ve added this link, we can reproduce the `:focus-within` pattern used elsewhere in the admin.

Resolving patch:
diff --git a/wagtail/contrib/modeladmin/templatetags/modeladmin_tags.py b/wagtail/contrib/modeladmin/templatetags/modeladmin_tags.py
--- a/wagtail/contrib/modeladmin/templatetags/modeladmin_tags.py
+++ b/wagtail/contrib/modeladmin/templatetags/modeladmin_tags.py
@@ -17,14 +17,14 @@
 register = Library()
 
 
-def items_for_result(view, result):
+def items_for_result(view, result, request):
     """
     Generates the actual list of data.
     """
     modeladmin = view.model_admin
     for field_name in view.list_display:
         empty_value_display = modeladmin.get_empty_value_display(field_name)
-        row_classes = ['field-%s' % field_name]
+        row_classes = ['field-%s' % field_name, 'title']
         try:
             f, attr, value = lookup_field(field_name, result, modeladmin)
         except ObjectDoesNotExist:
@@ -69,12 +69,22 @@ def items_for_result(view, result):
         row_attrs = modeladmin.get_extra_attrs_for_field_col(result, field_name)
         row_attrs['class'] = ' ' . join(row_classes)
         row_attrs_flat = flatatt(row_attrs)
-        yield format_html('<td{}>{}</td>', row_attrs_flat, result_repr)
+        if field_name == modeladmin.get_list_display_add_buttons(request):
+            edit_url = view.url_helper.get_action_url('edit', result.pk)
+            yield format_html(
+                '<td{}><div class="title-wrapper"><a href="{}" title="{}">{}</a></div></td>',
+                row_attrs_flat,
+                edit_url,
+                _('Edit this %s') % view.verbose_name,
+                result_repr
+            )
+        else:
+            yield format_html('<td{}>{}</td>', row_attrs_flat, result_repr)
 
 
-def results(view, object_list):
+def results(view, object_list, request):
     for item in object_list:
-        yield ResultList(None, items_for_result(view, item))
+        yield ResultList(None, items_for_result(view, item, request))
 
 
 @register.inclusion_tag("modeladmin/includes/result_list.html",
@@ -93,7 +103,7 @@ def result_list(context):
     context.update({
         'result_headers': headers,
         'num_sorted_fields': num_sorted_fields,
-        'results': list(results(view, object_list))})
+        'results': list(results(view, object_list, context['request']))})
     return context
 
 


Test patch:
diff --git a/wagtail/contrib/modeladmin/tests/test_simple_modeladmin.py b/wagtail/contrib/modeladmin/tests/test_simple_modeladmin.py
--- a/wagtail/contrib/modeladmin/tests/test_simple_modeladmin.py
+++ b/wagtail/contrib/modeladmin/tests/test_simple_modeladmin.py
@@ -184,7 +184,7 @@ def test_col_extra_class_names(self):
         response = self.get()
         self.assertEqual(response.status_code, 200)
         test_html = """
-            <td class="field-first_book for-author-1">The Lord of the Rings</td>
+            <td class="field-first_book for-author-1 title">The Lord of the Rings</td>
         """
         self.assertContains(response, test_html, html=True)
 
@@ -192,7 +192,7 @@ def test_col_extra_attributes(self):
         response = self.get()
         self.assertEqual(response.status_code, 200)
         test_html = """
-            <td class="field-last_book" data-for_author="1">The Hobbit</td>
+            <td class="field-last_book title" data-for_author="1">The Hobbit</td>
         """
         self.assertContains(response, test_html, html=True)