diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index 325bdc298..5bd8106c0 100644 --- a/app/assets/javascripts/application.js +++ b/app/assets/javascripts/application.js @@ -2,3 +2,6 @@ //= require govuk_publishing_components/all_components //= require_tree ./modules //= require rails-ujs + +// Components from this application +//= require_tree ./components diff --git a/app/assets/javascripts/components/table.js b/app/assets/javascripts/components/table.js new file mode 100644 index 000000000..720eb3f2c --- /dev/null +++ b/app/assets/javascripts/components/table.js @@ -0,0 +1,52 @@ +window.GOVUK = window.GOVUK || {} +window.GOVUK.Modules = window.GOVUK.Modules || {}; + +(function (Modules) { + function Table ($module) { + this.$module = $module + this.searchInput = $module.querySelector('input[name="filter"]') + this.tableRows = $module.querySelectorAll('.js-govuk-table__row') + this.filter = $module.querySelector('.js-app-c-table__filter') + this.filterCount = this.filter.querySelector('.js-filter-count') + this.message = $module.querySelector('.js-app-c-table__message') + this.hiddenClass = 'govuk-!-display-none' + this.filterCountText = this.filterCount.getAttribute('data-count-text') + this.tableRowsContent = [] + + for (var i = 0; i < this.tableRows.length; i++) { + this.tableRowsContent.push(this.tableRows[i].textContent.toUpperCase()) + } + } + + Table.prototype.init = function () { + this.$module.updateRows = this.updateRows.bind(this) + this.filter.classList.remove(this.hiddenClass) + this.searchInput.addEventListener('input', this.$module.updateRows) + } + + // Reads value of input and filters content + Table.prototype.updateRows = function () { + var value = this.searchInput.value + var hiddenRows = 0 + var length = this.tableRows.length + + for (var i = 0; i < length; i++) { + if (this.tableRowsContent[i].includes(value.toUpperCase())) { + this.tableRows[i].classList.remove(this.hiddenClass) + } else { + this.tableRows[i].classList.add(this.hiddenClass) + hiddenRows++ + } + } + + this.filterCount.textContent = (length - hiddenRows) + ' ' + this.filterCountText + + if (length === hiddenRows) { + this.message.classList.remove(this.hiddenClass) + } else { + this.message.classList.add(this.hiddenClass) + } + } + + Modules.Table = Table +})(window.GOVUK.Modules) diff --git a/app/assets/stylesheets/components/_table.scss b/app/assets/stylesheets/components/_table.scss new file mode 100644 index 000000000..3390b5345 --- /dev/null +++ b/app/assets/stylesheets/components/_table.scss @@ -0,0 +1,206 @@ +@import "govuk_publishing_components/individual_component_support"; +@import "govuk/components/table/table"; + +$table-border-width: 1px; +$table-border-colour: govuk-colour("mid-grey", $legacy: "grey-2"); +$table-header-border-width: 2px; +$table-header-background-colour: govuk-colour("light-grey", $legacy: "grey-3"); +$vertical-row-bottom-border-width: 3px; +$sort-link-active-colour: govuk-colour("white"); +$sort-link-arrow-size: 14px; +$sort-link-arrow-size-small: 8px; +$sort-link-arrow-spacing: $sort-link-arrow-size / 2; +$table-row-hover-background-colour: rgba(43, 140, 196, .2); +$table-row-even-background-colour: govuk-colour("light-grey", $legacy: "grey-4"); + +/* stylelint-disable */ +.govuk-table__cell:empty, +.govuk-table__cell--empty { + color: $govuk-secondary-text-colour; +} + +.govuk-table--sortable { + outline: $table-border-width solid $table-border-colour; + outline-offset: 0; + + .govuk-table__header { + padding: govuk-spacing(2); + border-right: $table-header-border-width solid govuk-colour("white"); + border-bottom: $table-header-border-width solid govuk-colour("white"); + background: $table-header-background-colour; + font-weight: normal; + + &:last-child { + border-right: 0; + } + + .app-table__sort-link { + @include govuk-link-style-no-visited-state; + position: relative; + padding-right: $sort-link-arrow-size; + color: $govuk-link-colour; + text-decoration: none; + } + + .app-table__sort-link:focus { + @include govuk-focused-text; + } + + .app-table__sort-link:after { + content: ""; + position: absolute; + top: 5px; + right: 0; + @include govuk-shape-arrow($direction: up, $base: $sort-link-arrow-size-small, $display: block); + } + + .app-table__sort-link:before { + content: ""; + position: absolute; + top: 13px; + right: 0; + @include govuk-shape-arrow($direction: down, $base: $sort-link-arrow-size-small, $display: block); + } + } + + .govuk-table__header--active { + color: $sort-link-active-colour; + background: $govuk-link-colour; + + .app-table__sort-link { + padding-right: govuk-spacing(4); + + &:link, + &:visited, + &:hover, + &:active { + color: $sort-link-active-colour; + } + + &:focus { + color: $govuk-focus-text-colour; + } + } + + .app-table__sort-link--ascending:before, + .app-table__sort-link--descending:before { + content: none; + } + + .app-table__sort-link--ascending:after { + content: ""; + position: absolute; + top: $sort-link-arrow-spacing; + right: 0; + margin-left: govuk-spacing(1); + + @include govuk-shape-arrow($direction: up, $base: $sort-link-arrow-size, $display: inline-block); + } + + .app-table__sort-link--descending:after { + content: ""; + position: absolute; + top: $sort-link-arrow-spacing; + right: 0; + margin-left: govuk-spacing(1); + + @include govuk-shape-arrow($direction: down, $base: $sort-link-arrow-size, $display: inline-block); + } + } + + .govuk-table__row { + &:hover { + background-color: $table-row-hover-background-colour; + } + + &:nth-child(even) { + background-color: $table-row-even-background-colour; + + &:hover { + background-color: $table-row-hover-background-colour; + } + } + } + + .govuk-table__cell { + padding: govuk-spacing(2); + border: 0; + } +} +/* stylelint-enable */ + +.app-c-table { + .govuk-table__cell { + word-break: break-word; + } +} + +.app-c-table--vertical { + .govuk-table__head { + clip: rect(0 0 0 0); + -webkit-clip-path: inset(50%); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + width: 1px; + } + + .govuk-table__body .govuk-table__row { + display: block; + } + + .govuk-table__cell { + display: flex; + justify-content: space-between; + min-width: 1px; + text-align: right; + } + + @include govuk-media-query($until: tablet) { + .govuk-table__cell { + padding-right: 0; + } + + .govuk-table__cell:last-child { + border-bottom: 0 + } + + .govuk-table__body .govuk-table__row { + border-bottom: $vertical-row-bottom-border-width solid $table-border-colour; + } + } + + .app-c-table__duplicate-heading { + font-weight: 700; + padding-right: 1em; + text-align: left; + word-break: initial; + } + + @include govuk-media-query($from: tablet) { + .govuk-table__head { + clip: auto; + -webkit-clip-path: none; + clip-path: none; + display: table-header-group; + height: auto; + overflow: auto; + position: relative; + width: auto; + } + + .govuk-table__body .govuk-table__row { + display: table-row; + } + + .govuk-table__cell { + display: table-cell; + text-align: left; + } + + .app-c-table__duplicate-heading { + display: none; + } + } +} diff --git a/app/helpers/components/table_helper.rb b/app/helpers/components/table_helper.rb new file mode 100644 index 000000000..982601420 --- /dev/null +++ b/app/helpers/components/table_helper.rb @@ -0,0 +1,75 @@ +module Components + module TableHelper + def self.helper(context, caption = nil, opt = {}) + builder = TableBuilder.new(context.tag) + + classes = %w[app-c-table govuk-table] + classes << "govuk-table--sortable" if opt[:sortable] + classes << opt[:classes] if opt[:classes] + + caption_classes = %w[govuk-table__caption] + caption_classes << opt[:caption_classes] if opt[:caption_classes] + + context.tag.table class: classes, id: opt[:table_id] do + context.concat context.tag.caption caption, class: caption_classes + yield(builder) + end + end + + class TableBuilder + include ActionView::Helpers::UrlHelper + include ActionView::Helpers::TagHelper + + attr_reader :tag + + def initialize(tag) + # rubocop:disable Rails/HelperInstanceVariable + @tag = tag + # rubocop:enable Rails/HelperInstanceVariable + end + + def head + tag.thead class: "govuk-table__head" do + tag.tr class: "govuk-table__row", role: "row" do + yield(self) + end + end + end + + def body + tag.tbody class: "govuk-table__body" do + yield(self) + end + end + + def row + tag.tr class: "govuk-table__row js-govuk-table__row", role: "row" do + yield(self) + end + end + + def header(str, opt = {}) + classes = %w[govuk-table__header] + classes << "govuk-table__header--#{opt[:format]}" if opt[:format] + classes << "govuk-table__header--active" if opt[:sort_direction] + link_classes = %w[app-table__sort-link] + link_classes << "app-table__sort-link--#{opt[:sort_direction]}" if opt[:sort_direction] + str = link_to str, opt[:href], class: link_classes, data: opt[:data_attributes] if opt[:href] + tag.th str, class: classes, scope: opt[:scope] || "col", role: "columnheader" + end + + def cell(str, opt = {}, &block) + classes = %w[govuk-table__cell] + classes << "govuk-table__cell--#{opt[:format]}" if opt[:format] + classes << "govuk-table__cell--empty" unless str || block_given? + str ||= "Not set" + + if block_given? + tag.td class: classes, role: "cell", &block + else + tag.td str, class: classes, role: "cell" + end + end + end + end +end diff --git a/app/helpers/component_helper.rb b/app/helpers/navigation_items_helper.rb similarity index 97% rename from app/helpers/component_helper.rb rename to app/helpers/navigation_items_helper.rb index a95e26bd8..90745ab91 100644 --- a/app/helpers/component_helper.rb +++ b/app/helpers/navigation_items_helper.rb @@ -1,4 +1,4 @@ -module ComponentHelper +module NavigationItemsHelper def navigation_items return [] unless current_user diff --git a/app/views/api_users/index.html.erb b/app/views/api_users/index.html.erb index 8f9ec32f5..32fc2851c 100644 --- a/app/views/api_users/index.html.erb +++ b/app/views/api_users/index.html.erb @@ -7,10 +7,11 @@ } %> -<%= render "govuk_publishing_components/components/table", { +<%= render "components/table", { caption: "API users", caption_classes: "govuk-visually-hidden", filterable: true, + vertical_on_small_screen: true, label: "Filter API users", head: [ { diff --git a/app/views/components/_table.html.erb b/app/views/components/_table.html.erb new file mode 100644 index 000000000..5ec626729 --- /dev/null +++ b/app/views/components/_table.html.erb @@ -0,0 +1,88 @@ +<% + caption ||= nil + head ||= [] + rows ||= [] + first_cell_is_header ||= false + caption_classes ||= nil + sortable ||= false + filterable ||= false + vertical_on_small_screen ||= false + label ||= t("components.table.filter_label") + + table_id = "table-id-#{SecureRandom.hex(4)}" + filter_count_id = "filter-count-id-#{SecureRandom.hex(4)}" + classes = "app-c-table--vertical" if vertical_on_small_screen +%> + +<% @table = capture do %> + <%= Components::TableHelper.helper(self, caption, { + sortable: sortable, + filterable: filterable, + caption_classes: caption_classes, + table_id: table_id, + classes: classes, + }) do |t| %> + + <% if head.any? %> + <%= t.head do %> + <% head.each_with_index do |item, cellindex| %> + <%= t.header item[:text], { + format: item[:format], + href: item[:href], + data_attributes: item[:data_attributes], + sort_direction: item[:sort_direction] + } %> + <% end %> + <% end %> + <% end %> + + <%= t.body do %> + <% rows.each_with_index do |row, rowindex| %> + <%= t.row do %> + <% row.each_with_index do |cell, cellindex| %> + <% if cellindex == 0 && first_cell_is_header %> + <%= t.header cell[:text], { + scope: "row", + format: cell[:format] + } %> + <% elsif vertical_on_small_screen && head.any? %> + <%= t.cell nil, { format: cell[:format] } do %> + + <%= cell[:text] %> + <% end %> + <% else %> + <%= t.cell cell[:text], { + format: cell[:format] + } %> + <% end %> + <% end %> + <% end %> + <% end %> + <% end %> + <% end %> +<% end %> + +<% if filterable %> +
+
+ <%= render "govuk_publishing_components/components/input", { + label: { + text: label + }, + name: "filter", + controls: table_id, + aria_described_by: filter_count_id, + } %> + +

