Skip to content

Commit 5ee34b5

Browse files
gunndabadclaude
andcommitted
Let the table's head and rows take classes and attributes
<govuk-table-head> and <govuk-table-row> threw for any attribute passed to them, because <thead> and <tr> had nowhere in TableOptions to take one from. Both are elements someone will reasonably want to put a class on, so give them somewhere to go. The head is singular, so its classes and attributes sit flat on TableOptions next to the caption's, and land on <thead> — the element <govuk-table-head> names — rather than the <tr> nested inside it. Rows are a collection, so they need an object each: Rows becomes a collection of TableOptionsRow, holding the cells alongside the row's own classes and attributes, which brings it in line with SummaryListOptions. The reference fixtures give a row as a bare array of cells, so reading them into the wrapper needs a converter. Options are only ever deserialized by the fixture tests, so it lives in the test project with the three converters already there rather than shaping the public API around the JSON. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 47af2c5 commit 5ee34b5

14 files changed

Lines changed: 215 additions & 58 deletions

File tree

src/GovUk.Frontend.AspNetCore/ComponentGeneration/DefaultComponentGenerator.Table.cs

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ public virtual ValueTask<GovUkComponent> GenerateTableAsync(TableOptions options
3535
HtmlTag GenerateTableHead(IReadOnlyCollection<TableOptionsHead> head)
3636
{
3737
var theadTag = new HtmlTag("thead", attrs => attrs
38-
.WithClasses("govuk-table__head"));
38+
.WithClasses("govuk-table__head", options.HeadClasses)
39+
.With(options.HeadAttributes));
3940

4041
var trTag = new HtmlTag("tr", attrs => attrs
4142
.WithClasses("govuk-table__row"));
@@ -72,7 +73,7 @@ HtmlTag GenerateTableHead(IReadOnlyCollection<TableOptionsHead> head)
7273
return theadTag;
7374
}
7475

