Repository: google/blockly-keyboard-experimentation
Pull request #482: feat: Use FocusManager for navigation & state management
Author: login: BenHenning
Created: 2025-04-29T23:43:48Z | Merged: 2025-05-02T19:02:04Z

Fixes #142
Fixes #481

This removes nearly all custom focus/blur management and instead leverages `FocusManager` as the source of truth and primary system for focusing/selecting elements.

This largely continues the work in Core starting with https://github.com/google/blockly/pull/8938 and ending with https://github.com/google/blockly/pull/8941 in order to bring basic parity in the keyboard navigation plugin using `FocusManager`.

Specifically:
- This replaces all cases where explicit focus/opening logic is needed with `focusNode`/`focusTree` calls via `FocusManager`.
- `Navigation` now infers state based on what's currently focused rather than tracking it separately.
- All of the existing focus/blur response logic has been made redundant due to the inferred navigation mode and focus being tightly coupled with the components themselves.
- The gesture monkey patch seems no longer necessary since, as far as I can tell, focus gets forced back to where it's supposed to be at specific lifecycle moments (which helps undo many of the problems caused by `WorkspaceSvg`'s `markFocused`).
- The passive indicator is no longer necessary now that the `FocusManager`-driven `blocklyPassiveFocus` CSS class exists for styling.
- Note that there's a lot of automatic state synchronizing that has been purposely pushed into Core to simplify the navigation pieces, particularly:
  - Blocks will auto-select/unselect themselves based on focus.
  - `Toolbox` will automatically synchronize its selected item state with its item that has active focus.
  - `Toolbox` and `Flyout` cooperate for automatically closing the `Flyout` at the correct times.
- The new CSS classes are not yet final as the overall strategy for indicating active/passive focus isn't final. There's also additional work needed to ensure selection and active focus can be properly stylized as a combined state, but this is blocked on the completion of https://github.com/google/blockly/issues/8942.
- `Toolbox` navigation was moved to using its methods directly instead of its event since selection logic now needs to be paired with `focusNode`.
- There are a number of changes in this PR that could probably go into Core, but these can wait for a future PR as they shouldn't be API breaking. Some of the changes include:
  - `Toolbox` item selection could auto-focus within `Toolbox` itself, or this could be done as part of `Toolbox`-specific navigation.
  - `Flyout` focus could automatically focus its first item if nothing is currently focused.
