Skip to content

Commit

Permalink
Table accessability (#131)
Browse files Browse the repository at this point in the history
* Added some ARIA roles to the table in range_selector.njk

* WIP: Remove tab handler

* WIP: Dynamically move the tabindex around to preserve the focus when we tab out and back

* Fix CellRef right/below logic so cursor movements work around merged cells

* Fix #73 by tracking the *actual* end cell around when we shift+arrow key, rather than the *effective* end cell

We were getting stuck in loops where the actual end cell was a cell that, due to
merging, was expanding the selection further - and then trying to shift+arrow
in the opposite direction to that extension put the end cell back in the
original "actual" end cell, which then expanded out to put the selection
exactly back where it was.

* Tabbing in and out of the table now works and preserves the focus...

...but tabbing out destroys the selection, which means our use case of
setting up a selection then tabbing to a submit button doesn't work.

* Stopped tabbing out of a table from deleting the selection

* Remove debug logging, fix indentation

* Apply aria-selected attributes to grid cells, and aria-multiselectable to the table

* Fix crtl+a (select all)

* Comments and TODOs

* Minimum width on table cells

* WCAG 2.2 focused element has a solid border

* Max column width, text wrapping

---------

Co-authored-by: Alaric Snell-Pym <[email protected]>
  • Loading branch information
alaric-rd and alaricsp authored Dec 12, 2024
1 parent 2b39fe4 commit 04b6c80
Show file tree
Hide file tree
Showing 4 changed files with 118 additions and 28 deletions.
Binary file modified fixtures/realistic.ods
Binary file not shown.
8 changes: 7 additions & 1 deletion lib/importer/assets/css/selectable_table.css
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,15 @@ table.selectable td {
border-right-width: 1px;
white-space: pre-wrap;
font-variant-numeric: tabular-nums;
min-width: 8em;
max-width: 15em;
}

table.selectable th *,
table.selectable td * {
box-sizing: border-box;
font-family: inherit;
cursor: inherit
cursor: inherit;
}

table.selectable th a,
Expand Down Expand Up @@ -237,6 +239,10 @@ table.selectable:not(.editable) tbody:empty::before {
box-sizing: border-box
}

table.selectable td.selected.focus {
border: 2px solid blue;
}

table.selectable tbody td,
table.selectable tbody td.selected.focus {
background-color: white
Expand Down
122 changes: 100 additions & 22 deletions lib/importer/assets/js/selectable_table.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/*
Accessability TODOs:
- Implement all of the data grid keyboard commands in https://www.w3.org/WAI/ARIA/apg/patterns/grid/: Page Up, Page Down, Home, End, Ctrl+Home, Ctrl+End, Ctrl+Space, Shift+Space
*/

const get_platform = () => {
// userAgentData is not widely supported yet
if (typeof navigator.userAgentData !== 'undefined' && navigator.userAgentData != null) {
Expand Down Expand Up @@ -64,6 +71,48 @@ window.addEventListener("load", function() {
.join("|") ;

let EventState = class {

/*
A map of event states that you want to go full-screen to view because
it's wide, but believe me, it's impossible to read when I wrote it as a
tree:
| EventState | Event handlers active in this state | When we enter | When we leave |
|-----------------------+---------------------------------------------------------------------------------------------------------------+------------------------------------------------------+------------------------------------------------------|
| DocumentSelectMode | keydown -> function updateFromKeyEvent, function selectModeKeyboardShortcuts | function cellSelectMode | function cellEditMode |
| | keyup -> function updateFromKeyEvent | function tableFocusIn | function loseFocus |
| | click -> function loseFocus -> -DocumentSelectMode, +DocumentUnfocusedMode | function getFocus | |
| | cut -> function cutSelection | | |
| | copy -> function copySelection | | |
| | paste -> function pasteSelection | | |
|-----------------------+---------------------------------------------------------------------------------------------------------------+------------------------------------------------------+------------------------------------------------------|
| DocumentEditMode | keydown -> function editModeKeyboardShortcuts | function cellEditMode | function cellSelectMode |
|-----------------------+---------------------------------------------------------------------------------------------------------------+------------------------------------------------------+------------------------------------------------------|
| DocumentUnfocusedMode | mousedown -> getFocus -> +DocumentSelectMode, -DocumentUnfocusedMode | function loseFocus | function tableFocusIn |
| | | initialisation | function getFocus |
|-----------------------+---------------------------------------------------------------------------------------------------------------+------------------------------------------------------+------------------------------------------------------|
| TableSelectMode | selectstart -> function preventDefault | function cellSelectMode | function cellEditMode |
| | mousedown -> function startDrag | function tableFocusIn | |
| | | initialisation | |
|-----------------------+---------------------------------------------------------------------------------------------------------------+------------------------------------------------------+------------------------------------------------------|
| TableEditMode | | function cellEditMode | function cellSelectMode |
|-----------------------+---------------------------------------------------------------------------------------------------------------+------------------------------------------------------+------------------------------------------------------|
| TableUnfocusedMode | focusIn -> tableFocusIn -> -DocumentUnfocusedMode, -TableUnfocusedMode, +TableSelectMode, +DocumentSelectMode | function loseFocus | function tableFocusIn |
| | | | function getFocus |
|-----------------------+---------------------------------------------------------------------------------------------------------------+------------------------------------------------------+------------------------------------------------------|
| InputSelectMode | mousedown -> function preventInputFocus, function removeFocus | function cellSelectMode (input elements inside cell) | function cellEditMode (input elements inside cell) |
| | click -> function preventInputFocus | initialisation ("new row"/"new column" logic?!?) | |
| | focus -> function enterCell | initialisation (all input elements in table) | |
| | blur -> function leaveCell | | |
|-----------------------+---------------------------------------------------------------------------------------------------------------+------------------------------------------------------+------------------------------------------------------|
| InputEditMode | blur -> function leaveCell | function cellEditMode (input elements inside cell) | function cellSelectMode (input elements inside cell) |
|-----------------------+---------------------------------------------------------------------------------------------------------------+------------------------------------------------------+------------------------------------------------------|
| InputAliveState | input -> function autoResize, (function createNewRows/function createNewColumns?), function addRowClasses | initialisation ("new row"/"new column" logic?!?) | |
| | change -> function autoResize, (function createNewRows/function createNewColumns?), function addRowClasses | initialisation (all input elements in table) | |
|-----------------------+---------------------------------------------------------------------------------------------------------------+------------------------------------------------------+------------------------------------------------------|
*/

constructor(name) {
this.name = name;
this.events = [];
Expand Down Expand Up @@ -165,9 +214,9 @@ window.addEventListener("load", function() {
};

get left() { return CellRef.fromCoords(this.table, this.row + 0, this.col - 1); }
get right() { return CellRef.fromCoords(this.table, this.row + 0, this.col + 1); }
get right() { return CellRef.fromCoords(this.table, this.row + 0, this.col + this.colspan); }
get above() { return CellRef.fromCoords(this.table, this.row - 1, this.col + 0); }
get below() { return CellRef.fromCoords(this.table, this.row + 1, this.col + 0); }
get below() { return CellRef.fromCoords(this.table, this.row + this.rowspan, this.col + 0); }

equals(cell) {
return this.table == cell.table &&
Expand Down Expand Up @@ -408,7 +457,6 @@ window.addEventListener("load", function() {
// getRangeOfCells will do the CellRef.fromCoords lookup for every cell,
// any of which might require a scan through the entire <table>.


// Start by normalising min/max values
let minRow = Math.min(this.actualStartCell.row, this.actualEndCell.row);
let maxRow = Math.max(this.actualStartCell.row, this.actualEndCell.row);
Expand Down Expand Up @@ -605,6 +653,11 @@ window.addEventListener("load", function() {

var tables = document.querySelectorAll("table.selectable");
for (var table of tables) {
{
const selectables = table.querySelectorAll('td');
selectables[0].setAttribute("tabindex", 0); // First one is tabbable, to get the user into the table
}

var selection = NilSelection;

const changeSelection = function(startCell, endCell) {
Expand All @@ -619,13 +672,25 @@ window.addEventListener("load", function() {
for (var cell of oldCells) {
for (var className of CellSelectedClasses) {
cell.node.classList.remove(className);
cell.node.setAttribute("aria-selected", "false");
}
}

// Should just be one, but let's be sure
var oldSelectables = table.querySelectorAll("[tabindex=\"0\"]")
for (var cell of oldSelectables) {
cell.setAttribute("tabindex", -1);
}

selection = newSelection;
if (selection == NilSelection) { return; }
selection.focus.node.focus();
selection.focus.node.setAttribute("tabindex", 0);
selection.focus.node.classList.add(CellSelectedFocusClassName);
for (var cell of selection.cells) { cell.node.classList.add(CellSelectedClassName); }
for (var cell of selection.cells) {
cell.node.classList.add(CellSelectedClassName);
cell.node.setAttribute("aria-selected", "truee");
}
for (var cell of selection.bottomCells) { cell.node.classList.add(CellSelectedBottomClassName); }
for (var cell of selection.topCells) { cell.node.classList.add(CellSelectedTopClassName); }
for (var cell of selection.leftCells) { cell.node.classList.add(CellSelectedLeftClassName); }
Expand Down Expand Up @@ -783,6 +848,7 @@ window.addEventListener("load", function() {
}

var TableSelectMode = new EventState("select");
var TableUnfocusedMode = new EventState("unfocused");
var TableEditMode = new EventState("edit");
var InputSelectMode = new EventState("select");
var InputEditMode = new EventState("edit");
Expand Down Expand Up @@ -901,15 +967,16 @@ window.addEventListener("load", function() {
}

var selectAll = function() {
changeSelection(TableSelection.fromElement(table));
applySelection(TableSelection.fromElement(table));
}

// List of key codes that we think shouldn't trigger cell editing
const ControlKeyCodes = Object.keys(KeyGroups)
.filter(g => g != "Whitespace" && g != "IMEAndComposition")
.map(k => KeyGroups[k])
.reduce((acc, cur) => acc.concat(cur), [])
.filter(k => k != "Backspace");
.filter(k => k != "Backspace")
.concat("Tab");

var valueKeyPressed = function(keyEvent) {
return (!ControlKeyCodes.includes(keyEvent.key) && !keyEvent.ctrlKey && !keyEvent.metaKey);
Expand All @@ -925,14 +992,12 @@ window.addEventListener("load", function() {
else if (!keyEvent.shiftKey && keyEvent.key == "ArrowRight") { changeSelection(selection.focus.right); keyEvent.preventDefault(); }
else if (!keyEvent.shiftKey && keyEvent.key == "ArrowDown") { changeSelection(selection.focus.below); keyEvent.preventDefault(); }
else if (!keyEvent.shiftKey && keyEvent.key == "ArrowUp") { changeSelection(selection.focus.above); keyEvent.preventDefault(); }
else if (keyEvent.shiftKey && keyEvent.key == "ArrowLeft") { changeSelection(selection.focus, selection.endCell.left); keyEvent.preventDefault(); }
else if (keyEvent.shiftKey && keyEvent.key == "ArrowRight") { changeSelection(selection.focus, selection.endCell.right); keyEvent.preventDefault(); }
else if (keyEvent.shiftKey && keyEvent.key == "ArrowDown") { changeSelection(selection.focus, selection.endCell.below); keyEvent.preventDefault(); }
else if (keyEvent.shiftKey && keyEvent.key == "ArrowUp") { changeSelection(selection.focus, selection.endCell.above); keyEvent.preventDefault(); }
else if (keyEvent.shiftKey && keyEvent.key == "ArrowLeft") { changeSelection(selection.focus, selection.actualEndCell.left); keyEvent.preventDefault(); }
else if (keyEvent.shiftKey && keyEvent.key == "ArrowRight") { changeSelection(selection.focus, selection.actualEndCell.right); keyEvent.preventDefault(); }
else if (keyEvent.shiftKey && keyEvent.key == "ArrowDown") { changeSelection(selection.focus, selection.actualEndCell.below); keyEvent.preventDefault(); }
else if (keyEvent.shiftKey && keyEvent.key == "ArrowUp") { changeSelection(selection.focus, selection.actualEndCell.above); keyEvent.preventDefault(); }
else if (!keyEvent.shiftKey && keyEvent.key == "Enter") { applySelection(selection.focusCursor.nextFocusByColumn()); keyEvent.preventDefault(); }
else if (!keyEvent.shiftKey && keyEvent.key == "Tab") { applySelection(selection.focusCursor.nextFocusByRow()); keyEvent.preventDefault(); }
else if (keyEvent.shiftKey && keyEvent.key == "Enter") { applySelection(selection.focusCursor.prevFocusByColumn()); keyEvent.preventDefault(); }
else if (keyEvent.shiftKey && keyEvent.key == "Tab") { applySelection(selection.focusCursor.prevFocusByRow()); keyEvent.preventDefault(); }
else if (valueKeyPressed(keyEvent)) {
var input = selection.focus.node.querySelector(InputElementsSelector);
var event = new KeyboardEvent(keyEvent.type, keyEvent);
Expand All @@ -952,10 +1017,6 @@ window.addEventListener("load", function() {
cellSelectMode(selection.focus);
applySelection(selection.focusCursor.nextFocusByColumn());
}
if (keyEvent.key == "Tab") {
cellSelectMode(selection.focus);
applySelection(selection.focusCursor.nextFocusByRow());
}
if (keyEvent.key == "Escape") {
cellSelectMode(selection.focus);
}
Expand Down Expand Up @@ -986,14 +1047,14 @@ window.addEventListener("load", function() {
if (event.target.closest("table") !== table) {
DocumentSelectMode.leave(document);


// If we not set the table's data-persist-selection attribute to "true" then we will apply
// the Nil selection when the table loses focus.
if ( !table.dataset.persistSelection || table.dataset.persistSelection.toLowerCase() != "true") {
applySelection(NilSelection);
}
// If we not set the table's data-persist-selection attribute to "true" then we will apply
// the Nil selection when the table loses focus.
if ( !table.dataset.persistSelection || table.dataset.persistSelection.toLowerCase() != "true") {
applySelection(NilSelection);
}

DocumentUnfocusedMode.enter(document);
TableUnfocusedMode.enter(table);
}
}
DocumentSelectMode.addEvent("click", loseFocus);
Expand All @@ -1002,8 +1063,25 @@ window.addEventListener("load", function() {
elementsOutsideTable.snapshotItem(e).addEventListener("focus", loseFocus);
}

var tableFocusIn = function(event) {
var tableSelectables = table.querySelectorAll("[tabindex=\"0\"]")
if(tableSelectables[0]) {
// If we don't have a focus, set it to the externally-focussed cell
if(selection == NilSelection) {
selection = TableSelection.fromElement(tableSelectables[0]);
applySelection(selection);
}
}
DocumentUnfocusedMode.leave(document);
TableUnfocusedMode.leave(table);
TableSelectMode.enter(table);
DocumentSelectMode.enter(document);
}
TableUnfocusedMode.addEvent("focusin", tableFocusIn);

var getFocus = function(event) {
DocumentUnfocusedMode.leave(document);
TableUnfocusedMode.leave(table);
DocumentSelectMode.enter(document);
}
DocumentUnfocusedMode.addEvent("mousedown", getFocus);
Expand Down
16 changes: 11 additions & 5 deletions lib/importer/nunjucks/importer/macros/range_selector.njk
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,21 @@
</div>

<div class="rd-range-selector">
<table class="selectable govuk-body" data-persist-selection="true">
<table class="selectable govuk-body"
data-persist-selection="true"
role="grid" aria-multiselectable="true"
{% if caption %}
aria-labelledby="tablecaption"
{% endif %}
>
{% if caption %}
<caption class="govuk-table__caption govuk-table__caption--m">{{caption}}</caption>
<caption id="tablecaption" class="govuk-table__caption govuk-table__caption--m">{{caption}}</caption>
{% endif %}
<tbody>
<tbody role="rowgroup">
{% for row in rows %}
<tr>
<tr role="row">
{% for cell in row %}
<td
<td aria-selected="false"
{% if cell.colspan %}
colspan="{{ cell.colspan }}"
{% endif %}
Expand Down

0 comments on commit 04b6c80

Please sign in to comment.