75-
HtmlTag GenerateTableBody(IReadOnlyCollection<IReadOnlyCollection<TableOptionsColumn?>?>? rows, bool? firstCellIsHeader)
76+
HtmlTag GenerateTableBody(IReadOnlyCollection<TableOptionsRow?>? rows, bool? firstCellIsHeader)
7677
{
7778
var tbodyTag = new HtmlTag("tbody", attrs => attrs
7879
.WithClasses("govuk-table__body"));
@@ -84,10 +85,11 @@ HtmlTag GenerateTableBody(IReadOnlyCollection<IReadOnlyCollection<TableOptionsCo
8485
if (row is not null)
8586
{
8687
var trTag = new HtmlTag("tr", attrs => attrs
87-
.WithClasses("govuk-table__row"));
88+
.WithClasses("govuk-table__row", row.Classes)
89+
.With(row.Attributes));
8890

8991
var isFirstCell = true;
90-
foreach (var cell in row)
92+
foreach (var cell in row.Cells ?? [])
9193
{
9294
if (cell is null)
9395
{

src/GovUk.Frontend.AspNetCore/ComponentGeneration/TableOptions.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ namespace GovUk.Frontend.AspNetCore.ComponentGeneration;
77

88
public record TableOptions
99
{
10-
public IReadOnlyCollection<IReadOnlyCollection<TableOptionsColumn>>? Rows { get; set; }
10+
public IReadOnlyCollection<TableOptionsRow?>? Rows { get; set; }
1111
public IReadOnlyCollection<TableOptionsHead>? Head { get; set; }
1212
public TemplateString? Caption { get; set; }
1313
public TemplateString? CaptionClasses { get; set; }
@@ -17,6 +17,20 @@ public record TableOptions
1717

1818
[NonStandardParameter]
1919
public AttributeCollection? CaptionAttributes { get; set; }
20+
[NonStandardParameter]
21+
public TemplateString? HeadClasses { get; set; }
22+
[NonStandardParameter]
23+
public AttributeCollection? HeadAttributes { get; set; }
24+
}
25+
26+
public record TableOptionsRow
27+
{
28+
public IReadOnlyCollection<TableOptionsColumn?>? Cells { get; set; }
29+
30+
[NonStandardParameter]
31+
public TemplateString? Classes { get; set; }
32+
[NonStandardParameter]
33+
public AttributeCollection? Attributes { get; set; }
2034
}
2135

2236
public record TableOptionsColumn

src/GovUk.Frontend.AspNetCore/TagHelpers/TableContext.cs

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,17 @@ namespace GovUk.Frontend.AspNetCore.TagHelpers;
44

55
internal class TableContext
66
{
7-
private readonly List<IReadOnlyCollection<TableOptionsColumn>> _rows = [];
7+
private readonly List<TableOptionsRow?> _rows = [];
88

99
private string? _captionTagName;
1010
private string? _headTagName;
1111
private string? _rowTagName;
1212

1313
public (TemplateString Content, TemplateString? Classes, AttributeCollection Attributes)? Caption { get; private set; }
1414

15-
public IReadOnlyCollection<TableOptionsHead>? Head { get; private set; }
15+
public (IReadOnlyCollection<TableOptionsHead> Cells, TemplateString? Classes, AttributeCollection Attributes)? Head { get; private set; }
1616

17-
public IReadOnlyCollection<IReadOnlyCollection<TableOptionsColumn>> Rows => _rows.AsReadOnly();
17+
public IReadOnlyCollection<TableOptionsRow?> Rows => _rows.AsReadOnly();
1818

1919
public void SetCaption(TemplateString content, TemplateString? classes, AttributeCollection attributes, string tagName)
2020
{
@@ -45,9 +45,14 @@ public void SetCaption(TemplateString content, TemplateString? classes, Attribut
4545
_captionTagName = tagName;
4646
}
4747

48-
public void SetHead(IReadOnlyCollection<TableOptionsHead> head, string tagName)
48+
public void SetHead(
49+
IReadOnlyCollection<TableOptionsHead> cells,
50+
TemplateString? classes,
51+
AttributeCollection attributes,
52+
string tagName)
4953
{
50-
ArgumentNullException.ThrowIfNull(head);
54+
ArgumentNullException.ThrowIfNull(cells);
55+
ArgumentNullException.ThrowIfNull(attributes);
5156
ArgumentNullException.ThrowIfNull(tagName);
5257

5358
CheckChildTagNameSpelling(tagName);
@@ -64,11 +69,11 @@ public void SetHead(IReadOnlyCollection<TableOptionsHead> head, string tagName)
6469
throw ExceptionHelper.ChildElementMustBeSpecifiedBefore(tagName, rowTagName);
6570
}
6671

67-
Head = head;
72+
Head = (cells, classes, attributes);
6873
_headTagName = tagName;
6974
}
7075

71-
public void AddRow(IReadOnlyCollection<TableOptionsColumn> row, string tagName)
76+
public void AddRow(TableOptionsRow row, string tagName)
7277
{
7378
ArgumentNullException.ThrowIfNull(row);
7479
ArgumentNullException.ThrowIfNull(tagName);

src/GovUk.Frontend.AspNetCore/TagHelpers/TableHeadTagHelper.cs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using GovUk.Frontend.AspNetCore.ComponentGeneration;
12
using Microsoft.AspNetCore.Razor.TagHelpers;
23

34
namespace GovUk.Frontend.AspNetCore.TagHelpers;
@@ -34,14 +35,12 @@ public override async Task ProcessAsync(TagHelperContext context, TagHelperOutpu
3435

3536
await output.GetChildContentAsync();
3637

37-
if (output.Attributes.Any())
38-
{
39-
throw ExceptionHelper.AttributesNotSupported();
40-
}
41-
4238
headContext.ThrowIfIncomplete();
4339

44-
tableContext.SetHead(headContext.Cells, context.TagName);
40+
var attributes = new AttributeCollection(output.Attributes);
41+
attributes.Remove("class", out var classes);
42+
43+
tableContext.SetHead(headContext.Cells, classes, attributes, context.TagName);
4544

4645
output.SuppressOutput();
4746
}

src/GovUk.Frontend.AspNetCore/TagHelpers/TableRowTagHelper.cs

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
using GovUk.Frontend.AspNetCore.ComponentGeneration;
12
using Microsoft.AspNetCore.Razor.TagHelpers;
23

34
namespace GovUk.Frontend.AspNetCore.TagHelpers;
@@ -34,14 +35,19 @@ public override async Task ProcessAsync(TagHelperContext context, TagHelperOutpu
3435

3536
await output.GetChildContentAsync();
3637

37-
if (output.Attributes.Any())
38-
{
39-
throw ExceptionHelper.AttributesNotSupported();
40-
}
41-
4238
rowContext.ThrowIfIncomplete();
4339

44-
tableContext.AddRow(rowContext.Cells, context.TagName);
40+
var attributes = new AttributeCollection(output.Attributes);
41+
attributes.Remove("class", out var classes);
42+
43+
tableContext.AddRow(
44+
new TableOptionsRow
45+
{
46+
Cells = rowContext.Cells,
47+
Classes = classes,
48+
Attributes = attributes
49+
},
50+
context.TagName);
4551

4652
output.SuppressOutput();
4753
}

src/GovUk.Frontend.AspNetCore/TagHelpers/TableTagHelper.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,9 @@ public override async Task ProcessAsync(TagHelperContext context, TagHelperOutpu
6666
var component = await _componentGenerator.GenerateTableAsync(new TableOptions
6767
{
6868
Rows = tableContext.Rows,
69-
Head = tableContext.Head,
69+
Head = tableContext.Head?.Cells,
70+
HeadClasses = tableContext.Head?.Classes,
71+
HeadAttributes = tableContext.Head?.Attributes,
7072
Caption = tableContext.Caption?.Content,
7173
CaptionClasses = tableContext.Caption?.Classes,
7274
CaptionAttributes = tableContext.Caption?.Attributes,

tests/GovUk.Frontend.AspNetCore.IntegrationTests/ShortTagNamesTestsViews/Table.cshtml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@
66
<div data-testid="short">
77
<govuk-table first-cell-is-header="true">
88
<table-caption class="govuk-table__caption--m">Dates and amounts</table-caption>
9-
<table-head>
9+
<table-head class="head-class" data-head="head">
1010
<table-head-cell>Date</table-head-cell>
1111
<table-head-cell format="numeric">Amount</table-head-cell>
1212
</table-head>
13-
<table-row>
13+
<table-row class="row-class" data-row="row">
1414
<table-cell>First 6 weeks</table-cell>
1515
<table-cell format="numeric">£109.80 per week</table-cell>
1616
</table-row>
17-
<table-row>
17+
<table-row class="row-class" data-row="row">
1818
<table-cell>Next 33 weeks</table-cell>
1919
<table-cell format="numeric">£109.80 per week</table-cell>
2020
</table-row>
@@ -24,15 +24,15 @@
2424
<div data-testid="long">
2525
<govuk-table first-cell-is-header="true">
2626
<govuk-table-caption class="govuk-table__caption--m">Dates and amounts</govuk-table-caption>
27-
<govuk-table-head>
27+
<govuk-table-head class="head-class" data-head="head">
2828
<govuk-table-head-cell>Date</govuk-table-head-cell>
2929
<govuk-table-head-cell format="numeric">Amount</govuk-table-head-cell>
3030
</govuk-table-head>
31-
<govuk-table-row>
31+
<govuk-table-row class="row-class" data-row="row">
3232
<govuk-table-cell>First 6 weeks</govuk-table-cell>
3333
<govuk-table-cell format="numeric">£109.80 per week</govuk-table-cell>
3434
</govuk-table-row>
35-
<govuk-table-row>
35+
<govuk-table-row class="row-class" data-row="row">
3636
<govuk-table-cell>Next 33 weeks</govuk-table-cell>
3737
<govuk-table-cell format="numeric">£109.80 per week</govuk-table-cell>
3838
</govuk-table-row>

tests/GovUk.Frontend.AspNetCore.Tests/ComponentGeneration/ComponentFixtureData.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ static ComponentFixtureData()
2424
_serializerOptions.Converters.Add(new StringHtmlContentJsonConverter());
2525
_serializerOptions.Converters.Add(new AttributeCollectionJsonConverter());
2626
_serializerOptions.Converters.Add(new TemplateStringJsonConverter());
27+
_serializerOptions.Converters.Add(new TableOptionsRowJsonConverter());
2728

2829
}
2930

tests/GovUk.Frontend.AspNetCore.Tests/ComponentGeneration/DefaultComponentGeneratorTests.NonStandardParameters.cs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,6 +1156,70 @@ public async Task FileUpload_MultipleFilesChosenText_AllCategoriesAreIncludedInO
11561156
}
11571157
}
11581158

1159+
[Fact]
1160+
public async Task Table_CaptionAttributes_IsIncludedInOutput()
1161+
{
1162+
// Arrange
1163+
var options = new TableOptions
1164+
{
1165+
Caption = "Caption",
1166+
CaptionAttributes = new AttributeCollection { { "data-test", "caption-attr" } }
1167+
};
1168+
1169+
// Act
1170+
var result = await _componentGenerator.GenerateTableAsync(options);
1171+
var html = result.GetHtml();
1172+
1173+
// Assert
1174+
Assert.Contains("data-test=\"caption-attr\"", html);
1175+
}
1176+
1177+
[Fact]
1178+
public async Task Table_HeadClassesAndAttributes_AreIncludedInOutput()
1179+
{
1180+
// Arrange
1181+
var options = new TableOptions
1182+
{
1183+
Head = [new TableOptionsHead { Text = "Header" }],
1184+
HeadClasses = "head-class",
1185+
HeadAttributes = new AttributeCollection { { "data-test", "head-attr" } }
1186+
};
1187+
1188+
// Act
1189+
var result = await _componentGenerator.GenerateTableAsync(options);
1190+
var html = result.GetHtml();
1191+
1192+
// Assert
1193+
Assert.Contains("class=\"govuk-table__head head-class\"", html);
1194+
Assert.Contains("data-test=\"head-attr\"", html);
1195+
}
1196+
1197+
[Fact]
1198+
public async Task Table_RowClassesAndAttributes_AreIncludedInOutput()
1199+
{
1200+
// Arrange
1201+
var options = new TableOptions
1202+
{
1203+
Rows =
1204+
[
1205+
new TableOptionsRow
1206+
{
1207+
Cells = [new TableOptionsColumn { Text = "Cell" }],
1208+
Classes = "row-class",
1209+
Attributes = new AttributeCollection { { "data-test", "row-attr" } }
1210+
}
1211+
]
1212+
};
1213+
1214+
// Act
1215+
var result = await _componentGenerator.GenerateTableAsync(options);
1216+
var html = result.GetHtml();
1217+
1218+
// Assert
1219+
Assert.Contains("class=\"govuk-table__row row-class\"", html);
1220+
Assert.Contains("data-test=\"row-attr\"", html);
1221+
}
1222+
11591223
[Fact]
11601224
public async Task Tabs_Title_IsEncoded()
11611225
{
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
using System.Text.Json;
2+
using System.Text.Json.Serialization;
3+
using GovUk.Frontend.AspNetCore.ComponentGeneration;
4+
5+
namespace GovUk.Frontend.AspNetCore.Tests.ComponentGeneration;
6+
7+
/// <summary>
8+
/// Reads a row from the reference fixtures, where a row is a bare array of cells rather than an
9+
/// object; <see cref="TableOptionsRow"/> wraps that array so the row can carry its own classes and
10+
/// attributes.
11+
/// </summary>
12+
public class TableOptionsRowJsonConverter : JsonConverter<TableOptionsRow>
13+
{
14+
public override TableOptionsRow Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
15+
new()
16+
{
17+
Cells = JsonSerializer.Deserialize<IReadOnlyCollection<TableOptionsColumn?>>(ref reader, options)
18+
};
19+
20+
public override void Write(Utf8JsonWriter writer, TableOptionsRow value, JsonSerializerOptions options) =>
21+
JsonSerializer.Serialize(writer, value.Cells, options);
22+
}

0 commit comments

Comments
 (0)