- For regression testing, the following has been checked:
  - 'T' and 'I' do work as expected (ensuring https://github.com/google/blockly/pull/8933#pullrequestreview-2795396046 is no longer an issue).
  - Verified #227, #200, #229, #222, and #287 have not regressed. https://github.com/google/blockly-samples/issues/2498 shouldn't be possible anymore (even with the fix) due to ephemeral focus, and we seem to be matching parity with #326 per the `handleFocus*()` functions, though there may be cases from `handleFocusWorkspace` yet to check.

The test updates were necessary because, at present, clicking does not force focus which means the tests need to rely purely on keys for navigation. I think these changes are actually an improvement since it closer aligns them with keyboard-only usage.

**Current gaps** that may need resolution prior to this PR being mergeable:
- There's a regression with clicking to create a variable closing the flyout that I'm still investigating. This is detailed more in https://github.com/google/blockly/pull/8941.
- At some point between the latest changes to this branch and the ones in https://github.com/google/blockly/pull/8941 block movement regressed slightly: finishing a movement gesture now defocuses the workspace entirely (even though this was working earlier).
- There are a lot of inconsistencies between the focus and selection styling before this PR and with this PR. It's unclear how much of this requires actual resolution.
- Much more testing is needed to understand gaps as this changes a significant amount of state handling for the plugin.

Linked issue #142: Use focus to denote cursor location
## TL;DR

Moving the cursor should should move (browser) focus, and vice versa.

## Background
### History

This plugin uses `LinceCursor`, which is a kind of `Marker`, to denote the location the user has navigated to.  Originally it always used the marker's dawer* (a [`MakerSvg`](https://github.com/google/blockly/blob/a42c2d15082d3261a643f205bae1c7860ba48416/core/renderers/common/marker_svg.ts#L45) instance) to denote the location of the cursor.

When marking a block, `MarkerSvg` draws a rectangle around the entire block _and all it's child blocks_.  This behaviour seems unintuitive, especially when navigating stacks of statement blocks—we'd like just the individual block to be marked.  In preparation for user testing on October 28 2024 we decided to fix this, but because changing `MarkerSvg` was non-trivial @rachel-fenichel opted to make a quick, kludgy fix #76 to use the existing block selection code to denote the cursor, when the cursor was on a block.

This was successful, insofar as the students participating in user testing generally did not get confused about the cursor location, and in post-testing discussions we realised that we probably _do_ in most cases want navigating to a block to select that block: it is natural to navigate to a block and then use the delete/cut/copy shortcuts, in expectation that they will apply to that block (without the need to first place the marker or do something else to explicitly select the block).

\* N.B.: that's draw-er—as in something that draws—not [drawer](https://en.wikipedia.org/wiki/Drawer)!

### Focus vs. Selection

It is helpful to [distinguish focus from selection](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#focusvsselectionandtheperceptionofdualfocus)—in short, in the abstract:

- Only one item can have focus at a time; there should be a visible focus indicator at all times.
- Zero, one, or multiple items can be selected; there should be a distinct visual indicator to indicate which item(s) are selected.
- Moving focus may or may not change what is selected, e.g.:
  - In a typical spreadsheet, moving focus to a different cell selects that cell and unselects any previously-selected ones.
  - In a multiselect list box, moving focus does not change which items are selected.

Currently core Blockly does not have a distinct concept of "focus", and restricts selection to at most one block at a time.  This plugin conflates focus and selection by using selection to denote the location of the cursor.

## Proposals

### Introduce focus; unify with cursor

We should introduce formal support for focus, separately from selection.
- This will probably entail adding support for focus in core Blockly, integrating this with [the DOM's idea of what is focused](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus), providing a visual focus indicator, and ensuring that clicking on a block or field focuses it.
- As previously discussed, we will probably want to use [a roving `tabIndex`](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/#keyboardnavigationinsidecomponents) to manage browser focus, treating each workspace as a separate component with a single movable tab stop.

We should modify this plugin to denote the cursor location using focus, and move the cursor in response to focus changes, including by other means such as clicking on a block or field.  Ideally we will unify focus and the cursor a single concept—but note that, as we wish to ensure that focus location within a workspace is maintained when tabbing (thus changing focus) between toolbox / flyout / workspace, it will be necessary to track "previous focus location" for each workspace—a task currently managed by each having its own cursor instance.

### Move selection with focus by default

Per discussions noted above, moving the cursor/focus to a block should continue to select it, when possible, so that it will be the target of keyboard shortcuts such as cut/copy/delete.  Since shadow blocks cannot be selected but need to be focusable (so that they can be navigated and their shortcuts menu accessed via the keyboard) we'll need separate focus vs. selection indicators.

We may in future wish to provide additional keyboard shortcuts to support multi-select.


## In the interim

Until focus support is more fleshed out, this plugin should continue to use selection to denote the cursor location where possible.

## See also

* #97
Linked issue comments: user: login: BenHenning
created_at: 2025-02-13T23:44:20Z
body: This is a technical design proposal that has gone through a few weeks of feedback and iteration with the core Blockly team and is meant to largely address critical focus design problems to solve many of the core issues brought up in this thread, and to provide solutions for peripheral a11y problem spaces.

Note that this may well undergo additional iterations as it enters implementation phase, though it's expected that the remaining changes in this design will likely be small.

Key tracking issues:
- https://github.com/google/blockly/issues/8770: For tracking introducing the new system in core Blockly.
- https://github.com/google/blockly/issues/8771: For tracking updating Blockly components to integrate correctly with the new focus system in order for it to be made useful.
- #142: This issue for tracking integrating the new focus system into the plugin.

## 1 Overview

Much of the needs for bringing full accessibility support to Blockly come down to two categories of work:

1. Navigation
2. Screen reader output

Both of these two sides of accessibility have a commonality in the DOM: element focus. By framing each of these categories based on focus, the problem space becomes seemingly simpler: navigation is responsible for specifying which elements should be focused and when, and focus indicates to the screen reader when something should be read out to the user. The work to bring accessibility support into Blockly now becomes a matter of implementing navigation in a way that's intuitive and in-line with user expectations, and ensuring that Blockly-generated DOM and SVG elements are correctly set up to be read out in a way that provides the user with sufficient context.

There's another aspect to consider as well when framing this problem space in terms of focus: selections and selectability of nodes. The best analogy here is that of spreadsheet cells: more than one cell can be selected, but only one ever has focus (i.e. will be updated if the user starts typing), per user. While Blockly core currently doesn't support multi-selection, it will in the future and thus there's an important distinction between selection and focus (even though both have the concept of visual indication). The two concepts are intentionally kept apart because:



*   Focus is needed to ensure both keyboard input and screen reader output are correctly set to the corresponding representative element of a Blockly node.
*   Selection simplifies common operations such as copy, paste, and deletion, that would also benefit from multi-selection in the future.

Note that 'focus' here is kept intentionally high-level, but in the document it has two very specific meanings:



*   [Browser focus](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus): that is, the actual DOM element currently focused.
*   Blockly focus: that is, the node that Blockly currently knows as focused.


## 2 Existing API & Behavior

Blockly currently doesn't track explicit focus of nodes, and has support for managing selection for some types of nodes (implementers of `ISelectable` and `ICopyable`). Overall, only the following types are selectable:



*   `BlockSvg`
*   `Bubble`
*   `RenderedWorkspaceComment`

Beyond that, blocks have additional support for 'adding selections' (`BlockSvg.addSelect`) which essentially involves adding a selection visual effect to the block, but has no other changes in state.


## 3 Proposed Design Changes

The high-level design goals include:



*   Introducing a means of allowing important Blockly components to be able to be focused, `IFocusableNode`. This should support two types of focus: active (which means the node can receive immediate input), and passive (which means the node previously was actively focused, and may become actively focused again in the future).
*   Introducing support for distinct hierarchical focus contexts that are self-registering, such as the workspace, via `IFocusableTree`.
*   Introducing automatic bidirectional focus syncing between the DOM and Blockly, and automatic active/passive focus hand-off between different focusable trees via a top-level singleton `FocusManager`.
*   Introducing support for ephemeral context cases where focus is taken away until a deliberate user action reverts back to the original focus (such as modals).

Items the design does not include:



*   Node traversal, though this is something that could be extended upon `IFocusableNode` in the future.
*   Automatic selection. This was originally part of the design, but it has since been removed since it's unclear as to what the long-term benefits are (over the added complexity of combining the systems).
*   Focus change events. This was originally part of the design, but introducing events adds asynchronous state syncing that can substantially increase the complexity of certain classes of problems, and events are inherently highly restrictive in Blockly (due to the requirement of needing to be serializable). Since events don't fundamentally simplify the utility of a new focus system, they can be reevaluated at a later time.


### 3.1 New APIs

There are a number of new APIs being introduced to facilitate focus management in core Blockly, as well as several API and behavioral changes to existing components in order to integrate with this new functionality as needed. A brief summary of the new APIs is as follows:



*   `IFocusableNode`: Represents a Blockly component that has a visual DOM element that can have active DOM focus (and have a visual indication of active or passive focus).
*   `IFocusableTree`: Represents a unique hierarchy of focus (that may contain other `IFocusableTree`s) for distinct focus separation, such as the toolbox and workspace (each should have a separate indicator for 'passive' focus so that the user knows which node will be actively focused upon 'returning' to that tree).
*   `FocusManager`: The page-wide singleton that's responsible for orchestrating focus state across all `IFocusableNode`s and `IFocusableTree`s.

How these components interact with each other and the DOM can be seen in the following diagrams:

![Image](https://github.com/user-attachments/assets/b810285f-ca7f-425c-8760-fb07457822e0)


Diagram 1: User clicking initiating a DOM focus change & `FocusManager` synchronizing state.

![Image](https://github.com/user-attachments/assets/54d36e49-0ccd-4c70-9679-45f371eb91bf)


Diagram 2: User navigating to a Block using the keyboard navigation plugin.

Note that Diagram 2 does not demonstrate the flow for knowing which element to focus. This would require a separate diagram to demonstrate, but essentially the plugin is expected to retrieve the current focused element from `FocusManager` and typecast it in order to determine which nodes to navigate next (which then results in a call to `FocusManager.focusNode()`).


#### 3.1.1 IFocusableNode


```typescript
/** Represents anything that can have input focus. */
export interface IFocusableNode {
  /**
   * Returns the DOM element that can be explicitly requested to receive focus.
   *
   * IMPORTANT: Please note that this element is expected to have a visual presence
   * on the page as it will both be explicitly focused and have its style changed
   * depending on its current focus state (i.e. blurred, actively focused, and
   * passively focused). The element will have one of two styles attached (where
   * no style indicates blurred/not focused):
   * - blocklyActiveFocus
   * - blocklyPassiveFocus
   *
   * The returned element must also have a valid ID specified, and unique to the
   * element relative to its nearest IFocusableTree parent.
   *
   * It's expected the return element will not change for the lifetime of the node.
   */
  getFocusableElement(): HTMLElement | SVGElement;

  /**
   * Returns the closest parent tree of this node (in cases where a tree has
   * distinct trees underneath it), which represents the tree to which this node
   * belongs.
   */
  getFocusableTree(): IFocusableTree;
}
```


Specific details:



*   `IFocusableNode` is meant to be anything that can have focus, such as a Block.
*   Requiring the element to be returned vs. requesting focus directly is a deliberate design decision to simplify `FocusManager` (since DOM focus itself needs to be tracked via the tree's root).
*   Two new default CSS classes will be added for focus (and can be cascaded/further specified as needed for specific elements or by Blockly users), based on `blocklySelected` (note that the exact default styling is likely to change during development and after user feedback & testing):

```css
.blocklyActiveFocus {
  stroke: #2ae;
  stroke-width: 2px;
}
.blocklyPassiveFocus {
  stroke: #3fdfff;
  stroke-width: 1.5px;
}
```


*   Nodes that do not have a UI to focus (such as connections) still must provide an element as this is the context by which a screen reader will provide information about the current page state back to the user.
*   The provided element must be either an `HTMLElement` or an `SVGElement` as these individually have options to focus them (via `focus()`). Unfortunately, their common `Element` parent does not have this functionality (though it _does_ support focus events). See:
    *   <code>[HTMLElement.focus()](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus)</code>
    *   <code>[SVGElement.focus()](https://developer.mozilla.org/en-US/docs/Web/API/SVGElement/focus)</code>
*   This interface is designed partly as a node to fit within the tree metaphor, but also to address the potential design change in the future that amends this interface to include traversal functions (such as for visiting children or siblings of the node). This could be a means of removing <code>ASTNode</code> and the like while also potentially simplifying navigation management for the keyboard navigation plugin.
*   External uses may require spatial information about the node (such as its size and coordinate such as for displaying the context/action menu) which can be retrieved by performing type checks and casts as necessary. Note that it's expected no explicit cursor rendering logic will be needed with the new system since the visual indicator from focus should be sufficient.
*   The need for <code>getFocusableTree</code> isn't ideal, but probably necessary since <code>FocusManager</code> will not have any context to which tree a node belongs. An alternative design would be to pass in the tree directly in <code>focus()</code> when focusing a specific node.
*   It's expected that there is exactly one noteworthy element for highlighting and focusing per <code>IFocusableNode</code> implementation (and Blockly components that have more than one such element should introduce new classes that implement <code>IFocusableNode</code>, as needed, for each one).

| Alternatives Considered:                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| <ul><li> Having separate `focus`/`unfocus` (or `blur`) methods for abstracting away DOM elements (and to provide additional capabilities in implementations to perform other logic during a focus gained/focus lost scenario). This was dropped because the element needs to be exposed for DOM event monitoring at the tree level, and the additional signal on focus gained/lost is not currently an important requirement to codify into the API (though it can be added later if it's needed). </li><li> Having a separate method for updating highlights. This was originally designed, but dropped to aim for API simplicity and to prevent potentially highly complex rendering interactions (e.g. if an `updateHighlight` method were to call `render` on a Blockly element, it could negatively interact with surrounding rendering code and cause unforeseen issues). Additionally, the decision to show a highlight is a display-level decision and shouldn't affect document layout (such as in the case where an implementation could create a new highlight element). </li><li> Supporting a 1:many relationship for nodes and highlightable elements, though this substantially complicates the focus system implementation. </li></ul> |


#### 3.1.2 IFocusableTree


```typescript
/**
 * Represents a tree of focusable elements with its own active/passive focus
 * context.
 *
 * Note that focus is handled by FocusManager, and tree implementations can have at
 * most one IFocusableNode focused at one time. If the tree itself has focus, then
 * the tree's focused node is considered 'active' ('passive' if another tree has
 * focus).
 *
 * Focus is shared between one or more trees, where each tree can have exactly one
 * active or passive node (and only one active node can exist on the whole page at
 * any given time). The idea of passive focus is to provide context to users on
 * where their focus will be restored upon navigating back to a previously focused
 * tree.
 */
export interface IFocusableTree {
  /**
   * Returns the current node with focus in this tree, or null if none (or if the
   * root has focus).
   *
   * Note that this will never return a node from a nested sub-tree as that tree
   * should specifically be called in order to retrieve its focused node.
   */
  getFocusedNode(): IFocusableNode | null;

  /**
   * Returns the top-level focusable node of the tree.
   *
   * It's expected that the returned node will be focused in cases where
   * FocusManager wants to focus a tree in a situation where it does not currently
   * have a focused node.
   */
  getRootFocusableNode(): IFocusableNode;

  /**
   * Returns the IFocusableNode corresponding to the select element, or null if the
   * element does not have such a node.
   *
   * The provided element must have a non-null ID that conforms to the contract
   * mentioned in IFocusableNode.
   */
  findFocusableNodeFor(element: HTMLElement | SVGElement): IFocusableNode | null;
}
```


Specific details:



*   `IFocusableTree`s are meant to be disjointed hierarchical arrangements of Blockly components with independent focus management. For example, a toolbox and a workspace may be separate trees so that they can have independent focus tracking and seamless focus switching via `FocusManager`.
*   Note that the design is intentionally open so that it's possible for nested trees to either be modeled as distinct `IFocusableTree`s or as a single `IFocusableTree` (based on the specific scenario). One scenario where this could occur are mutator bubbles since there's a nested workspace within the outer workspace containing the bubble.
*   There's a bit of complexity here in cases where the tree can have focus as there are essentially two: either it contains a node that has focus, or its root is focused. This distinction is largely unimportant, but the root being retrievable is necessary in order for `FocusManager` to be able to actually watch for focus changes across the tree's hierarchy (otherwise it would need to track every element individually which quickly becomes quite complex).
*   `getFocusedNode` is expected to be implemented by using a DOM query selection upon its root's children to find whichever node (if any) currently has active or passive focus (using the corresponding CSS class), filtering out any sub-trees in the same way as done for `findFocusableNodeFor` (described below). While this is the expected implementation, subclasses may implement this method however they see fit so long as it does not cross tree boundaries.
*   `findFocusableNodeFor` will need to use the lowest common ancestor ([LCA](https://en.wikipedia.org/wiki/Lowest_common_ancestor)) algorithm in order to ensure the nearest parent to a matching node is found. The common ancestor element should be navigated back up to the root of the tree where each element visited should be mapped back to an `IFocusableNode` using the element's ID and verified to not also be an `IFocusableTree` (which would indicate 'crossing' a nested tree boundary).

| Alternatives Considered:                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| <ul><li>Introducing tree navigation functionality to facilitate mapping DOM elements back to Blockly focusable nodes, and as a means to replace cursor/`ASTNode` support. This was deemed too complex for the initial implementation of the system, and there are other advantages in having the proposed solution (a fast lookup for a node given its element).</li><li>Subclassing DOM element classes to either augment their functionality (and obviate the need for `IFocusableNode`) or to avoid needing an explicit tree representation. It's unclear how complex this would be, and it doesn't follow existing Blockly design paradigms or patterns.</li><li>Other types of registrations that would require `IFocusableNode` to self-register (e.g. for ID or hierarchy tracking) were considered, but eliminated since the boilerplate overhead of each node needing to register itself is likely prohibitively high (and error-prone since each node would need to be built to ensure it correctly self-registers).</li></ul> |




#### 3.1.3 FocusManager


```typescript
/**
 * A per-page singleton that manages Blockly focus across one or more
 * IFocusableTrees, and bidirectionally synchronizes this focus with the DOM.
 *
 * Callers that wish to explicitly change input focus for select Blockly components
 * on the page should use the focus functions in this manager.
 *
 * The manager is responsible for handling focus events from the DOM (which may
 * may arise from users clicking on page elements) and ensuring that corresponding
 * IFocusableNodes are clearly marked as actively/passively highlighted in the same
 * way that this would be represented with calls to focusNode().
 */
export class FocusManager {
  /** Registers a new IFocusableTree for automatic focus management. */
  export function registerTree(tree: IFocusableTree): void { ... }
  /** Unregisters a IFocusableTree from automatic focus management. */
  export function unregisterTree(tree: IFocusableTree): void { ... }

  /**
   * Returns the current IFocusableTree that has focus, or null if none currently
   * do.
   */
  export function getFocusedTree(): IFocusableTree | null { ... }
  /**
   * Returns the current IFocusableNode with focus (which is always tied to a
   * focused IFocusableTree), or null if there isn't one.
   *
   * Note that this function will maintain parity with
   * IFocusableTree.getFocusedNode(). That is, if a tree itself has focus but none
   * of its non-root children do, this will return null but getFocusedTree() will
   * not.
   */
  export function getFocusedNode(): IFocusableNode | null { ... }
  /**
   * Focuses the specific IFocusableTree. This either means restoring active focus
   * to the tree's passively focused node, or focusing the tree's root node.
   *
   * See the other focus method for details on how other nodes are affected.
   */
  export function focusTree(focusableTree: IFocusableTree): void { ... }
  /**
   * Focuses DOM input on the selected node, and marks it as actively focused. If
   * there was a previously focused node it will be updated to be passively
   * highlighted (if it's in a different focusable tree) or blurred (if it's in the
   * same one).
   */
  export function focusNode(focusableNode: IFocusableNode): void { ... }

  /**
   * Ephemerally captures focus for a selected element until the returned lambda is
   * called, especially useful for ephemeral UI flows like modals.
   *
   * IMPORTANT: the returned lambda *must* be called, otherwise automatic focus
   * will no longer work anywhere on the page. It is highly recommended to tie
   * the lambda call to the closure of the corresponding UI so that if input is
   * manually changed to an element outside of the ephemeral UI, the UI should
   * close and automatic input restored.
   *
   * Note that the manager will continue to track DOM input signals even when
   * ephemeral focus is active, but it won't actually change node state until the
   * returned lambda is called. Additionally, only 1 ephemeral focus context can be
   * active at any given time (attempting to activate more than one simultaneously
   * will result in an error being thrown).
   */
  export function takeEphemeralFocus(focusableElement: Element): () => void { ... }
}

/**
 * Returns the page-global FocusManager.
 *
 * The returned instance is guaranteed to not change across function calls, but may
 * change across page loads.
 */
export function getFocusManager(): FocusManager { ... }
```


Specific details:



*   Trees are able to self-manage their registration to keep things simpler (and easier for cases where a tree may only want automatic focus tracking some parts of the time).
*   Tree registration results in the tree's root element having `focusin` and `focusout` events registered to capture all focus changes for every node in the tree since both events bubble (unlike `focus` and `blur`). See the corresponding MDN page for more details: [link](https://developer.mozilla.org/en-US/docs/Web/API/Element/focusin_event).
*   There are top-level methods for retrieving current focus for convenience, and it's expected that these will be the primary providers of such data (and just call through to the interfaces, anyway). This is particularly noteworthy for keyboard navigation management as it's expected that the current focused node will be retrieved and analyzed upon a keybinding being pressed to determine where to navigate next, or how to handle that node.
*   The only way to deliberately change focus of a tree or element is to use the `focus` methods here, otherwise focus state will respond to DOM changes.
*   The concept of ephemeral focus is mainly for modals or other transient UI affordances that should close upon losing input focus. It's actually very important that callers call back to the returned lambda otherwise the automatic focus experience will break.
*   The exact design of using a class with a singleton instance vs. exporting functions and using private static state is unimportant and may change during implementation.
*   All of the DOM-Blockly focus synchronizing and logic to actually ensure trees have at most one active/passive focused node (and among those, at most one can be 'active') is completely handled within `FocusManager`.
*   Some specific details on focus synchronizing:
    *   Changes to DOM focus on elements directly will result in the nearest parent focusable node receiving Blockly focus.
    *   Changes to Blockly focus will trigger the corresponding relevant node in the DOM to receive browser focus. This is especially important as it's both fundamental for keyboard input (such as for fields) and for screen reader read-out as the context for reading is directly tied to browser focus.
    *   Since it's possible for trees to be nested, it's expected that the `FocusManager` implementation will need to ensure that focus is in the innermost tree at any given time by tracking `focusin` and `focusout` events on all registered trees simultaneously.
    *   `FocusManager` must ensure that focus is correctly removed for the actively focused element, likely by tracking `focusin` and `focusout` on the `document` and ignoring signals when it also receives events from a tracked tree.
*   Different possible focus scenarios and how it affects the behaviors of the various `get*()` functions:


<table>
  <tr>
   <td>
Focus on:
   </td>
   <td><code>getFocusedTree()</code>
   </td>
   <td><code>FocusManager.getFocusedNode()</code>
   </td>
   <td><code>IFocusableTree.getFocusedNode()</code>
   </td>
  </tr>
  <tr>
   <td>Nothing
   </td>
   <td><code>null</code>
   </td>
   <td><code>null</code>
   </td>
   <td><code>null</code>
   </td>
  </tr>
  <tr>
   <td>Tree root
   </td>
   <td>Non-<code>null</code> tree.
   </td>
   <td><code>null</code>
   </td>
   <td><code>null</code>
   </td>
  </tr>
  <tr>
   <td>Non-root
   </td>
   <td>Non-<code>null</code> tree.
   </td>
   <td>Non-<code>null</code> node.
   </td>
   <td>Non-<code>null</code> node.
   </td>
  </tr>
</table>



| Alternatives Considered:                                                                                                                                                                                                                                                                                                                                                                   |
|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| <ul><li>Using a per-workspace `FocusManager`: this idea is complex because, without centralized coordination of focus, additional machinery needs to be built to support proper 'hand-off' of focus between components.</li><li>Supporting direct registration of `IFocusableNode`: this adds a lot of boilerplate complexity. See `IFocusableTree`'s alternatives considered section.</li></ul> |



### 3.2 API Changes

This section is intentionally kept simpler and non-comprehensive. It's expected that the main areas of required change will be determined through a combination of code exploration and testing. However, it''s expected that, at minimum, each of the following Blockly components are likely to have at least one `IFocusableNode` implementation required:



*   Toolbox categories and subcategories.
*   The workspace and comments.
*   The flyout (such as for buttons and labels), and arbitrary items that can be added as of v12 (see [#8687](https://github.com/google/blockly/pull/8687) for some context on how this affected the old keyboard navigation plugin).
*   Blocks, connections, and fields.
*   Icons, mutators, and bubbles.
*   Flyout buttons and labels.
*   Zoom controls.
*   The trashcan control and the trashcan flyout.
*   Any custom flyout items with a visual representation.
*   Other positionables, such as a [search widget](https://google.github.io/blockly-samples/plugins/workspace-search/test/index.html) if we're putting that in the top right corner.
*   Arbitrary modals (such as the confirmation dialog when deleting a variable that is used in multiple places).

Separately there are changes in `Workspace` that will be needed in order to accommodate the new `FocusManager`.


### 3.3 Open Questions

There are several open questions that are not expected to be answered during the design phase of this project but, rather, should be verified through implementation review and user testing:



*   Will a custom visual indicator for Blockly focus conflict with the existing indicator for browser focus (e.g. when using tab navigation)? Does this matter for SVG elements (i.e. do some SVG elements support/not support browser focus visual indication)?
*   It's important that users are aware when a `Workspace` is focused for keyboard navigation. Are the visual indicators planned above sufficient, or does an explicit indicator need to be added at the `Workspace` level? If the latter, that may well be something more appropriate for the keyboard navigation plugin (but this needs further consideration).


## 4 Additional Changes



*   The keyboard plugin should be updated to still perform selection as necessary, but otherwise to mainly 'select' nodes by focusing them via `FocusManager`. Custom cursor rendering will no longer be necessary.
*   `ASTNode` may be able to be removed in favor of adding traversal functions to `IFocusableNode` which could potentially substantially simplify `NavigationController` and avoid the need for the custom line cursor (though it's unclear how much complexity would be added to individual nodes, and if any of that needs to be factored out into a common helper utility).
*   Element read-out will be made possible by removing tab indexes and ensuring correct ARIA labels and roles are specified for all focusable nodes. Simply focusing elements should be sufficient for the screen reader to read out the correct thing in most situations.
*   While not directly related, other parts of the Blockly experience will need to be changed in order for the full accessibility experience to work, including removing the page singleton for drop down menus (in order for some ARIA roles to be made to work correctly).
*   It's expected that all navigation logic will eventually be moved into core Blockly.
author_association: COLLABORATOR
user: login: kmcnaught
created_at: 2025-02-14T10:13:39Z
body: Just a note to remind us to do some additional testing when implemented...

> Will a custom visual indicator for Blockly focus conflict with the existing indicator for browser focus (e.g. when using tab navigation)? Does this matter for SVG elements (i.e. do some SVG elements support/not support browser focus visual indication)?

We should ensure we test with additional browser-level focus settings, e.g. Chrome's "Show a quick highlight on the focused object" in case that conflicts in a more problematic way. 
author_association: CONTRIBUTOR

Diff:
diff --git a/src/actions/arrow_navigation.ts b/src/actions/arrow_navigation.ts
index 54b0227f..7945e72d 100644
--- a/src/actions/arrow_navigation.ts
+++ b/src/actions/arrow_navigation.ts
@@ -8,6 +8,7 @@ import {ASTNode, ShortcutRegistry, utils as BlocklyUtils} from 'blockly/core';
 
 import type {Field, Toolbox, WorkspaceSvg} from 'blockly/core';
 
+import * as Blockly from 'blockly/core';
 import * as Constants from '../constants';
 import type {Navigation} from '../navigation';
 
@@ -53,6 +54,7 @@ export class ArrowNavigation {
       shortcut: ShortcutRegistry.KeyboardShortcut,
     ): boolean => {
       const toolbox = workspace.getToolbox() as Toolbox;
+      const flyout = workspace.getFlyout();
       let isHandled = false;
       switch (this.navigation.getState(workspace)) {
         case Constants.STATE.WORKSPACE:
@@ -67,12 +69,11 @@ export class ArrowNavigation {
           }
           return isHandled;
         case Constants.STATE.TOOLBOX:
-          isHandled =
-            toolbox && typeof toolbox.onShortcut === 'function'
-              ? toolbox.onShortcut(shortcut)
-              : false;
-          if (!isHandled) {
-            this.navigation.focusFlyout(workspace);
+          // @ts-expect-error private method
+          isHandled = toolbox && toolbox.selectChild();
+          if (!isHandled && flyout) {
+            Blockly.getFocusManager().focusTree(flyout.getWorkspace());
+            this.navigation.defaultFlyoutCursorIfNeeded(workspace);
           }
           return true;
         default:
@@ -100,12 +101,13 @@ export class ArrowNavigation {
           }
           return isHandled;
         case Constants.STATE.FLYOUT:
-          this.navigation.focusToolbox(workspace);
+          if (toolbox) {
+            Blockly.getFocusManager().focusTree(toolbox);
+          }
           return true;
         case Constants.STATE.TOOLBOX:
-          return toolbox && typeof toolbox.onShortcut === 'function'
-            ? toolbox.onShortcut(shortcut)
-            : false;
+          // @ts-expect-error private method
+          return toolbox && toolbox.selectParent();
         default:
           return false;
       }
@@ -173,9 +175,24 @@ export class ArrowNavigation {
               }
               return isHandled;
             case Constants.STATE.TOOLBOX:
-              return toolbox && typeof toolbox.onShortcut === 'function'
-                ? toolbox.onShortcut(shortcut)
-                : false;
+              if (toolbox) {
+                if (!toolbox.getSelectedItem()) {
+                  const firstItem =
+                    toolbox
+                      .getToolboxItems()
+                      .find((item) => item.isSelectable()) ?? null;
+                  toolbox.setSelectedItem(firstItem);
+                  isHandled = true;
+                } else {
+                  // @ts-expect-error private method
+                  isHandled = toolbox.selectNext();
+                }
+                const selectedItem = toolbox.getSelectedItem();
+                if (selectedItem) {
+                  Blockly.getFocusManager().focusNode(selectedItem);
+                }
+              }
+              return isHandled;
             default:
               return false;
           }
@@ -221,9 +238,15 @@ export class ArrowNavigation {
               }
               return isHandled;
             case Constants.STATE.TOOLBOX:
-              return toolbox && typeof toolbox.onShortcut === 'function'
-                ? toolbox.onShortcut(shortcut)
-                : false;
+              if (toolbox) {
+                // @ts-expect-error private method
+                isHandled = toolbox.selectPrevious();
+                const selectedItem = toolbox.getSelectedItem();
+                if (selectedItem) {
+                  Blockly.getFocusManager().focusNode(selectedItem);
+                }
+              }
+              return isHandled;
             default:
               return false;
           }
diff --git a/src/actions/clipboard.ts b/src/actions/clipboard.ts
index d34991a7..677bdf7f 100644
--- a/src/actions/clipboard.ts
+++ b/src/actions/clipboard.ts
@@ -13,6 +13,7 @@ import {
   clipboard,
   ICopyData,
   LineCursor,
+  getFocusManager,
 } from 'blockly';
 import * as Constants from '../constants';
 import type {BlockSvg, WorkspaceSvg} from 'blockly';
@@ -281,7 +282,7 @@ export class Clipboard {
     const copied = !!this.copyData;
     if (copied) {
       if (navigationState === Constants.STATE.FLYOUT) {
-        this.navigation.focusWorkspace(workspace);
+        getFocusManager().focusTree(workspace);
       }
       showCopiedHint(workspace);
     }
@@ -375,10 +376,10 @@ export class Clipboard {
       ? workspace
       : this.copyWorkspace;
 
-    const targetNode = this.navigation.getStationaryNode(pasteWorkspace);
-    // If we're pasting in the flyout it still targets the workspace. Focus first
-    // so ensure correct selection handling.
-    this.navigation.focusWorkspace(workspace);
+    const targetNode = this.navigation.getFocusedASTNode(pasteWorkspace);
+    // If we're pasting in the flyout it still targets the workspace. Focus
+    // first as to ensure correct selection handling.
+    getFocusManager().focusTree(workspace);
 
     Events.setGroup(true);
     const block = clipboard.paste(this.copyData, pasteWorkspace) as BlockSvg;
diff --git a/src/actions/enter.ts b/src/actions/enter.ts
index d8016483..161199f6 100644
--- a/src/actions/enter.ts
+++ b/src/actions/enter.ts
@@ -9,6 +9,7 @@ import {
   Events,
   ShortcutRegistry,
   utils as BlocklyUtils,
+  getFocusManager,
 } from 'blockly/core';
 
 import type {
@@ -134,7 +135,7 @@ export class EnterAction {
       Events.setGroup(true);
     }
 
-    const stationaryNode = this.navigation.getStationaryNode(workspace);
+    const stationaryNode = this.navigation.getFocusedASTNode(workspace);
     const newBlock = this.createNewBlock(workspace);
     if (!newBlock) return;
     const insertStartPoint = stationaryNode
@@ -146,7 +147,7 @@ export class EnterAction {
 
     workspace.setResizesEnabled(true);
 
-    this.navigation.focusWorkspace(workspace);
+    getFocusManager().focusTree(workspace);
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     workspace.getCursor()?.setCurNode(ASTNode.createBlockNode(newBlock)!);
     this.mover.startMove(workspace, newBlock, insertStartPoint);
diff --git a/src/actions/exit.ts b/src/actions/exit.ts
index 3b9666be..e1afb99d 100644
--- a/src/actions/exit.ts
+++ b/src/actions/exit.ts
@@ -4,7 +4,12 @@
  * SPDX-License-Identifier: Apache-2.0
  */
 
-import {ShortcutRegistry, utils as BlocklyUtils} from 'blockly/core';
+import {
+  ShortcutRegistry,
+  utils as BlocklyUtils,
+  getFocusManager,
+  Gesture,
+} from 'blockly/core';
 
 import * as Constants from '../constants';
 import type {Navigation} from '../navigation';
@@ -29,7 +34,10 @@ export class ExitAction {
         switch (this.navigation.getState(workspace)) {
           case Constants.STATE.FLYOUT:
           case Constants.STATE.TOOLBOX:
-            this.navigation.focusWorkspace(workspace);
+            getFocusManager().focusTree(workspace);
+            if (!Gesture.inProgress()) {
+              workspace.hideChaff();
+            }
             return true;
           default:
             return false;
diff --git a/src/actions/mover.ts b/src/actions/mover.ts
index 9093e065..56134f6b 100644
--- a/src/actions/mover.ts
+++ b/src/actions/mover.ts
@@ -15,6 +15,7 @@ import {
   ASTNode,
   Connection,
   dragging,
+  getFocusManager,
   registry,
   utils,
   WorkspaceSvg,
@@ -132,6 +133,9 @@ export class Mover {
     // Begin drag.
     dragger.onDragStart(info.fakePointerEvent('pointerdown'));
     info.updateTotalDelta();
+    // In case the block is detached, ensure that it still retains focus
+    // (otherwise dragging will break).
+    getFocusManager().focusNode(block);
     return true;
   }
 
@@ -159,6 +163,8 @@ export class Mover {
     this.moves.delete(workspace);
     // Delay scroll until after block has finished moving.
     setTimeout(() => this.scrollCurrentBlockIntoView(workspace), 0);
+    // If the block gets reattached, ensure it retains focus.
+    getFocusManager().focusNode(info.block);
     return true;
   }
 
@@ -205,6 +211,8 @@ export class Mover {
     this.moves.delete(workspace);
     // Delay scroll until after block has finished moving.
     setTimeout(() => this.scrollCurrentBlockIntoView(workspace), 0);
+    // If the block gets reattached, ensure it retains focus.
+    getFocusManager().focusNode(info.block);
     return true;
   }
 
diff --git a/src/gesture_monkey_patch.js b/src/gesture_monkey_patch.js
deleted file mode 100644
index 3e5a1bff..00000000
--- a/src/gesture_monkey_patch.js
+++ /dev/null
@@ -1,32 +0,0 @@
-/**
- * @license
- * Copyright 2021 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-/**
- * @fileoverview Overrides methods on Blockly.Gesture to integrate focus mangagement
- * with the gesture handling.
- * @author aschmiedt@google.com (Abby Schmiedt)
- */
-
-import * as Blockly from 'blockly/core';
-
-const oldDispose = Blockly.Gesture.prototype.dispose;
-
-/**
- * Intercept the end of a gesture and ensure the workspace is focused.
- * See also the listener is index.ts that subverts the markFocused call
- * in `doStart`.
- * @this {Blockly.Gesture}
- * @override
- */
-Blockly.Gesture.prototype.dispose = function () {
-  // This is a bit of a cludge and focus management needs to be better
-  // integrated with Gesture. The intent is to move focus at the end of
-  // a drag from a non-auto-closing flyout.
-  if (this.isDragging()) {
-    this.creatorWorkspace?.getSvgGroup().focus();
-  }
-  oldDispose.call(this);
-};
diff --git a/src/index.ts b/src/index.ts
index 6acc6f38..77a03369 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -6,7 +6,6 @@
 
 import * as Blockly from 'blockly/core';
 import {NavigationController} from './navigation_controller';
-import {getFlyoutElement, getToolboxElement} from './workspace_utilities';
 import {enableBlocksOnDrag} from './disabled_blocks';
 
 /** Options object for KeyboardNavigation instances. */
@@ -24,27 +23,6 @@ export class KeyboardNavigation {
   /** The workspace. */
   protected workspace: Blockly.WorkspaceSvg;
 
-  /** Event handler run when the workspace gains focus. */
-  private focusListener: () => void;
-
-  /** Event handler run when the workspace loses focus. */
-  private blurListener: () => void;
-
-  /** Event handler run when the widget or dropdown div loses focus. */
-  private widgetDropDownDivFocusOutListener: (e: Event) => void;
-
-  /** Event handler run when the toolbox gains focus. */
-  private toolboxFocusInListener: (e: Event) => void;
-
-  /** Event handler run when the toolbox loses focus. */
-  private toolboxFocusOutListener: (e: Event) => void;
-
-  /** Event handler run when the flyout gains focus. */
-  private flyoutFocusListener: () => void;
-
-  /** Event handler run when the flyout loses focus. */
-  private flyoutBlurListener: (e: Event) => void;
-
   /** Keyboard navigation controller instance for the workspace. */
   private navigationController: NavigationController;
 
@@ -55,19 +33,8 @@ export class KeyboardNavigation {
    * These fields are used to preserve the workspace's initial state to restore
    * it when/if keyboard navigation is disabled.
    */
-  private injectionDivTabIndex: string | null;
-  private workspaceParentTabIndex: string | null;
   private originalTheme: Blockly.Theme;
 
-  /**
-   * Stored to enable us to undo workspace monkey patching.
-   */
-  private oldMarkFocused: () => void;
-  /**
-   * Stored to enable us to undo flyout workspace monkey patching.
-   */
-  private oldFlyoutMarkFocused: (() => void) | null = null;
-
   /**
    * Constructs the keyboard navigation.
    *
@@ -97,190 +64,26 @@ export class KeyboardNavigation {
     // Add the event listener to enable disabled blocks on drag.
     workspace.addChangeListener(enableBlocksOnDrag);
 
-    // Ensure that only the root SVG G (group) has a tab index.
-    this.injectionDivTabIndex = workspace
-      .getInjectionDiv()
-      .getAttribute('tabindex');
-    workspace.getInjectionDiv().removeAttribute('tabindex');
-    this.workspaceParentTabIndex = workspace
-      .getParentSvg()
-      .getAttribute('tabindex');
-    // We add a focus listener below so use -1 so it doesn't become focusable.
-    workspace.getParentSvg().setAttribute('tabindex', '-1');
-
     // Move the flyout for logical tab order.
-    const flyoutElement = getFlyoutElement(workspace);
-    flyoutElement?.parentElement?.insertBefore(
-      flyoutElement,
-      workspace.getParentSvg(),
-    );
-
-    // Temporary workaround for #136.
-    this.oldMarkFocused = workspace.markFocused;
-    workspace.markFocused = () => {
-      // Starting a gesture unconditionally calls markFocused on the parent SVG
-      // but we really don't want to move to the workspace (and close the
-      // flyout) if all you did was click in a flyout, potentially on a
-      // button. See also `gesture_monkey_patch.js`.
+    const flyout = workspace.getFlyout();
+    if (flyout != null && flyout instanceof Blockly.Flyout) {
+      // This relies on internals.
       // eslint-disable-next-line @typescript-eslint/no-explicit-any
-      const gestureInternals = this.workspace.currentGesture_ as any;
-      const gestureFlyout = gestureInternals?.flyout;
-      const gestureFlyoutAutoClose = gestureFlyout?.autoClose;
-      const gestureOnBlock = gestureInternals?.startBlock;
-
-      if (
-        // When clicking on flyout that cannot close.
-        (gestureFlyout && !gestureFlyoutAutoClose) ||
-        // When clicking on a block in a flyout that can close.
-        (gestureFlyout && gestureFlyoutAutoClose && !gestureOnBlock)
-      ) {
-        this.navigationController.focusFlyout(workspace);
-      } else {
-        this.navigationController.focusWorkspace(workspace);
-      }
-    };
-    const flyout = this.workspace.getFlyout();
-    if (flyout) {
-      this.oldFlyoutMarkFocused = flyout.getWorkspace().markFocused;
-      flyout.getWorkspace().markFocused = () => {
-        // By default this would call markFocused on the main workspace.  It is
-        // called when you click on the flyout scrollbars, which is a
-        // particularly unfortunate time for the flyout to close.
-        this.navigationController.focusFlyout(this.workspace);
-      };
-    }
-
-    this.focusListener = () => {
-      this.navigationController.handleFocusWorkspace(workspace);
-    };
-    this.blurListener = () => {
-      this.navigationController.handleBlurWorkspace(workspace);
-    };
-
-    workspace.getSvgGroup().addEventListener('focus', this.focusListener);
-    workspace.getSvgGroup().addEventListener('blur', this.blurListener);
-
-    this.widgetDropDownDivFocusOutListener = (e: Event) => {
-      this.navigationController.handleFocusOutWidgetDropdownDiv(
-        workspace,
-        (e as FocusEvent).relatedTarget,
-      );
-    };
-
-    Blockly.WidgetDiv.getDiv()?.addEventListener(
-      'focusout',
-      this.widgetDropDownDivFocusOutListener,
-    );
-    Blockly.DropDownDiv.getContentDiv()?.addEventListener(
-      'focusout',
-      this.widgetDropDownDivFocusOutListener,
-    );
-
-    const toolboxElement = getToolboxElement(workspace);
-    this.toolboxFocusInListener = (e: Event) => {
-      if (
-        (e.currentTarget as Element).contains(
-          (e as FocusEvent).relatedTarget as Node,
-        )
-      ) {
-        return;
-      }
-
-      this.navigationController.handleFocusToolbox(workspace);
-    };
-    this.toolboxFocusOutListener = (e: Event) => {
-      if (
-        (e.currentTarget as Element).contains(
-          (e as FocusEvent).relatedTarget as Node,
-        )
-      ) {
-        return;
-      }
-
-      this.navigationController.handleBlurToolbox(
-        workspace,
-        this.shouldCloseFlyoutOnBlur(
-          (e as FocusEvent).relatedTarget,
-          flyoutElement,
-        ),
-      );
-    };
-    toolboxElement?.addEventListener('focusin', this.toolboxFocusInListener);
-    toolboxElement?.addEventListener('focusout', this.toolboxFocusOutListener);
-
-    this.flyoutFocusListener = () => {
-      this.navigationController.handleFocusFlyout(workspace);
-    };
-    this.flyoutBlurListener = (e: Event) => {
-      this.navigationController.handleBlurFlyout(
-        workspace,
-        this.shouldCloseFlyoutOnBlur(
-          (e as FocusEvent).relatedTarget,
-          toolboxElement,
-        ),
+      const flyoutElement = ((flyout as any).svgGroup_ as SVGElement) ?? null;
+      flyoutElement?.parentElement?.insertBefore(
+        flyoutElement,
+        workspace.getParentSvg(),
       );
-    };
-    flyoutElement?.addEventListener('focus', this.flyoutFocusListener);
-    flyoutElement?.addEventListener('blur', this.flyoutBlurListener);
+    }
   }
 
   /**
    * Disables keyboard navigation for this navigator's workspace.
    */
   dispose() {
-    // Revert markFocused monkey patch.
-    this.workspace.markFocused = this.oldMarkFocused;
-    if (this.oldFlyoutMarkFocused) {
-      const flyout = this.workspace.getFlyout();
-      if (flyout) {
-        flyout.getWorkspace().markFocused = this.oldFlyoutMarkFocused;
-      }
-    }
-
     // Remove the event listener that enables blocks on drag
     this.workspace.removeChangeListener(enableBlocksOnDrag);
 
-    this.workspace.getSvgGroup().removeEventListener('blur', this.blurListener);
-    this.workspace
-      .getSvgGroup()
-      .removeEventListener('focus', this.focusListener);
-
-    Blockly.WidgetDiv.getDiv()?.removeEventListener(
-      'focusout',
-      this.widgetDropDownDivFocusOutListener,
-    );
-    Blockly.DropDownDiv.getContentDiv()?.removeEventListener(
-      'focusout',
-      this.widgetDropDownDivFocusOutListener,
-    );
-
-    const toolboxElement = getToolboxElement(this.workspace);
-    toolboxElement?.removeEventListener('focusin', this.toolboxFocusInListener);
-    toolboxElement?.removeEventListener(
-      'focusout',
-      this.toolboxFocusOutListener,
-    );
-
-    const flyoutElement = getFlyoutElement(this.workspace);
-    flyoutElement?.removeEventListener('focus', this.flyoutFocusListener);
-    flyoutElement?.removeEventListener('blur', this.flyoutBlurListener);
-
-    if (this.workspaceParentTabIndex) {
-      this.workspace
-        .getParentSvg()
-        .setAttribute('tabindex', this.workspaceParentTabIndex);
-    } else {
-      this.workspace.getParentSvg().removeAttribute('tabindex');
-    }
-
-    if (this.injectionDivTabIndex) {
-      this.workspace
-        .getInjectionDiv()
-        .setAttribute('tabindex', this.injectionDivTabIndex);
-    } else {
-      this.workspace.getInjectionDiv().removeAttribute('tabindex');
-    }
-
     this.workspace.setTheme(this.originalTheme);
 
     this.navigationController.dispose();
@@ -307,32 +110,4 @@ export class KeyboardNavigation {
     });
     this.workspace.setTheme(newTheme);
   }
-
-  /**
-   * Identify whether we should close the flyout when the toolbox or flyout
-   * blurs. If a gesture is in progerss or we're moving from one the other
-   * then we leave it open.
-   *
-   * @param relatedTarget The related target from the event on the flyout or toolbox.
-   * @param container The other element of flyout or toolbox (opposite to the event).
-   * @returns true if the flyout should be closed, false otherwise.
-   */
-  private shouldCloseFlyoutOnBlur(
-    relatedTarget: EventTarget | null,
-    container: Element | null,
-  ) {
-    if (Blockly.Gesture.inProgress()) {
-      return false;
-    }
-    if (!relatedTarget) {
-      return false;
-    }
-    if (
-      relatedTarget instanceof Node &&
-      container?.contains(relatedTarget as Node)
-    ) {
-      return false;
-    }
-    return true;
-  }
 }
diff --git a/src/navigation.ts b/src/navigation.ts
index c6929e28..14c2c67e 100644
--- a/src/navigation.ts
+++ b/src/navigation.ts
@@ -17,12 +17,6 @@ import {
   registrationType as cursorRegistrationType,
   FlyoutCursor,
 } from './flyout_cursor';
-import {
-  getFlyoutElement,
-  getToolboxElement,
-  getWorkspaceElement,
-} from './workspace_utilities';
-import {PassiveFocus} from './passive_focus';
 
 /**
  * The default coordinate to use when focusing on the workspace and no
@@ -44,12 +38,6 @@ const WS_COORDINATE_ON_DELETE: Blockly.utils.Coordinate =
  * Class that holds all methods necessary for keyboard navigation to work.
  */
 export class Navigation {
-  /**
-   * Object holding the location of the cursor for each workspace.
-   * Possible locations of the cursor are: workspace, flyout or toolbox.
-   */
-  workspaceStates: {[index: string]: Constants.STATE} = {};
-
   /**
    * Wrapper for method that deals with workspace changes.
    * Used for removing change listener.
@@ -68,11 +56,6 @@ export class Navigation {
    */
   protected workspaces: Blockly.WorkspaceSvg[] = [];
 
-  /**
-   * An object that renders a passive focus indicator at a specified location.
-   */
-  private passiveFocusIndicator: PassiveFocus = new PassiveFocus();
-
   /**
    * Constructor for keyboard navigation.
    */
@@ -114,7 +97,6 @@ export class Navigation {
     if (workspaceIdx > -1) {
       this.workspaces.splice(workspaceIdx, 1);
     }
-    this.passiveFocusIndicator.dispose();
     workspace.removeChangeListener(this.wsChangeWrapper);
 
     if (flyout) {
@@ -122,36 +104,56 @@ export class Navigation {
     }
   }
 
-  /**
-   * Sets the state for the given workspace.
-   *
-   * @param workspace The workspace to set the state on.
-   * @param state The navigation state.
-   */
-  setState(workspace: Blockly.WorkspaceSvg, state: Constants.STATE) {
-    this.workspaceStates[workspace.id] = state;
-  }
-
   /**
    * Gets the navigation state of the current workspace.
    *
+   * Note that this assumes a workspace with passive focus (including for its
+   * toolbox or flyout) has a state of NOWHERE.
+   *
    * @param workspace The workspace to get the state of.
    * @returns The state of the given workspace.
    */
   getState(workspace: Blockly.WorkspaceSvg): Constants.STATE {
-    return this.workspaceStates[workspace.id];
+    const focusedTree = Blockly.getFocusManager().getFocusedTree();
+    if (focusedTree instanceof Blockly.WorkspaceSvg) {
+      if (focusedTree.isFlyout && workspace === focusedTree.targetWorkspace) {
+        return Constants.STATE.FLYOUT;
+      } else if (workspace === focusedTree) {
+        return Constants.STATE.WORKSPACE;
+      }
+    } else if (focusedTree instanceof Blockly.Toolbox) {
+      if (workspace === focusedTree.getWorkspace()) {
+        return Constants.STATE.TOOLBOX;
+      }
+    } else if (focusedTree instanceof Blockly.Flyout) {
+      if (workspace === focusedTree.targetWorkspace) {
+        return Constants.STATE.FLYOUT;
+      }
+    }
+    // Either a non-Blockly element currently has DOM focus, or a different
+    // workspace holds it.
+    return Constants.STATE.NOWHERE;
   }
 
   /**
-   * Gets the node to use as context for insert operations.
+   * Searches the specified workspace for an ASTNode representation of its
+   * current focused node (which may be active or passive).
    *
-   * @param workspace The main workspace.
+   * @param workspace The workspace being searched.
+   * @returns An ASTNode representation of the current focused node, or null if
+   *     the specified workspace either doesn't have a focused node, or it
+   *     cannot be represented as an ASTNode.
    */
-  getStationaryNode(workspace: Blockly.WorkspaceSvg) {
-    return (
-      this.passiveFocusIndicator.getCurNode() ??
-      workspace.getCursor()?.getCurNode()
-    );
+  getFocusedASTNode(workspace: Blockly.WorkspaceSvg): Blockly.ASTNode | null {
+    const passive = Blockly.FocusableTreeTraverser.findFocusedNode(workspace);
+    if (passive instanceof Blockly.BlockSvg) {
+      return Blockly.ASTNode.createBlockNode(passive);
+    } else if (passive instanceof Blockly.Field) {
+      return Blockly.ASTNode.createFieldNode(passive);
+    } else if (passive instanceof Blockly.Connection) {
+      return Blockly.ASTNode.createConnectionNode(passive);
+    }
+    return null;
   }
 
   /**
@@ -213,19 +215,6 @@ export class Navigation {
           this.handleBlockMutation(workspace, e as Blockly.Events.BlockChange);
         }
         break;
-      case Blockly.Events.BLOCK_CREATE:
-        if (workspace.isDragging()) {
-          // Hide the passive focus indicator when dragging so as not to fight
-          // with the drop cues. Safe because of the gesture monkey patch.
-          this.passiveFocusIndicator.hide();
-        }
-        break;
-    }
-
-    // Hiding the cursor isn't permanent and can show again when we render.
-    // Rehide it:
-    if (this.passiveFocusIndicator.isVisible()) {
-      workspace.getCursor()?.hide();
     }
   }
 
@@ -372,191 +361,10 @@ export class Navigation {
         Blockly.ASTNode.createStackNode(curNodeBlock),
       );
     }
-    this.focusFlyout(mainWorkspace);
-  }
-
-  /**
-   * Sets browser focus to the workspace.
-   *
-   * @param workspace The workspace to focus.
-   */
-  focusWorkspace(workspace: Blockly.WorkspaceSvg) {
-    getWorkspaceElement(workspace).focus();
-  }
-
-  /**
-   * Sets the navigation state to workspace and moves the cursor to either the
-   * top block on a workspace or to the workspace. Switches from passive focus
-   * indication to showing the cursor.
-   *
-   * @param workspace The workspace that has gained focus.
-   */
-  handleFocusWorkspace(workspace: Blockly.WorkspaceSvg) {
-    this.setState(workspace, Constants.STATE.WORKSPACE);
-    if (!Blockly.Gesture.inProgress()) {
-      workspace.hideChaff();
-      // This will make a selection which would interfere with any gesture.
-      this.defaultWorkspaceCursorPositionIfNeeded(workspace);
-    }
-
-    const cursor = workspace.getCursor();
-    if (cursor) {
-      const passiveFocusNode = this.passiveFocusIndicator.getCurNode();
-      this.passiveFocusIndicator.hide();
-      const disposed = passiveFocusNode?.getSourceBlock()?.disposed;
-      // If there's a gesture then it will either set the node if it has not
-      // been disposed (which can happen when blocks are reloaded) or be a click
-      // that should not set one.
-      if (!Blockly.Gesture.inProgress() && passiveFocusNode && !disposed) {
-        cursor.setCurNode(passiveFocusNode);
-      }
-    }
-  }
-
-  /**
-   * Clears navigation state and switches to using the passive focus indicator
-   * if it is not the context menu / field input that is causing blur.
-   *
-   * @param workspace The workspace that has lost focus.
-   * @param ignorePopUpDivs Whether to skip the focus indicator change when
-   *     the widget/dropdown divs are open.
-   */
-  handleBlurWorkspace(
-    workspace: Blockly.WorkspaceSvg,
-    ignorePopUpDivs = false,
-  ) {
-    this.setState(workspace, Constants.STATE.NOWHERE);
-    const cursor = workspace.getCursor();
-    const popUpDivsShowing =
-      Blockly.WidgetDiv.isVisible() || Blockly.DropDownDiv.isVisible();
-    if (cursor && (ignorePopUpDivs || !popUpDivsShowing)) {
-      const curNode = cursor.getCurNode();
-      if (curNode) {
-        this.passiveFocusIndicator.show(curNode);
-      }
-      // It's initially null so this is a valid state despite the types.
-      cursor.setCurNode(null);
-    }
-  }
-
-  /**
-   * Handle the widget or dropdown div losing focus (via focusout).
-   *
-   * Because we skip the widget/dropdown div cases in `handleBlurWorkspace` we need
-   * to catch them here.
-   *
-   * @param workspace The workspace.
-   * @param relatedTarget The related target (newly focused element if any).
-   */
-  handleFocusOutWidgetDropdownDiv(
-    workspace: Blockly.WorkspaceSvg,
-    relatedTarget: EventTarget | null,
-  ) {
-    if (relatedTarget === null) {
-      // Workaround:
-      // Skip document.body/null case until this blur bug is fixed to avoid
-      // flipping to passive focus as the user moves their mouse over the
-      // colour picker.
-      // https://github.com/google/blockly-samples/issues/2498
-      return;
-    }
-    if (relatedTarget !== getWorkspaceElement(workspace)) {
-      this.handleBlurWorkspace(workspace, true);
-    }
-  }
-
-  /**
-   * Sets browser focus to the toolbox (if any).
-   *
-   * @param workspace The workspace with the toolbox.
-   */
-  focusToolbox(workspace: Blockly.WorkspaceSvg) {
-    getToolboxElement(workspace)?.focus();
-  }
-
-  /**
-   * Sets the navigation state to toolbox and selects the first category in the
-   * toolbox. No-op if a toolbox does not exist on the given workspace.
-   *
-   * @param workspace The workspace to get the toolbox on.
-   */
-  handleFocusToolbox(workspace: Blockly.WorkspaceSvg) {
-    const toolbox = workspace.getToolbox();
-    if (!toolbox) {
-      return;
-    }
-    this.setState(workspace, Constants.STATE.TOOLBOX);
-
-    if (!toolbox.getSelectedItem() && toolbox instanceof Blockly.Toolbox) {
-      // Find the first item that is selectable.
-      const toolboxItems = toolbox.getToolboxItems();
-      for (let i = 0, toolboxItem; (toolboxItem = toolboxItems[i]); i++) {
-        if (toolboxItem.isSelectable()) {
-          toolbox.selectItemByPosition(i);
-          break;
-        }
-      }
-    }
-  }
-
-  /**
-   * Clears the navigation state and closes the flyout if `allowClose` is true
-   * and a gesture is not in progress.
-   *
-   * @param workspace The workspace the flyout is on.
-   * @param closeFlyout True to close the flyout, false otherwise.
-   */
-  handleBlurToolbox(workspace: Blockly.WorkspaceSvg, closeFlyout: boolean) {
-    this.setState(workspace, Constants.STATE.NOWHERE);
-    if (closeFlyout) {
-      workspace.hideChaff();
-    }
-  }
-
-  /**
-   * Sets browser focus to the flyout (if any).
-   *
-   * @param workspace The workspace with the flyout.
-   */
-  focusFlyout(workspace: Blockly.WorkspaceSvg) {
-    getFlyoutElement(workspace)?.focus();
-  }
-
-  /**
-   * Sets the navigation state to flyout and moves the cursor to the first
-   * block or button in the flyout. We disable tabbing to the toolbox while
-   * the flyout has focus as we use left/right for that.
-   *
-   * @param workspace The workspace the flyout is on.
-   */
-  handleFocusFlyout(workspace: Blockly.WorkspaceSvg) {
-    // Note this can happen when the flyout was already focussed as regrettably
-    // a click on the flyout calls markFocused() on the workspace SVG and the
-    // focus is then redirected back to the flyout.
-
-    this.setState(workspace, Constants.STATE.FLYOUT);
-    this.getFlyoutCursor(workspace)?.draw();
-
-    // This doesn't identify a click on the scrollbars which will unfortunately
-    // default the cursor if the flyout didn't already have focus.
-    if (!Blockly.Gesture.inProgress()) {
-      this.defaultFlyoutCursorIfNeeded(workspace);
-    }
-  }
-
-  /**
-   * Clears the navigation state and closes the flyout if `allowClose` is true
-   * and a gesture is not in progress.
-   *
-   * @param workspace The workspace the flyout is on.
-   * @param closeFlyout True to close the flyout, false otherwise.
-   */
-  handleBlurFlyout(workspace: Blockly.WorkspaceSvg, closeFlyout: boolean) {
-    this.setState(workspace, Constants.STATE.NOWHERE);
-    if (closeFlyout) {
-      workspace.hideChaff();
+    const flyout = mainWorkspace.getFlyout();
+    if (flyout) {
+      Blockly.getFocusManager().focusTree(flyout.getWorkspace());
     }
-    this.getFlyoutCursor(workspace)?.hide();
   }
 
   /**
@@ -1121,10 +929,12 @@ export class Navigation {
    * @param workspace The active workspace.
    */
   openToolboxOrFlyout(workspace: Blockly.WorkspaceSvg) {
-    if (workspace.getToolbox()) {
-      this.focusToolbox(workspace);
-    } else {
-      this.focusFlyout(workspace);
+    const toolbox = workspace.getToolbox();
+    const flyout = workspace.getFlyout();
+    if (toolbox) {
+      Blockly.getFocusManager().focusTree(toolbox);
+    } else if (flyout) {
+      Blockly.getFocusManager().focusTree(flyout.getWorkspace());
     }
   }
 
diff --git a/src/navigation_controller.ts b/src/navigation_controller.ts
index 2afa4c89..c4301f43 100644
--- a/src/navigation_controller.ts
+++ b/src/navigation_controller.ts
@@ -10,7 +10,6 @@
  * @author aschmiedt@google.com (Abby Schmiedt)
  */
 
-import './gesture_monkey_patch';
 import './toolbox_monkey_patch';
 
 import * as Blockly from 'blockly/core';
@@ -171,53 +170,6 @@ export class NavigationController {
     this.navigation.removeWorkspace(workspace);
   }
 
-  focusWorkspace(workspace: WorkspaceSvg) {
-    this.navigation.focusWorkspace(workspace);
-  }
-
-  handleFocusWorkspace(workspace: Blockly.WorkspaceSvg) {
-    this.navigation.handleFocusWorkspace(workspace);
-  }
-
-  handleBlurWorkspace(workspace: Blockly.WorkspaceSvg) {
-    // Before we blur, end any ongoing move
-    if (this.mover.isMoving(workspace)) {
-      this.mover.finishMove(workspace);
-    }
-    this.navigation.handleBlurWorkspace(workspace);
-  }
-
-  handleFocusOutWidgetDropdownDiv(
-    workspace: Blockly.WorkspaceSvg,
-    relatedTarget: EventTarget | null,
-  ) {
-    this.navigation.handleFocusOutWidgetDropdownDiv(workspace, relatedTarget);
-  }
-
-  focusToolbox(workspace: Blockly.WorkspaceSvg) {
-    this.navigation.focusToolbox(workspace);
-  }
-
-  handleFocusToolbox(workspace: Blockly.WorkspaceSvg) {
-    this.navigation.handleFocusToolbox(workspace);
-  }
-
-  handleBlurToolbox(workspace: Blockly.WorkspaceSvg, closeFlyout: boolean) {
-    this.navigation.handleBlurToolbox(workspace, closeFlyout);
-  }
-
-  focusFlyout(workspace: Blockly.WorkspaceSvg) {
-    this.navigation.focusFlyout(workspace);
-  }
-
-  handleFocusFlyout(workspace: Blockly.WorkspaceSvg) {
-    this.navigation.handleFocusFlyout(workspace);
-  }
-
-  handleBlurFlyout(workspace: Blockly.WorkspaceSvg, closeFlyout: boolean) {
-    this.navigation.handleBlurFlyout(workspace, closeFlyout);
-  }
-
   /**
    * Turns on keyboard navigation.
    *
@@ -252,11 +204,9 @@ export class NavigationController {
       callback: (workspace) => {
         switch (this.navigation.getState(workspace)) {
           case Constants.STATE.WORKSPACE:
-            if (!workspace.getToolbox()) {
-              this.navigation.focusFlyout(workspace);
-            } else {
-              this.navigation.focusToolbox(workspace);
-            }
+            Blockly.getFocusManager().focusTree(
+              workspace.getToolbox() ?? workspace.getFlyout() ?? workspace,
+            );
             return true;
           default:
             return false;
diff --git a/src/passive_focus.ts b/src/passive_focus.ts
deleted file mode 100644
index 08719211..00000000
--- a/src/passive_focus.ts
+++ /dev/null
@@ -1,183 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import {
-  ASTNode,
-  RenderedConnection,
-  BlockSvg,
-  ConnectionType,
-  utils,
-} from 'blockly/core';
-
-/**
- * A renderer for passive focus on AST node locations on the main workspace.
- * Responsible for showing and hiding in an ephemeral way. Not
- * guaranteed to stay up to date if workspace contents change.
- * In general, passive focus should be hidden when the main workspace
- * has active focus.
- */
-export class PassiveFocus {
-  // The node where the indicator is drawn, if any.
-  private curNode: ASTNode | null = null;
-
-  // The line drawn to indicate passive focus on a next connection.
-  nextConnectionIndicator: SVGRectElement;
-
-  constructor() {
-    this.nextConnectionIndicator = this.createNextIndicator();
-  }
-
-  /**
-   * Get the current passive focus node.
-   *
-   * @returns the node or null.
-   */
-  getCurNode(): ASTNode | null {
-    return this.curNode;
-  }
-
-  /** Dispose of this indicator. Do any necessary cleanup. */
-  dispose() {
-    this.hide();
-    if (this.nextConnectionIndicator) {
-      utils.dom.removeNode(this.nextConnectionIndicator);
-    }
-  }
-
-  /**
-   * Hide the currently visible passive focus indicator.
-   * Implementation varies based on location type.
-   */
-  hide() {
-    if (!this.curNode) return;
-    const type = this.curNode.getType();
-    const location = this.curNode.getLocation();
-
-    // If old node was a block, unselect it or remove fake selection.
-    if (type === ASTNode.types.BLOCK) {
-      this.hideAtBlock(this.curNode);
-    } else if (this.curNode.isConnection()) {
-      const curNodeAsConnection = location as RenderedConnection;
-      const connectionType = curNodeAsConnection.type;
-      if (connectionType === ConnectionType.NEXT_STATEMENT) {
-        this.hideAtNext(this.curNode);
-      }
-    } else {
-      console.log('Could not hide passive focus indicator');
-    }
-    this.curNode = null;
-  }
-
-  /**
-   * Show the passive focus indicator at the specified location.
-   * Implementation varies based on location type.
-   *
-   * @param node The node to show passive focus for.
-   */
-  show(node: ASTNode) {
-    // Hide last shown.
-    this.hide();
-    this.curNode = node;
-
-    const type = this.curNode.getType();
-    const location = this.curNode.getLocation();
-    if (type === ASTNode.types.BLOCK) {
-      this.showAtBlock(this.curNode);
-      return;
-    } else if (this.curNode.isConnection()) {
-      const curNodeAsConnection = location as RenderedConnection;
-      const connectionType = curNodeAsConnection.type;
-      if (connectionType === ConnectionType.NEXT_STATEMENT) {
-        this.showAtNext(this.curNode);
-        return;
-      }
-    }
-    console.log('Could not show passive focus indicator');
-  }
-
-  /**
-   * Show a passive focus indicator on a block.
-   *
-   * @param node The passively-focused block.
-   */
-  showAtBlock(node: ASTNode) {
-    const block = node.getLocation() as BlockSvg;
-    utils.dom.addClass(block.pathObject.svgPath, 'passiveBlockFocus');
-  }
-
-  /**
-   * Hide a passive focus indicator on a block.
-   *
-   * @param node The passively-focused block.
-   */
-  hideAtBlock(node: ASTNode) {
-    const block = node.getLocation() as BlockSvg;
-    // When a block is selected we can end up with a duplicate svgPath.
-    const svgPaths = block.getSvgRoot().querySelectorAll('.passiveBlockFocus');
-    svgPaths.forEach((svgPath) =>
-      utils.dom.removeClass(svgPath, 'passiveBlockFocus'),
-    );
-  }
-
-  /**
-   * Creates DOM elements for the next connection indicator.
-   *
-   * @returns The root element of the next indicator.
-   */
-  createNextIndicator() {
-    // A horizontal line used to represent a next connection.
-    const indicator = utils.dom.createSvgElement(utils.Svg.RECT, {
-      'width': 100,
-      'height': 5,
-      'class': 'passiveNextIndicator',
-    });
-    return indicator;
-  }
-
-  /**
-   * Show a passive focus indicator on a next connection.
-   *
-   * @param node The passively-focused connection.
-   */
-  showAtNext(node: ASTNode) {
-    const connection = node.getLocation() as RenderedConnection;
-    const targetBlock = connection.getSourceBlock();
-
-    // Make the connection indicator a child of the block's SVG group.
-    const blockSvgRoot = targetBlock.getSvgRoot();
-    blockSvgRoot.appendChild(this.nextConnectionIndicator);
-
-    // Move the indicator relative to the origin of the block's SVG group.
-    let x = 0;
-    const y = connection.getOffsetInBlock().y;
-    const width = targetBlock.getHeightWidth().width;
-    if (targetBlock.workspace.RTL) {
-      x = -width;
-    }
-
-    this.nextConnectionIndicator.setAttribute('x', `${x}`);
-    this.nextConnectionIndicator.setAttribute('y', `${y}`);
-    this.nextConnectionIndicator.setAttribute('width', `${width}`);
-
-    this.nextConnectionIndicator.style.display = '';
-  }
-
-  /**
-   * Hide a passive focus indicator on a next connection.
-   *
-   * @param node The passively-focused connection.
-   */
-  hideAtNext(node: ASTNode) {
-    this.nextConnectionIndicator.parentNode?.removeChild(
-      this.nextConnectionIndicator,
-    );
-    this.nextConnectionIndicator.style.display = 'none';
-  }
-
-  isVisible(): boolean {
-    return !!this.curNode;
-  }
-}
diff --git a/src/workspace_utilities.ts b/src/workspace_utilities.ts
index 86daf832..ca319967 100644
--- a/src/workspace_utilities.ts
+++ b/src/workspace_utilities.ts
@@ -71,49 +71,3 @@ export function scrollBoundsIntoView(
   deltaY *= scale;
   workspace.scroll(workspace.scrollX + deltaX, workspace.scrollY + deltaY);
 }
-
-/**
- * Get the workspace SVG group which is the element that takes focus.
- *
- * @param workspace The workspace.
- * @returns The element.
- */
-export function getWorkspaceElement(workspace: Blockly.Workspace): SVGElement {
-  return (workspace as Blockly.WorkspaceSvg).getSvgGroup() as SVGElement;
-}
-
-/**
- * Get the toolbox element that takes the focus (if any).
- *
- * @param workspace The workspace.
- * @returns The element or null if a toolbox is not in use.
- */
-export function getToolboxElement(
-  workspace: Blockly.WorkspaceSvg,
-): HTMLElement | null {
-  const toolbox = workspace.getToolbox();
-  if (toolbox instanceof Blockly.Toolbox) {
-    return toolbox.HtmlDiv?.querySelector(
-      '.blocklyToolboxCategoryGroup',
-    ) as HTMLElement | null;
-  }
-  return null;
-}
-
-/**
- * Get the flyout element we focus.
- *
- * @param workspace The workspace.
- * @returns The element, or null if there is no flyout.
- */
-export function getFlyoutElement(
-  workspace: Blockly.WorkspaceSvg,
-): SVGElement | null {
-  const flyout = workspace.getFlyout();
-  if (flyout != null && flyout instanceof Blockly.Flyout) {
-    // This relies on internals.
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    return ((flyout as any).svgGroup_ as SVGElement) ?? null;
-  }
-  return null;
-}
diff --git a/test/index.html b/test/index.html
index 19399fa5..a528362f 100644
--- a/test/index.html
+++ b/test/index.html
@@ -34,11 +34,6 @@
         --outline-width: 5px;
       }
 
-      .blocklyWorkspace:focus .blocklyMainBackground {
-        outline: Highlight solid var(--outline-width);
-        outline-offset: -5px;
-      }
-
       .blocklyFlyout {
         top: var(--outline-width);
         left: var(--outline-width);
@@ -102,20 +97,52 @@
         font-weight: bold;
       }
 
-      .passiveBlockFocus.blocklyPath {
-        stroke-dasharray: 5 3;
-        stroke-width: 3;
+      .blocklyActiveFocus:is(
+          .blocklyField,
+          .blocklyPath,
+          .blocklyHighlightedConnectionPath
+        ) {
         stroke: #ffa200;
+        stroke-width: 3px;
       }
-
-      .passiveNextIndicator {
+      .blocklyActiveFocus > .blocklyFlyoutBackground,
+      .blocklyActiveFocus > .blocklyMainBackground {
         stroke: #ffa200;
-        fill: #ffa200;
+        stroke-width: 3px;
+      }
+      .blocklyActiveFocus:is(
+          .blocklyToolbox,
+          .blocklyToolboxCategoryContainer
+        ) {
+        outline: 3px solid #ffa200;
+      }
+      .blocklyPassiveFocus:is(
+          .blocklyField,
+          .blocklyPath,
+          .blocklyHighlightedConnectionPath
+        ) {
+        stroke: #ffa200;
+        stroke-dasharray: 5px 3px;
+        stroke-width: 3px;
       }
-
-      .inputActiveFocus {
-        stroke-width: 3;
+      .blocklyPassiveFocus > .blocklyFlyoutBackground,
+      .blocklyPassiveFocus > .blocklyMainBackground {
         stroke: #ffa200;
+        stroke-dasharray: 5px 3px;
+        stroke-width: 3px;
+      }
+      .blocklyPassiveFocus:is(
+          .blocklyToolbox,
+          .blocklyToolboxCategoryContainer
+        ) {
+        border: 3px dashed #ffa200;
+      }
+      .blocklySelected:is(.blocklyPath) {
+        stroke: #ffa200;
+        stroke-width: 5;
+      }
+      .blocklySelected > .blocklyPathLight {
+        display: none;
       }
     </style>
   </head>
diff --git a/test/webdriverio/index.html b/test/webdriverio/index.html
index 17451527..432dd1a0 100644
--- a/test/webdriverio/index.html
+++ b/test/webdriverio/index.html
@@ -57,22 +57,6 @@
       thead {
         font-weight: bold;
       }
-
-      .passiveBlockFocus.blocklyPath {
-        stroke-dasharray: 5 3;
-        stroke-width: 3;
-        stroke: #ffa200;
-      }
-
-      .passiveNextIndicator {
-        stroke: #ffa200;
-        fill: #ffa200;
-      }
-
-      .inputActiveFocus {
-        stroke-width: 3;
-        stroke: #ffa200;
-      }
     </style>
   </head>
 
diff --git a/test/webdriverio/test/basic_test.ts b/test/webdriverio/test/basic_test.ts
index e57dc4bd..6b142b0b 100644
--- a/test/webdriverio/test/basic_test.ts
+++ b/test/webdriverio/test/basic_test.ts
@@ -15,10 +15,9 @@ import {
   testSetup,
   testFileLocations,
   PAUSE_TIME,
-  getBlockElementById,
-  clickBlock,
+  tabNavigateToWorkspace,
 } from './test_setup.js';
-import {Key, ClickOptions} from 'webdriverio';
+import {Key} from 'webdriverio';
 
 suite('Keyboard navigation', function () {
   // Setting timeout to unlimited as these tests take a longer time to run than most mocha test
@@ -38,9 +37,7 @@ suite('Keyboard navigation', function () {
   });
 
   test('Selected block', async function () {
-    const block = await getBlockElementById(this.browser, 'p5_setup_1');
-    await clickBlock(this.browser, block, {button: 0} as ClickOptions);
-    await this.browser.pause(PAUSE_TIME);
+    await tabNavigateToWorkspace(this.browser);
 
     for (let i = 0; i < 8; i++) {
       await this.browser.keys(Key.ArrowDown);
diff --git a/test/webdriverio/test/clipboard_test.ts b/test/webdriverio/test/clipboard_test.ts
index f83c8368..6797c615 100644
--- a/test/webdriverio/test/clipboard_test.ts
+++ b/test/webdriverio/test/clipboard_test.ts
@@ -12,16 +12,10 @@ import {
   PAUSE_TIME,
   getBlockElementById,
   getSelectedBlockId,
-  clickBlock,
   ElementWithId,
+  tabNavigateToWorkspace,
 } from './test_setup.js';
-import {
-  ClickOptions,
-  Key,
-  KeyAction,
-  PointerAction,
-  WheelAction,
-} from 'webdriverio';
+import {Key, KeyAction, PointerAction, WheelAction} from 'webdriverio';
 
 suite('Clipboard test', function () {
   // Setting timeout to unlimited as these tests take longer time to run
@@ -34,17 +28,21 @@ suite('Clipboard test', function () {
   });
 
   test('Copy and paste while block selected', async function () {
-    const block = await getBlockElementById(this.browser, 'draw_circle_1');
-    await clickBlock(this.browser, block, {button: 0} as ClickOptions);
+    // Navigate to draw_circle_1.
+    await tabNavigateToWorkspace(this.browser);
+    for (let i = 0; i < 8; i++) {
+      await this.browser.keys(Key.ArrowDown);
+      await this.browser.pause(PAUSE_TIME);
+    }
 
     // Copy and paste
     await this.browser.keys([Key.Ctrl, 'c']);
     await this.browser.keys([Key.Ctrl, 'v']);
     await this.browser.pause(PAUSE_TIME);
 
+    const block = await getBlockElementById(this.browser, 'draw_circle_1');
     const blocks = await getSameBlocks(this.browser, block);
     const selectedId = await getSelectedBlockId(this.browser);
-
     chai.assert.equal(await blocks.length, 2);
     chai.assert.equal(
       selectedId,
@@ -54,8 +52,13 @@ suite('Clipboard test', function () {
   });
 
   test('Cut and paste while block selected', async function () {
+    // Navigate to draw_circle_1.
+    await tabNavigateToWorkspace(this.browser);
+    for (let i = 0; i < 8; i++) {
+      await this.browser.keys(Key.ArrowDown);
+      await this.browser.pause(PAUSE_TIME);
+    }
     const block = await getBlockElementById(this.browser, 'draw_circle_1');
-    await clickBlock(this.browser, block, {button: 0} as ClickOptions);
 
     // Cut and paste
     await this.browser.keys([Key.Ctrl, 'x']);
diff --git a/test/webdriverio/test/test_setup.ts b/test/webdriverio/test/test_setup.ts
index 2869a799..7e108295 100644
--- a/test/webdriverio/test/test_setup.ts
+++ b/test/webdriverio/test/test_setup.ts
@@ -260,6 +260,34 @@ export async function getBlockElementById(
   return elem;
 }
 
+/**
+ * Uses tabs to navigate to the workspace on the test page (i.e. by going
+ * through top-level tab stops).
+ *
+ * @param browser The active WebdriverIO Browser object.
+ * @param hasToolbox Whether a toolbox is configured on the test page.
+ * @param hasFlyout Whether a flyout is configured on the test page.
+ */
+export async function tabNavigateToWorkspace(
+  browser: WebdriverIO.Browser,
+  hasToolbox = true,
+  hasFlyout = true,
+) {
+  if (hasToolbox) tabNavigateForward(browser);
+  if (hasFlyout) tabNavigateForward(browser);
+  tabNavigateForward(browser); // Tab to the workspace itself.
+}
+
+/**
+ * Navigates forward to the test page's next tab stop.
+ *
+ * @param browser The active WebdriverIO Browser object.
+ */
+export async function tabNavigateForward(browser: WebdriverIO.Browser) {
+  await browser.keys(webdriverio.Key.Tab);
+  await browser.pause(PAUSE_TIME);
+}
+
 /**
  * Copied from blockly browser test_setup.mjs and amended for typescript
  *


Reviews:
user: login: BenHenning
body: Self-reviewed changes.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-01T06:16:53Z
user: login: BenHenning
body: Self-reviewed changes.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-02T05:22:53Z
user: login: rachel-fenichel
body: Let's merge this to move from "head is completely broken" to "head has broken flows that we can debug".


state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-02T17:34:12Z
user: login: BenHenning
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-02T19:01:06Z
user: login: BenHenning
body: Self-reviewed latest fix.
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-02T19:01:25Z

Review comments:
diff_hunk: @@ -9,14 +9,12 @@ import {
   Events,
   ShortcutRegistry,
   utils as BlocklyUtils,
-} from 'blockly/core';
-
-import type {
   Block,
   BlockSvg,
   Field,
   FlyoutButton,
   WorkspaceSvg,
+  getFocusManager,
path: src/actions/enter.ts
user: login: rachel-fenichel
body: Should not need to change all of the `import type`s to `imports` just to add the focus manager.
created_at: 2025-05-02T17:29:46Z
author_association: COLLABORATOR
diff_hunk: @@ -9,14 +9,12 @@ import {
   Events,
   ShortcutRegistry,
   utils as BlocklyUtils,
-} from 'blockly/core';
-
-import type {
   Block,
   BlockSvg,
   Field,
   FlyoutButton,
   WorkspaceSvg,
+  getFocusManager,
path: src/actions/enter.ts
user: login: BenHenning
body: Oops, I think I missed the type specifier and thought there were redundant import lines (which, in retrospect, I now realize would've been caught by the linter). Fixed in latest.
created_at: 2025-05-02T19:01:06Z
author_association: COLLABORATOR

PR comments:
user: login: BenHenning
created_at: 2025-04-30T22:39:55Z
body: I still need to push my latest changes (currently working through some focus issues with variable creation), but now that block movement is working I'm pretty happy with the stability of this PR with regards to it being testable. It's definitely not yet ready for review, but I'm hopeful the delta won't be too large.
author_association: COLLABORATOR
user: login: BenHenning
created_at: 2025-05-01T06:29:09Z
body: PTAL @rachel-fenichel for an initial review pass.

If you can, I'd also really appreciate your check on this @microbit-matt-hillsdon as it makes substantial changes to focus management that you had carefully considered as part of #326. I think that I've matched parity here with the removed focus handlers, but I can't 100% certain for flyout (due to `markFocused` quirks, though it seems to be working well for me minus the variables issues) and for workspace (since it had a few extra bits that are no longer covered).
author_association: COLLABORATOR
user: login: BenHenning
created_at: 2025-05-02T05:22:21Z
body: The build & test failures were partly due to https://github.com/google/blockly/pull/8941 not yet being merged, but it's merged now.

There were still test updates needed which I will document shortly.
author_association: COLLABORATOR
user: login: BenHenning
created_at: 2025-05-02T05:25:49Z
body: Thanks to the wonderful testing from @microbit-grace and @microbit-matt-hillsdon there are ~40 issues that have been found with this PR. However, I think we may still want to merge this as-is since, without these changes, the plugin is entirely unusable with tip-of-tree v12 (especially after https://github.com/google/blockly/pull/8941 was merged). I think we can follow up with the needed fixes in Core and the plugin to address those problems, plus a number warrant additional discussion (especially around styling).

I defer to you @rachel-fenichel on this.
author_association: COLLABORATOR
user: login: BenHenning
created_at: 2025-05-02T19:01:48Z
body: Going ahead and merging this as discussed above. Thanks for the review!
author_association: COLLABORATOR