Skip to content

Commit 3baba2d

Browse files
Implement keyboard and screen reader accessibility for LED matrix field (#10390)
* Implement keyboard and screen reader accessibility for LED matrix field * Review feedback
1 parent 1747bd9 commit 3baba2d

File tree

2 files changed

+179
-12
lines changed

2 files changed

+179
-12
lines changed

pxtblocks/fields/field_ledmatrix.ts

Lines changed: 175 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export class FieldMatrix extends Blockly.Field implements FieldCustom {
4545
private elt: SVGSVGElement;
4646

4747
private currentDragState_: boolean;
48+
private selected: number[] | undefined = undefined;
4849

4950
constructor(text: string, params: any, validator?: Blockly.FieldValidator) {
5051
super(text, validator);
@@ -80,17 +81,121 @@ export class FieldMatrix extends Blockly.Field implements FieldCustom {
8081
this.scale = 0.9;
8182
}
8283

84+
private keyHandler(e: KeyboardEvent) {
85+
if (!this.selected) {
86+
return
87+
}
88+
const [x, y] = this.selected;
89+
const ctrlCmd = pxt.BrowserUtils.isMac() ? e.metaKey : e.ctrlKey;
90+
switch(e.code) {
91+
case "KeyW":
92+
case "ArrowUp": {
93+
if (y !== 0) {
94+
this.selected = [x, y - 1]
95+
}
96+
break;
97+
}
98+
case "KeyS":
99+
case "ArrowDown": {
100+
if (y !== this.cells[0].length - 1) {
101+
this.selected = [x, y + 1]
102+
}
103+
break;
104+
}
105+
case "KeyA":
106+
case "ArrowLeft": {
107+
if (x !== 0) {
108+
this.selected = [x - 1, y]
109+
} else if (y !== 0){
110+
this.selected = [this.matrixWidth - 1, y - 1]
111+
}
112+
break;
113+
}
114+
case "KeyD":
115+
case "ArrowRight": {
116+
if (x !== this.cells.length - 1) {
117+
this.selected = [x + 1, y]
118+
} else if (y !== this.matrixHeight - 1) {
119+
this.selected = [0, y + 1]
120+
}
121+
break;
122+
}
123+
case "Home": {
124+
if (ctrlCmd) {
125+
this.selected = [0, 0]
126+
} else {
127+
this.selected = [0, y]
128+
}
129+
break;
130+
}
131+
case "End": {
132+
if (ctrlCmd) {
133+
this.selected = [this.matrixWidth - 1, this.matrixHeight - 1]
134+
} else {
135+
this.selected = [this.matrixWidth - 1, y]
136+
}
137+
break;
138+
}
139+
case "Enter":
140+
case "Space": {
141+
this.toggleRect(x, y, !this.cellState[x][y]);
142+
break;
143+
}
144+
case "Escape": {
145+
(this.sourceBlock_.workspace as Blockly.WorkspaceSvg).markFocused();
146+
return;
147+
}
148+
default: {
149+
return
150+
}
151+
}
152+
const [newX, newY] = this.selected;
153+
this.setFocusIndicator(this.cells[newX][newY], this.cellState[newX][newY]);
154+
this.elt.setAttribute('aria-activedescendant', `${this.sourceBlock_.id}:${newX}${newY}`);
155+
e.preventDefault();
156+
e.stopPropagation();
157+
}
158+
159+
private clearSelection() {
160+
if (this.selected) {
161+
this.setFocusIndicator();
162+
this.selected = undefined;
163+
}
164+
this.elt.removeAttribute('aria-activedescendant');
165+
}
166+
167+
private removeKeyboardFocusHandlers() {
168+
this.elt.removeEventListener("keydown", this.keyHandler)
169+
this.elt.removeEventListener("blur", this.blurHandler)
170+
}
171+
172+
private blurHandler() {
173+
this.removeKeyboardFocusHandlers();
174+
this.clearSelection();
175+
}
176+
177+
private setFocusIndicator(cell?: SVGRectElement, ledOn?: boolean) {
178+
this.cells.forEach(cell => cell.forEach(cell => cell.nextElementSibling.firstElementChild.classList.remove("selectedLedOn", "selectedLedOff")));
179+
if (cell) {
180+
const className = ledOn ? "selectedLedOn" : "selectedLedOff"
181+
cell.nextElementSibling.firstElementChild.classList.add(className);
182+
}
183+
}
184+
83185
/**
84186
* Show the inline free-text editor on top of the text.
85187
* @private
86188
*/
87189
showEditor_() {
88-
// Intentionally left empty
190+
this.selected = [0, 0];
191+
this.setFocusIndicator(this.cells[0][0], this.cellState[0][0])
192+
this.elt.setAttribute('aria-activedescendant', this.sourceBlock_.id + ":00");
193+
this.elt.focus();
89194
}
90195

91196
private initMatrix() {
92197
if (!this.sourceBlock_.isInsertionMarker()) {
93-
this.elt = pxsim.svg.parseString(`<svg xmlns="http://www.w3.org/2000/svg" id="field-matrix" />`);
198+
this.elt = pxsim.svg.parseString(`<svg xmlns="http://www.w3.org/2000/svg" id="field-matrix" class="blocklyMatrix" tabindex="-1" role="grid" aria-label="${lf("LED grid")}" />`);
94199

95200
// Initialize the matrix that holds the state
96201
for (let i = 0; i < this.matrixWidth; i++) {
@@ -104,9 +209,10 @@ export class FieldMatrix extends Blockly.Field implements FieldCustom {
104209
this.restoreStateFromString();
105210

106211
// Create the cells of the matrix that is displayed
107-
for (let i = 0; i < this.matrixWidth; i++) {
108-
for (let j = 0; j < this.matrixHeight; j++) {
109-
this.createCell(i, j);
212+
for (let y = 0; y < this.matrixHeight; y++) {
213+
const row = this.createRow()
214+
for (let x = 0; x < this.matrixWidth; x++) {
215+
this.createCell(x, y, row);
110216
}
111217
}
112218

@@ -132,6 +238,10 @@ export class FieldMatrix extends Blockly.Field implements FieldCustom {
132238
}
133239

134240
this.fieldGroup_.replaceChild(this.elt, this.fieldGroup_.firstChild);
241+
if (!this.sourceBlock_.isInFlyout) {
242+
this.elt.addEventListener("keydown", this.keyHandler.bind(this));
243+
this.elt.addEventListener("blur", this.blurHandler.bind(this));
244+
}
135245
}
136246
}
137247

@@ -179,20 +289,42 @@ export class FieldMatrix extends Blockly.Field implements FieldCustom {
179289
super.updateEditable();
180290
}
181291

182-
private createCell(x: number, y: number) {
292+
private createRow() {
293+
return pxsim.svg.child(this.elt, "g", { 'role': 'row' });
294+
}
295+
296+
private createCell(x: number, y: number, row: SVGElement) {
183297
const tx = this.scale * x * (FieldMatrix.CELL_WIDTH + FieldMatrix.CELL_HORIZONTAL_MARGIN) + FieldMatrix.CELL_HORIZONTAL_MARGIN + this.getYAxisWidth();
184298
const ty = this.scale * y * (FieldMatrix.CELL_WIDTH + FieldMatrix.CELL_VERTICAL_MARGIN) + FieldMatrix.CELL_VERTICAL_MARGIN;
185299

186-
const cellG = pxsim.svg.child(this.elt, "g", { transform: `translate(${tx} ${ty})` }) as SVGGElement;
300+
const cellG = pxsim.svg.child(row, "g", { transform: `translate(${tx} ${ty})`, 'role': 'gridcell' });
187301
const cellRect = pxsim.svg.child(cellG, "rect", {
302+
'id': `${this.sourceBlock_.id}:${x}${y}`, // For aria-activedescendant
188303
'class': `blocklyLed${this.cellState[x][y] ? 'On' : 'Off'}`,
304+
'aria-label': lf("LED"),
305+
'role': 'switch',
306+
'aria-checked': this.cellState[x][y].toString(),
189307
width: this.scale * FieldMatrix.CELL_WIDTH, height: this.scale * FieldMatrix.CELL_WIDTH,
190308
fill: this.getColor(x, y),
191309
'data-x': x,
192310
'data-y': y,
193311
rx: Math.max(2, this.scale * FieldMatrix.CELL_CORNER_RADIUS) }) as SVGRectElement;
194312
this.cells[x][y] = cellRect;
195313

314+
// Borders and box-shadow do not work in this context and outlines do not follow border-radius.
315+
// Stroke is harder to manage given the difference in stroke for an LED when it is on vs off.
316+
// This foreignObject/div is used to create a focus indicator for the LED when selected via keyboard navigation.
317+
const foreignObject = pxsim.svg.child(cellG, "foreignObject", {
318+
transform: 'translate(-4, -4)',
319+
width: this.scale * FieldMatrix.CELL_WIDTH + 8,
320+
height: this.scale * FieldMatrix.CELL_WIDTH + 8,
321+
});
322+
foreignObject.style.pointerEvents = "none";
323+
const div = document.createElement("div");
324+
div.classList.add("blocklyLedFocusIndicator");
325+
div.style.borderRadius = `${Math.max(2, this.scale * FieldMatrix.CELL_CORNER_RADIUS)}px`;
326+
foreignObject.append(div);
327+
196328
if ((this.sourceBlock_.workspace as any).isFlyout) return;
197329

198330
pxsim.pointerEvents.down.forEach(evid => cellRect.addEventListener(evid, (ev: MouseEvent) => {
@@ -217,11 +349,14 @@ export class FieldMatrix extends Blockly.Field implements FieldCustom {
217349

218350
ev.stopPropagation();
219351
ev.preventDefault();
352+
// Clear event listeners and selection used for keyboard navigation.
353+
this.removeKeyboardFocusHandlers();
354+
this.clearSelection();
220355
}, false));
221356
}
222357

223-
private toggleRect = (x: number, y: number) => {
224-
this.cellState[x][y] = this.currentDragState_;
358+
private toggleRect = (x: number, y: number, value?: boolean) => {
359+
this.cellState[x][y] = value ?? this.currentDragState_;
225360
this.updateValue();
226361
}
227362

@@ -262,6 +397,7 @@ export class FieldMatrix extends Blockly.Field implements FieldCustom {
262397
cellRect.setAttribute("fill", this.getColor(x, y));
263398
cellRect.setAttribute("fill-opacity", this.getOpacity(x, y));
264399
cellRect.setAttribute('class', `blocklyLed${this.cellState[x][y] ? 'On' : 'Off'}`);
400+
cellRect.setAttribute("aria-checked", this.cellState[x][y].toString());
265401
}
266402

267403
setValue(newValue: string | number, restoreState = true) {
@@ -287,10 +423,9 @@ export class FieldMatrix extends Blockly.Field implements FieldCustom {
287423
this.initMatrix();
288424
}
289425

290-
291426
// The height and width must be set by the render function
292427
this.size_.height = this.scale * Number(this.matrixHeight) * (FieldMatrix.CELL_WIDTH + FieldMatrix.CELL_VERTICAL_MARGIN) + FieldMatrix.CELL_VERTICAL_MARGIN * 2 + FieldMatrix.BOTTOM_MARGIN + this.getXAxisHeight()
293-
this.size_.width = this.scale * Number(this.matrixWidth) * (FieldMatrix.CELL_WIDTH + FieldMatrix.CELL_HORIZONTAL_MARGIN) + this.getYAxisWidth();
428+
this.size_.width = this.scale * Number(this.matrixWidth) * (FieldMatrix.CELL_WIDTH + FieldMatrix.CELL_HORIZONTAL_MARGIN) + FieldMatrix.CELL_HORIZONTAL_MARGIN + this.getYAxisWidth();
294429
}
295430

296431
// The return value of this function is inserted in the code
@@ -365,4 +500,32 @@ function removeQuotes(str: string) {
365500
return str.substr(1, str.length - 2).trim();
366501
}
367502
return str;
368-
}
503+
}
504+
505+
Blockly.Css.register(`
506+
.blocklyMatrix:focus-visible {
507+
outline: none;
508+
}
509+
510+
.blocklyMatrix .blocklyLedFocusIndicator {
511+
border: 4px solid transparent;
512+
height: 100%;
513+
}
514+
515+
.blocklyMatrix .blocklyLedFocusIndicator.selectedLedOn,
516+
.blocklyMatrix .blocklyLedFocusIndicator.selectedLedOff {
517+
border-color: white;
518+
transform: translateZ(0);
519+
}
520+
521+
.blocklyMatrix .blocklyLedFocusIndicator.selectedLedOn:after {
522+
content: "";
523+
position: absolute;
524+
top: -2px;
525+
left: -2px;
526+
right: -2px;
527+
bottom: -2px;
528+
border: 2px solid black;
529+
border-radius: inherit;
530+
}
531+
`)

pxtblocks/plugins/renderer/css.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,8 @@ Blockly.Css.register(`
44
.blocklyDropdownMenu .blocklyMenuItemCheckbox.goog-menuitem-checkbox {
55
filter: contrast(0) brightness(100);
66
}
7+
8+
.blocklyVerticalMarker {
9+
fill: none;
10+
}
711
`)

0 commit comments

Comments
 (0)