Treat Lists as separate tab stops
Declare the TabstopList
Stimulus controller, along with the
trapFocus()
and navigate()
actions.
When a list gains focus, find all focusable elements (like <button>
,
<input>
, <select>
, etc), and prevent focus by [setting their
[tabindex="-1"]
][mdn-tabindex-1]. Then, re-set the focused element’s
[tabindex="0"]
to treat it as the place to resume once focus is
regained.
To go along with that restriction, treat the up and down arrows as
keyboard shortcuts to move between focusable items in the list, and add
a System Test to exercise that behavior.
To make assertions about focus state, add a dependency on the
capybara_accessible_selectors
gem to
augment the standard set of Capybara
selectors and
filters .
Collapse Gemfile
Expand Gemfile
Gemfile
diff --git a/Gemfile b/Gemfile
index 72a0f96..8c4b1f0 100644
--- a/Gemfile
+++ b/Gemfile
@@ -46,6 +46,7 @@ end
group :test do
# Adds support for Capybara system testing and selenium driver
gem 'capybara', '>= 2.15'
+ gem 'capybara_accessible_selectors', github: 'citizensadvice/capybara_accessible_selectors', tag: 'v0.2.0'
gem 'selenium-webdriver'
# Easy installation and use of web drivers to run system tests with browsers
gem 'webdrivers'
Collapse Gemfile.lock
Expand Gemfile.lock
Gemfile.lock
diff --git a/Gemfile.lock b/Gemfile.lock
index 01e5291..8aa1ef7 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -1,3 +1,11 @@
+GIT
+ remote: https://github.com/citizensadvice/capybara_accessible_selectors.git
+ revision: 21b295b8f0650064738d0d2a115220be3066581a
+ tag: v0.2.0
+ specs:
+ capybara_accessible_selectors (0.2.0)
+ capybara (~> 3)
+
GIT
remote: https://github.com/rails/rails.git
revision: e3b8ba396bd2737ccc054178fda2e64ed20893e1
@@ -220,6 +228,7 @@ DEPENDENCIES
bootsnap (>= 1.4.2)
byebug
capybara (>= 2.15)
+ capybara_accessible_selectors!
jbuilder (~> 2.7)
listen (~> 3.2)
pg (>= 0.18, < 2.0)
Collapse app/javascript/controllers/tabstop_list_controller.js
Expand app/javascript/controllers/tabstop_list_controller.js
app/javascript/controllers/tabstop_list_controller.js
diff --git a/app/javascript/controllers/tabstop_list_controller.js b/app/javascript/controllers/tabstop_list_controller.js
new file mode 100644
index 0000000..776b11b
--- /dev/null
+++ b/app/javascript/controllers/tabstop_list_controller.js
@@ -0,0 +1,50 @@
+import { Controller } from "stimulus"
+
+export default class extends Controller {
+ static values = { horizontal: Boolean }
+
+ trapFocus({ target }) {
+ if (this.focusableElements.some(element => element.contains(target))) {
+ this.focusableElements.forEach(element => element.setAttribute("tabindex", -1))
+
+ target.setAttribute("tabindex", 0)
+ }
+ }
+
+ navigate(event) {
+ if (this.navigationKeys.includes(event.key)) {
+ event.preventDefault()
+
+ const { activeElement } = document
+ const index = this.focusableElements.findIndex(element => element == activeElement)
+ const direction = this.directions[event.key]
+ const element = this.focusableElements[index + direction]
+
+ element?.focus()
+ }
+ }
+
+ get navigationKeys() {
+ const vertical = [ "ArrowUp", "ArrowDown" ]
+ const horizontal = [ "ArrowRight", "ArrowLeft" ]
+
+ return this.horizontalValue ?
+ vertical + horizontal :
+ vertical
+ }
+
+ get directions() {
+ return {
+ ArrowUp: -1,
+ ArrowLeft: -1,
+ ArrowDown: +1,
+ ArrowRight: +1,
+ }
+ }
+
+ get focusableElements() {
+ const selector = `button, textarea, select, input:not([type="hidden"])`
+
+ return Array.from(this.element.querySelectorAll(selector))
+ }
+}
Collapse app/views/events/new.html.erb
Expand app/views/events/new.html.erb
app/views/events/new.html.erb
diff --git a/app/views/events/new.html.erb b/app/views/events/new.html.erb
index 744167e..211252a 100644
--- a/app/views/events/new.html.erb
+++ b/app/views/events/new.html.erb
@@ -4,7 +4,7 @@
} do |form| %>
<fieldset>
<table>
- <tbody>
+ <tbody data-controller="tabstop-list" data-action="focusin->tabstop-list#trapFocus keydown->tabstop-list#navigate">
<%= form.collection_check_boxes :task_ids, tasks.todo, :id, :name do |builder| %>
<%= render "row", class: "absolute", "data-controller": "tethered",
"data-tethered-selector-value": "#" + dom_id(builder.object) do %>
@@ -24,7 +24,7 @@
<% end %>
<% end %>
</tbody>
- <tfoot>
+ <tfoot data-controller="tabstop-list" data-tabstop-list-horizontal-value="true" data-action="focusin->tabstop-list#trapFocus keydown->tabstop-list#navigate">
<%= render "row", class: "absolute justify-center left-0 right-0" do %>
<% with_options scope: [:helpers, :submit, :event] do |action| %>
<td>
Collapse app/views/tasks/index.html.erb
Expand app/views/tasks/index.html.erb
app/views/tasks/index.html.erb
diff --git a/app/views/tasks/index.html.erb b/app/views/tasks/index.html.erb
index cf5debd..e132ab3 100644
--- a/app/views/tasks/index.html.erb
+++ b/app/views/tasks/index.html.erb
@@ -2,7 +2,7 @@
<section>
<h2 class="text-2xl"><%= translate(".todo") %></h2>
- <table>
+ <table data-controller="tabstop-list" data-action="focusin->tabstop-list#trapFocus keydown->tabstop-list#navigate">
<% tasks.todo.each do |task| %>
<%= render "row", id: dom_id(task) do %>
<td>
@@ -27,7 +27,7 @@
<section>
<h2 class="text-2xl"><%= translate(".delayed") %></h2>
- <table>
+ <table data-controller="tabstop-list" data-action="focusin->tabstop-list#trapFocus keydown->tabstop-list#navigate">
<% tasks.delayed.each do |task| %>
<%= render "row" do %>
<td><%= task.name %></td>
@@ -50,7 +50,7 @@
<section>
<h2 class="text-2xl"><%= translate(".completed") %></h2>
- <table>
+ <table data-controller="tabstop-list" data-action="focusin->tabstop-list#trapFocus keydown->tabstop-list#navigate">
<% tasks.completed.each do |task| %>
<%= render "row" do %>
<td>✓</td>
Collapse test/system/tasks_test.rb
Expand test/system/tasks_test.rb
test/system/tasks_test.rb
diff --git a/test/system/tasks_test.rb b/test/system/tasks_test.rb
index c447b4b..6a37831 100644
--- a/test/system/tasks_test.rb
+++ b/test/system/tasks_test.rb
@@ -83,6 +83,46 @@ class TasksTest < ApplicationSystemTestCase
end
end
+ test "treats the lists as separate tab stops" do
+ todo, completed = tasks(:pass_the_test, :read_the_book)
+
+ visit root_path
+ send_keys :tab
+ send_keys :down
+ send_keys :tab
+
+ assert_button completed.name, focused: true
+
+ send_keys [:shift, :tab]
+
+ assert_button todo.name, focused: true
+ end
+
+ test "navigates the Bulk Action buttons with arrow keys" do
+ do_the_homework = tasks(:do_the_homework)
+
+ visit root_path
+ click_on do_the_homework.name
+ send_keys :tab
+ send_keys :down
+
+ assert_button submit(:event, :delay), focused: true
+
+ send_keys :right
+
+ assert_button submit(:event, :close), focused: true
+ end
+
+ def send_keys(*arguments)
+ (active_element || find("body")).send_keys(*arguments)
+ end
+
+ def active_element
+ execute_script <<~JS
+ return document.activeElement
+ JS
+ end
+
def assert_no_section(i18n_key)
assert_no_text translate(i18n_key, scope: [:tasks, :index])
end