Repository: wagtail/wagtail

Issue:
Add table caption option as part of TypedTableBlock for accessibility
### Is your proposal related to a problem?

<!--
  Provide a clear and concise description of what the problem is.
  For example, "I'm always frustrated when..."
-->

I'm frustrated with the current state of tables in Wagtail.

`TableBlock` is the most feature complete (column headings, row headings, caption), but it doesn't allow for non-text content (e.g. links, bold, italics, etc.).

On the other hand, `TypedTableBlock` does not support some important features of HTML tables: row headings, caption.

### Describe the solution you'd like

<!--
  Provide a clear and concise description of what you want to happen.
-->

Similarly to the work done for #5235, I'd like to see a caption option added to `TypedTableBlock`

### Describe alternatives you've considered

<!--
  Let us know about other solutions you've tried or researched.
-->

I've thought about writing a custom table block to scratch my itch, but I believe contributing upstream is better.

### Additional context

<!--
  Is there anything else you can add about the proposal?
  You might want to link to related issues here, if you haven't already.
-->

Web Content Accessibility Guidelines (WCAG) recommends that tables have labels, or captions, or summaries so users with screen readers can find out ahead about the table contents.

I'm open to make a contribution myself


Discussion:
> I'm open to make a contribution myself

Hey @TommasoAmici,
Yes, please do, that would be lovely!

Resolving patch:
diff --git a/wagtail/contrib/table_block/blocks.py b/wagtail/contrib/table_block/blocks.py
--- a/wagtail/contrib/table_block/blocks.py
+++ b/wagtail/contrib/table_block/blocks.py
@@ -76,8 +76,8 @@ def js_args(self, widget):
                 "Display the first column as a header."
             ),
             "Table caption": _("Table caption"),
-            "A heading that identifies the overall topic of the table, and is useful for screen reader users": _(
-                "A heading that identifies the overall topic of the table, and is useful for screen reader users"
+            "A heading that identifies the overall topic of the table, and is useful for screen reader users.": _(
+                "A heading that identifies the overall topic of the table, and is useful for screen reader users."
             ),
             "Table": _("Table"),
         }
diff --git a/wagtail/contrib/typed_table_block/blocks.py b/wagtail/contrib/typed_table_block/blocks.py
--- a/wagtail/contrib/typed_table_block/blocks.py
+++ b/wagtail/contrib/typed_table_block/blocks.py
@@ -40,13 +40,15 @@ def as_json_data(self):
 class TypedTable:
     template = "typed_table_block/typed_table_block.html"
 
-    def __init__(self, columns, row_data):
+    def __init__(self, columns, row_data, caption: str):
         # a list of dicts, each with items 'block' (the block instance) and 'heading'
         self.columns = columns
 
         # a list of dicts, each with an item 'values' (the list of block values)
         self.row_data = row_data
 
+        self.caption = caption
+
     @property
     def rows(self):
         """
@@ -86,6 +88,8 @@ def __init__(self, local_blocks=None, **kwargs):
                 self.child_blocks[name] = block
 
     def value_from_datadict(self, data, files, prefix):
+        caption = data["%s-caption" % prefix]
+
         column_count = int(data["%s-column-count" % prefix])
         columns = [
             {
@@ -123,6 +127,7 @@ def value_from_datadict(self, data, files, prefix):
                 {"block": col["block"], "heading": col["heading"]} for col in columns
             ],
             row_data=[{"values": row["values"]} for row in rows],
+            caption=caption,
         )
 
     def get_prep_value(self, table):
@@ -141,11 +146,13 @@ def get_prep_value(self, table):
                     }
                     for row in table.row_data
                 ],
+                "caption": table.caption,
             }
         else:
             return {
                 "columns": [],
                 "rows": [],
+                "caption": "",
             }
 
     def to_python(self, value):
@@ -170,11 +177,13 @@ def to_python(self, value):
                     {"values": [column_data[row_index] for column_data in columns_data]}
                     for row_index in range(0, len(value["rows"]))
                 ],
+                caption=value.get("caption", ""),
             )
         else:
             return TypedTable(
                 columns=[],
                 row_data=[],
+                caption="",
             )
 
     def get_form_state(self, table):
@@ -193,11 +202,13 @@ def get_form_state(self, table):
                     }
                     for row in table.row_data
                 ],
+                "caption": table.caption,
             }
         else:
             return {
                 "columns": [],
                 "rows": [],
+                "caption": "",
             }
 
     def clean(self, table):
@@ -223,10 +234,16 @@ def clean(self, table):
             if cell_errors:
                 raise TypedTableBlockValidationError(cell_errors=cell_errors)
             else:
-                return TypedTable(columns=table.columns, row_data=cleaned_rows)
+                return TypedTable(
+                    columns=table.columns, row_data=cleaned_rows, caption=table.caption
+                )
 
         else:
-            return TypedTable(columns=[], row_data=[])
+            return TypedTable(
+                columns=[],
+                row_data=[],
+                caption="",
+            )
 
     def deconstruct(self):
         """
