-
Notifications
You must be signed in to change notification settings - Fork 6.8k
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
fix(select): Improve the a11y of select component by making overlay #11806
Conversation
@@ -165,6 +165,9 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges { | |||
@Input('cdkConnectedOverlayScrollStrategy') scrollStrategy: ScrollStrategy = | |||
this._scrollStrategy(); | |||
|
|||
/** Strategy to detach the overlay panel on ESCAPE keydown */ | |||
@Input('cdkConnectedOverlayDetachOnEscape') detachOnEscape: boolean = true; |
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 would invert this property so that it's just disableClose
with a description:
/** Whether the overlay closes when the user pressed the Escape key. */
and also using coerceBooleanProperty
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.
In that case the same check would have to be added to the backdropClick
so it's consistent with all the other overlays.
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.
@crisbeto backdropClick
emits an event and developers decide what will happen after backdropClick
. (Here in select we close the panel, but other things can be done)
src/lib/select/select.html
Outdated
@@ -19,31 +19,31 @@ | |||
cdk-connected-overlay | |||
cdkConnectedOverlayLockPosition | |||
cdkConnectedOverlayHasBackdrop | |||
[cdkConnectedOverlayOpen]="true" |
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 think this needs the square braces any more
Also add a comment that explains that why the overlay is always "open"?
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.
My concern with this one is how it would perform on a page with a lot of selects. I wonder whether we couldn't get away with not rendering it on init, but doing it when the user focuses on a select or hovers over it. That way we should have a split second to get it into the DOM before the user decides to open the panel.
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.
Not mentioned here, but the second half of this change is going to be support the native <select>
in mat-form-field
for situations where mat-select
is too heavy. The user can then decide to make the trade-off- of having the Material Design style panel or the much more performant native dropdown.
src/lib/select/select.spec.ts
Outdated
@@ -396,6 +395,7 @@ describe('MatSelect', () => { | |||
it('should open the panel when pressing a vertical arrow key on a closed multiple select', | |||
fakeAsync(() => { | |||
fixture.destroy(); | |||
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.
I'm worried that if we need to add flush
in these tests, there will be a ton of downstream apps that will need to do the same
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.
Could you elaborate on what makes these flush
operations necessary now?
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.
The flush is because something is run outside the angular zone when the overlay is attached: https://github.com/angular/material2/blob/master/src/cdk/overlay/overlay-ref.ts#L304
We can attach backdrop when the select panel is opened.
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.
We can see how it affects google3 on presubmit
src/lib/select/select.ts
Outdated
// Defer setting the value in order to void the "Expression has changed after it was checked" | ||
// errors from Angular | ||
Promise.resolve().then(() => { | ||
this._initializePanel(); |
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.
Does this have to happen in ngAfterContentInit
, or can it happen once the panel is visually displayed?
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.
@jelbourn How can we know the panel is visually displayed?
src/lib/select/select.ts
Outdated
@@ -554,20 +563,32 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit, | |||
this._keyManager.withHorizontalOrientation(null); | |||
this._calculateOverlayPosition(); | |||
this._highlightCorrectOption(); | |||
this._showBackdrop(); | |||
this.overlayDir.overlayRef.overlayElement.style.minWidth = `${this._triggerRect.width}px`; |
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.
Why are setting this minWidth
and the direction necessary now when they weren't before?
I'm also concerned about setting any geometry properties directly to the overlay panel, since that's supposed to be the responsibility of the overlay code itself.
src/lib/select/select.ts
Outdated
|
||
this.overlayDir.overlayRef.updatePosition(); | ||
|
||
this._changeDetectorRef.detectChanges(); |
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.
Why is the extra detectChanges
necessary?
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.
Otherwise we cannot calculate the correct scrollTop
and offsetX
values in the next steps
@@ -194,7 +195,7 @@ export class MatSelectTrigger {} | |||
'[attr.aria-required]': 'required.toString()', | |||
'[attr.aria-disabled]': 'disabled.toString()', | |||
'[attr.aria-invalid]': 'errorState', | |||
'[attr.aria-owns]': 'panelOpen ? _optionIds : null', | |||
'[attr.aria-owns]': '_optionIds', |
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.
Wasn't part of this change doing to make the select role="combobox"
? That part is separate from where the options live, and I think it still reflects the interaction here more accurately.
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.
Combobox requires a textbox inside it. I think it means there's an editable textbox and user may choose to enter something other than the list of options, which doesn't match our select component.
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 #7835 ends up being merged, it would be nice to be able to use role="combobox"
in correlation with the editable textbox that is focus-trapped in the mat-select-header.
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.
The text input is optional for combobox
; this prototype uses it without one:
https://stackblitz.com/edit/angular-a8j36n?file=app%2Fmat-select%2Fmat-select.component.html
As well as the deque select:
https://pattern-library.dequelabs.com/components/selects
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.
In aria 1.0 it's not required and in aria 1.1 textbox or searchbox is required.
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.
Ah, interesting. We'll have to explore using combobox
for a select with a search input in the future
@@ -165,6 +165,9 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges { | |||
@Input('cdkConnectedOverlayScrollStrategy') scrollStrategy: ScrollStrategy = | |||
this._scrollStrategy(); | |||
|
|||
/** Strategy to detach the overlay panel on ESCAPE keydown */ | |||
@Input('cdkConnectedOverlayDetachOnEscape') detachOnEscape: boolean = true; |
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.
In that case the same check would have to be added to the backdropClick
so it's consistent with all the other overlays.
src/lib/select/select.html
Outdated
[ngClass]="panelClass" | ||
[@transformPanel]="multiple ? 'showing-multiple' : 'showing'" | ||
[@transformPanel]="panelOpen ? (multiple ? 'showing-multiple' : 'showing') : '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.
Would be a good idea to move this into a function rather than having the nested ternary.
src/lib/select/select.spec.ts
Outdated
@@ -396,6 +395,7 @@ describe('MatSelect', () => { | |||
it('should open the panel when pressing a vertical arrow key on a closed multiple select', | |||
fakeAsync(() => { | |||
fixture.destroy(); |
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.
We should have a test that verifies that the select panel is in the DOM and with the correct amount of options on init.
@@ -758,6 +782,45 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit, | |||
return !this._selectionModel || this._selectionModel.isEmpty(); | |||
} | |||
|
|||
/** Show the backdrop of the overlay. */ | |||
private _showBackdrop() { |
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.
Not necessarily something for this PR, but rather than adding something special for the select, we can add the ability to detach and re-attach the backdrop via the OverlayRef
.
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 tried detach and re-attach the backdrop via the OverlayRef
and I think there's a risk of detaching other overlay's backdrop accidentally or failed to detach correctly since there're some async activities outside angular zone. It's more reliable to make the backdrop visible/invisible.
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 there's a risk that that detaching one overlay could unintentionally detach another, that sounds like a larger problem with the cdk or its implementation 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.
I'm not sure I see how that could happen; each OverlayRef has a reference to its own specific backdrop element
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.
Each OverlayRef has its own backdrop. The detach backdrop is an async call
this._ngZone.runOutsideAngular(() => setTimeout(finishDetach, 500));
When user click on the backdrop, it takes 500ms to fully detach the overlay. If in that 500ms user open the select panel again, a backdrop stilll exists so we cannot reattach it, and is removed after 500ms which means click outside the select panel will not close the panel.
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.
There's transition.
backdropToDetach.classList.remove('cdk-overlay-backdrop-showing');
This triggers transition and finishDetach
is triggered when the transitionend
.
https://github.com/angular/material2/blob/master/src/cdk/overlay/overlay-ref.ts#L348
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 see. So would you say the deeper issue here is that this._ngZone.runOutsideAngular(() => setTimeout(finishDetach, 500));
is meant to accommodate situations where there is no transitionend
but currently it runs regardless of whether there's a transition or not?
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.
What we were trying to avoid with that setTimeout
is a call to getComputedStyle
just so we can figure out whether the backdrop has a transition. I had a PR a while ago that switched the backdrop to be a component which would've given us better control over the animation, however we never managed to get it presubmitted (see #6627).
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.
@crisbeto Do you think it would work better if, instead of having a 500ms timeout, there was a 10ms timeout that gets cleared with clearTimeout()
if transitionstart
is 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.
If I remember correctly, transitionstart
was flaky between browsers which means that if we started depending on it, people's tests would start flaking as well.
src/lib/select/select.ts
Outdated
this._changeDetectorRef.markForCheck(); | ||
|
||
// Set the font size on the panel element once it exists. | ||
this._ngZone.onStable.asObservable().pipe(take(1)).subscribe(() => { |
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 waiting for onStable
and then having a null check for the overlayRef
, we should be able to use the attach
on the overlay directive to know when it was attached.
src/lib/select/select.html
Outdated
@@ -19,31 +19,31 @@ | |||
cdk-connected-overlay | |||
cdkConnectedOverlayLockPosition | |||
cdkConnectedOverlayHasBackdrop | |||
[cdkConnectedOverlayOpen]="true" |
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.
My concern with this one is how it would perform on a page with a lot of selects. I wonder whether we couldn't get away with not rendering it on init, but doing it when the user focuses on a select or hovers over it. That way we should have a split second to get it into the DOM before the user decides to open the panel.
src/lib/select/select.ts
Outdated
@@ -512,6 +513,14 @@ export class MatSelect extends _MatSelectMixinBase implements AfterContentInit, | |||
this._resetOptions(); | |||
this._initializeSelection(); | |||
}); | |||
|
|||
this._triggerRect = this.trigger.nativeElement.getBoundingClientRect(); |
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 seems like you're recalculating the _triggerRect
here and right afterwards inside _initializePanel
. We should be able to get away with only doing it in _initializePanel
.
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.
The trigger's bounding rectangle may change after the panel is initialized.
The panel is only initialized once when the component is loaded, but if the trigger's rect changed after that, we still need to get the correct scrollTop
, offsetX
, offsetY
based on the new values.
This comment has been minimized.
This comment has been minimized.
@crisbeto We discussed the possibility of loading options when user focus on the select, and we think it's too late. |
Looks like there are just some lint errors now |
c14331f
to
dbecfa3
Compare
CLAs look good, thanks! |
@Input('cdkConnectedOverlayOpen') open: boolean = false; | ||
@Input('cdkConnectedOverlayOpen') | ||
get open() { return this._open; } | ||
set open(value: any) { this._open = coerceBooleanProperty(value); } |
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.
Just curious: any reason to not use boolean
(in getter/setter)?
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.
LGTM
Caretaker note: may need to change this from patch to something else depending on how many tests need to be updated in google |
This comment has been minimized.
This comment has been minimized.
always exist in the DOM
dbecfa3
to
113997b
Compare
Hi @tinayuangao! This PR has merge conflicts due to recent upstream merges. |
Just wanted to link this over to the related issue: #11083. |
@jelbourn I think that this one is a good candidate for closing or reviewing for closing at the next meeting? It hasn't been stated here, but #11083 (comment) mentions that
|
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. |
always exist in the DOM