Skip to content

Commit 433c540

Browse files
Add keyboard controls to imagedropdown and gridpicker field editors (#10571)
* Add keyboard controls to imagedropdown and gridpicker field editors * Address minor review feedback * Refactor duplicated code into abstract class Update extending classes accordingly * Ensure dispose grid is called, add null checks * Remove use of stray `bind` * Remove unused imports * Remove pointless row and tidy --------- Co-authored-by: Richard Knoll <riknoll@users.noreply.github.com>
1 parent 5ede095 commit 433c540

File tree

5 files changed

+390
-112
lines changed

5 files changed

+390
-112
lines changed
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import * as Blockly from "blockly";
2+
import { FieldDropdown } from "./field_dropdown";
3+
import { PointerCoords, UserInputAction } from "./field_utils";
4+
5+
6+
export abstract class FieldDropdownGrid extends FieldDropdown {
7+
public isFieldCustom_ = true;
8+
// Width in pixels
9+
protected width_: number;
10+
// Columns in grid
11+
protected columns_: number;
12+
// Number of rows to display (if there are extra rows, the grid will be scrollable)
13+
protected maxRows_: number;
14+
protected backgroundColour_: string;
15+
protected borderColour_: string;
16+
17+
// Properties for grid keyboard controls
18+
protected activeDescendantIndex: number | undefined;
19+
protected gridItems: HTMLDivElement[] = [];
20+
protected openingPointerCoords: PointerCoords | undefined;
21+
protected lastUserInputAction: UserInputAction | undefined;
22+
protected keyDownBinding: Blockly.browserEvents.Data | null = null;
23+
protected pointerMoveBinding: Blockly.browserEvents.Data | null = null;
24+
25+
/**
26+
* Callback for when a grid item is clicked inside the dropdown or widget div.
27+
* Should be bound to the FieldIconMenu.
28+
* @param {string | null} value the value to set for the field
29+
* @protected
30+
*/
31+
protected abstract buttonClickAndClose_(value: string | null): void;
32+
33+
/**
34+
* Callback for when a grid item is highlighted using keyboard navigation.
35+
* Use this method to classNames for grid items and scroll to the highlighted item if required.
36+
* @param {HTMLElement} gridItemContainer the HTMLElement containing the grid items
37+
* @protected
38+
*/
39+
protected abstract setFocusedItem_(gridItemContainer: HTMLElement): void;
40+
41+
private setFocusedItem(gridItemContainer: HTMLElement, e: KeyboardEvent) {
42+
this.lastUserInputAction = 'keymove';
43+
this.setFocusedItem_(gridItemContainer);
44+
gridItemContainer.setAttribute('aria-activedescendant', ":" + this.activeDescendantIndex);
45+
e.preventDefault();
46+
e.stopPropagation();
47+
}
48+
49+
/**
50+
* Set openingPointerCoords if the Event is a PointerEvent.
51+
* @param {Event} e the event that triggered showEditor_
52+
* @protected
53+
*/
54+
protected setOpeningPointerCoords(e: Event) {
55+
if (!e) {
56+
return;
57+
}
58+
const {pageX, pageY} = e as PointerEvent;
59+
if (pageX !== undefined && pageY !== undefined) {
60+
this.openingPointerCoords = {
61+
x: pageX,
62+
y: pageY
63+
}
64+
}
65+
}
66+
67+
protected addKeyDownHandler(gridItemContainer: HTMLElement) {
68+
this.keyDownBinding = Blockly.browserEvents.bind(gridItemContainer, 'keydown', this, (e: KeyboardEvent) => {
69+
if (this.activeDescendantIndex === undefined) {
70+
if (e.code === 'ArrowDown' || e.code === 'ArrowRight' || e.code === 'Home' ) {
71+
this.activeDescendantIndex = 0;
72+
return this.setFocusedItem(gridItemContainer, e);
73+
} else if (e.code === 'ArrowUp' || e.code === 'ArrowLeft' || e.code === 'End') {
74+
this.activeDescendantIndex = this.gridItems.length - 1;
75+
return this.setFocusedItem(gridItemContainer, e);
76+
}
77+
}
78+
79+
const ctrlCmd = pxt.BrowserUtils.isMac() ? e.metaKey : e.ctrlKey;
80+
switch(e.code) {
81+
case 'ArrowUp':
82+
if (this.activeDescendantIndex - this.columns_ >= 0) {
83+
this.activeDescendantIndex -= this.columns_;
84+
}
85+
break;
86+
case 'ArrowDown':
87+
if (this.activeDescendantIndex + this.columns_ < this.gridItems.length) {
88+
this.activeDescendantIndex += this.columns_;
89+
}
90+
break;
91+
case 'ArrowRight':
92+
if (this.activeDescendantIndex < this.gridItems.length - 1) {
93+
this.activeDescendantIndex++;
94+
}
95+
break;
96+
case 'ArrowLeft':
97+
if (this.activeDescendantIndex !== 0) {
98+
this.activeDescendantIndex--;
99+
}
100+
break;
101+
case "Home": {
102+
if (ctrlCmd) {
103+
this.activeDescendantIndex = 0;
104+
} else {
105+
while (this.activeDescendantIndex % this.columns_ !== 0) {
106+
this.activeDescendantIndex--;
107+
}
108+
}
109+
break;
110+
}
111+
case "End": {
112+
if (ctrlCmd) {
113+
this.activeDescendantIndex = this.gridItems.length - 1;
114+
} else {
115+
while (
116+
this.activeDescendantIndex % this.columns_ !== this.columns_ - 1 &&
117+
this.activeDescendantIndex < this.gridItems.length - 1
118+
) {
119+
this.activeDescendantIndex++;
120+
}
121+
}
122+
break;
123+
}
124+
case "Enter":
125+
case "Space": {
126+
this.buttonClickAndClose_(this.gridItems[this.activeDescendantIndex].getAttribute('data-value'));
127+
e.preventDefault();
128+
e.stopPropagation();
129+
return;
130+
}
131+
default: {
132+
return;
133+
}
134+
}
135+
this.setFocusedItem(gridItemContainer, e);
136+
});
137+
}
138+
139+
protected addPointerListener(parentDiv: HTMLElement) {
140+
this.pointerMoveBinding = Blockly.browserEvents.bind(parentDiv, 'pointermove', this, () => {
141+
this.lastUserInputAction = 'pointermove';
142+
});
143+
}
144+
145+
protected pointerMoveTriggeredByUser() {
146+
return this.openingPointerCoords && !this.lastUserInputAction || this.lastUserInputAction === 'pointermove';
147+
}
148+
149+
protected pointerOutTriggeredByUser() {
150+
return this.lastUserInputAction === 'pointermove';
151+
}
152+
153+
protected disposeGrid(): void {
154+
if (this.keyDownBinding) {
155+
Blockly.browserEvents.unbind(this.keyDownBinding);
156+
}
157+
if (this.pointerMoveBinding) {
158+
Blockly.browserEvents.unbind(this.pointerMoveBinding);
159+
}
160+
this.keyDownBinding = null;
161+
this.pointerMoveBinding = null;
162+
this.openingPointerCoords = undefined;
163+
this.lastUserInputAction = undefined;
164+
this.activeDescendantIndex = undefined;
165+
this.gridItems = [];
166+
}
167+
}

0 commit comments

Comments
 (0)