8000 feat: Introduce FocusManager implementation by BenHenning · Pull Request #8814 · google/blockly · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

feat: Introduce FocusManager implementation #8814

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged

Conversation

BenHenning
Copy link
Contributor
@BenHenning BenHenning commented Mar 21, 2025

The basics

The details

Resolves

Fixes #8782

Proposed Changes

This is the bulk of the work for introducing the central logical unit for managing and sychronizing focus as a first-class Blockly concept with that of DOM focus.

While this introduces the core support needed for focus synchronization and management (along with its tests), there are some likely functional gaps that will need to be fixed in a subsequent change:

  • Ensuring clicks within Blockly's scope correctly sync back to focus changes.
  • Ensuring that FocusManager behaves in real world scenarios (i.e. those exposed by the experimental keyboard plugin). Early testing suggests that the manager handles focusing correctly, but that more work is likely needed around active/passive tracking and styling specifics, the former requiring much deeper changes in the plugin in order to properly test and validate than the surface-level changes tested so far.

Some design considerations:

  • Common algorithms for retrieving the current node or looking up a node by an element in a tree context were added to a new FocusableTreeTraverser to reduce code duplication and ensure direct testing (the latter of which was very handy as the traverser's tests exposed issues with its initial implementation). One open design question is whether it's preferable to remove getFocusedNode and findFocusableNodeFor from IFocusableTree in favor of just using the traverser directly. This reduces the customizability of tree implementations, but it's not obvious that trees will ever need to opt into a custom implementation, anyway (or, the manager could allow trees to pass custom functions during registration to override default traverser behavior).
    • Note: The design question has been resolved. IFocusasbleTree's API has been simplified in favor of using the traverser. We can either extend its API in the future, or leverage the registry pattern to allow customization of the traverser itself.
  • The current CSS highlighting properties for active and passive focus are not final. They are expected to be reasonable placeholders that will be iterated upon as the system is more broadly integrated, and later after user testing.
  • IFocusableTree was updated to:
    • Expand the contract of findFocusableNodeFor.
    • Introduce a direct lookup method by element ID (which seems reasonable and simplifies FocusableTreeTraverser).
    • Introduce direct tracking of nested trees. This was to avoid much more complex workarounds that would have been needed in the manager in order to ensure element->node matching worked correctly.
  • It's unclear whether tab index should be directly handled by FocusManager vs. making it a requirement of the Element provided by IFocusableNode. This remains an open question.
    • Note: The design question has been resolved. IFocusableNode implementations will be responsible for ensuring their elements have a tab index of -1 set correctly.
  • The core test index page was updated to include a bunch of HTML and SVG focusable 'trees' and nodes for the purpose of testing both new components. These are elements that will exist on the page for all Blockly tests, but it seems other tests are unaffected by them. Note that this page expands the active/passive focus to make it much more obvious when running tests (though state is reset during test tear-down so this is only visible when disabling state resetting when investigating a specific test).

Reason for Changes

FocusManager is the core state management system for synchronizing DOM and Blockly focus. This will be leveraged by updating Blockly components to implement IFocusableTree and IFocusableNode, as needed, per #8771. The experimental keyboard plugin will also be updated to leverage FocusManager as a stand-in replacement for node highlighting and cursor management.

Test Coverage

The tests added to this PR are meant to be highly comprehensive and robust in testing the many different combinations of focus state and how those affect FocusManager. These tests actually had a substantial impact on fixing core issues with the manager, or adding better robustness checks to ensure the manager is always in a sane state.

Documentation

Not directly from this PR, but eventually we may want to consider adding formal documentation for focus management along with how and why to integrate with the system.

Additional Information

See also google/blockly-keyboard-experimentation#142 (comment) for broader technical design context on the introduction of these new interfaces.

This is the bulk of the work for introducing the central logical unit
for managing and sychronizing focus as a first-class Blockly concept
with that of DOM focus.

There's a lot to do yet, including:
- Ensuring clicks within Blockly's scope correctly sync back to focus
  changes.
- Adding support for, and testing, cases when focus is lost from all
  registered trees.
- Testing nested tree propagation.
- Testing the traverser utility class.
- Adding implementations for IFocusableTree and IFocusableNode
  throughout Blockly.
@BenHenning
Copy link
Contributor Author

PTAL @gonfunko. I think it'll be useful for you to have a basis on the implementation. I still have a bit of the PR description to finish, plus more tests and some additional manager functionality.

I think the main contentious design change (or, rather, unexpected) detail is the addition of a utility class to help with tree implementations. FocusManager's test makes use of these which can be referenced as an example.

This adds new tests for the FocusableTreeTraverser and fixes a number of
issues with the original implementation (one of which required two new
API methods to be added to IFocusableTree). More tests have also been
added for FocusManager, and defocusing tracked nodes/trees has been
fully implemented in FocusManager.
Copy link
Contributor Author
@BenHenning BenHenning left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-reviewed all but the tests.

@BenHenning BenHenning marked this pull request as ready for review March 27, 2025 23:54
@BenHenning BenHenning requested a review from a team as a code owner March 27, 2025 23:54
@BenHenning BenHenning requested a review from maribethb March 27, 2025 23:54
@BenHenning BenHenning linked an issue Mar 27, 2025 that may be closed by this pull request
@BenHenning
Copy link
Contributor Author

PTAL @gonfunko.

@maribethb feel free to defer to Aaron on the review, or take a pass if you'd like. I'd appreciate any amount of review coverage here as the system is fairly complex, and likely still has problems as indicated by my early testing.

/cc @rachel-fenichel as well. Rachel: I suspect you'll not want to review this, but in case you do here's the initial (and hopefully majority) implementation of FocusManager.

All: please let me know if you have any thoughts on the open design questions in the PR description. I'm happy to merge this PR as-is if no major issues are found during review, but those questions could influence the implementation.

Copy link
Contributor
@maribethb maribethb left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will let Aaron leave a more thorough review since I haven't been working with this code much recently.

Regarding your open design question about the Traverser code: the idea is that Workspace and Flyout and whatever other Blockly methods all need to implement the findFocusableNodeFor method, and they'd all do it with an identical method, but it's not possible to have them all extend from the same base class that provides a default implementation of that method. Is that right?

If so this seems like a decent use case for mixins, but those don't seem like they're really a first-class feature of TS, and I don't know how much type hijinks we want to get up to. I don't think I know enough about which external objects are going to be implementing these interfaces to know if we want to retain the functionality of letting them do it in a custom way.

const matchingNodes = this.registeredTrees.map((tree) =>
tree.findFocusableNodeFor(activeElement),
);
newNode = matchingNodes.find((node) => !!node) ?? null;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, but I feel like there is something kind of funny about using the nullish coalescing operator to make the value null instead of undefined. Why not just use undefined? You could change line 48 to let newNode: IFocusableNode | undefined; and then remove this bit

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that it does seem odd. I suspect there's some argument here about using an intentional absent (null) vs. an unintentional absent (undefined). I'll think on this more, but I'm happy to defer to your suggestion if I can't come up with a strong argument.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like it basically has to be null | undefined so I went ahead with that. Couldn't find strong advice on the best practices of one vs. the other, unfortunately.

// updated. Per the contract of findFocusableNodeFor only one tree
// should claim the element.
const matchingNodes = this.registeredTrees.map((tree) =>
tree.findFocusableNodeFor(activeElement),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this expensive? If so would it be better to abort early once we've found the one tree where the node is claimed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is, and aborting early is definitely possible. This code has changed a lot, and I like the simplification suggestion that's possible now. Will update.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

Copy link
Contributor
@gonfunko gonfunko left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sending feedback now to mostly unblock things; still need to review the giant test file, but broadly this LGTM.

* unregisterTree.
*/
isRegistered(tree: IFocusableTree): boolean {
return this.registeredTrees.findIndex((reg) => reg == tree) !== -1;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the == (vs ===) intentional here and in unregisterTree?

Copy link
Contributor Author
@BenHenning BenHenning Apr 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It isn't, it's me forgetting that this is JavaScript. Will update.

On a similar note, I realized I wasn't consistent on test assertions for equality vs. strict equality. How much does that practically matter?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated--everything now uses strict equal where correct.

* focus.
*/
focusNode(focusableNode: IFocusableNode): void {
const curTree = focusableNode.getFocusableTree();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a me issue, but I read "current" as the one that existed prior to this method being called/whatever mutation it might perform; perhaps change this to newTree? Keeping prev* for the existing values would be fine in that case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely a preference, and I have no issues with using prev. Will update.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also realized there were some inconsistencies in the naming here, too. Updated.

passive = tree.findFocusableNodeFor(passiveEl);
}

return active ?? passive;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps just check and return active in the if statement where it's assigned so we don't go hunting for the passively focused node if it's not needed?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely reasonable, will update.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to exit earlier.

Comment on lines 29 to 30
dom.hasClass(root, 'blocklyActiveFocus') ||
dom.hasClass(root, 'blocklyPassiveFocus')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Especially given that they're used across several files, it might be good to make blocklyActiveFocus and blocklyPassiveFocus shared constants.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the typical way to do this just adding static values to FocusManager?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done--added the constants.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did need to duplicate between the focus manager and the traverser class, however, to avoid a circular dependency (now that FocusManager depends on the traverser class). I double checked and both suites have failures if the two constants diverge, fortunately, so it seems that the bit of duplication should have a low chance of resulting in breaking parity issues.

Comment on lines 18 to 26
const FocusableNodeImpl = function (element, tree) {
this.getFocusableElement = function () {
return element;
};

this.getFocusableTree = function () {
return tree;
};
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we not use ES6 classes here? That might save some effort if/when we migrate this to TS.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I read this backwards originally (advocating to not use ES6 classes) :). Will update--that seems completely reasonable.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done--converted the helper classes for both new test suites to ES6 classes.

Comment on lines +113 to +129
// Ensure all node CSS styles are reset so that state isn't leaked between tests.
removeFocusIndicators(document.getElementById('testFocusableTree1'));
removeFocusIndicators(document.getElementById('testFocusableTree1.node1'));
removeFocusIndicators(
document.getElementById('testFocusableTree1.node1.child1'),
);
removeFocusIndicators(document.getElementById('testFocusableTree1.node2'));
removeFocusIndicators(document.getElementById('testFocusableTree2'));
removeFocusIndicators(document.getElementById('testFocusableTree2.node1'));
removeFocusIndicators(document.getElementById('testFocusableNestedTree4'));
removeFocusIndicators(
document.getElementById('testFocusableNestedTree4.node1'),
);
removeFocusIndicators(document.getElementById('testFocusableNestedTree5'));
removeFocusIndicators(
document.getElementById('testFocusableNestedTree5.node1'),
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than removing the focus indicators on the various test elements, what about document.querySelectorAll() for .blocklyActiveFocus and .blocklyPassiveFocus and then remove the classes from the resulting elements? I'm imagining the case where somebody adds a new test element but misses that they need to explicitly clean it up here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a tricky situation. Normally I'm a very strong advocate of avoiding logic in tests, but there are practical points where there are diminishing returns (and the logic is simple enough to verify by reading it). I agree and will update this accordingly.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in the latest changes.

assert.equal(finding, node);
});

test('for tree with nested tree root active no parent highlights returns null', function () {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this and the next three tests, the traverser appears to be finding the focused node, not null?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great catch. I also realized from this that I forgot to update the third test (it had the same name as the first). I also made sure to verify that the corrected names are what we want to be the behavior here, as well (in case I had the test names right but there was an issue with the test bodies or the implementation behaviors).

@BenHenning
Copy link
Contributor Author

Will let Aaron leave a more thorough review since I haven't been working with this code much recently.

Thanks for taking a pass @maribethb!

Regarding your open design question about the Traverser code: the idea is that Workspace and Flyout and whatever other Blockly methods all need to implement the findFocusableNodeFor method, and they'd all do it with an identical method, but it's not possible to have them all extend from the same base class that provides a default implementation of that method. Is that right?

That's correct. TypeScript, unfortunately, doesn't seem to support default interface methods which would have otherwise been a good solution to the problem.

If so this seems like a decent use case for mixins, but those don't seem like they're really a first-class feature of TS, and I don't know how much type hijinks we want to get up to. I don't think I know enough about which external objects are going to be implementing these interfaces to know if we want to retain the functionality of letting them do it in a custom way.

That is a gnarly pattern haha. I'm very uncertain about using inheritance to compose state like this. It's interesting, but it would require wide implications for everything that implements IFocusableTree as I understand it, which likely will introduce a lot of future complexity.

I think it's the lack of an obvious pattern that has me lean toward "don't overcomplicate the API" (i.e. always use the traverser directly in FocusManager rather than soft requiring using it in each implementation override), but I also don't have a good sense on future proofing APIs that must maintain strong backward compatibility. There's a real trade-off here in that changing this decision later on will affect a whole lot of classes in a pretty aggressive breaking change (i.e. must implement new methods). I suspect these will largely be isolated, though. As you alluded to, I don't think we expect a lot of external classes to implement IFocusableNode or IFocusableTree, and in fact doing so would be difficult as the two are closely linked for identification.

*
* See FocusManager.takeEphemeralFocus for more details.
*/
export type ReturnEphemeralFocus = () => void;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is something we expect external developers to need to call, it should be exported from blockly.ts as well

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@rachel-fenichel
Copy link
Collaborator

I think it's the lack of an obvious pattern that has me lean toward "don't overcomplicate the API" (i.e. always use the traverser directly in FocusManager rather than soft requiring using it in each implementation override), but I also don't have a good sense on future proofing APIs that must maintain strong backward compatibility.

I agree with always using the traverser directly, unless/until we have concrete examples of it being an issue. I think a benefit of centralizing this code into the Traverser is that it's easy to iterate on it in one place, as well.

There's a real trade-off here in that changing this decision later on will affect a whole lot of classes in a pretty aggressive breaking change (i.e. must implement new methods). I suspect these will largely be isolated, though.

Adding methods to classes now and removing them later would count as a big breaking change, but the opposite would not.

If we end up wanting some classes to define their own Traversers, we can use the registry, have them render custom traversers, and do a lookup based on type or a string. But let's not do that unless we actually need it, which we don't yet.

As you alluded to, I don't think we expect a lot of external classes to implement IFocusableNode or IFocusableTree, and in fact doing so would be difficult as the two are closely linked for identification.

Toolboxes and flyouts are the cases to consider here.

const currNodeElem =
this.testFocusableNestedTree4Node1.getFocusableElement();
assert.notInclude(
Array.from(prevNodeElem.classList),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that classList supports contains() - it might be nicer to make these assert.true(node.classList.contains('blocklyActiveFocus')) or the like throughout, and removes the need for the array conversions fwiw.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, and moved both cases to helpers to try and improve readability a bit.

assert.isNull(this.focusManager.getFocusedNode());
});

test('unfocuasble element focus()ed after registered node focused returns original node', function () {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: unfocusable

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent catch--fixed.

@BenHenning
Copy link
Contributor Author

Thanks @maribethb @gonfunko and @rachel-fenichel for the reviews & thoughts!

@gonfunko could you please take another pass on the changes and make sure they look good to you before I merge? There were a decent amount of changes, including some new documentation and documentation updates.

I think it's the lack of an obvious pattern that has me lean toward "don't overcomplicate the API" (i.e. always use the traverser directly in FocusManager rather than soft requiring using it in each implementation override), but I also don't have a good sense on future proofing APIs that must maintain strong backward compatibility.

I agree with always using the traverser directly, unless/until we have concrete examples of it being an issue. I think a benefit of centralizing this code into the Traverser is that it's easy to iterate on it in one place, as well.

There's a real trade-off here in that changing this decision later on will affect a whole lot of classes in a pretty aggressive breaking change (i.e. must implement new methods). I suspect these will largely be isolated, though.

Adding methods to classes now and removing them later would count as a big breaking change, but the opposite would not.

@rachel-fenichel wouldn't it be the case that adding methods to an export interface also is a breaking change? That's what I meant, anyway. It seems to be that any API changes to an exported interface is always breaking as it will require changes to downstream implementations (since TypeScript doesn't support default interface method).

If we end up wanting some classes to define their own Traversers, we can use the registry, have them render custom traversers, and do a lookup based on type or a string. But let's not do that unless we actually need it, which we don't yet.

Using the registry pattern is a really nice idea to solve this and one I hadn't considered. Updated the PR description to mention it, too, for future reference.

As you alluded to, I don't think we expect a lot of external classes to implement IFocusableNode or IFocusableTree, and in fact doing so would be difficult as the two are closely linked for identification.

Toolboxes and flyouts are the cases to consider here.

Good point--I could see these being cases when Blockly users may need to implement a custom IFocusableNode. However, it could get really tricky since the default behavior of, say, Toolbox would need to change (which would presumably mean subclassing it and overriding the IFocusableTree behaviors). I could see this getting quickly quite complicated, actually.

Copy link
Contributor Author
@BenHenning BenHenning left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

C98F

Self-reviewed latest changes as well.

@BenHenning
Copy link
Contributor Author

From my self-review, I think everything has been addressed. If I missed anything, I'm happy to follow up fixes in a different PR @gonfunko and @maribethb.

Merging to help keep things moving.

@BenHenning BenHenning merged commit 17171ab into google:rc/v12.0.0 Apr 3, 2025
7 of 8 checks passed
@BenHenning BenHenning deleted the introduce-focus-system-implementation branch April 3, 2025 23:20
@gonfunko gonfunko added the PR: feature Adds a feature label Apr 16, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
PR: feature Adds a feature
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support for a singleton, page-wide FocusManager.
4 participants
0