@@ -274,6 +291,10 @@ def js_args(self, block):
             "required": block.required,
             "icon": block.meta.icon,
             "strings": {
+                "CAPTION": _("Caption"),
+                "CAPTION_HELP_TEXT": _(
+                    "A heading that identifies the overall topic of the table, and is useful for screen reader users."
+                ),
                 "ADD_COLUMN": _("Add column"),
                 "ADD_ROW": _("Add row"),
                 "COLUMN_HEADING": _("Column heading"),


Test patch:
diff --git a/client/src/entrypoints/contrib/table_block/__snapshots__/table.test.js.snap b/client/src/entrypoints/contrib/table_block/__snapshots__/table.test.js.snap
--- a/client/src/entrypoints/contrib/table_block/__snapshots__/table.test.js.snap
+++ b/client/src/entrypoints/contrib/table_block/__snapshots__/table.test.js.snap
@@ -28,7 +28,7 @@ exports[`telepath: wagtail.widgets.TableInput it renders correctly 1`] = `
         <label class="w-field__label" for="the-id-handsontable-col-caption">Table caption</label>
         <div class="w-field w-field--char_field w-field--text_input" data-field="">
           <div class="w-field__help" id="the-id-handsontable-col-caption-helptext" data-field-help="">
-            <div class="help">A heading that identifies the overall topic of the table, and is useful for screen reader users</div>
+            <div class="help">A heading that identifies the overall topic of the table, and is useful for screen reader users.</div>
           </div>
           <div class="w-field__input" data-field-input="">
             <input type="text" id="the-id-handsontable-col-caption" name="handsontable-col-caption" aria-describedby="the-id-handsontable-col-caption-helptext">
diff --git a/client/src/entrypoints/contrib/table_block/table.test.js b/client/src/entrypoints/contrib/table_block/table.test.js
--- a/client/src/entrypoints/contrib/table_block/table.test.js
+++ b/client/src/entrypoints/contrib/table_block/table.test.js
@@ -40,8 +40,8 @@ const TEST_STRINGS = {
     'Display the first column as a header.',
   'Table caption': 'Table caption',
 
-  'A heading that identifies the overall topic of the table, and is useful for screen reader users':
-    'A heading that identifies the overall topic of the table, and is useful for screen reader users',
+  'A heading that identifies the overall topic of the table, and is useful for screen reader users.':
+    'A heading that identifies the overall topic of the table, and is useful for screen reader users.',
   'Table': 'Table',
 };
 
@@ -163,7 +163,7 @@ describe('telepath: wagtail.widgets.TableInput', () => {
         "Affichez la première colonne sous forme d'en-tête.",
       'Table caption': 'Légende du tableau',
 
-      'A heading that identifies the overall topic of the table, and is useful for screen reader users':
+      'A heading that identifies the overall topic of the table, and is useful for screen reader users.':
         "Un en-tête qui identifie le sujet général du tableau et qui est utile pour les utilisateurs de lecteurs d'écran",
       'Table': 'Tableau',
     };
diff --git a/client/src/entrypoints/contrib/typed_table_block/__snapshots__/typed_table_block.test.js.snap b/client/src/entrypoints/contrib/typed_table_block/__snapshots__/typed_table_block.test.js.snap
--- a/client/src/entrypoints/contrib/typed_table_block/__snapshots__/typed_table_block.test.js.snap
+++ b/client/src/entrypoints/contrib/typed_table_block/__snapshots__/typed_table_block.test.js.snap
@@ -2,6 +2,22 @@
 
 exports[`wagtail.contrib.typed_table_block.blocks.TypedTableBlock it renders correctly 1`] = `
 "<div class="typed-table-block ">
+        <div class="w-field__wrapper" data-field-wrapper="">
+          <label class="w-field__label" for="mytable-caption">
+            Caption
+          </label>
+          <div class="w-field w-field--char_field w-field--text_input" data-field="">
+            <div class="w-field__help" data-field-help="">
+              <div class="help">
+                A heading that identifies the overall topic of the table, and is useful for screen reader users.
+              </div>
+            </div>
+            <div class="w-field__input" data-field-input="">
+              <input type="text" id="mytable-caption" name="mytable-caption" value="">
+              <span></span>
+            </div>
+          </div>
+        </div>
         <input type="hidden" name="mytable-column-count" data-column-count="" value="2">
         <input type="hidden" name="mytable-row-count" data-row-count="" value="2">
         <div data-deleted-fields=""><input type="hidden" name="mytable-column-0-deleted" value=""><input type="hidden" name="mytable-column-1-deleted" value=""><input type="hidden" name="mytable-row-0-deleted" value=""><input type="hidden" name="mytable-row-1-deleted" value=""></div>
@@ -92,6 +108,22 @@ exports[`wagtail.contrib.typed_table_block.blocks.TypedTableBlock it renders cor
 
 exports[`wagtail.contrib.typed_table_block.blocks.TypedTableBlock setError passes error messages to children 1`] = `
 "<div class="typed-table-block ">
+        <div class="w-field__wrapper" data-field-wrapper="">
+          <label class="w-field__label" for="mytable-caption">
+            Caption
+          </label>
+          <div class="w-field w-field--char_field w-field--text_input" data-field="">
+            <div class="w-field__help" data-field-help="">
+              <div class="help">
+                A heading that identifies the overall topic of the table, and is useful for screen reader users.
+              </div>
+            </div>
+            <div class="w-field__input" data-field-input="">
+              <input type="text" id="mytable-caption" name="mytable-caption" value="">
+              <span></span>
+            </div>
+          </div>
+        </div>
         <input type="hidden" name="mytable-column-count" data-column-count="" value="2">
         <input type="hidden" name="mytable-row-count" data-row-count="" value="2">
         <div data-deleted-fields=""><input type="hidden" name="mytable-column-0-deleted" value=""><input type="hidden" name="mytable-column-1-deleted" value=""><input type="hidden" name="mytable-row-0-deleted" value=""><input type="hidden" name="mytable-row-1-deleted" value=""></div>
@@ -182,6 +214,22 @@ exports[`wagtail.contrib.typed_table_block.blocks.TypedTableBlock setError passe
 
 exports[`wagtail.contrib.typed_table_block.blocks.TypedTableBlock setError shows non-block errors 1`] = `
 "<div class="typed-table-block "><p class="help-block help-critical">This is just generally wrong</p>
+        <div class="w-field__wrapper" data-field-wrapper="">
+          <label class="w-field__label" for="mytable-caption">
+            Caption
+          </label>
+          <div class="w-field w-field--char_field w-field--text_input" data-field="">
+            <div class="w-field__help" data-field-help="">
+              <div class="help">
+                A heading that identifies the overall topic of the table, and is useful for screen reader users.
+              </div>
+            </div>
+            <div class="w-field__input" data-field-input="">
+              <input type="text" id="mytable-caption" name="mytable-caption" value="">
+              <span></span>
+            </div>
+          </div>
+        </div>
         <input type="hidden" name="mytable-column-count" data-column-count="" value="2">
         <input type="hidden" name="mytable-row-count" data-row-count="" value="2">
         <div data-deleted-fields=""><input type="hidden" name="mytable-column-0-deleted" value=""><input type="hidden" name="mytable-column-1-deleted" value=""><input type="hidden" name="mytable-row-0-deleted" value=""><input type="hidden" name="mytable-row-1-deleted" value=""></div>
diff --git a/client/src/entrypoints/contrib/typed_table_block/typed_table_block.test.js b/client/src/entrypoints/contrib/typed_table_block/typed_table_block.test.js
--- a/client/src/entrypoints/contrib/typed_table_block/typed_table_block.test.js
+++ b/client/src/entrypoints/contrib/typed_table_block/typed_table_block.test.js
@@ -101,6 +101,9 @@ describe('wagtail.contrib.typed_table_block.blocks.TypedTableBlock', () => {
         helpText: 'use <strong>plenty</strong> of these',
         helpIcon: '<svg></svg>',
         strings: {
+          CAPTION: 'Caption',
+          CAPTION_HELP_TEXT:
+            'A heading that identifies the overall topic of the table, and is useful for screen reader users.',
           ADD_COLUMN: 'Add column',
           ADD_ROW: 'Add row',
           COLUMN_HEADING: 'Column heading',
@@ -123,6 +126,7 @@ describe('wagtail.contrib.typed_table_block.blocks.TypedTableBlock', () => {
           { type: 'test_block_b', heading: 'Quantity' },
         ],
         rows: [{ values: ['Cheese', 3] }, { values: ['Peas', 5] }],
+        caption: 'A shopping list',
       },
     );
   });
@@ -131,6 +135,9 @@ describe('wagtail.contrib.typed_table_block.blocks.TypedTableBlock', () => {
     expect(document.body.innerHTML).toMatchSnapshot();
     expect(boundBlock.columns.length).toBe(2);
     expect(boundBlock.rows.length).toBe(2);
+    expect(document.getElementsByName('mytable-caption')[0].value).toBe(
+      'A shopping list',
+    );
   });
 
   test('can be cleared', () => {
@@ -141,6 +148,14 @@ describe('wagtail.contrib.typed_table_block.blocks.TypedTableBlock', () => {
       '0',
     );
     expect(document.getElementsByName('mytable-row-count')[0].value).toBe('0');
+    expect(document.getElementsByName('mytable-caption')[0].value).toBe('');
+  });
+
+  test('supports adding a caption', () => {
+    boundBlock.setCaption('A shopping list');
+    expect(document.getElementsByName('mytable-caption')[0].value).toBe(
+      'A shopping list',
+    );
   });
 
   test('supports inserting columns', () => {
diff --git a/wagtail/contrib/typed_table_block/tests.py b/wagtail/contrib/typed_table_block/tests.py
--- a/wagtail/contrib/typed_table_block/tests.py
+++ b/wagtail/contrib/typed_table_block/tests.py
@@ -38,6 +38,7 @@ def setUp(self):
         )
 
         self.form_data = {
+            "table-caption": "Countries and their food",
             "table-column-count": "2",
             "table-row-count": "3",
             "table-column-0-type": "country",
@@ -71,6 +72,7 @@ def setUp(self):
                 {"values": ["nl", "A small country with stroopwafels"]},
                 {"values": ["fr", "A large country with baguettes"]},
             ],
+            "caption": "Countries and their food",
         }
 
     def test_value_from_datadict(self):
@@ -81,6 +83,7 @@ def test_value_from_datadict(self):
         table = self.block.value_from_datadict(self.form_data, {}, "table")
 
         self.assertIsInstance(table, TypedTable)
+        self.assertEqual(table.caption, "Countries and their food")
         self.assertEqual(len(table.columns), 2)
         self.assertEqual(table.columns[0]["heading"], "Country")
         self.assertEqual(table.columns[1]["heading"], "Description")
@@ -103,6 +106,7 @@ def test_submission_with_column_deletion_and_insertion(self):
         # Column id 1 is a population column that was deleted before being replaced by the
         # current one with id 3.
         form_data = {
+            "table-caption": "Countries and their food",
             # table-column-count includes deleted columns, as it's telling the server code
             # the maximum column ID number it should consider
             "table-column-count": "4",
@@ -129,6 +133,7 @@ def test_submission_with_column_deletion_and_insertion(self):
         table = self.block.value_from_datadict(form_data, {}, "table")
 
         self.assertIsInstance(table, TypedTable)
+        self.assertEqual(table.caption, "Countries and their food")
         self.assertEqual(len(table.columns), 3)
         self.assertEqual(table.columns[0]["heading"], "Country")
         self.assertEqual(table.columns[1]["heading"], "Population")
@@ -145,6 +150,7 @@ def test_to_python(self):
         Test that we can turn JSONish data from the database into a TypedTable instance
         """
         table = self.block.to_python(self.db_data)
+        self.assertEqual(table.caption, "Countries and their food")
         self.assertIsInstance(table, TypedTable)
         self.assertEqual(len(table.columns), 2)
         self.assertEqual(table.columns[0]["heading"], "Country")
@@ -188,6 +194,7 @@ def test_render(self):
         table = self.block.value_from_datadict(self.form_data, {}, "table")
         html = self.block.render(table)
 
+        self.assertIn("<caption>Countries and their food</caption>", html)
         self.assertIn('<th scope="col">Country</th>', html)
         # rendering should use the block renderings of the child blocks ('FR' not 'fr')
         self.assertIn("<td>FR</td>", html)