@@ -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+ ` )
0 commit comments