">

+
+ + <%= @table %> + +

<%= t("components.table.filter_message") %>

+
+<% else %> + <%= @table %> +<% end %> diff --git a/app/views/components/docs/table.yml b/app/views/components/docs/table.yml new file mode 100644 index 000000000..19e87012a --- /dev/null +++ b/app/views/components/docs/table.yml @@ -0,0 +1,200 @@ +name: Table +description: A table component to make information easier to compare and scan for users. +accessibility_criteria: | + Accessible tables need HTML markup that indicates header cells and data cells and defines their relationship. Assistive technologies use this information to provide context to users. + Header cells must be marked up with ``, and data cells with `` to make tables accessible. + For more complex tables, explicit associations is needed using scope attributes. +shared_accessibility_criteria: + - link +type: helper +examples: + default: + data: + rows: + - + - text: January + - text: £85 + format: numeric + - text: £95 + format: numeric + - + - text: February + - text: £75 + format: numeric + - text: £55 + format: numeric + - + - text: March + - text: £165 + format: numeric + - text: £125 + format: numeric + with_head: + data: + head: + - text: Month you apply + - text: Rate for bicycles + format: numeric + - text: Rate for vehicles + format: numeric + rows: + - + - text: January + - text: £85 + format: numeric + - text: £95 + format: numeric + - + - text: February + - text: £75 + format: numeric + - text: £55 + format: numeric + - + - text: March + - text: £165 + format: numeric + - text: £125 + format: numeric + with_head_and_caption: + data: + caption: 'Caption 1: Months and rates' + caption_classes: govuk-heading-m + first_cell_is_header: true + head: + - text: Month you apply + - text: Rate for bicycles + format: numeric + - text: Rate for vehicles + format: numeric + rows: + - + - text: January + - text: £85 + format: numeric + - text: £95 + format: numeric + - + - text: February + - text: £75 + format: numeric + - text: £55 + format: numeric + - + - text: March + - text: £165 + format: numeric + - text: £125 + format: numeric + with_sortable_head: + description: | + This option allows links to be added to the table headers for sorting. Sorting must be handled server side, it is not done by the component. + + The example shown applies a tracking attribute specifically for use by Google Tag Manager in [Content Publisher](https://github.com/alphagov/content-publisher). + + Other data attributes can also be applied as required. Note that the component does not include built in tracking. If this is required consider using the [track click script](https://github.com/alphagov/govuk_publishing_components/blob/main/docs/analytics/track-click.md). + data: + sortable: true + head: + - text: Month you apply + - text: Rate for bicycles + format: numeric + sort_direction: descending + href: /?sort_direction=desc + data_attributes: + tracking: "UTM-123A" + - text: Rate for vehicles + format: numeric + href: /?sort_direction=desc + data_attributes: + tracking: "UTM-123B" + rows: + - + - text: January + - text: + format: numeric + - text: £95 + format: numeric + - + - text: February + - text: £75 + format: numeric + - text: £55 + format: numeric + - + - text: March + - text: £125 + format: numeric + - text: £60 + format: numeric + - + - text: April + - text: £135 + format: numeric + - text: £62 + format: numeric + - + - text: May + - text: £150 + format: numeric + - text: £80 + format: numeric + with_filter: + description: This option allows table rows to be filtered by user input. Since this filtering is implemented client-side the filter section is not displayed by default but displays only when JavaScript is enabled. The label for the input field can be set when the coponent is rendered via the `label` key. if this is not set a fallback value will display. + data: + filterable: true + label: Filter months + head: + - text: Month you apply + - text: Rate for bicycles + format: numeric + - text: Rate for vehicles + format: numeric + rows: + - + - text: January + - text: £85 + format: numeric + - text: £95 + format: numeric + - + - text: February + - text: £75 + format: numeric + - text: £55 + format: numeric + - + - text: March + - text: £165 + format: numeric + - text: £125 + format: numeric + vertical_layout_on_small_screens: + description: This option only kicks-in on tablet-sized screens and smaller. It switches the layout of the table into a vertical list of cards. In this new layout, the table's main header is hidden but a copy of the relevant heading text is embedded into each table cell. + data: + vertical_on_small_screen: true + head: + - text: Month you apply + - text: Rate for bicycles + format: numeric + - text: Rate for vehicles + format: numeric + rows: + - + - text: January + - text: £85 + format: numeric + - text: £95 + format: numeric + - + - text: February + - text: £75 + format: numeric + - text: £55 + format: numeric + - + - text: March + - text: £165 + format: numeric + - text: £125 + format: numeric diff --git a/test/controllers/api_users_controller_test.rb b/test/controllers/api_users_controller_test.rb index 533bc4585..df6819e71 100644 --- a/test/controllers/api_users_controller_test.rb +++ b/test/controllers/api_users_controller_test.rb @@ -30,13 +30,13 @@ class ApiUsersControllerTest < ActionController::TestCase should "list api users" do create(:api_user, email: "api_user@email.com") get :index - assert_select "td", "api_user@email.com" + assert_select "td", /api_user@email.com/ end should "not list web users" do create(:user, email: "web_user@email.com") get :index - assert_select "td", count: 0, text: "web_user@email.com" + assert_select "td", count: 0, text: /web_user@email.com/ end end