Skip to content
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(cdk/a11y): Add a new InputModalityDetector service to detect the user's current input modality. #22371

Merged
merged 2 commits into from Apr 14, 2021

Conversation

zelliott
Copy link
Collaborator

Note: This is a first-stab at an input modality detection service and I expect there to be some churn regarding its exact behavior, so feel free to leave many comments.

Motivation

In our app, we conditionally render strong focus styles on elements if the element is determined to have been focused via keyboard. This is done by attaching a global FocusMonitor to the document.body. One problem with this approach is that we can only detect input modality changes when focus also changes (see #21555 for why this is a problem with aria-activedescendant elements). The purpose of the InputModalityDetector service is to allow users to detect a broader array of input modality changes, regardless of whether focus changes.

Approach

The InputModalityDetector attempts to detect input modality by listening to keydown, mousedown, and touchstart events, and attributing each event to its corresponding input modality ('keyboard', 'mouse', 'touch'). For example, when a keydown event is fired, the service detects the 'keyboard' input modality. I chose this small subset of events as I thought they best represented legitimate, intentional, and committed user interaction.

The service uses some additional heuristics under-the-hood to support scenarios when these events are fired but likely don't represent an actual change in input modality. For example:

  • By default, the service ignores modifier keys (please see the comment in the source for why). We allow the user to override this behavior if they wish, but would cause some difference in behavior between VoiceOver and other SRs.
  • The service ignores mousedown events that occur too closely after a touchstart event (again, please see the comments in the source for why).

Demo

https://stackblitz.com/edit/angular-ivy-9cxqav?file=src/app/input-modality-detector.ts

Behavior

The service essentially works as you would expect when interacting with a page. Interesting edge cases are as follows:

  • The input modality is null when the service is first instantiated, as we have no clue what the user's input modality is.
  • Interacting with an inert element (e.g. clicking empty space or just pressing a random key) changes the input modality accordingly. My current opinion is that this is fine, as it represents an attempted interaction with the page.
  • If a user's input modality changes in the middle of interacting with a built-in popup control such as a <select> or <input type="datepicker">, this change is not detected. This is because no events are emitted.
  • If a user clicks while pressing a modifier key such as Control or Option (to either "right-click" or "open in a new tab"), this is detected as mouse input modality (as modifier keys are ignored by default, as mentioned earlier).
  • If a user is using a screen reader, input modality for the most part will not be detected (there's a comment in the source that goes into more detail).

Testing

I'm planning on testing the service on the following platforms, browsers, and screen readers:

  • Platforms: Web (Windows, Mac, Chrome OS), Mobile (Android, iOS)
  • Browsers: Chrome, FF, Safari, Edge, IE11.
  • SRs: NVDA, JAWS, VoiceOver, ChromeVox, Android Accessibility Suite.

I've currently tested most of the combinations above, with the exception of Chrome OS (+ChromeVox), Android (+Android Accessibility Suite), and IE11.

Prior Art / Resources

@google-cla google-cla bot added the cla: yes PR author has agreed to Google's Contributor License Agreement label Mar 30, 2021
* The amount of time needed to pass after a touchstart event in order for a subsequent mousedown
* event to be attributed as mouse and not touch.
*/
export const TIME_AFTER_TOUCH_MS = 650;
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This was grabbed from https://github.com/angular/components/blob/master/src/cdk/a11y/focus-monitor/focus-monitor.ts#L34 and from what I can tell they're both used for the same purpose? Should one of these usages import the other or should we move it into some shared file?

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, I think focus monitor should probably consume this. We should also preserve the comment about the origin of this value. However, we probably want to avoid adding this symbol to the public API.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done.

private _onKeydown = (event: KeyboardEvent) => {
// If this is one of the keys we should ignore, then ignore it and don't update the input
// modality to keyboard.
if (this._options?.ignoreKeys?.some(key => key === event.key)) { return; }
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I'd prefer to use event.key instead of keyCode, but if you're still targeting IE11 it looks like it has only partial support (https://caniuse.com/?search=KeyboardEvent.key), which may not be good enough (I'll need to test).

Copy link
Member

Choose a reason for hiding this comment

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

Yeah, we unfortunately still use keyCode everywhere and plan to switch to key or code once IE11 support is dropped (see #7374). We plan to deprecate IE11 support in v12 (this May) and fully remove it in either v13 or v14 based on community feedback.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Gotcha. Switched to keyCode.

@zelliott zelliott marked this pull request as ready for review March 30, 2021 18:09
@zelliott zelliott marked this pull request as draft March 30, 2021 18:10
@jelbourn jelbourn added target: minor This PR is targeted for the next minor release feature This issue represents a new feature or feature request rather than a bug or bug fix Accessibility This issue is related to accessibility (a11y) area: cdk/a11y G This is is related to a Google internal issue labels Mar 31, 2021
Copy link
Member

@jelbourn jelbourn left a comment

Choose a reason for hiding this comment

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

Overall this looks good; if we can show that FocusMonitor can use this it would be good to go IMO

css @mmalerba and @crisbeto who have done most of the work on FocusMonitor

* The amount of time needed to pass after a touchstart event in order for a subsequent mousedown
* event to be attributed as mouse and not touch.
*/
export const TIME_AFTER_TOUCH_MS = 650;
Copy link
Member

Choose a reason for hiding this comment

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

Yeah, I think focus monitor should probably consume this. We should also preserve the comment about the origin of this value. However, we probably want to avoid adding this symbol to the public API.

* Event listener options that enable capturing and also mark the listener as passive if the browser
* supports it.
*/
const opts = normalizePassiveListenerOptions({
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
const opts = normalizePassiveListenerOptions({
const modalityEventListenerOptions = normalizePassiveListenerOptions({

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done.

Comment on lines 89 to 95
get inputModality(): InputModality {
return this._inputModality;
}
Copy link
Member

Choose a reason for hiding this comment

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

Any reason to make this a getter vs. making the actual property public?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Only to prevent people from setting it themselves for whatever reason. Happy to change it to a public property, just LMK.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Note that now the getter is more necessary because we're grabbing the value from the underlying BehaviorSubject.

/** The underlying ReplaySubject that emits input modality changes. */
private readonly _change = new ReplaySubject<InputModality>(1);

/** The current / last detected input modality by this service. */
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
/** The current / last detected input modality by this service. */
/** The most recently detected input modality by this service. */

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done.

private _onKeydown = (event: KeyboardEvent) => {
// If this is one of the keys we should ignore, then ignore it and don't update the input
// modality to keyboard.
if (this._options?.ignoreKeys?.some(key => key === event.key)) { return; }
Copy link
Member

Choose a reason for hiding this comment

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

Yeah, we unfortunately still use keyCode everywhere and plan to switch to key or code once IE11 support is dropped (see #7374). We plan to deprecate IE11 support in v12 (this May) and fully remove it in either v13 or v14 based on community feedback.

* these keys so as to not update the input modality.
*/
export const INPUT_MODALITY_DETECTOR_DEFAULT_OPTIONS: InputModalityDetectorOptions = {
ignoreKeys: ['Alt', 'Control', 'Meta', 'Shift'],
Copy link
Member

Choose a reason for hiding this comment

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

These are all modifier keys so shouldn't we be looking at KeyboardEvent.altKey etc.? We have some helpers for dealing with modifiers in @angular/cdk/keycodes already.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I considered that, but I wanted to give users the ability to configure which keys they'd like to ignore/not ignore, and it seemed easiest to support that via a single ignoreKeys API.

Copy link
Member

Choose a reason for hiding this comment

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

It could have a modifiers array and ignoreKeys. If I remember correctly, the keydown will dispatch Alt if you just press the Alt key once, but if you pressed Alt + DOWN_ARROW, you'd get a DOWN_ARROW event with the Alt modifier.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

From my testing, if I press Alt + Down Arrow, I get both an Alt and a Down Arrow event, so this should work fine (I tested with a number of modifier keys and on both Mac and Windows). Changing the API to both modifiers and ignoreKeys increases its surface error, and it's not clear to me what the benefit is (do we think it'll be clearer to users?).

}

/** The underlying ReplaySubject that emits input modality changes. */
private readonly _change = new ReplaySubject<InputModality>(1);
Copy link
Member

Choose a reason for hiding this comment

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

You could replace the ReplaySubject with a BehaviorSubject which has identical behavior, but it also allows you to read the latest value which can then be used for the inputModality getter.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This was actually my previous implementation, but I ended switching to a ReplaySubject because I didn't like how the BehaviorSubject emitted null at the start. My thinking was that inputModalityChange should only emit when the input modality changes, and I didn't want subscribers to have to filter out the first null value in their logic. WDYT?

Copy link
Member

Choose a reason for hiding this comment

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

You could add .pipe(skip(1)) when exposing the subject publicly. It's not a bit deal to leave it as it is though.

Copy link
Collaborator Author

@zelliott zelliott Apr 2, 2021

Choose a reason for hiding this comment

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

Okay, I don't feel strongly so I switched to the BehaviorSubject.

this.inputModalityChange = this._change.pipe(distinctUntilChanged());

// Add the event listeners used to detect the user's input modality.
document.addEventListener('keydown', this._onKeydown, opts);
Copy link
Member

Choose a reason for hiding this comment

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

All of these events should be bound outside of the Angular zone.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Thanks, I made this change, but it's causing some problems in the dev-app demo and Stackblitz. If you take a look at the Stackblitz (I updated with the new implementation of binding these event listeners outside of the ngZone), you'll notice that the input modality no longer updates in the view (though of course the subscription still emits). Without knowing too much about zones my it seems like Angular isn't running CD because it doesn't think anything is changing. Is this expected behavior? If so, I'll need to update the dev-app demo accordingly.

Copy link
Member

Choose a reason for hiding this comment

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

If the listener is bound outside of the zone, you'll have to use NgZone.run when updating the view, otherwise change detection won't run. It's a little cumbersome, but we may end up triggering unnecessary change detections otherwise.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Gotcha, done.

src/cdk/a11y/input-modality/input-modality-detector.ts Outdated Show resolved Hide resolved

constructor(
platform: Platform,
@Inject(DOCUMENT) document: Document,
Copy link
Member

Choose a reason for hiding this comment

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

AFAIK there are some issues in ViewEngine with typing constructor parameters as Document. The way we work around it in other places is to type the parameter as any and then cast it to Document afterwards.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I did notice this when looking at other services. Do you recall how to reproduce these issues? I'd prefer to be able to reproduce the issue to confirm the any + cast is actually needed, but if you're confident, then I'll just go ahead and make the change.

Copy link
Member

Choose a reason for hiding this comment

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

I think that we were running into it in our Angular Universal integration tests. There's a readme under src\universal-app\DEBUG.md.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I added the InputModalityDetector to the universal kitchen sink app as-is, and ran bazel test //src/universal-app:server_test --test_output=all. The target passed, and I didn't see any relevant console warnings/logs. Is there something in particular I should be looking for?

src/cdk/a11y/input-modality/input-modality-detector.ts Outdated Show resolved Hide resolved
src/cdk/a11y/input-modality/input-modality-detector.ts Outdated Show resolved Hide resolved
@zelliott
Copy link
Collaborator Author

zelliott commented Apr 1, 2021

if we can show that FocusMonitor can use this it would be good to go IMO

I think I responded to all open comments, but I still need to evaluate integration with FocusMonitor.

@zelliott
Copy link
Collaborator Author

zelliott commented Apr 6, 2021

It's possible to update FocusMonitor to use this new InputModalityDetector service to determine focus origin, but it requires us to tweak the API of this service a bit, and causes some changes in FocusMonitor's behavior. db685c0 demonstrates one potential integration. I left comments on the commit above documenting various things and explaining any changes in behavior. I'll let people look over the commit before I add the changes to this PR.

Additionally, here's a Stackblitz demo-ing integration: https://stackblitz.com/edit/angular-ivy-ls9hgx?file=src%2Fapp%2Ffocus-monitor.ts

Copy link
Contributor

@mmalerba mmalerba left a comment

Choose a reason for hiding this comment

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

LGTM

@jelbourn do you want to put this straight into master or create a feature branch so that the focus monitor can be migrated to use this before merging to master?

@zelliott
Copy link
Collaborator Author

zelliott commented Apr 7, 2021

@jelbourn do you want to put this straight into master or create a feature branch so that the focus monitor can be migrated to use this before merging to master?

I'd recommend a feature branch. Please see #22371 (comment) regarding integration with FocusMonitor. I left a number of open comments on the commit linked to in that comment.

@jelbourn jelbourn added target: feature This PR is targeted for a feature branch (outside of main and semver branches) and removed target: minor This PR is targeted for the next minor release labels Apr 7, 2021
@jelbourn jelbourn changed the base branch from master to input-modality April 7, 2021 18:44
@jelbourn
Copy link
Member

jelbourn commented Apr 7, 2021

I've updated the PR to target a feature branch called input-modality

/**
* Service that detects the user's input modality.
*
* Note: This service will not update the input modality when a user is navigating with a screen
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
* Note: This service will not update the input modality when a user is navigating with a screen
* This service does not update the input modality when a user navigates with a screen

nit: slightly more active voice, present tense

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done.

@Injectable({ providedIn: 'root' })
export class InputModalityDetector implements OnDestroy {
/** Emits when the input modality changes. */
readonly inputModalityChange: Observable<InputModality>;
Copy link
Member

Choose a reason for hiding this comment

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

Is it redundant to have inputModalityDetector.inputModalityChange? Maybe we should shorten to just
modalityChanges or changes?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I don't feel strongly, changed to modalityChanges to align with mostRecentModality.

Comment on lines 92 to 95
/** Returns the most recently detected input modality. */
get inputModality(): InputModality {
return this._inputModality.value;
}
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
/** Returns the most recently detected input modality. */
get inputModality(): InputModality {
return this._inputModality.value;
}
/** The most recently detected input modality. */
get mostRecentModality(): InputModality {
return this._inputModality.value;
}

What do you think about making this mostRecentModality?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done.

constructor(
private readonly _platform: Platform,
ngZone: NgZone,
@Inject(DOCUMENT) document: Document,
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
@Inject(DOCUMENT) document: Document,
@Inject(DOCUMENT) document: Document|Document,

Using Document here will break in SSR because TypeScript preserves type information on decorated params, and Document doesn't existing in a nodejs environment. We can use this weird workaround to trick TS into not emitting the type as-is.

Can go in a follow-up, but you can test this by adding something to make this API run as part of our kitchen-sink.ts component (which is our SSR test component).

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

See the thread at #22371 (comment). The kitchen sink e2e test seems to pass - what should I be looking for?

Copy link
Member

Choose a reason for hiding this comment

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

Hmm, maybe something changed in the tooling so that we don't have to worry about this any more ¯\(ツ)

@zelliott
Copy link
Collaborator Author

zelliott commented Apr 7, 2021

@jelbourn I think I may have accidentally pulled in a commit from annieyw (212c026) when rebasing my branch off of master. Maybe we can address this by updating the remote input-modality branch? My git knowledge is forever out of date. :\ LMK if there's something I should do on my end.

@mmalerba
Copy link
Contributor

mmalerba commented Apr 7, 2021

I synced input-modality up to master, it should go away if you rebase on input-modality

@zelliott zelliott marked this pull request as ready for review April 7, 2021 23:30
@zelliott
Copy link
Collaborator Author

zelliott commented Apr 7, 2021

Tried that (and also tried an empty commit to get the rebase picked up), to no avail. It's weird because comparing local with remote here (https://github.com/angular/components/compare/input-modality...zelliott:input-modality?expand=1) shows the commits I'd expect, whereas this PR has an extra one.

@zelliott
Copy link
Collaborator Author

zelliott commented Apr 8, 2021

Okay fixed by squashing.

Copy link
Member

@jelbourn jelbourn left a comment

Choose a reason for hiding this comment

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

lgtm - I also fixed the rebase issue

@jelbourn jelbourn added the action: merge The PR is ready for merge by the caretaker label Apr 12, 2021
Adds a new InputModalityDetector service to detect a user's input
modality.

feat(cdk/a11y): Small fixes to InputModalityDetector

feat(cdk/a11y): Add new API goldens

feat(cdk/a11y): Respond to reviewer feedback

feat(cdk/a11y): Respond to additional PR reviewer feedback

feat(cdk/a11y): Respond to PR feedback

feat(cdk/a11y): Accept new goldens

feat(cdk/a11y): Empty commit to try to get a rebase picked up
@mmalerba mmalerba merged commit fbf8e44 into angular:input-modality Apr 14, 2021
jelbourn pushed a commit that referenced this pull request Apr 26, 2021
jelbourn pushed a commit that referenced this pull request Apr 26, 2021
@angular-automatic-lock-bot
Copy link

This issue has been automatically locked due to inactivity.
Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.

@angular-automatic-lock-bot angular-automatic-lock-bot bot locked and limited conversation to collaborators May 15, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Accessibility This issue is related to accessibility (a11y) action: merge The PR is ready for merge by the caretaker area: cdk/a11y cla: yes PR author has agreed to Google's Contributor License Agreement feature This issue represents a new feature or feature request rather than a bug or bug fix G This is is related to a Google internal issue target: feature This PR is targeted for a feature branch (outside of main and semver branches)
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

5 participants