diff --git a/app/components/govuk_component/table_component.html.erb b/app/components/govuk_component/table_component.html.erb deleted file mode 100644 index 81bfeda2..00000000 --- a/app/components/govuk_component/table_component.html.erb +++ /dev/null @@ -1,7 +0,0 @@ -<%= tag.table(**html_attributes) do %> - <%= caption %> - <%= head %> - <% bodies.each do |body| %> - <%= body %> - <% end %> -<% end %> diff --git a/app/components/govuk_component/table_component.rb b/app/components/govuk_component/table_component.rb index 1b0e66b6..cd1c731b 100644 --- a/app/components/govuk_component/table_component.rb +++ b/app/components/govuk_component/table_component.rb @@ -20,6 +20,10 @@ def initialize(id: nil, rows: nil, head: nil, caption: nil, first_cell_is_header build(*(head ? [head, rows] : [rows[0], rows[1..]]), caption_text) end + def call + tag.table(**html_attributes) { safe_join([caption, head, bodies]) } + end + private def build(head_data, body_data, caption_text) diff --git a/app/components/govuk_component/table_component/body_component.html.erb b/app/components/govuk_component/table_component/body_component.html.erb deleted file mode 100644 index 38e338b0..00000000 --- a/app/components/govuk_component/table_component/body_component.html.erb +++ /dev/null @@ -1,5 +0,0 @@ -<%= tag.tbody(**html_attributes) do %> - <% rows.each do |row| %> - <%= row %> - <% end %> -<% end %> diff --git a/app/components/govuk_component/table_component/body_component.rb b/app/components/govuk_component/table_component/body_component.rb index a8368636..b014ff66 100644 --- a/app/components/govuk_component/table_component/body_component.rb +++ b/app/components/govuk_component/table_component/body_component.rb @@ -1,5 +1,13 @@ class GovukComponent::TableComponent::BodyComponent < GovukComponent::Base - renders_many :rows, "GovukComponent::TableComponent::RowComponent" + renders_many :rows, ->(cell_data: nil, first_cell_is_header: false, classes: [], html_attributes: {}, &block) do + GovukComponent::TableComponent::RowComponent.from_body( + cell_data: cell_data, + first_cell_is_header: first_cell_is_header, + classes: classes, + html_attributes: html_attributes, + &block + ) + end def initialize(rows: nil, first_cell_is_header: false, classes: [], html_attributes: {}) super(classes: classes, html_attributes: html_attributes) @@ -7,6 +15,10 @@ def initialize(rows: nil, first_cell_is_header: false, classes: [], html_attribu build_rows_from_row_data(rows, first_cell_is_header) end + def call + tag.tbody(**html_attributes) { safe_join(rows) } + end + private def build_rows_from_row_data(data, first_cell_is_header) diff --git a/app/components/govuk_component/table_component/cell_component.rb b/app/components/govuk_component/table_component/cell_component.rb index 1f880d42..4d5cdf14 100644 --- a/app/components/govuk_component/table_component/cell_component.rb +++ b/app/components/govuk_component/table_component/cell_component.rb @@ -1,7 +1,8 @@ class GovukComponent::TableComponent::CellComponent < GovukComponent::Base - attr_reader :text, :header, :numeric, :width + attr_reader :text, :header, :numeric, :width, :scope, :parent alias_method :numeric?, :numeric + alias_method :header?, :header WIDTHS = { "full" => "govuk-!-width-full", @@ -12,11 +13,13 @@ class GovukComponent::TableComponent::CellComponent < GovukComponent::Base "one-quarter" => "govuk-!-width-one-quarter", }.freeze - def initialize(header: false, text: nil, numeric: false, width: nil, classes: [], html_attributes: {}) + def initialize(scope: nil, header: false, numeric: false, text: nil, width: nil, parent: nil, classes: [], html_attributes: {}) @header = header @text = text @numeric = numeric @width = width + @scope = scope + @parent = parent super(classes: classes, html_attributes: html_attributes) end @@ -36,18 +39,37 @@ def cell_content end def cell_element - header ? :th : :td + header ? 'th' : 'td' end def default_attributes - { class: default_classes } + { class: default_classes, scope: determine_scope } + end + + def determine_scope + conditions = { scope: scope, parent: parent, header: header, auto_table_scopes: config.enable_auto_table_scopes } + + case conditions + in { scope: String } + scope + in { scope: false } | { header: false } | { auto_table_scopes: false } + nil + in { auto_table_scopes: true, parent: 'thead' } + 'col' + in { auto_table_scopes: true, parent: 'tbody' } + 'row' + else + Rails.logger.warning("No scope pattern matched") + + nil + end end def default_classes if header - class_names("govuk-table__header", "govuk-table__header--numeric" => numeric?, width_class => width?).split + class_names("govuk-table__header", "govuk-table__header--numeric" => numeric?, width_class => width?) else - class_names("govuk-table__cell", "govuk-table__cell--numeric" => numeric?, width_class => width?).split + class_names("govuk-table__cell", "govuk-table__cell--numeric" => numeric?, width_class => width?) end end diff --git a/app/components/govuk_component/table_component/head_component.html.erb b/app/components/govuk_component/table_component/head_component.html.erb deleted file mode 100644 index 70d6173a..00000000 --- a/app/components/govuk_component/table_component/head_component.html.erb +++ /dev/null @@ -1,5 +0,0 @@ -<%= tag.thead(**html_attributes) do %> - <% rows.each do |row| %> - <%= row %> - <% end %> -<% end %> diff --git a/app/components/govuk_component/table_component/head_component.rb b/app/components/govuk_component/table_component/head_component.rb index 8863f60e..1dd843b3 100644 --- a/app/components/govuk_component/table_component/head_component.rb +++ b/app/components/govuk_component/table_component/head_component.rb @@ -1,5 +1,13 @@ class GovukComponent::TableComponent::HeadComponent < GovukComponent::Base - renders_many :rows, "GovukComponent::TableComponent::RowComponent" + renders_many :rows, ->(cell_data: nil, header: true, classes: [], html_attributes: {}, &block) do + GovukComponent::TableComponent::RowComponent.from_head( + cell_data: cell_data, + header: header, + classes: classes, + html_attributes: html_attributes, + &block + ) + end attr_reader :row_data @@ -9,6 +17,10 @@ def initialize(rows: nil, classes: [], html_attributes: {}) build_rows_from_row_data(rows) end + def call + tag.thead(**html_attributes) { safe_join(rows) } + end + private def build_rows_from_row_data(data) diff --git a/app/components/govuk_component/table_component/row_component.html.erb b/app/components/govuk_component/table_component/row_component.html.erb deleted file mode 100644 index 2a156666..00000000 --- a/app/components/govuk_component/table_component/row_component.html.erb +++ /dev/null @@ -1,5 +0,0 @@ -<%= tag.tr(**html_attributes) do %> - <% cells.each do |cell| %> - <%= cell %> - <% end %> -<% end %> diff --git a/app/components/govuk_component/table_component/row_component.rb b/app/components/govuk_component/table_component/row_component.rb index 2e827604..000b9df4 100644 --- a/app/components/govuk_component/table_component/row_component.rb +++ b/app/components/govuk_component/table_component/row_component.rb @@ -1,23 +1,54 @@ class GovukComponent::TableComponent::RowComponent < GovukComponent::Base - renders_many :cells, "GovukComponent::TableComponent::CellComponent" + renders_many :cells, ->(scope: nil, header: false, text: nil, numeric: false, width: nil, classes: [], html_attributes: {}, &block) do + GovukComponent::TableComponent::CellComponent.new( + scope: scope, + header: header, + text: text, + numeric: numeric, + width: width, + parent: parent, + classes: classes, + html_attributes: html_attributes, + &block + ) + end - attr_reader :header, :first_cell_is_header + attr_reader :header, :first_cell_is_header, :parent - def initialize(cell_data: nil, first_cell_is_header: false, header: false, classes: [], html_attributes: {}) + def initialize(cell_data: nil, first_cell_is_header: false, header: false, parent: nil, classes: [], html_attributes: {}) @header = header @first_cell_is_header = first_cell_is_header + @parent = parent super(classes: classes, html_attributes: html_attributes) build_cells_from_cell_data(cell_data) end + def self.from_head(*args, **kwargs, &block) + new(*args, parent: 'thead', **kwargs, &block) + end + + def self.from_body(*args, **kwargs, &block) + new(*args, parent: 'tbody', **kwargs, &block) + end + + def call + tag.tr(**html_attributes) { safe_join(cells) } + end + private def build_cells_from_cell_data(cell_data) return if cell_data.blank? - cell_data.map.with_index { |cd, i| cell(header: cell_is_header?(i), text: cd) } + cell_data.each.with_index { |data, i| cell(text: data, **cell_attributes(i)) } + end + + def cell_attributes(count) + cell_is_header?(count).then do |cell_is_header| + { header: cell_is_header } + end end def cell_is_header?(count) diff --git a/guide/content/components/table.slim b/guide/content/components/table.slim index f36d54ac..35aa596d 100644 --- a/guide/content/components/table.slim +++ b/guide/content/components/table.slim @@ -47,6 +47,9 @@ p Use the table component to make information easier to compare and scan for using the `head` argument. If nothing is set, the first row will be used for headers. + The `first_cell_is_header` parameter can be used to change the first column + in the table body to header cells with `scope='row'`. + == render('/partials/example.*', caption: "Table with resized columns", code: table_with_resized_columns) do diff --git a/guide/lib/examples/table_helpers.rb b/guide/lib/examples/table_helpers.rb index 06921b86..a5177cf2 100644 --- a/guide/lib/examples/table_helpers.rb +++ b/guide/lib/examples/table_helpers.rb @@ -60,7 +60,7 @@ def table_with_header_column def table_from_arrays <<~TABLE - = govuk_table(rows: data, caption: "Pokémon species and types") + = govuk_table(rows: data, caption: "Pokémon species and types", first_cell_is_header: true) TABLE end @@ -68,11 +68,11 @@ def table_data <<~TABLE_DATA { data: [ - ["Name", "Primary type"], - ["Weedle", "Bug"], - ["Rattata", "Normal"], - ["Raichu", "Electric"], - ["Golduck", "Water"] + ["Name" , "Primary type", "Catch rate", "Other types"], + ["Weedle" , "Bug" , 255 , "Poison"], + ["Rattata", "Normal" , 255 , "Dark"], + ["Raichu" , "Electric" , 75 , "Psychic"], + ["Golduck", "Water" , 75 , "No other types"] ] } TABLE_DATA diff --git a/lib/govuk/components/engine.rb b/lib/govuk/components/engine.rb index 9353617c..6ea59926 100644 --- a/lib/govuk/components/engine.rb +++ b/lib/govuk/components/engine.rb @@ -64,6 +64,7 @@ def reset! # +:default_warning_text_icon+ "!" # # +:require_summary_list_action_visually_hidden_text+ when true forces visually hidden text to be set for every action. It can still be explicitly skipped by passing in +nil+. Defaults to +false+ + # +:enable_auto_table_scopes+ automatically adds a scope of 'col' to th elements in thead and 'row' to th elements in tbody. DEFAULTS = { default_back_link_text: 'Back', default_breadcrumbs_collapse_on_mobile: false, @@ -99,6 +100,7 @@ def reset! default_link_new_tab_text: "(opens in new tab)", require_summary_list_action_visually_hidden_text: false, + enable_auto_table_scopes: true, }.freeze DEFAULTS.each_key { |k| config_accessor(k) { DEFAULTS[k] } } diff --git a/spec/components/govuk_component/configuration/table_component_configuration_spec.rb b/spec/components/govuk_component/configuration/table_component_configuration_spec.rb new file mode 100644 index 00000000..3c0c1f3c --- /dev/null +++ b/spec/components/govuk_component/configuration/table_component_configuration_spec.rb @@ -0,0 +1,37 @@ +require 'spec_helper' + +RSpec.describe(GovukComponent::TableComponent::CellComponent, type: :component) do + let(:text) { 'Content' } + let(:kwargs) { { text: text, header: true, parent: 'tbody' } } + let(:component_css_class) { 'govuk-table__cell' } + + describe 'configuration' do + describe 'disabling automatic scopes' do + context "when enable_auto_table_scopes: true" do + subject! { render_inline(GovukComponent::TableComponent::CellComponent.new(**kwargs)) } + + specify "renders a scopeless table cell" do + expect(rendered_content).to have_tag("th", text: "Content") + expect(html.at_css('th').attributes.keys).to match_array(%w(class scope)) + end + end + + context "when enable_auto_table_scopes: false" do + after { Govuk::Components.reset! } + + before do + Govuk::Components.configure do |config| + config.enable_auto_table_scopes = false + end + end + + subject! { render_inline(GovukComponent::TableComponent::CellComponent.new(**kwargs)) } + + specify "renders a scopeless table cell" do + expect(rendered_content).to have_tag("th", text: "Content") + expect(html.at_css('th').attributes.keys).to match_array(%w(class)) + end + end + end + end +end diff --git a/spec/components/govuk_component/table_component_spec.rb b/spec/components/govuk_component/table_component_spec.rb index e95fed5d..b5530a42 100644 --- a/spec/components/govuk_component/table_component_spec.rb +++ b/spec/components/govuk_component/table_component_spec.rb @@ -22,7 +22,10 @@ end specify "renders a table with thead and tbody elements" do - expect(rendered_content).to have_tag("table", with: { class: "govuk-table" }) + expect(rendered_content).to have_tag("table", with: { class: "govuk-table" }) do + with_tag('thead', with: { class: "govuk-table__head" }) + with_tag('tbody', with: { class: "govuk-table__body" }) + end end specify "table has the provided id" do @@ -141,7 +144,9 @@ specify "renders one header row" do expect(rendered_content).to have_tag("table", with: { class: component_css_class }) do with_tag("thead", with: { class: "govuk-table__head" }) do - with_tag("tr", with: { class: "govuk-table__row" }, count: 1) + with_tag("tr", with: { class: "govuk-table__row" }, count: 1) do + with_tag("th", with: { class: "govuk-table__header", scope: "col" }, count: 4) + end end end end @@ -193,9 +198,17 @@ render_inline(GovukComponent::TableComponent.new(**kwargs.merge(head: head, rows: rows, first_cell_is_header: true))) end - specify "renders the header content in table header (th) cells" do + specify "renders the table header" do + expect(rendered_content).to have_tag("table > thead") do + head.each do |heading| + with_tag('th', text: heading, with: { class: 'govuk-table__header', scope: 'col' }) + end + end + end + + specify "renders the header column in table body cells" do expect(rendered_content).to have_tag("table > tbody") do - with_tag('th', text: row_header_text, count: number_of_rows) + with_tag('th', text: row_header_text, count: number_of_rows, with: { scope: 'row' }) with_tag('td', text: row_cell_text, count: number_of_rows * 2) end end @@ -203,6 +216,15 @@ specify "the header is always first" do html.css('tbody > tr').map(&:elements).each { |r| expect(r.first.name).to eql('th') } end + + specify "all header cells in the tbody have a scope but no data cells do" do + attributes_per_element_type = %w(th td).each.with_object({}) do |element, h| + h[element] = html.css(element).map(&:attributes).map(&:keys) + end + + expect(attributes_per_element_type["th"]).to all(eql(%w(class scope))) + expect(attributes_per_element_type["td"]).to all(eql(%w(class))) + end end end @@ -220,7 +242,7 @@ body.row do |row| row.cell(text: "row-#{i}-col-1") row.cell(text: "row-#{i}-col-2") - row.cell(text: "row-#{i}-col-3") + row.cell(text: "row-#{i}-col-3", scope: "bananas") end end end @@ -235,7 +257,7 @@ expect(rendered_content).to have_tag('table') do with_tag('thead') do with_tag('tr', with: { class: "govuk-table__row" }, count: 1) do - with_tag('th', with: { class: "govuk-table__header" }, count: 3) + with_tag('th', with: { class: "govuk-table__header", scope: "col" }, count: 3) end end end @@ -252,6 +274,10 @@ end end end + + specify "scopes can be set to arbitrary values" do + expect(rendered_content).to have_tag("td", with: { scope: "bananas" }, count: 3) + end end context 'when some data is numeric' do @@ -402,10 +428,47 @@ RSpec.describe(GovukComponent::TableComponent::CellComponent, type: :component) do let(:component_css_class) { 'govuk-table__cell' } - let(:kwargs) { {} } + let(:kwargs) { { scope: nil } } it_behaves_like 'a component that accepts custom classes' it_behaves_like 'a component that accepts custom HTML attributes' + + describe "controlling scopes" do + subject! do + render_inline(GovukComponent::TableComponent::RowComponent.new(parent: 'thead')) do |row| + row.cell( + text: "ABC", + scope: false, + header: true, + html_attributes: { class: "scope_is_false" }, + ) + row.cell( + text: "DEF", + scope: true, + header: true, + html_attributes: { class: "scope_is_true" }, + ) + row.cell( + text: "GHI", + scope: "custom", + header: false, + html_attributes: { class: "scope_on_td" }, + ) + end + end + + it "suppresses the scope attribute when scope: false" do + expect(html.at_css('th.scope_is_false').attributes.keys).to match_array(%w(class)) + end + + it "doesn't suppress the scope attribute when scope: true" do + expect(html.at_css('th.scope_is_true').attributes.keys).to match_array(%w(class scope)) + end + + it "sets the custom scope when scope: 'custom'" do + expect(rendered_content).to have_tag('td', with: { class: 'scope_on_td', scope: 'custom' }) + end + end end RSpec.describe(GovukComponent::TableComponent::CaptionComponent, type: :component) do