Repository: wagtail/wagtail

Issue:
Feature request: Switch TableBlock’s header controls to a field that requires user input
### Is your proposal related to a problem?

Related: #5865, #5977. Wagtail’s [TableBlock](https://docs.wagtail.io/en/v2.8.1/reference/contrib/table_block.html#tableblock) has support for defining the first row, or column, as table headers. 

While it’s not mandatory for a table to have header cells, if the table isn’t meant for layout then using header cells is a usability and accessibility benefit (or requirement) – header cells are generally displayed differently (e.g. bold), and screen readers will use the header cell’s label when announcing items is the header for. I believe failing to mark up header cells as such is a failure of [WCAG2.1’s Success Criterion 1.3.1 (Info and Relationships)](https://www.w3.org/TR/WCAG20-TECHS/F91.html).

I think the current form makes it a bit too easy to miss those checkboxes and leave the table without headers.

### Describe the solution you'd like

I would suggest changing the header cells’ two checkboxes to a single "Header cells" dropdown, with the following options:

- [Choose] (Default)
- First row
- First column
- First row and first column
- No header cells

This field would be required. The main improvement here is the fact that contrary to checkboxes, the field has to be interacted with for the user to select the "No header cells" option that we want to discourage the use of (but should still be an option).

### Describe alternatives you've considered

One alternative would be to do this as a per-project customization, but it feels like an improvement that could easily live in Wagtail directly. Other changes to the TableBlock to remove the ability to have no header cells aren’t particularly compelling, because we still want the ability to select row/column/both.

### Additional context

Additionally to the above, as part of this change we could consider:

- Improving the help text for this field, to clarify its usage and the accessibility consequences
- Adding a straightforward way to customize this to disable the "No header cells" option – in practice all data tables should have header cells, and a table that doesn’t have any would be better as a list, or heading sections

In terms of migration, this looks like it could be easy:

- First row checkbox -> First row dropdown option
- First column checkbox -> First column dropdown option
- Both checkboxes -> "Both" dropdown option
- No checkboxes -> "No header cells" option


Discussion:
cc @zmustapha and @rjpruitt16 if you have any thoughts on this.
Just to add, I don't think it's feasible for us to perform a data migration on TableBlock data that already exists in people's projects. That doesn't rule out making the change at the UI level, though - we'd just need to make the appropriate translations between the `first_row_is_table_header` / `first_col_is_header` flags (as stored in the JSON) and the dropdown values, either within `render_form` / `value_from_datadict` or on the Javascript side.
👍 good point, that seems like a neat solution. And we won’t have to change the table’s front-end rendering, which already works as intended with the current setup.
Marking this as "good first issue", and removing "Needs Design Decision" – if anyone wants to pick this up please post a comment and go ahead. Changing this require some Django knowledge, and changing the UI to a `<select>` (well, the equivalent Django/Wagtail widget). Not super straightforward but should be approachable, and it should be relatively self-contained / easy to write tests for.
Hey! @thibaudcolas as a starter task I wish to give this one an attempt 😅 

This issue has two very old PR's active 😅 ? Can i still take up this issue @thibaudcolas @lb- ??

@ParitoshKabra as you’ve noticed this has already been assigned to someone and there is a PR awaiting review, so no.
@ParitoshKabra I don't have time to work on this issue. As you noticed my PR is super old now. If you would like to take it over (or start again if you prefer), I am fine with that.
@cnk  shall i work on this issue?
@salty-ivy Yes please this is still something that could use some attention. Please look at gasman's comments about how to preserve compatibility with existing table data. And if you have any questions, please post them here so Thibaud or I can advise.
okay, thanks a lot ! 
@cnk  could you have  a look at the new PR, its pretty much adapted but i am still not sure where the frontend test is failing, thank you, some mismatch in the snapshot, would soon look into it
I want to work on this issue. kindly give me permission and access to work on this issue
Can i work on this issue?
@DevilsAutumn go for it, there is an existing PR (draft) with feedback. Please review that and I would suggest building a new PR based on where it started.

https://github.com/wagtail/wagtail/pull/9673

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
@@ -1,6 +1,9 @@
 import json
 
 from django import forms
+from django.core.exceptions import ValidationError
+from django.forms.fields import Field
+from django.forms.utils import ErrorList
 from django.template.loader import render_to_string
 from django.utils import translation
 from django.utils.functional import cached_property
@@ -68,12 +71,18 @@ class TableInputAdapter(WidgetAdapter):
     def js_args(self, widget):
         strings = {
             "Row header": _("Row header"),
-            "Display the first row as a header.": _(
-                "Display the first row as a header."
+            "Table headers": _("Table headers"),
+            "Display the first row as a header": _("Display the first row as a header"),
+            "Display the first column as a header": _(
+                "Display the first column as a header"
             ),
             "Column header": _("Column header"),
-            "Display the first column as a header.": _(
-                "Display the first column as a header."
+            "Display the first row AND first column as headers": _(
+                "Display the first row AND first column as headers"
+            ),
+            "No headers": _("No headers"),
+            "Which cells should be displayed as headers?": _(
+                "Which cells should be displayed as headers?"
             ),
             "Table caption": _("Table caption"),
             "A heading that identifies the overall topic of the table, and is useful for screen reader users.": _(
@@ -117,6 +126,44 @@ def value_from_form(self, value):
     def value_for_form(self, value):
         return json.dumps(value)
 
+    def to_python(self, value):
+        """
+        If value came from a table block stored before Wagtail 6.0, we need to set an appropriate
+        value for the header choice. I would really like to have this default to "" and force the
+        editor to reaffirm they don't want any headers, but that woud be a breaking change.
+        """
+        if not value.get("table_header_choice", ""):
+            if value.get("first_row_is_table_header", False) and value.get(
+                "first_col_is_header", False
+            ):
+                value["table_header_choice"] = "both"
+            elif value.get("first_row_is_table_header", False):
+                value["table_header_choice"] = "row"
+            elif value.get("first_col_is_header", False):
+                value["table_header_choice"] = "col"
+            else:
+                value["table_header_choice"] = "neither"
+        return value
+
+    def clean(self, value):
+        if not value:
+            return value
+
+        if value.get("table_header_choice", ""):
+            value["first_row_is_table_header"] = value["table_header_choice"] in [
+                "row",
+                "both",
+            ]
+            value["first_col_is_header"] = value["table_header_choice"] in [
+                "column",
+                "both",
+            ]
+        else:
+            # Ensure we have a choice for the table_header_choice
+            errors = ErrorList(Field.default_error_messages["required"])
+            raise ValidationError("Validation error in TableBlock", params=errors)
+        return self.value_from_form(self.field.clean(self.value_for_form(value)))
+
     def get_form_state(self, value):
         # pass state to frontend as a JSON-ish dict - do not serialise to a JSON string
         return value


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
@@ -3,26 +3,23 @@
 exports[`telepath: wagtail.widgets.TableInput it renders correctly 1`] = `
 "<div>
       <div class="w-field__wrapper" data-field-wrapper="">
-        <label class="w-field__label" for="the-id-handsontable-header">Row header</label>
-        <div class="w-field w-field--boolean_field w-field--checkbox_input" data-field="">
-          <div class="w-field__help" id="the-id-handsontable-header-helptext" data-field-help="">
-            <div class="help">Display the first row as a header.</div>
-          </div>
-          <div class="w-field__input" data-field-input="">
-            <input type="checkbox" id="the-id-handsontable-header" name="handsontable-header" aria-describedby="the-id-handsontable-header-helptext">
-          </div>
-        </div>
-      </div>
-      <div class="w-field__wrapper" data-field-wrapper="">
-        <label class="w-field__label" for="the-id-handsontable-col-header">Column header</label>
-        <div class="w-field w-field--boolean_field w-field--checkbox_input" data-field="">
-          <div class="w-field__help" id="the-id-handsontable-col-header-helptext" data-field-help="">
-            <div class="help">Display the first column as a header.</div>
-          </div>
-          <div class="w-field__input" data-field-input="">
-            <input type="checkbox" id="the-id-handsontable-col-header" name="handsontable-col-header" aria-describedby="the-id-handsontable-col-header-helptext">
-          </div>
-        </div>
+        <label class="w-field__label" for="the-id-table-header-choice">Table headers</label>
+          <select id="the-id-table-header-choice" name="table-header-choice">
+            <option value="">Select a header option</option>
+            <option value="row">
+                Display the first row as a header
+            </option>
+            <option value="column">
+                Display the first column as a header
+            </option>
+            <option value="both">
+                Display the first row AND first column as headers
+            </option>
+            <option value="neither">
+                No headers
+            </option>
+          </select>
+        <p class="help">Which cells should be displayed as headers?</p>
       </div>
       <div class="w-field__wrapper" data-field-wrapper="">
         <label class="w-field__label" for="the-id-handsontable-col-caption">Table caption</label>
@@ -43,26 +40,23 @@ exports[`telepath: wagtail.widgets.TableInput it renders correctly 1`] = `
 exports[`telepath: wagtail.widgets.TableInput translation 1`] = `
 "<div>
       <div class="w-field__wrapper" data-field-wrapper="">
-        <label class="w-field__label" for="the-id-handsontable-header">En-tête de ligne</label>
-        <div class="w-field w-field--boolean_field w-field--checkbox_input" data-field="">
-          <div class="w-field__help" id="the-id-handsontable-header-helptext" data-field-help="">
-            <div class="help">Affichez la première ligne sous forme d'en-tête.</div>
-          </div>
-          <div class="w-field__input" data-field-input="">
-            <input type="checkbox" id="the-id-handsontable-header" name="handsontable-header" aria-describedby="the-id-handsontable-header-helptext">
-          </div>
-        </div>
-      </div>
-      <div class="w-field__wrapper" data-field-wrapper="">
-        <label class="w-field__label" for="the-id-handsontable-col-header">En-tête de colonne</label>
-        <div class="w-field w-field--boolean_field w-field--checkbox_input" data-field="">
-          <div class="w-field__help" id="the-id-handsontable-col-header-helptext" data-field-help="">
-            <div class="help">Affichez la première colonne sous forme d'en-tête.</div>
-          </div>
-          <div class="w-field__input" data-field-input="">
-            <input type="checkbox" id="the-id-handsontable-col-header" name="handsontable-col-header" aria-describedby="the-id-handsontable-col-header-helptext">
-          </div>
-        </div>
+        <label class="w-field__label" for="the-id-table-header-choice">En-têtes de tableau</label>
+          <select id="the-id-table-header-choice" name="table-header-choice">
+            <option value="">Select a header option</option>
+            <option value="row">
+                Afficher la première ligne sous forme d'en-tête
+            </option>
+            <option value="column">
+                Afficher la première colonne sous forme d'en-tête
+            </option>
+            <option value="both">
+                Afficher la première ligne ET la première colonne sous forme d'en-têtes
+            </option>
+            <option value="neither">
+                Pas d'en-têtes
+            </option>
+          </select>
+        <p class="help">Quelles cellules doivent être affichées en tant qu'en-têtes?</p>
       </div>
       <div class="w-field__wrapper" data-field-wrapper="">
         <label class="w-field__label" for="the-id-handsontable-col-caption">Légende du tableau</label>
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
@@ -33,11 +33,15 @@ const TEST_OPTIONS = {
 };
 
 const TEST_STRINGS = {
-  'Row header': 'Row header',
-  'Display the first row as a header.': 'Display the first row as a header.',
-  'Column header': 'Column header',
-  'Display the first column as a header.':
-    'Display the first column as a header.',
+  'Table headers': 'Table headers',
+  'Display the first row as a header': 'Display the first row as a header',
+  'Display the first column as a header':
+    'Display the first column as a header',
+  'Display the first row AND first column as headers':
+    'Display the first row AND first column as headers',
+  'No headers': 'No headers',
+  'Which cells should be displayed as headers?':
+    'Which cells should be displayed as headers?',
   'Table caption': 'Table caption',
 
   'A heading that identifies the overall topic of the table, and is useful for screen reader users.':
@@ -155,12 +159,16 @@ describe('telepath: wagtail.widgets.TableInput', () => {
 
   test('translation', () => {
     testStrings = {
-      'Row header': 'En-tête de ligne',
-      'Display the first row as a header.':
-        "Affichez la première ligne sous forme d'en-tête.",
-      'Column header': 'En-tête de colonne',
-      'Display the first column as a header.':
-        "Affichez la première colonne sous forme d'en-tête.",
+      'Table headers': 'En-têtes de tableau',
+      'Display the first row as a header':
+        "Afficher la première ligne sous forme d'en-tête",
+      'Display the first column as a header':
+        "Afficher la première colonne sous forme d'en-tête",
+      'Display the first row AND first column as headers':
+        "Afficher la première ligne ET la première colonne sous forme d'en-têtes",
+      'No headers': "Pas d'en-têtes",
+      'Which cells should be displayed as headers?':
+        "Quelles cellules doivent être affichées en tant qu'en-têtes?",
       'Table caption': 'Légende du tableau',
 
       'A heading that identifies the overall topic of the table, and is useful for screen reader users.':
diff --git a/wagtail/contrib/table_block/tests.py b/wagtail/contrib/table_block/tests.py
--- a/wagtail/contrib/table_block/tests.py
+++ b/wagtail/contrib/table_block/tests.py
@@ -116,7 +116,7 @@ def test_render_empty_table(self):
         """
         self.assertHTMLEqual(result, expected)
 
-    def test_do_not_render_html(self):
+    def test_do_not_render_html_by_default(self):
         """
         Ensure that raw html doesn't render
         by default.
@@ -145,6 +145,36 @@ def test_do_not_render_html(self):
         result = block.render(value)
         self.assertHTMLEqual(result, expected)
 
+    def test_does_render_html_if_allowed(self):
+        """
+        Ensure html renders if table_options set renderer to allow html
+        """
+        value = {
+            "first_row_is_table_header": False,
+            "first_col_is_header": False,
+            "data": [
+                ["<p><strong>Test</strong></p>", None, None],
+                [None, None, None],
+                [None, None, None],
+            ],
+        }
+
+        expected = """
+            <table>
+                <tbody>
+                    <tr><td><p><strong>Test</strong></p></td><td></td><td></td></tr>
+                    <tr><td></td><td></td><td></td></tr>
+                    <tr><td></td><td></td><td></td></tr>
+                </tbody>
+            </table>
+        """
+
+        new_options = self.default_table_options.copy()
+        new_options["renderer"] = "html"
+        block = TableBlock(table_options=new_options)
+        result = block.render(value)
+        self.assertHTMLEqual(result, expected)
+
     def test_row_headers(self):
         """
         Ensure that row headers are properly rendered.
@@ -226,6 +256,33 @@ def test_row_and_column_headers(self):
         result = block.render(value)
         self.assertHTMLEqual(result, expected)
 
+    def test_no_headers(self):
+        """
+        Test table without headers.
+        """
+        value = {
+            "first_row_is_table_header": False,
+            "first_col_is_header": False,
+            "data": [
+                ["Foo", "Bar", "Baz"],
+                ["one", "two", "three"],
+                ["four", "five", "six"],
+            ],
+        }
+
+        expected = """
+            <table>
+                <tbody>
+                    <tr><td>Foo</td><td>Bar</td><td>Baz</td></tr>
+                    <tr><td>one</td><td>two</td><td>three</td></tr>
+                    <tr><td>four</td><td>five</td><td>six</td></tr>
+                </tbody>
+            </table>
+        """
+        block = TableBlock()
+        result = block.render(value)
+        self.assertHTMLEqual(result, expected)
+
     def test_value_for_and_from_form(self):
         """
         Make sure we get back good json and make