-
Notifications
You must be signed in to change notification settings - Fork 628
Implement keyboard and screen reader accessibility for LED matrix field #10390
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -45,6 +45,7 @@ export class FieldMatrix extends Blockly.Field implements FieldCustom { | |
| private elt: SVGSVGElement; | ||
|
|
||
| private currentDragState_: boolean; | ||
| private selected: number[] | undefined = undefined; | ||
|
|
||
| constructor(text: string, params: any, validator?: Blockly.FieldValidator) { | ||
| super(text, validator); | ||
|
|
@@ -80,17 +81,121 @@ export class FieldMatrix extends Blockly.Field implements FieldCustom { | |
| this.scale = 0.9; | ||
| } | ||
|
|
||
| private keyHandler(e: KeyboardEvent) { | ||
| if (!this.selected) { | ||
| return | ||
| } | ||
| const [x, y] = this.selected; | ||
| const ctrlCmd = pxt.BrowserUtils.isMac() ? e.metaKey : e.ctrlKey; | ||
| switch(e.code) { | ||
| case "KeyW": | ||
| case "ArrowUp": { | ||
| if (y !== 0) { | ||
| this.selected = [x, y - 1] | ||
| } | ||
| break; | ||
| } | ||
| case "KeyS": | ||
| case "ArrowDown": { | ||
| if (y !== this.cells[0].length - 1) { | ||
| this.selected = [x, y + 1] | ||
| } | ||
| break; | ||
| } | ||
| case "KeyA": | ||
| case "ArrowLeft": { | ||
| if (x !== 0) { | ||
| this.selected = [x - 1, y] | ||
| } else if (y !== 0){ | ||
| this.selected = [this.matrixWidth - 1, y - 1] | ||
| } | ||
| break; | ||
| } | ||
| case "KeyD": | ||
| case "ArrowRight": { | ||
| if (x !== this.cells.length - 1) { | ||
| this.selected = [x + 1, y] | ||
| } else if (y !== this.matrixHeight - 1) { | ||
| this.selected = [0, y + 1] | ||
| } | ||
| break; | ||
| } | ||
| case "Home": { | ||
| if (ctrlCmd) { | ||
| this.selected = [0, 0] | ||
| } else { | ||
| this.selected = [0, y] | ||
| } | ||
| break; | ||
| } | ||
| case "End": { | ||
| if (ctrlCmd) { | ||
| this.selected = [this.matrixWidth - 1, this.matrixHeight - 1] | ||
| } else { | ||
| this.selected = [this.matrixWidth - 1, y] | ||
| } | ||
| break; | ||
| } | ||
| case "Enter": | ||
| case "Space": { | ||
| this.toggleRect(x, y, !this.cellState[x][y]); | ||
| break; | ||
| } | ||
| case "Escape": { | ||
| (Blockly.getMainWorkspace() as Blockly.WorkspaceSvg).markFocused(); | ||
| return; | ||
| } | ||
| default: { | ||
| return | ||
| } | ||
| } | ||
| const [newX, newY] = this.selected; | ||
| this.setFocusIndicator(this.cells[newX][newY], this.cellState[newX][newY]); | ||
| this.elt.setAttribute('aria-activedescendant', `${this.sourceBlock_.id}:${newX}${newY}`); | ||
| e.preventDefault(); | ||
| e.stopPropagation(); | ||
| } | ||
|
|
||
| private clearSelection() { | ||
| if (this.selected) { | ||
| this.setFocusIndicator(); | ||
| this.selected = undefined; | ||
| } | ||
| this.elt.removeAttribute('aria-activedescendant'); | ||
| } | ||
|
|
||
| private removeKeyboardFocusHandlers() { | ||
| this.elt.removeEventListener("keydown", this.keyHandler) | ||
| this.elt.removeEventListener("blur", this.blurHandler) | ||
| } | ||
|
|
||
| private blurHandler() { | ||
| this.removeKeyboardFocusHandlers(); | ||
| this.clearSelection(); | ||
| } | ||
|
|
||
| private setFocusIndicator(cell?: SVGRectElement, ledOn?: boolean) { | ||
| this.cells.forEach(cell => cell.forEach(cell => cell.nextElementSibling.firstElementChild.classList.remove("selectedLedOn", "selectedLedOff"))); | ||
| if (cell) { | ||
| const className = ledOn ? "selectedLedOn" : "selectedLedOff" | ||
| cell.nextElementSibling.firstElementChild.classList.add(className); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Show the inline free-text editor on top of the text. | ||
| * @private | ||
| */ | ||
| showEditor_() { | ||
| // Intentionally left empty | ||
| this.selected = [0, 0]; | ||
| this.setFocusIndicator(this.cells[0][0], this.cellState[0][0]) | ||
| this.elt.setAttribute('aria-activedescendant', this.sourceBlock_.id + ":00"); | ||
| this.elt.focus(); | ||
| } | ||
|
|
||
| private initMatrix() { | ||
| if (!this.sourceBlock_.isInsertionMarker()) { | ||
| this.elt = pxsim.svg.parseString(`<svg xmlns="http://www.w3.org/2000/svg" id="field-matrix" />`); | ||
| 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")}" />`); | ||
|
|
||
| // Initialize the matrix that holds the state | ||
| for (let i = 0; i < this.matrixWidth; i++) { | ||
|
|
@@ -104,9 +209,10 @@ export class FieldMatrix extends Blockly.Field implements FieldCustom { | |
| this.restoreStateFromString(); | ||
|
|
||
| // Create the cells of the matrix that is displayed | ||
| for (let i = 0; i < this.matrixWidth; i++) { | ||
| for (let j = 0; j < this.matrixHeight; j++) { | ||
| this.createCell(i, j); | ||
| for (let y = 0; y < this.matrixHeight; y++) { | ||
| const row = this.createRow() | ||
| for (let x = 0; x < this.matrixWidth; x++) { | ||
| this.createCell(x, y, row); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -132,6 +238,8 @@ export class FieldMatrix extends Blockly.Field implements FieldCustom { | |
| } | ||
|
|
||
| this.fieldGroup_.replaceChild(this.elt, this.fieldGroup_.firstChild); | ||
| this.elt.addEventListener("keydown", this.keyHandler.bind(this)); | ||
|
||
| this.elt.addEventListener("blur", this.blurHandler.bind(this)); | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -179,20 +287,42 @@ export class FieldMatrix extends Blockly.Field implements FieldCustom { | |
| super.updateEditable(); | ||
| } | ||
|
|
||
| private createCell(x: number, y: number) { | ||
| private createRow() { | ||
| return pxsim.svg.child(this.elt, "g", { 'role': 'row' }); | ||
| } | ||
|
|
||
| private createCell(x: number, y: number, row: SVGElement) { | ||
| const tx = this.scale * x * (FieldMatrix.CELL_WIDTH + FieldMatrix.CELL_HORIZONTAL_MARGIN) + FieldMatrix.CELL_HORIZONTAL_MARGIN + this.getYAxisWidth(); | ||
| const ty = this.scale * y * (FieldMatrix.CELL_WIDTH + FieldMatrix.CELL_VERTICAL_MARGIN) + FieldMatrix.CELL_VERTICAL_MARGIN; | ||
|
|
||
| const cellG = pxsim.svg.child(this.elt, "g", { transform: `translate(${tx} ${ty})` }) as SVGGElement; | ||
| const cellG = pxsim.svg.child(row, "g", { transform: `translate(${tx} ${ty})`, 'role': 'gridcell' }); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. does this need some sort of disabled aria indicator if the workspace is readonly? you can test a readonly workspace by turning on the debugger
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ideally, the We thought it better not to include In practice, we think this does no harm, as the field editor isn't focusable in readonly mode, including in the debugger. |
||
| const cellRect = pxsim.svg.child(cellG, "rect", { | ||
| 'id': `${this.sourceBlock_.id}:${x}${y}`, // For aria-activedescendant | ||
| 'class': `blocklyLed${this.cellState[x][y] ? 'On' : 'Off'}`, | ||
| 'aria-label': lf("LED"), | ||
| 'role': 'switch', | ||
| 'aria-checked': this.cellState[x][y].toString(), | ||
| width: this.scale * FieldMatrix.CELL_WIDTH, height: this.scale * FieldMatrix.CELL_WIDTH, | ||
| fill: this.getColor(x, y), | ||
| 'data-x': x, | ||
| 'data-y': y, | ||
| rx: Math.max(2, this.scale * FieldMatrix.CELL_CORNER_RADIUS) }) as SVGRectElement; | ||
| this.cells[x][y] = cellRect; | ||
|
|
||
| // Borders and box-shadow do not work in this context and outlines do not follow border-radius. | ||
| // Stroke is harder to manage given the difference in stroke for an LED when it is on vs off. | ||
| // This foreignObject/div is used to create a focus indicator for the LED when selected via keyboard navigation. | ||
| const foreignObject = pxsim.svg.child(cellG, "foreignObject", { | ||
| transform: 'translate(-4, -4)', | ||
| width: this.scale * FieldMatrix.CELL_WIDTH + 8, | ||
| height: this.scale * FieldMatrix.CELL_WIDTH + 8, | ||
| }); | ||
| foreignObject.style.pointerEvents = "none"; | ||
| const div = document.createElement("div"); | ||
| div.classList.add("blocklyLedFocusIndicator"); | ||
| div.style.borderRadius = `${Math.max(2, this.scale * FieldMatrix.CELL_CORNER_RADIUS)}px`; | ||
| foreignObject.append(div); | ||
|
|
||
| if ((this.sourceBlock_.workspace as any).isFlyout) return; | ||
|
|
||
| pxsim.pointerEvents.down.forEach(evid => cellRect.addEventListener(evid, (ev: MouseEvent) => { | ||
|
|
@@ -217,11 +347,14 @@ export class FieldMatrix extends Blockly.Field implements FieldCustom { | |
|
|
||
| ev.stopPropagation(); | ||
| ev.preventDefault(); | ||
| // Clear event listeners and selection used for keyboard navigation. | ||
| this.removeKeyboardFocusHandlers(); | ||
| this.clearSelection(); | ||
| }, false)); | ||
| } | ||
|
|
||
| private toggleRect = (x: number, y: number) => { | ||
| this.cellState[x][y] = this.currentDragState_; | ||
| private toggleRect = (x: number, y: number, value?: boolean) => { | ||
| this.cellState[x][y] = value ?? this.currentDragState_; | ||
| this.updateValue(); | ||
| } | ||
|
|
||
|
|
@@ -262,6 +395,7 @@ export class FieldMatrix extends Blockly.Field implements FieldCustom { | |
| cellRect.setAttribute("fill", this.getColor(x, y)); | ||
| cellRect.setAttribute("fill-opacity", this.getOpacity(x, y)); | ||
| cellRect.setAttribute('class', `blocklyLed${this.cellState[x][y] ? 'On' : 'Off'}`); | ||
| cellRect.setAttribute("aria-checked", this.cellState[x][y].toString()); | ||
| } | ||
|
|
||
| setValue(newValue: string | number, restoreState = true) { | ||
|
|
@@ -287,10 +421,9 @@ export class FieldMatrix extends Blockly.Field implements FieldCustom { | |
| this.initMatrix(); | ||
| } | ||
|
|
||
|
|
||
| // The height and width must be set by the render function | ||
| 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() | ||
| this.size_.width = this.scale * Number(this.matrixWidth) * (FieldMatrix.CELL_WIDTH + FieldMatrix.CELL_HORIZONTAL_MARGIN) + this.getYAxisWidth(); | ||
| this.size_.width = this.scale * Number(this.matrixWidth) * (FieldMatrix.CELL_WIDTH + FieldMatrix.CELL_HORIZONTAL_MARGIN) + FieldMatrix.CELL_HORIZONTAL_MARGIN + this.getYAxisWidth(); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
|
|
||
| // The return value of this function is inserted in the code | ||
|
|
@@ -365,4 +498,32 @@ function removeQuotes(str: string) { | |
| return str.substr(1, str.length - 2).trim(); | ||
| } | ||
| return str; | ||
| } | ||
| } | ||
|
|
||
| Blockly.Css.register(` | ||
| .blocklyMatrix:focus-visible { | ||
| outline: none; | ||
| } | ||
|
|
||
| .blocklyMatrix .blocklyLedFocusIndicator { | ||
| border: 4px solid transparent; | ||
| height: 100%; | ||
| } | ||
|
|
||
| .blocklyMatrix .blocklyLedFocusIndicator.selectedLedOn, | ||
| .blocklyMatrix .blocklyLedFocusIndicator.selectedLedOff { | ||
| border-color: white; | ||
| transform: translateZ(0); | ||
| } | ||
|
|
||
| .blocklyMatrix .blocklyLedFocusIndicator.selectedLedOn:after { | ||
| content: ""; | ||
| position: absolute; | ||
| top: -2px; | ||
| left: -2px; | ||
| right: -2px; | ||
| bottom: -2px; | ||
| border: 2px solid black; | ||
| border-radius: inherit; | ||
| } | ||
| `) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,4 +4,8 @@ Blockly.Css.register(` | |
| .blocklyDropdownMenu .blocklyMenuItemCheckbox.goog-menuitem-checkbox { | ||
| filter: contrast(0) brightness(100); | ||
| } | ||
|
|
||
| .blocklyVerticalMarker { | ||
| fill: none; | ||
| } | ||
|
Comment on lines
+8
to
+10
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| `) | ||



There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should this be
this.sourceBlock_.workspace?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, it should! Changed here 09c91ce.