diff --git a/ThirdPartyNotice b/ThirdPartyNotice index abf210b40fdc..5fc87b571793 100644 --- a/ThirdPartyNotice +++ b/ThirdPartyNotice @@ -881,7 +881,7 @@ General Public License. 863. dashjs 4.4.0 (https://www.npmjs.com/package/dashjs/v/4.4.0) 864. @fortawesome/fontawesome-free 5.15.4 (https://www.npmjs.com/package/@fortawesome/fontawesome-free/v/5.15.4) 865. @blockly/plugin-workspace-search 4.0.10 (https://www.npmjs.com/package/@blockly/plugin-workspace-search/v/4.0.10) -866. @blockly/keyboard-navigation 0.1.18 (https://www.npmjs.com/package/@blockly/keyboard-navigation/v/0.1.18) +866. @blockly/keyboard-experiment 0.0.7 (https://www.npmjs.com/package/@blockly/keyboard-experiment/v/0.0.7) @@ -28749,7 +28749,7 @@ to represent the company, product, or service to which they refer.** END OF @blockly/plugin-workspace-search 4.0.10 NOTICES AND INFORMATION -%% @blockly/keyboard-navigation 0.1.18 NOTICES AND INFORMATION BEGIN HERE (https://www.npmjs.com/package/@blockly/keyboard-navigation/v/0.1.18) +%% @blockly/keyboard-experiment 0.0.7 NOTICES AND INFORMATION BEGIN HERE (https://www.npmjs.com/package/@blockly/keyboard-experiment/v/0.0.7) ========================================= Apache License @@ -28954,4 +28954,4 @@ END OF @blockly/plugin-workspace-search 4.0.10 NOTICES AND INFORMATION See the License for the specific language governing permissions and limitations under the License. ========================================= -END OF @blockly/keyboard-navigation 0.1.18 NOTICES AND INFORMATION \ No newline at end of file +END OF @blockly/keyboard-experiment 0.0.7 NOTICES AND INFORMATION diff --git a/docs/static/experiments/accessibleblocks.png b/docs/static/experiments/accessibleblocks.png deleted file mode 100644 index d53592f4ee91..000000000000 Binary files a/docs/static/experiments/accessibleblocks.png and /dev/null differ diff --git a/localtypings/blockly-keyboard-experiment.d.ts b/localtypings/blockly-keyboard-experiment.d.ts new file mode 100644 index 000000000000..55cdbdcd1184 --- /dev/null +++ b/localtypings/blockly-keyboard-experiment.d.ts @@ -0,0 +1,7 @@ +declare module "@blockly/keyboard-experiment" { + import { WorkspaceSvg } from "blockly"; + + class KeyboardNavigation { + constructor(workspace: WorkspaceSvg) + } +} \ No newline at end of file diff --git a/localtypings/navigationController.d.ts b/localtypings/navigationController.d.ts deleted file mode 100644 index bf91779abaf8..000000000000 --- a/localtypings/navigationController.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -type WorkspaceSvg = import("blockly").WorkspaceSvg; - -declare module '@blockly/keyboard-navigation' { - class NavigationController { - init(): void; - addWorkspace(workspace: WorkspaceSvg): void; - enable(workspace: WorkspaceSvg): void; - disable(workspace: WorkspaceSvg): void; - focusToolbox(workspace: WorkspaceSvg): void; - navigation: Navigation; - } - - class Navigation { - resetFlyout(workspace: WorkspaceSvg, shouldHide: boolean): void; - setState(workspace: WorkspaceSvg, state: BlocklyNavigationState): void; - focusFlyout(workspace: WorkspaceSvg): void; - } - - type BlocklyNavigationState = "workspace" | "toolbox" | "flyout"; -} \ No newline at end of file diff --git a/localtypings/pxtarget.d.ts b/localtypings/pxtarget.d.ts index cdbca2a79db1..4c19be1b30b4 100644 --- a/localtypings/pxtarget.d.ts +++ b/localtypings/pxtarget.d.ts @@ -415,7 +415,6 @@ declare namespace pxt { extendEditor?: boolean; // whether a target specific editor.js is loaded extendFieldEditors?: boolean; // wether a target specific fieldeditors.js is loaded highContrast?: boolean; // simulator has a high contrast mode - accessibleBlocks?: boolean; // enable keyboard navigation in blockly print?: boolean; //Print blocks and text feature greenScreen?: boolean; // display webcam stream in background instructions?: boolean; // display make instructions diff --git a/localtypings/pxteditor.d.ts b/localtypings/pxteditor.d.ts index 8cd36c8b3aa4..77f5f1a9393b 100644 --- a/localtypings/pxteditor.d.ts +++ b/localtypings/pxteditor.d.ts @@ -1037,7 +1037,6 @@ declare namespace pxt.editor { setHighContrast(on: boolean): void; toggleGreenScreen(): void; toggleAccessibleBlocks(): void; - setAccessibleBlocks(enabled: boolean): void; launchFullEditor(): void; resetWorkspace(): void; diff --git a/package.json b/package.json index 02b555b933c7..26abba513dd3 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ }, "dependencies": { "@blockly/field-colour": "5.0.12", - "@blockly/keyboard-navigation": "0.6.5", + "@blockly/keyboard-experiment": "0.0.7", "@blockly/plugin-workspace-search": "9.1.0", "@crowdin/crowdin-api-client": "^1.33.0", "@fortawesome/fontawesome-free": "^5.15.4", @@ -161,9 +161,6 @@ }, "@blockly/plugin-workspace-search": { "blockly": "^12.0.0-beta.4" - }, - "@blockly/keyboard-navigation": { - "blockly": "^12.0.0-beta.4" } }, "scripts": { diff --git a/pxtblocks/blockDragger.ts b/pxtblocks/blockDragger.ts index 1efa0e08d33c..e20d745668b8 100644 --- a/pxtblocks/blockDragger.ts +++ b/pxtblocks/blockDragger.ts @@ -9,8 +9,10 @@ export class BlockDragger extends Blockly.dragging.Dragger { || document.getElementsByClassName('blocklyFlyout')[0] as HTMLElement; const trashIcon = document.getElementById("blocklyTrashIcon"); if (blocklyTreeRoot && trashIcon) { + const rect = blocklyTreeRoot.getBoundingClientRect() const distance = calculateDistance(blocklyTreeRoot.getBoundingClientRect(), e.clientX); - if (distance < 200) { + const isMouseDrag = Blockly.Gesture.inProgress(); + if ((isMouseDrag && distance < 200) || (!isMouseDrag && isOverlappingRect(rect, e.clientX))) { const opacity = distance / 200; trashIcon.style.opacity = `${1 - opacity}`; trashIcon.style.display = 'block'; @@ -45,4 +47,8 @@ export class BlockDragger extends Blockly.dragging.Dragger { function calculateDistance(elemBounds: DOMRect, mouseX: number) { return Math.abs(mouseX - (elemBounds.left + (elemBounds.width / 2))); +} + +function isOverlappingRect(elemBounds: DOMRect, mouseX: number) { + return (mouseX - (elemBounds.left + (elemBounds.width))) < 0; } \ No newline at end of file diff --git a/pxtblocks/contextMenu/blockItems.ts b/pxtblocks/contextMenu/blockItems.ts index 487578a05e82..024daedb5c6e 100644 --- a/pxtblocks/contextMenu/blockItems.ts +++ b/pxtblocks/contextMenu/blockItems.ts @@ -24,8 +24,16 @@ export function registerBlockitems() { registerHelp(); // Fix the weights of the builtin options we do use - Blockly.ContextMenuRegistry.registry.getItem("blockDelete").weight = BlockContextWeight.DeleteBlock; - Blockly.ContextMenuRegistry.registry.getItem("blockComment").weight = BlockContextWeight.AddComment; + // Defensiveness due to action changes in the keyboard navigation plugin. + // Needs revisiting when actions are final. + const blockDelete = Blockly.ContextMenuRegistry.registry.getItem("blockDelete"); + if (blockDelete) { + blockDelete.weight = BlockContextWeight.DeleteBlock; + } + const blockComment = Blockly.ContextMenuRegistry.registry.getItem("blockComment"); + if (blockComment) { + blockComment.weight = BlockContextWeight.AddComment; + } } /** diff --git a/pxtblocks/copyPaste.ts b/pxtblocks/copyPaste.ts index 80bdcc180500..a929f15be577 100644 --- a/pxtblocks/copyPaste.ts +++ b/pxtblocks/copyPaste.ts @@ -8,7 +8,7 @@ let oldCut: Blockly.ShortcutRegistry.KeyboardShortcut; let oldPaste: Blockly.ShortcutRegistry.KeyboardShortcut; export function initCopyPaste() { - if (oldCopy) return; + if (oldCopy || !getCopyPasteHandlers()) return; const shortcuts = Blockly.ShortcutRegistry.registry.getRegistry() diff --git a/pxtblocks/fields/field_ledmatrix.ts b/pxtblocks/fields/field_ledmatrix.ts index e5e2268a51cf..b9ff5fdcb7a4 100644 --- a/pxtblocks/fields/field_ledmatrix.ts +++ b/pxtblocks/fields/field_ledmatrix.ts @@ -352,6 +352,8 @@ export class FieldMatrix extends Blockly.Field implements FieldCustom { // Clear event listeners and selection used for keyboard navigation. this.removeKeyboardFocusHandlers(); this.clearSelection(); + // This enables keyboard navigation in the Blockly workspace if not focused already. + (this.sourceBlock_.workspace as Blockly.WorkspaceSvg).markFocused(); }, false)); } diff --git a/pxtblocks/plugins/flyout/blockInflater.ts b/pxtblocks/plugins/flyout/blockInflater.ts index ab2a52e3c7bd..2656ff06dbf3 100644 --- a/pxtblocks/plugins/flyout/blockInflater.ts +++ b/pxtblocks/plugins/flyout/blockInflater.ts @@ -1,6 +1,6 @@ import * as Blockly from "blockly"; -const HIDDEN_CLASS_NAME = "pxtFlyoutHidden"; +export const HIDDEN_CLASS_NAME = "pxtFlyoutHidden"; export class MultiFlyoutRecyclableBlockInflater extends Blockly.BlockFlyoutInflater { protected keyToBlock: Map = new Map(); @@ -43,6 +43,7 @@ export class MultiFlyoutRecyclableBlockInflater extends Blockly.BlockFlyoutInfla this.blockToKey.set(block, key); block.removeClass(HIDDEN_CLASS_NAME); + block.setDisabledReason(false, HIDDEN_CLASS_NAME); return block; } @@ -72,6 +73,7 @@ export class MultiFlyoutRecyclableBlockInflater extends Blockly.BlockFlyoutInfla const xy = block.getRelativeToSurfaceXY(); block.moveBy(-xy.x, -xy.y); block.addClass(HIDDEN_CLASS_NAME); + block.setDisabledReason(true, HIDDEN_CLASS_NAME); const key = this.blockToKey.get(block); this.keyToBlock.set(key, block); this.removeListeners(block.id); diff --git a/pxtblocks/plugins/flyout/cachingFlyout.ts b/pxtblocks/plugins/flyout/cachingFlyout.ts index 27c7b34eb1e6..30a6c132cc96 100644 --- a/pxtblocks/plugins/flyout/cachingFlyout.ts +++ b/pxtblocks/plugins/flyout/cachingFlyout.ts @@ -9,4 +9,8 @@ export class CachingFlyout extends Blockly.VerticalFlyout { inflater.clearCache(); } } + + getFlyoutElement(): SVGElement { + return this.svgGroup_ + } } \ No newline at end of file diff --git a/pxtblocks/plugins/flyout/flyoutButton.ts b/pxtblocks/plugins/flyout/flyoutButton.ts index 8f40d8384d5c..b01375e3506d 100644 --- a/pxtblocks/plugins/flyout/flyoutButton.ts +++ b/pxtblocks/plugins/flyout/flyoutButton.ts @@ -110,4 +110,8 @@ export class FlyoutButton extends Blockly.FlyoutButton { svgLine.setAttribute('y2', (this.height + 10) + ""); } } + + isDisposed(): boolean { + return this.getSvgRoot().parentNode === null; + } } diff --git a/pxtblocks/plugins/renderer/connectionPreviewer.ts b/pxtblocks/plugins/renderer/connectionPreviewer.ts index eabeef006f22..6b48c1f944c9 100644 --- a/pxtblocks/plugins/renderer/connectionPreviewer.ts +++ b/pxtblocks/plugins/renderer/connectionPreviewer.ts @@ -43,10 +43,11 @@ export class ConnectionPreviewer extends Blockly.InsertionMarkerPreviewer { const atan = Math.atan2(dy, dx); const len = Math.sqrt(dx * dx + dy * dy); - // When the indicators are overlapping, we hide the line - if (len < radius * 2 + 1) { + const isMouseDrag = Blockly.Gesture.inProgress(); + // When the indicators are overlapping, or if the drag is keyboard driven, we hide the line + if (len < radius * 2 + 1 || !isMouseDrag) { Blockly.utils.dom.addClass(this.connectionLine, "hidden"); - } else { + } else if (isMouseDrag) { Blockly.utils.dom.removeClass(this.connectionLine, "hidden"); this.connectionLine.setAttribute("x1", String(offset.x + Math.cos(atan) * radius)); this.connectionLine.setAttribute("y1", String(offset.y + Math.sin(atan) * radius)); diff --git a/pxteditor/experiments.ts b/pxteditor/experiments.ts index 4c874452f7cd..88df0ed47ec4 100644 --- a/pxteditor/experiments.ts +++ b/pxteditor/experiments.ts @@ -161,12 +161,6 @@ export function all(): Experiment[] { name: lf("Open in New Connected Tab"), description: lf("Open connected editors in different browser tabs.") }, - { - id: "accessibleBlocks", - name: lf("Accessible Blocks"), - description: lf("Use the WASD keys to move and modify blocks."), - feedbackUrl: "https://github.com/microsoft/pxt/issues/6850" - }, { id: "errorList", name: lf("Error List"), diff --git a/pxtlib/auth.ts b/pxtlib/auth.ts index a4c05921610d..5e9987c01c29 100644 --- a/pxtlib/auth.ts +++ b/pxtlib/auth.ts @@ -62,6 +62,7 @@ namespace pxt.auth { export type UserPreferences = { language?: string; highContrast?: boolean; + accessibleBlocks?: boolean; colorThemeIds?: ColorThemeIdsState; reader?: string; skillmap?: UserSkillmapState; @@ -72,6 +73,7 @@ namespace pxt.auth { export const DEFAULT_USER_PREFERENCES: () => UserPreferences = () => ({ language: pxt.appTarget.appTheme.defaultLocale, highContrast: false, + accessibleBlocks: false, colorThemeIds: {}, // Will lookup pxt.appTarget.appTheme.defaultColorTheme for active target reader: "", skillmap: { mapProgress: {}, completedTags: {} }, diff --git a/theme/blockly-core.less b/theme/blockly-core.less index edad85d70f74..a2e758a189c2 100644 --- a/theme/blockly-core.less +++ b/theme/blockly-core.less @@ -283,6 +283,24 @@ text.blocklyCheckbox { } } +/* Keyboard navigation plugin styles */ + +.passiveBlockFocus.blocklyPath { + stroke-dasharray: 5 3; + stroke-width: 3; + stroke: #ffa200; +} + +.passiveNextIndicator { + stroke: #ffa200; + fill: #ffa200; + } + +.inputActiveFocus { + stroke-width: 3; + stroke: #ffa200; +} + /******************************* Scrollbars *******************************/ @@ -373,3 +391,45 @@ text.blocklyCheckbox { font-size: 17pt !important; } } + +/******************************* + Focus styles +*******************************/ + +// Avoid focus outlines when not using the keyboard navigation plugin. +div.blocklyTreeRoot > div > div[role="tree"]:focus-visible { + outline: none; +} + +.accessibleBlocks .blocklyWorkspace:focus,.blocklyWorkspaceFocusLayer:focus { + outline: none; +} + +.accessibleBlocks .blocklyWorkspaceFocusRingLayer { + position: absolute; + top: 0; + left: 0; + bottom: 0; + right: 0; + pointer-events: none; + z-index: 99; +} + +.accessibleBlocks .blocklyWorkspaceFocusRingLayer[data-focused="true"] { + outline: 3px solid black; + outline-offset: -3px; +} + +.accessibleBlocks div.blocklyTreeRoot > div > div[role="tree"]:focus-visible { + outline: 3px solid black; + outline-offset: -3px; +} + +.accessibleBlocks .blocklyFlyout:focus { + outline: none; +} + +.accessibleBlocks .blocklyFlyout:focus-visible { + outline: 3px solid black; + outline-offset: -3px; +} diff --git a/theme/toolbox.less b/theme/toolbox.less index e0523f24abe4..1dda14aeed7c 100644 --- a/theme/toolbox.less +++ b/theme/toolbox.less @@ -271,6 +271,10 @@ div.blocklyTreeIcon span { background-color: var(--pxt-target-background3-hover); } +.blocklyToolbox .blocklyTreeRoot [role="treeitem"]:focus-visible { + outline: none; +} + /******************************* Inverted Toolbox *******************************/ diff --git a/webapp/src/accessibility.tsx b/webapp/src/accessibility.tsx index c20220a1915f..afae763efcae 100644 --- a/webapp/src/accessibility.tsx +++ b/webapp/src/accessibility.tsx @@ -28,6 +28,11 @@ export class EditorAccessibilityMenu extends data.Component) { + this.props.parent.openBlocks(); } openJavaScript() { @@ -73,6 +78,7 @@ export class EditorAccessibilityMenu extends data.Component + {this.getData(auth.ACCESSIBLE_BLOCKS) ? : undefined} {targetTheme.python ? : undefined} {targetTheme.selectLanguage ? : undefined} diff --git a/webapp/src/accessibleblocks.tsx b/webapp/src/accessibleblocks.tsx deleted file mode 100644 index 36f299946501..000000000000 --- a/webapp/src/accessibleblocks.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import * as React from "react"; -import * as data from "./data"; -import * as sui from "./sui"; - -export interface AccessibleBlocksInfoState { - shown?: boolean; -} - -export class AccessibleBlocksInfo extends data.Component<{}, AccessibleBlocksInfoState> { - constructor(props: {}) { - super(props); - this.state = { shown: false } - this.handleOnClose = this.handleOnClose.bind(this); - } - - handleOnClose() { - this.setState({ shown: true }) - } - - render() { - const shown = this.state.shown; - if (shown) return
; - return -
-
-

{lf("Navigate")}

- {lf("Use the WASD keys to move between blocks. Hold shift to move on the workspace.")} -
-
-

{lf("Modify")}

- {lf("Press Enter to 'mark' a block, then X to disconnect, I to reconnect.")} -
-
-

{lf("Insert")}

- {lf("Use T to open the toolbox. Inside the toolbox, use WASD to navigate, and Enter to insert.")} -
-
- -
- } -} \ No newline at end of file diff --git a/webapp/src/app.tsx b/webapp/src/app.tsx index e3852fe3b49d..8634f631f173 100644 --- a/webapp/src/app.tsx +++ b/webapp/src/app.tsx @@ -38,7 +38,6 @@ import * as make from "./make"; import * as blocklyToolbox from "./blocksSnippets"; import * as monacoToolbox from "./monacoSnippets"; import * as greenscreen from "./greenscreen"; -import * as accessibleblocks from "./accessibleblocks"; import * as socketbridge from "./socketbridge"; import * as webusb from "./webusb"; import * as auth from "./auth"; @@ -687,6 +686,9 @@ export class ProjectView if (this.isBlocksActive()) { if (this.state.embedSimView) this.setState({ embedSimView: false }); + // This timeout prevents key events from being handled by Blockly's keyboard + // navigation plugin prematurely. + setTimeout(() => {this.editor.focusWorkspace()}, 0) return; } @@ -2807,7 +2809,7 @@ export class ProjectView this.stopSimulator(true); // don't keep simulator around this.showKeymap(false); // close keymap if open cmds.disconnectAsync(); // turn off any kind of logging - if (this.editor) this.editor.unloadFileAsync(); + if (this.editor) this.editor.unloadFileAsync(home); this.extensions.unload(); this.editorFile = undefined; @@ -4661,7 +4663,7 @@ export class ProjectView extensionsVisible: false }) - if (this.state.accessibleBlocks) { + if (this.getData(auth.ACCESSIBLE_BLOCKS)) { this.editor.focusToolbox(CategoryNameID.Extensions); } } @@ -5170,14 +5172,9 @@ export class ProjectView this.setState({ greenScreen: greenScreenOn }); } - toggleAccessibleBlocks() { - this.setAccessibleBlocks(!this.state.accessibleBlocks); - } - - setAccessibleBlocks(enabled: boolean) { - pxt.tickEvent("app.accessibleblocks", { on: enabled ? 1 : 0 }); - // this.blocksEditor.enableAccessibleBlocks(enabled); - this.setState({ accessibleBlocks: enabled }) + async toggleAccessibleBlocks() { + await core.toggleAccessibleBlocks() + this.reloadEditor(); } setBannerVisible(b: boolean) { @@ -5353,7 +5350,8 @@ export class ProjectView const inDebugMode = this.state.debugging; const inHome = this.state.home && !sandbox; const inEditor = !!this.state.header && !inHome; - const { lightbox, greenScreen, accessibleBlocks } = this.state; + const { lightbox, greenScreen } = this.state; + const accessibleBlocks = this.getData(auth.ACCESSIBLE_BLOCKS) const hideTutorialIteration = inTutorial && tutorialOptions.metadata?.hideIteration; const hideToolbox = inTutorial && tutorialOptions.metadata?.hideToolbox; // flyoutOnly has become a de facto css class for styling tutorials (especially minecraft HOC), so keep it if hideToolbox is true, even if flyoutOnly is false. @@ -5443,12 +5441,11 @@ export class ProjectView header={this.state.header} reloadHeaderAsync={async () => { await this.reloadHeaderAsync() - this.shouldFocusToolbox = !!this.state.accessibleBlocks; + this.shouldFocusToolbox = !!accessibleBlocks; }} /> } {greenScreen ? : undefined} - {accessibleBlocks && } {hideMenuBar || inHome ? undefined :
{inEditor ? : undefined} diff --git a/webapp/src/auth.ts b/webapp/src/auth.ts index a3b656c8abd3..35d05a707b77 100644 --- a/webapp/src/auth.ts +++ b/webapp/src/auth.ts @@ -15,11 +15,13 @@ export const LOGGED_IN = `${MODULE}:${FIELD_LOGGED_IN}`; const USER_PREF_MODULE = "user-pref"; const FIELD_USER_PREFERENCES = "preferences"; const FIELD_HIGHCONTRAST = "high-contrast"; +const FIELD_ACCESSIBLE_BLOCKS = "accessible-blocks"; const FIELD_COLOR_THEME_IDS = "colorThemeIds"; const FIELD_LANGUAGE = "language"; const FIELD_READER = "reader"; export const USER_PREFERENCES = `${USER_PREF_MODULE}:${FIELD_USER_PREFERENCES}` export const HIGHCONTRAST = `${USER_PREF_MODULE}:${FIELD_HIGHCONTRAST}` +export const ACCESSIBLE_BLOCKS = `${USER_PREF_MODULE}:${FIELD_ACCESSIBLE_BLOCKS}` export const COLOR_THEME_IDS = `${USER_PREF_MODULE}:${FIELD_COLOR_THEME_IDS}` export const LANGUAGE = `${USER_PREF_MODULE}:${FIELD_LANGUAGE}` export const READER = `${USER_PREF_MODULE}:${FIELD_READER}` @@ -63,6 +65,7 @@ class AuthClient extends pxt.auth.AuthClient { switch (op.path.join('/')) { case "language": data.invalidate(LANGUAGE); break; case "highContrast": data.invalidate(HIGHCONTRAST); break; + case "accessibleBlocks": data.invalidate(ACCESSIBLE_BLOCKS); break; case "colorThemeIds": data.invalidate(COLOR_THEME_IDS); break; case "reader": data.invalidate(READER); break; } @@ -114,6 +117,7 @@ class AuthClient extends pxt.auth.AuthClient { // Identity not available, read from local storage switch (path) { case HIGHCONTRAST: return /^true$/i.test(pxt.storage.getLocal(HIGHCONTRAST)); + case ACCESSIBLE_BLOCKS: return /^true$/i.test(pxt.storage.getLocal(ACCESSIBLE_BLOCKS)); case COLOR_THEME_IDS: return pxt.U.jsonTryParse(pxt.storage.getLocal(COLOR_THEME_IDS)) as pxt.auth.ColorThemeIdsState; case LANGUAGE: return pxt.storage.getLocal(LANGUAGE); case READER: return pxt.storage.getLocal(READER); @@ -130,6 +134,7 @@ class AuthClient extends pxt.auth.AuthClient { switch (field) { case FIELD_USER_PREFERENCES: return { ...state.preferences }; case FIELD_HIGHCONTRAST: return state.preferences?.highContrast ?? pxt.auth.DEFAULT_USER_PREFERENCES().highContrast; + case FIELD_ACCESSIBLE_BLOCKS: return state.preferences?.accessibleBlocks ?? pxt.auth.DEFAULT_USER_PREFERENCES().accessibleBlocks; case FIELD_COLOR_THEME_IDS: return state.preferences?.colorThemeIds ?? pxt.auth.DEFAULT_USER_PREFERENCES().colorThemeIds; case FIELD_LANGUAGE: return state.preferences?.language ?? pxt.auth.DEFAULT_USER_PREFERENCES().language; case FIELD_READER: return state.preferences?.reader ?? pxt.auth.DEFAULT_USER_PREFERENCES().reader; @@ -238,6 +243,21 @@ export async function setHighContrastPrefAsync(highContrast: boolean): Promise { + const cli = await clientAsync(); + if (cli) { + await cli.patchUserPreferencesAsync({ + op: 'replace', + path: ['accessibleBlocks'], + value: accessibleBlocks + }); + } else { + // Identity not available, save this setting locally + pxt.storage.setLocal(ACCESSIBLE_BLOCKS, accessibleBlocks.toString()); + data.invalidate(ACCESSIBLE_BLOCKS); + } +} + export async function setThemePrefAsync(themeId: string): Promise { const cli = await clientAsync(); const targetId = pxt.appTarget.id; diff --git a/webapp/src/blocks.tsx b/webapp/src/blocks.tsx index 557b54240c79..26cf4b2c0c92 100644 --- a/webapp/src/blocks.tsx +++ b/webapp/src/blocks.tsx @@ -1,9 +1,11 @@ -/// +/// import * as React from "react"; import * as ReactDOM from "react-dom"; import * as Blockly from "blockly"; import * as pkg from "./package"; +import * as data from "./data"; +import * as auth from "./auth"; import * as core from "./core"; import * as toolboxeditor from "./toolboxeditor" import * as compiler from "./compiler" @@ -17,10 +19,9 @@ import { CreateFunctionDialog } from "./createFunction"; import { initializeSnippetExtensions } from './snippetBuilder'; import * as pxtblockly from "../../pxtblocks"; -// import { NavigationController, Navigation } from "@blockly/keyboard-navigation"; +import { KeyboardNavigation } from '@blockly/keyboard-experiment'; import { WorkspaceSearch } from "@blockly/plugin-workspace-search"; - import Util = pxt.Util; import { DebuggerToolbox } from "./debuggerToolbox"; import { ErrorDisplayInfo, ErrorList, StackFrameDisplayInfo } from "./errorList"; @@ -36,6 +37,9 @@ import SimState = pxt.editor.SimState; import { DuplicateOnDragConnectionChecker } from "../../pxtblocks/plugins/duplicateOnDrag"; import { PathObject } from "../../pxtblocks/plugins/renderer/pathObject"; import { Measurements } from "./constants"; +import { flow } from "../../pxtblocks"; +import { HIDDEN_CLASS_NAME } from "../../pxtblocks/plugins/flyout/blockInflater"; +import { FlyoutButton } from "../../pxtblocks/plugins/flyout/flyoutButton"; interface CopyDataEntry { version: 1; @@ -54,7 +58,7 @@ export class Editor extends toolboxeditor.ToolboxEditor { loadingXml: boolean; loadingXmlPromise: Promise; compilationResult: pxtblockly.BlockCompilationResult; - isFirstBlocklyLoad = true; + shouldFocusWorkspace = false; functionsDialog: CreateFunctionDialog = null; showCategories: boolean = true; @@ -69,7 +73,8 @@ export class Editor extends toolboxeditor.ToolboxEditor { protected highlightedStatement: pxtc.LocationInfo; // Blockly plugins - // protected navigationController: NavigationController; + protected keyboardNavigation: KeyboardNavigation; + protected workspaceSearch: WorkspaceSearch; public nsMap: pxt.Map; @@ -241,7 +246,7 @@ export class Editor extends toolboxeditor.ToolboxEditor { this.resize(); Blockly.svgResize(this.editor); - this.isFirstBlocklyLoad = false; + this.shouldFocusWorkspace = true; }).finally(() => { try { // It's possible Blockly reloads and the loading dimmer is no longer a child of the editorDiv @@ -333,6 +338,10 @@ export class Editor extends toolboxeditor.ToolboxEditor { this.updateGrayBlocks(); this.typeScriptSaveable = true; + + if (this.shouldFocusWorkspace) { + this.focusWorkspace(); + } } catch (e) { pxt.log(e); pxtblockly.clearWithoutEvents(this.editor); @@ -482,7 +491,10 @@ export class Editor extends toolboxeditor.ToolboxEditor { pxtblockly.external.setPromptTranslateBlock(dialogs.promptTranslateBlock); } - pxtblockly.external.setCopyPaste(copy, cut, this.pasteCallback, this.copyPrecondition, this.pastePrecondition); + // Disable clipboard overwrite (initCopyPaste) when accessible blocks enabled. + if (!data.getData(auth.ACCESSIBLE_BLOCKS)) { + pxtblockly.external.setCopyPaste(copy, cut, this.pasteCallback, this.copyPrecondition, this.pastePrecondition); + } } private initBlocklyToolbox() { @@ -494,12 +506,23 @@ export class Editor extends toolboxeditor.ToolboxEditor { (Blockly as any).Toolbox.prototype.position = function () { oldToolboxPosition.call(this); editor.resizeToolbox(); - } + }; /** * Override blockly methods to support our custom toolbox. + * + * We don't generally use this selection but keyboard nav will trigger + * it to select the first category and clear the selection. */ const that = this; + (Blockly as any).Toolbox.prototype.setSelectedItem = function (newItem: Blockly.ISelectableToolboxItem | null) { + if (newItem === null) { + that.hideFlyout(); + } + }; + (Blockly as any).Toolbox.prototype.clearSelection = function () { + that.hideFlyout(); + }; (Blockly.WorkspaceSvg as any).prototype.refreshToolboxSelection = function () { let ws = this.isFlyout ? this.targetWorkspace : this; if (ws && !ws.currentGesture_ && ws.toolbox_ && ws.toolbox_.flyout_) { @@ -535,31 +558,35 @@ export class Editor extends toolboxeditor.ToolboxEditor { }; } - // private initAccessibleBlocks() { - // const enabled = pxt.appTarget.appTheme?.accessibleBlocks; - // if (enabled && !this.navigationController) { - // this.navigationController = new NavigationController() as any; - - // this.navigationController.init(); - // this.navigationController.addWorkspace(this.editor); - - // (Navigation as any).prototype.focusToolbox = (workspace: Blockly.WorkspaceSvg) => { - // const toolbox = this.toolbox; - // if (!toolbox) return; - // this.focusToolbox(); - // this.navigationController.navigation.resetFlyout(workspace, false); - // this.navigationController.navigation.setState(workspace, "toolbox"); - // } - // } - // } - - // public enableAccessibleBlocks(enable: boolean) { - // if (enable) { - // this.navigationController.enable(this.editor); - // } else { - // this.navigationController.disable(this.editor); - // } - // } + private initAccessibleBlocks() { + const enabled = data.getData(auth.ACCESSIBLE_BLOCKS) + if (enabled && !this.keyboardNavigation) { + this.keyboardNavigation = new KeyboardNavigation(this.editor); + + const injectionDiv = document.getElementById("blocksEditor"); + injectionDiv.classList.add("accessibleBlocks"); + const focusRingDiv = injectionDiv.appendChild(document.createElement("div")) + focusRingDiv.className = "blocklyWorkspaceFocusRingLayer"; + this.editor.getSvgGroup().addEventListener("focus", () => { + focusRingDiv.dataset.focused = "true"; + }) + this.editor.getSvgGroup().addEventListener("blur", () => { + delete focusRingDiv.dataset.focused; + }) + + const cleanUpWorkspace = Blockly.ShortcutRegistry.registry.getRegistry()["clean_up_workspace"]; + Blockly.ShortcutRegistry.registry.unregister(cleanUpWorkspace.name); + Blockly.ShortcutRegistry.registry.register({ + ...cleanUpWorkspace, + // The default key is 'c' to "clean up workspace". Use 'f' instead to align with "format code". + keyCodes: [Blockly.ShortcutRegistry.registry.createSerializedKey(Blockly.utils.KeyCodes.F, null)], + callback: (workspace) => { + flow(workspace, { useViewWidth: true }); + return true + } + }); + } + } private initWorkspaceSearch() { if (pxt.appTarget.appTheme.workspaceSearch && !this.workspaceSearch) { @@ -664,6 +691,7 @@ export class Editor extends toolboxeditor.ToolboxEditor { Blockly.Events.VIEWPORT_CHANGE, Blockly.Events.BUBBLE_OPEN, Blockly.Events.THEME_CHANGE, + Blockly.Events.MARKER_MOVE, pxtblockly.FIELD_EDITOR_OPEN_EVENT_TYPE ]; @@ -753,7 +781,7 @@ export class Editor extends toolboxeditor.ToolboxEditor { this.initPrompts(); this.initBlocklyToolbox(); this.initWorkspaceSounds(); - // this.initAccessibleBlocks(); + this.initAccessibleBlocks(); this.initWorkspaceSearch(); this.setupIntersectionObserver(); this.resize(); @@ -823,6 +851,13 @@ export class Editor extends toolboxeditor.ToolboxEditor { } } + focusWorkspace() { + const accessibleBlocksEnabled = data.getData(auth.ACCESSIBLE_BLOCKS) + if (accessibleBlocksEnabled) { + (this.editor.getSvgGroup() as SVGElement).focus(); + } + } + hasUndo() { const undoStack = this.editor?.getUndoStack(); const redoStack = this.editor?.getRedoStack(); @@ -969,6 +1004,10 @@ export class Editor extends toolboxeditor.ToolboxEditor { return blocksArea ? blocksArea.getElementsByClassName('blocklyToolbox')[0] as HTMLDivElement : undefined; } + getToolboxDiv(): HTMLDivElement { + return this.getBlocklyToolboxDiv(); + } + handleToolboxRef = (c: toolbox.Toolbox) => { this.toolbox = c; } @@ -978,17 +1017,62 @@ export class Editor extends toolboxeditor.ToolboxEditor { } public moveFocusToFlyout() { - // if (this.navigationController) { - // this.navigationController.navigation.focusFlyout(this.editor); - // } + if (this.keyboardNavigation) { + const flyout = this.editor.getFlyout() as pxtblockly.CachingFlyout;; + const element = flyout.getFlyoutElement(); + element?.focus(); + this.defaultFlyoutCursorIfNeeded(flyout); + } + } + + // Modified from blockly-keyboard-experimentation plugin + // https://github.com/google/blockly-keyboard-experimentation/blob/main/src/navigation.ts + // This modification is required to workaround the fact that cached blocks are not disposed in MakeCode. + private isFlyoutItemDisposed(node: Blockly.ASTNode) { + const sourceBlock = node.getSourceBlock(); + if ( + sourceBlock?.disposed || + sourceBlock?.hasDisabledReason(HIDDEN_CLASS_NAME) + ) { + return true; + } + const location = node.getLocation(); + if (location instanceof FlyoutButton) { + return location.isDisposed(); + } + return false; + } - (this.editor.getInjectionDiv() as HTMLDivElement).focus(); + // Modified from blockly-keyboard-experimentation plugin + // https://github.com/google/blockly-keyboard-experimentation/blob/main/src/navigation.ts + private defaultFlyoutCursorIfNeeded(flyout: Blockly.IFlyout): void { + const flyoutCursor = flyout.getWorkspace().getCursor(); + if (!flyoutCursor) { + return; + } + const curNode = flyoutCursor.getCurNode(); + if (curNode && !this.isFlyoutItemDisposed(curNode)) { + return; + } + const flyoutContents = flyout.getContents(); + const defaultFlyoutItem = flyoutContents[0]; + if (!defaultFlyoutItem) { + return; + } + const defaultFlyoutItemElement = defaultFlyoutItem.getElement(); + if (defaultFlyoutItemElement instanceof Blockly.FlyoutButton) { + const astNode = Blockly.ASTNode.createButtonNode(defaultFlyoutItemElement as Blockly.FlyoutButton); + flyoutCursor.setCurNode(astNode); + } else if (defaultFlyoutItemElement instanceof Blockly.BlockSvg) { + const astNode = Blockly.ASTNode.createStackNode(defaultFlyoutItemElement as Blockly.BlockSvg); + flyoutCursor.setCurNode(astNode); + } } renderToolbox(immediate?: boolean) { if (pxt.shell.isReadOnly()) return; const blocklyToolboxDiv = this.getBlocklyToolboxDiv(); - const blocklyToolbox =
+ const blocklyToolbox =
{
}
; @@ -998,6 +1082,12 @@ export class Editor extends toolboxeditor.ToolboxEditor { if (!immediate) this.toolbox.showLoading(); } + private handleToolboxContentsFocusCapture = (e: React.FocusEvent) => { + if (e.target === e.currentTarget) { + (this.getBlocklyToolboxDiv().querySelector("[role=tree]") as HTMLElement).focus() + } + } + updateToolbox() { const container = document.getElementById('debuggerToolbox'); if (!container) return; @@ -1167,10 +1257,11 @@ export class Editor extends toolboxeditor.ToolboxEditor { }) } - unloadFileAsync(): Promise { + unloadFileAsync(unloadToHome?: boolean): Promise { this.delayLoadXml = undefined; this.errors = []; if (this.toolbox) this.toolbox.clearSearch(); + if (unloadToHome) this.shouldFocusWorkspace = false; return Promise.resolve(); } @@ -1790,7 +1881,6 @@ export class Editor extends toolboxeditor.ToolboxEditor { this.flyoutXmlList.push(label); } this.showFlyoutInternal_(this.flyoutXmlList, "search"); - this.toolbox.setSearch(); } private showTopBlocksFlyout() { @@ -2253,7 +2343,7 @@ function fixHighlight(block: Blockly.BlockSvg) { } function shouldEventHideFlyout(ev: Blockly.Events.Abstract) { - if (ev.type === "var_create" || ev.type === "marker_move") { + if (ev.type === "var_create" || ev.type === "marker_move" || ev.type === "toolbox_item_select") { return false; } diff --git a/webapp/src/container.tsx b/webapp/src/container.tsx index c425d15f7416..0d1359925091 100644 --- a/webapp/src/container.tsx +++ b/webapp/src/container.tsx @@ -300,7 +300,8 @@ export class SettingsMenu extends data.Component(auth.HIGHCONTRAST) - const { greenScreen, accessibleBlocks } = this.state; + const { greenScreen } = this.state; + const accessibleBlocks = this.getData(auth.ACCESSIBLE_BLOCKS); const targetTheme = pxt.appTarget.appTheme; const packages = pxt.appTarget.cloud && !!pxt.appTarget.cloud.packages; const reportAbuse = pxt.appTarget.cloud && pxt.appTarget.cloud.sharing && pxt.appTarget.cloud.importing; @@ -354,7 +355,7 @@ export class SettingsMenu extends data.Component
{targetTheme.selectLanguage ? : undefined} - {targetTheme.accessibleBlocks ? : undefined} + {showGreenScreen ? : undefined} {docItems && renderDocItems(this.props.parent, docItems, "setting-docs-item mobile only inherit")} {githubUser ?
: undefined} @@ -378,7 +379,7 @@ export class SettingsMenu extends data.Component void; + onClick: (e: React.MouseEvent) => void; isActive: () => boolean; icon?: string; @@ -443,7 +444,7 @@ class BlocksMenuItem extends data.Component { super(props); } - protected onClick = (): void => { + protected onClick = (e: React.MouseEvent): void => { pxt.tickEvent("menu.blocks", undefined, { interactiveConsent: true }); this.props.parent.openBlocks(); } diff --git a/webapp/src/core.ts b/webapp/src/core.ts index 1167ef0cf62c..5137dbe01fc9 100644 --- a/webapp/src/core.ts +++ b/webapp/src/core.ts @@ -357,6 +357,13 @@ export async function setHighContrast(on: boolean) { await auth.setHighContrastPrefAsync(on); } +export async function toggleAccessibleBlocks() { + await setAccessibleBlocks(!data.getData(auth.ACCESSIBLE_BLOCKS)); +} +export async function setAccessibleBlocks(on: boolean) { + await auth.setAccessibleBlocksPrefAsync(on); +} + export async function setLanguage(lang: string) { pxt.BrowserUtils.setCookieLang(lang); pxt.Util.setUserLanguage(lang); diff --git a/webapp/src/monaco.tsx b/webapp/src/monaco.tsx index db95de9a75a9..6f74dafc2aab 100644 --- a/webapp/src/monaco.tsx +++ b/webapp/src/monaco.tsx @@ -836,6 +836,12 @@ export class Editor extends toolboxeditor.ToolboxEditor { return false; } + getToolboxDiv(): HTMLElement | undefined { + const monacoArea = document.getElementById('monacoEditorArea'); + if (!monacoArea) return undefined; + return monacoArea.getElementsByClassName('monacoToolboxDiv')[0] as HTMLElement; + } + resize(e?: Event) { let monacoArea = document.getElementById('monacoEditorArea'); if (!monacoArea) return; @@ -1187,6 +1193,10 @@ export class Editor extends toolboxeditor.ToolboxEditor { } } + focusWorkspace(): void { + this.editor.focus(); + } + undo() { if (!this.editor) return; this.editor.trigger('keyboard', 'undo', null); diff --git a/webapp/src/srceditor.tsx b/webapp/src/srceditor.tsx index 740f1b66cbf9..edb5962619b5 100644 --- a/webapp/src/srceditor.tsx +++ b/webapp/src/srceditor.tsx @@ -74,12 +74,16 @@ export class Editor implements IEditor { snapshotState(): any { return null } - unloadFileAsync(): Promise { return Promise.resolve() } + unloadFileAsync(unloadToHome?: boolean): Promise { return Promise.resolve() } isIncomplete() { return false } + getToolboxDiv(): HTMLElement | undefined { + return undefined; + } + hasHistory() { return true; } hasUndo() { return true; } hasRedo() { return true; } @@ -149,6 +153,9 @@ export class Editor implements IEditor { focusToolbox(itemToFocus?: string) { } + focusWorkspace() { + } + // allows all editors to send exceptions to error list onExceptionDetected(exception: pxsim.DebuggerBreakpointMessage) { core.warningNotification(lf("Program Error: {0}", exception?.exceptionMessage)); diff --git a/webapp/src/toolbox.tsx b/webapp/src/toolbox.tsx index 204cf7ad50b8..a975d76059b5 100644 --- a/webapp/src/toolbox.tsx +++ b/webapp/src/toolbox.tsx @@ -147,6 +147,7 @@ export class Toolbox extends data.Component { this.handleRemoveExtension = this.handleRemoveExtension.bind(this); this.deleteExtension = this.deleteExtension.bind(this); this.cancelDeleteExtension= this.cancelDeleteExtension.bind(this); + this.handleKeyDown = this.handleKeyDown.bind(this); } getElement() { @@ -264,23 +265,17 @@ export class Toolbox extends data.Component { } focus(itemToFocus?: string) { - if (!this.rootElement) return; - if (this.selectedItem && this.selectedItem.getTreeRow()) { - // Focus the selected item - const selectedItem = this.selectedItem.props.treeRow; - const selectedItemIndex = this.items.indexOf(selectedItem); - this.setSelection(selectedItem, selectedItemIndex, true); - } else { - // Focus first item in the toolbox - if (itemToFocus) { - for (const item of this.items) { - if (item.nameid === itemToFocus) { - this.setSelection(item, this.items.indexOf(item), true); - return; - } + if (itemToFocus) { + for (const item of this.items) { + if (item.nameid === itemToFocus) { + this.setSelection(item, this.items.indexOf(item), true); + return; } } - this.selectFirstItem(); + } else { + // If there is not a specific item to focus, the handleCategoryTreeFocus focus + // handler will highlight the currently selected item if it exists, else the first item. + (this.refs.categoryTree as HTMLDivElement).focus() } } @@ -453,11 +448,85 @@ export class Toolbox extends data.Component { this.rootElement = c; } + handleCategoryTreeFocus = (e: React.FocusEvent) => { + // Don't handle focus resulting from click events on category tree items. + // Rely on the click handler instead. + if (e.target === this.refs.categoryTree) { + if (!this.rootElement) return; + if (this.selectedIndex !== undefined && this.selectedTreeRow) { + if (this.selectedTreeRow === this.selectedItem.props.treeRow) { + // The flyout is already open with appropriate content. + return; + } + // 'Focus' the selected item + this.setSelection(this.selectedTreeRow, this.selectedIndex, true); + } else { + // 'Focus' first item in the toolbox + this.selectFirstItem(); + } + } + } + isRtl() { const { editorname } = this.props; return editorname == 'monaco' ? false : Util.isUserLanguageRtl(); } + handleKeyDown(e: React.KeyboardEvent) { + const isRtl = Util.isUserLanguageRtl(); + + const charCode = core.keyCodeFromEvent(e); + if (charCode == 40 /* Down arrow key */) { + this.nextItem(); + // Don't trigger scroll behaviour inside the toolbox. + e.preventDefault(); + } else if (charCode == 38 /* Up arrow key */) { + this.previousItem(); + // Don't trigger scroll behaviour inside the toolbox. + e.preventDefault(); + } else if ((charCode == 39 /* Right arrow key */ && !isRtl) + || (charCode == 37 /* Left arrow key */ && isRtl)) { + if (this.selectedTreeRow.nameid !== "addpackage") { + // Focus inside flyout + this.moveFocusToFlyout(); + } + } else if (charCode == 27) { // ESCAPE + // Close the flyout + this.closeFlyout(); + this.props.parent.focusWorkspace(); + } else if (charCode == core.ENTER_KEY || charCode == core.SPACE_KEY) { + const {onCategoryClick, treeRow, index} = this.selectedItem.props; + if (onCategoryClick) { + onCategoryClick(treeRow, index); + e.preventDefault(); + e.stopPropagation(); + } + } else if (charCode == core.TAB_KEY + || charCode == 37 /* Left arrow key */ + || charCode == 39 /* Right arrow key */ + || charCode == 17 /* Ctrl Key */ + || charCode == 16 /* Shift Key */ + || charCode == 91 /* Cmd Key */) { + // Escape tab and shift key + } else { + this.setSearch(); + } + } + + previousItem() { + const editorname = this.props.editorname; + + pxt.tickEvent(`${editorname}.toolbox.keyboard.prev`, undefined, { interactiveConsent: true }); + this.setPreviousItem(); + } + + nextItem() { + const editorname = this.props.editorname; + + pxt.tickEvent(`${editorname}.toolbox.keyboard.next`, undefined, { interactiveConsent: true }); + this.setNextItem(); + } + renderCore() { const { editorname, parent } = this.props; const { showAdvanced, visible, loading, selectedItem, expandedItem, hasSearch, showSearchBox, hasError, tryToDeleteNamespace } = this.state; @@ -543,104 +612,137 @@ export class Toolbox extends data.Component { /> }
-
- {tryToDeleteNamespace && - - } - {hasSearch && - - } - {hasTopBlocks && - - } - {nonAdvancedCategories.map(treeRow => - - {treeRow.subcategories && - treeRow.subcategories.map(subTreeRow => - - ) - } - - )} - {hasAdvanced && - <> - + {/* This layer is needed to differentiate between keyboard tab focus and click focus */} +
+
+ {tryToDeleteNamespace && + + } + {hasSearch && + } + {hasTopBlocks && + - - } - {showAdvanced && - advancedCategories.map(treeRow => + } + {nonAdvancedCategories.map(treeRow => - {treeRow.subcategories && - treeRow.subcategories.map(subTreeRow => - - ) - } + {treeRow.subcategories && ( +
+ {treeRow.subcategories.map(subTreeRow => + + )} +
+ )}
- ) - } + )} + {hasAdvanced && + <> + + +
+ { + advancedCategories.map(treeRow => + + {treeRow.subcategories && ( +
+ {treeRow.subcategories.map(subTreeRow => + + )} +
+ )} +
+ ) + } +
+
+ + } +
@@ -650,12 +752,14 @@ export class Toolbox extends data.Component { export interface CategoryItemProps extends TreeRowProps { toolbox: Toolbox; - childrenVisible?: boolean; onCategoryClick?: (treeRow: ToolboxCategory, index: number) => void; index?: number; + selectedIndex?: number; topRowIndex?: number; hasDeleteButton?: boolean; onDeleteClick?: (ns: string) => void; + ariaLevel: number; + isExpanded?: boolean; } export interface CategoryItemState { @@ -672,7 +776,6 @@ export class CategoryItem extends data.Component toolboxRect.bottom) { + this.scrollElementIntoView({block: "end"}); + } else if (activeCategoryRect.top < toolboxRect.top) { + this.scrollElementIntoView({block: "start"}); + } + } } } @@ -699,6 +815,14 @@ export class CategoryItem extends data.Component) { const { treeRow, onCategoryClick, index } = this.props; if (onCategoryClick) onCategoryClick(treeRow, index); @@ -707,92 +831,27 @@ export class CategoryItem extends data.Component) { - const { toolbox } = this.props; - const isRtl = Util.isUserLanguageRtl(); - - const mainWorkspace = Blockly.getMainWorkspace() as Blockly.WorkspaceSvg; - const accessibleBlocksEnabled = mainWorkspace.keyboardAccessibilityMode; - const accessibleBlocksState = accessibleBlocksEnabled - && (toolbox.props.parent as any).navigationController?.navigation?.getState(mainWorkspace); - const keyMap: { [key: string]: number } = { - "DOWN": accessibleBlocksEnabled ? 83 : 40, // 'S' || down arrow - "UP": accessibleBlocksEnabled ? 87 : 38, // 'W' || up arrow - "LEFT": accessibleBlocksEnabled ? 65 : 37, // 'A' || left arrow - "RIGHT": accessibleBlocksEnabled ? 68 : 39 // 'D' || right arrow - } - - const charCode = core.keyCodeFromEvent(e); - if (!accessibleBlocksEnabled || accessibleBlocksState == "toolbox") { - if (charCode == keyMap["DOWN"]) { - this.nextItem(); - } else if (charCode == keyMap["UP"]) { - this.previousItem(); - } else if ((charCode == keyMap["RIGHT"] && !isRtl) - || (charCode == keyMap["LEFT"] && isRtl)) { - // Focus inside flyout - toolbox.moveFocusToFlyout(); - } else if (charCode == 27) { // ESCAPE - // Close the flyout - toolbox.closeFlyout(); - } else if (charCode == core.ENTER_KEY || charCode == core.SPACE_KEY) { - fireClickOnEnter.call(this, e); - } else if (charCode == core.TAB_KEY - || charCode == 37 /* Left arrow key */ - || charCode == 39 /* Left arrow key */ - || charCode == 17 /* Ctrl Key */ - || charCode == 16 /* Shift Key */ - || charCode == 91 /* Cmd Key */) { - // Escape tab and shift key - } else if (!accessibleBlocksEnabled) { - toolbox.setSearch(); - } - } else if (accessibleBlocksEnabled && accessibleBlocksState == "flyout" - && ((charCode == keyMap["LEFT"] && !isRtl) - || (charCode == keyMap["RIGHT"] && isRtl))) { - this.focusElement(); - e.stopPropagation(); - } - } - - previousItem() { - const { toolbox } = this.props; - const editorname = toolbox.props.editorname; - - pxt.tickEvent(`${editorname}.toolbox.keyboard.prev"`, undefined, { interactiveConsent: true }); - toolbox.setPreviousItem(); - } - - nextItem() { - const { toolbox } = this.props; - const editorname = toolbox.props.editorname; - - pxt.tickEvent(`${editorname}.toolbox.keyboard.next"`, undefined, { interactiveConsent: true }); - toolbox.setNextItem(); - } - - handleTreeRowRef = (c: TreeRow) => { + handleTreeRowRef = (c: TreeRow) => { this.treeRowElement = c; } renderCore() { - const { toolbox, childrenVisible, hasDeleteButton } = this.props; + const { toolbox, hasDeleteButton, treeRow, ariaLevel, isExpanded } = this.props; const { selected } = this.state; + const ariaExpanded = treeRow.subcategories ? isExpanded : undefined; + return ( - + - - {this.props.children} - + {this.props.children} ); } @@ -855,6 +914,17 @@ export class TreeRow extends data.Component { if (this.treeRow) this.treeRow.focus(); } + scrollIntoView(options: ScrollIntoViewOptions) { + if (this.treeRow) this.treeRow.scrollIntoView(options); + } + + getBoundingClientRect() { + if (this.treeRow) { + return this.treeRow.getBoundingClientRect(); + } + return undefined; + } + getProperties() { const { treeRow } = this.props; return treeRow; @@ -918,35 +988,37 @@ export class TreeRow extends data.Component { const extraIconClass = !subns && Object.keys(this.brandIcons).includes(icon) ? 'brandIcon' : '' return (
- - - {iconContent} - - - {rowTitle} - - {hasDeleteButton && - - } + {/* + pointEvents style required to work around non-null assertion operator in Blockly code. + See https://github.com/google/blockly/blob/develop/core/toolbox/toolbox.ts#L263 + */} +
+ + + {iconContent} + + + {rowTitle} + + {hasDeleteButton && + + } +
); } @@ -955,50 +1027,40 @@ export class TreeRow extends data.Component { export class TreeSeparator extends data.Component<{}, {}> { renderCore() { return ( - -
- -
-
+
+ +
); } } export interface TreeItemProps { - selected?: boolean; + selected: boolean; children?: any; + id: string; + ariaLevel: number; + ariaExpanded: boolean | undefined; } export class TreeItem extends data.Component { renderCore() { - const { selected } = this.props; + const { selected, id, ariaLevel, ariaExpanded } = this.props; return ( -
- {this.props.children} -
- ); - } -} - -export interface TreeGroupProps { - visible?: boolean; - children?: any; -} - -export class TreeGroup extends data.Component { - renderCore() { - const { visible } = this.props; - if (!this.props.children) return
; - - return ( -
+
{this.props.children}
); } } - export interface ToolboxSearchProps { parent: editor.ToolboxEditor; editorname: string; @@ -1041,8 +1103,11 @@ export class ToolboxSearch extends data.Component