-
Notifications
You must be signed in to change notification settings - Fork 3.8k
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
feat: Introduce FocusManager implementation #8814
Conversation
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.
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. |
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.
There was a problem hiding this 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.
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 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. |
There was a problem hiding this 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.
core/focus_manager.ts
Outdated
const matchingNodes = this.registeredTrees.map((tree) => | ||
tree.findFocusableNodeFor(activeElement), | ||
); | ||
newNode = matchingNodes.find((node) => !!node) ?? null; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
core/focus_manager.ts
Outdated
// updated. Per the contract of findFocusableNodeFor only one tree | ||
// should claim the element. | ||
const matchingNodes = this.registeredTrees.map((tree) => | ||
tree.findFocusableNodeFor(activeElement), |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
There was a problem hiding this 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.
core/focus_manager.ts
Outdated
* unregisterTree. | ||
*/ | ||
isRegistered(tree: IFocusableTree): boolean { | ||
return this.registeredTrees.findIndex((reg) => reg == tree) !== -1; |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
core/focus_manager.ts
Outdated
* focus. | ||
*/ | ||
focusNode(focusableNode: IFocusableNode): void { | ||
const curTree = focusableNode.getFocusableTree(); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Definitely reasonable, will update.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Updated to exit earlier.
dom.hasClass(root, 'blocklyActiveFocus') || | ||
dom.hasClass(root, 'blocklyPassiveFocus') |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done--added the constants.
There was a problem hiding this comment.
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.
const FocusableNodeImpl = function (element, tree) { | ||
this.getFocusableElement = function () { | ||
return element; | ||
}; | ||
|
||
this.getFocusableTree = function () { | ||
return tree; | ||
}; | ||
}; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
// 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'), | ||
); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 () { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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).
Thanks for taking a pass @maribethb!
That's correct. TypeScript, unfortunately, doesn't seem to support default interface methods which would have otherwise been a good solution to the problem.
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 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 |
* | ||
* See FocusManager.takeEphemeralFocus for more details. | ||
*/ | ||
export type ReturnEphemeralFocus = () => void; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done.
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
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
Toolboxes and flyouts are the cases to consider here. |
tests/mocha/focus_manager_test.js
Outdated
const currNodeElem = | ||
this.testFocusableNestedTree4Node1.getFocusableElement(); | ||
assert.notInclude( | ||
Array.from(prevNodeElem.classList), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
tests/mocha/focus_manager_test.js
Outdated
assert.isNull(this.focusManager.getFocusedNode()); | ||
}); | ||
|
||
test('unfocuasble element focus()ed after registered node focused returns original node', function () { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: unfocusable
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Excellent catch--fixed.
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.
@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).
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.
Good point--I could see these being cases when Blockly users may need to implement a custom |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
C98FSelf-reviewed latest changes as well.
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. |
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:
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:
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 removegetFocusedNode
andfindFocusableNodeFor
fromIFocusableTree
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).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.IFocusableTree
was updated to:findFocusableNodeFor
.FocusableTreeTraverser
).FocusManager
vs. making it a requirement of theElement
provided byIFocusableNode
. This remains an open question.IFocusableNode
implementations will be responsible for ensuring their elements have a tab index of -1 set correctly.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 implementIFocusableTree
andIFocusableNode
, as needed, per #8771. The experimental keyboard plugin will also be updated to leverageFocusManager
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.