-
Notifications
You must be signed in to change notification settings - Fork 6.8k
refactor(cdk/a11y): FocusMonitor now uses InputModalityDetector under the hood. #22489
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
refactor(cdk/a11y): FocusMonitor now uses InputModalityDetector under the hood. #22489
Conversation
@@ -728,6 +728,9 @@ describe('MDC-based MatTooltip', () => { | |||
tick(500); | |||
|
|||
expect(overlayContainerElement.querySelector('.mat-mdc-tooltip')).toBeNull(); | |||
|
|||
// Flush due to the additional tick that is necessary for the FocusMonitor. | |||
flush(); |
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.
These flush
calls are because this test only ticks 500 ms, but the FocusMonitor
creates a timer for 650 ms (TOUCH_BUFFER_MS
) for touch events. Ticking for 650 ms, or adding this flush
, allows the pending timer to complete. Previously this timer was only created for touchstart
, not focusVia(el, 'touch')
, now it's created for both (to match the behavior of keyboard and mouse).
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's worth noting that the last time we did something like this in the FocusMonitor
, it was difficult to land in g3, because it broke tests using fakeAsync
.
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.
Ack. I'll see what things look like in g3 and rework things if 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.
I tested the change in g3 and things fortunately look good.
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: These flush
calls ended up being removed to keep FocusMonitor
's existing behavior and because ultimately not clearing the origin after 650 ms when using focusVia
is unnecessary (see #22489 (comment)).
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.
Regarding the touch issue, maybe we should just expose the last event target?
@@ -728,6 +728,9 @@ describe('MDC-based MatTooltip', () => { | |||
tick(500); | |||
|
|||
expect(overlayContainerElement.querySelector('.mat-mdc-tooltip')).toBeNull(); | |||
|
|||
// Flush due to the additional tick that is necessary for the FocusMonitor. | |||
flush(); |
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's worth noting that the last time we did something like this in the FocusMonitor
, it was difficult to land in g3, because it broke tests using fakeAsync
.
// window blurred. | ||
// 2) The element was programmatically focused, and thus we set the origin as 'program'. | ||
// 3) The element was focused via navigating with a screen reader, and thus we set the origin | ||
// as 'program' because keyboard events are rarely fired. |
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 don't totally follow the part here about keyboard events being rarely fired. Wouldn't screen reader navigation almost always be keyboard navigation?
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.
When navigating with JAWS/NVDA/VO, no keyboard events are fired (for the most part). For example, if a user is linearly navigating with VO via Caps Lock + Arrow Keys, no keyboard events are fired despite focus moving along with VO's virtual cursor. As another example, if a user is focused on element A, then uses JAWS' browse mode to navigate to element B (note focus doesn't yet move in this case), and then presses Enter to enter forms mode and move focus to B, again, no keyboard events are fired despite focus moving.
In these scenarios, we can't detect the input modality as keyboard, and as a result, any subsequent focus change will be attributed to program. Note that this is FocusMonitor
's existing behavior today, it just isn't documented anywhere.
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.
Got it- I knew screen reader interaction didn't kick off keyboard events, I just misunderstood the comment (the point being that you can't distinguish between programatic and screen-reader). I might reword slightly:
// If the window has just regained focus, we can restore the most recent origin
// from before the window blurred. Otherwise, we've reached the point where
// we can't identify the source of the focus. This typically means one of two things
// happened:
// 1) The element was programmatically focused, or
// 2) The element was focused via screen reader navigation (which
// generally doesn't fire events).
// Because we can't distinguish between these two cases, we default to
// setting `program`, erring on the side of not showing keyboard focus indicators.
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 (but omitted the final piece "erring on the side of not showing keyboard focus indicators" because FocusMonitor doesn't know about keyboard focus indicators, and can be used with or without them).
this._originTimeoutId = setTimeout(() => this._origin = null, 1); | ||
clearTimeout(this._originTimeoutId); | ||
const ms = (origin === 'touch') ? TOUCH_BUFFER_MS : 1; | ||
this._originTimeoutId = setTimeout(() => this._origin = null, ms); |
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.
Previously, in cases when we could attribute touch
immediately, we would have done so, right? With this change, it looks like touch is always delayed by 650ms?
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 I understand you correctly, that's almost correct. Previously, in the case where we could attribute touch immediately (i.e. via focusVia(el, 'touch'
), we'd immediately update the origin and reset it at the start of the next tick. Now, we still immediately update the origin, but we reset it 650ms later, instead of at the start of the next tick. Thus, this change does slightly change the behavior of focusVia
in that regard. It's easy to fix this, if we want, I think it'd be easy to do. Just want to make sure I understand where you're coming from before changing anything.
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 was just trying to make sure I understood (which I did not). Now I follow, I think this makes sense.
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.
Okay. I added a isFromInteractionEvent
parameter to this method that allows us to distinguish from when it is called from focusVia
versus from the InputModalityService
. Please LMK if you have a better way of distinguishing between the two.
re: what to do about the touch regression I'm leaning toward just having a slight regression (assuming its not too hard to get in to g3). I would guess in 99% of cases it makes no difference. I don't think any of our components apply special styling for touch events, and I doubt that's a common use case many people have. If it turns out I'm wrong and everyone starts demanding we go back to the old behavior we can always add an internal API to expose the last event and use that in FocusMonitor |
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'm also okay with the touch behavior, I think we can revisit if we get reports of issues
Overall LGTM
@zelliott you can add the "merge ready" label when ready to merge |
Okay, should be ready to merge now. I made a few nit additional changes, please see the latest commit. I tested both the
|
This issue has been automatically locked due to inactivity. Read more about our automatic conversation locking policy. This action has been performed automatically by a bot. |
Demo: https://angular-ivy-ls9hgx.stackblitz.io/#
InputModality
changes:Added
InputModalityDetector.modalityDetected
that emits whenever an input modality is detected. This is in contrast to the existingInputModalityDetector.modalityChanged
, which emits whenever the input modality changes. This method is specifically needed forFocusMonitor
, but presumably it could be useful to any user. We could also make this an internal-only API potentially? WhenFocusMonitor
is inIMMEDIATE
mode, it attempts to associate the focus event with the input modality event in the current/previous tick (note that this isn't entirely true for touch interactions). This means we need to updateInputModalityDetector
to expose all modality detections, as opposed to just when the modality changes). Otherwise, the modality thatFocusMonitor
receives from theInputModalityDetector
could be stale.Previously,
InputModalityDetector
ignored fakemousedown
andtouchstart
events, now it detects them as keyboard input modality. It did this because I mistakenly thought these events where emitted while linearly navigating / in browse mode with a screen reader. It turns out they're emitted when activating a control with a screen reader (e.g. VO + Space to activate a button), in which case we probably do want to detect keyboard input modality. We'd want to make this change regardless of whether this PR goes in.FocusMonitor
changes:Regression when touch events are quickly followed by programmatic focus events: Previously, when a touch event was followed by a focus event, we'd use the
_wasCausedByTouch
method to determine if the focus event should be attributed to touch. This method attributes the event to touch if (1) the focus event occurred < 650ms after the touch event and (2) the touch event target is contained within the focus event target (or the targets are the same). It had some issues (see the huge comment in the method itself) but as a heuristic apparently worked well enough.Now, we only attribute the event to touch if the former is true. The reason we can't perform the latter logic is because we no longer have the touch event itself (as it's not exposed by
InputModalityDetector
). What this means in practice is that in scenarios where the user touches an element, and focus is programmatically moved as a result of that touch, that focus will be more often mis-attributed as touch instead of program.At the end of the day, the core problem is that the
IMMEDIATE
mode simply doesn't really work with touch events. Per the comment inFocusMonitor
:This is inaccurate for how touch events are handled. We're forced to wait 650 ms to attribute the touch event. I'm not really sure what the best way to solve this is, some options below.
FocusMonitor.focusVia(el, 'program')
instead of.focus()
directly.InputModalityDetector
exposes the underlying events themselves so thatFocusMonitor
can perform its current logic.IMMEDIATE
to instead be within the last N ms, where N >= 650 and is the same regardless of keyboard/touch/mouse.Note: The source itself suggests another solution, copied below:
However, this doesn't work for the case where a non-focusable element is clicked, and upon click, programmatically moves focus to a different element. No focus/blur events will be fired for the first interaction.