-
Notifications
You must be signed in to change notification settings - Fork 6.8k
feat(overlay): detach on outside click #16611
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(overlay): detach on outside click #16611
Conversation
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.
On top of the code comments, I spent some time testing it against an iOS device and it looks like this approach has the same issue that I ran into when I tried doing this a couple of years ago and which was raised in the design doc: touch devices won't dispatch mouse events when the user is tapping on a non-clickable element. This is a problem because the user can't tap anywhere outside of the overlay, like they can now, to close the element. The recording is from the iOS Simulator from xcode, but I'm seeing the same issue on a device running iOS 12.3.1. Note how tapping anywhere on the body doesn't close it, but it works once I tap on a clickable element like one of the buttons.
/** | ||
* Array of HTML elements clicking on which should not be considered as outside click | ||
*/ | ||
excludeFromOutsideClick?: HTMLElement[] = []; |
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 quite sure what the use case is for this.
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 array will contain elements which will be excluded from outside clicks. Let say you have two buttons. Clicking on each one will open a mat-menu. Right now if you click on button one mat-menu will be shown. Then if you click on button two mat-menu will be closed, but second mat-menu will not be shown. As described in this issue.
If we set detachOnOutsideClick
for mat-menu to true
this will work (as well as set hasBackdrop
to false). However in this case when you click on button one mat-menu will be shown. If at this point you click again at button one this will happen:
- Document's body will recognize this as outside click and will force mat-menu to close.
- Click event will reach the button one. At this point the mat-menu is already closed, so the button will try to open it.
To prevent this from happen we should say to overlay consider clicking on the button as not outside click. And we can achieve this by adding the button to excludeFromOutsideClick
array.
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.
Now that detachOnOutsideClick
has been removed in favor of having a click stream, is this option still necessary? Consumers of the API can now make the determination of what to do on the click based on what was clicked, right?
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 thinking for the situations where overlayRef
has no clue who opened it, and one could not determine in the handler of outsideClickEvents
, clicking on which element on the page should or should not detach the overlay.
* on event target and order of overlay opens. | ||
*/ | ||
@Injectable({providedIn: 'root'}) | ||
export class OverlayMouseClickDispatcher implements OnDestroy { |
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 looks like this class shares most of its code with the keyboard dispatcher. I think that we should only have one dispatcher that deals with both so we don't have to duplicate all of the code. If we rolled this into the keyboard dispatcher the class name won't make sense anymore, but we can rename it in a backwards-compatible way.
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.
Agreed that, if they share a lot of code, we should combine the two classes. This name also says "Mouse"- does this apply for touch event 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.
@crisbeto and @jelbourn you are right. I was looking at OverlayKeyboardDispatcher
when I was implementing OverlayClickDispatcher
. However looking at the code if we combine both these in one single class it will be a little awkward. On add
OverlayKeyboardDispatcher
adds event listener for keydown
while OverlayClickDispatcher
adds one for click
. Then in the event listener function both dispatchers implements different logic. If I put both in one dispatcher it should have a lot of ifs
inside. Additionally if in future a need for another dispatcher arise we should add more ifs
. This is why I’ve extracted the common logic in base OverlayBaseDispatcher
class which both keyboard and click dispatchers extend. What you think about this?
return; | ||
} else { | ||
if (config.detachOnOutsideClick) { | ||
overlayRef.detach(); |
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.
Having this call happen here feel a little weird, because all of the other similar functionality happens inside the OverlayRef
. I think that we should follow the same pattern where the dispatcher just emits some kind of event and then it's up to the overlay to decide what it wants to do based on its options.
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.
Agreed, it should be outside the responsibility of the event manager to actually dismiss the overlay; the overlay should instead respond to a signal from the keyboard manager.
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've fixed this. Now new OverlayClickDispatcher
fires _clickEvents
. Anyone interested should subscribe to the public clickEvents
observable in the overlay ref.
I can solve this by forcing the |
I don't know whether this is only an issue on iOS or whether we'd need to do this for all touch devices. If we end up having to do it for all, it could look weird on hybrid devices that both have a touch screen and a mouse. What do you think @jelbourn? |
Yes, we would definitely have to confirm that this works for all touch devices. I have a pixelbook I can test if you make the change. I would go with adding a no-op event listener to the document outside the zone if that makes the browser see all of the content as "clickable". The listener should only be applied while the overlay is open. |
@crisbeto and @jelbourn regarding the iOS issue I am setting now the body's cursor style to |
I tried this out locally just now- I set |
Now if
In this way this is now working similarly to |
I see; I think the naming is currently misleading, then. Would it make more sense to just have a stream of |
The initial idea was setting the |
That sounds 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.
Looked though against based on the latest API; mainly just some comments about clarification about exactly what the APIs are doing
* on event target and order of overlay opens. | ||
*/ | ||
@Injectable({ providedIn: 'root' }) | ||
export class OverlayClickDispatcher extends OverlayBaseDispatcher { |
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 I'm torn on whether it makes more sense to have separate dispatchers for keyboard and pointer events or to have one single dispatcher for captures both. Thoughts?
if (!this._isAttached) { | ||
this._document.body.addEventListener('click', this._clickListener, true); | ||
|
||
// click event is not fired on iOS. To make element "clickable" we are |
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 expand this comment a bit:
// Safari on iOS does not generate click events for non-interactive
// elements. However, we want to receive a click for any element outside
// the overlay. We can force a "clickable" state by setting
// `cursor: pointer` on the document body.
// See https://developer.mozilla.org/en-US/docs/Web/API/Element/click_event#Safari_Mobile
// and https://developer.apple.com/library/archive/documentation/AppleApplications/Reference/SafariWebContent/HandlingEvents/HandlingEvents.html
elements.push(overlayRef.overlayElement); | ||
const isInsideClick: boolean = elements.some((e: HTMLElement) => { | ||
return e.contains(event.target as any); | ||
}); |
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 simplify this a bit and avoid the any
const isInsideClick = elements.some(e => e.contains(event.target as Node));
// As soon as we reach an overlay for which the click is not outside click we break off | ||
// the loop. | ||
for (let i = overlays.length - 1; i > -1; i--) { | ||
const overlayRef: OverlayRef = overlays[i]; |
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.
const overlayRef: OverlayRef = overlays[i]; | |
const overlayRef = overlays[i]; |
Type can be inferred here
|
||
const config = overlayRef.getConfig(); | ||
const elements = [...config.excludeFromOutsideClick!]; | ||
elements.push(overlayRef.overlayElement); |
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 name this excludedElements
. You can also omit the push and include the element in the array directly:
const excludedElements = [...config.excludeFromOutsideClick, overlayRef.overlayElement];
/** | ||
* Array of HTML elements clicking on which should not be considered as outside click | ||
*/ | ||
excludeFromOutsideClick?: HTMLElement[] = []; |
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.
Now that detachOnOutsideClick
has been removed in favor of having a click stream, is this option still necessary? Consumers of the API can now make the determination of what to do on the click based on what was clicked, right?
@@ -222,6 +222,9 @@ export class CdkConnectedOverlay implements OnDestroy, OnChanges { | |||
/** Emits when there are keyboard events that are targeted at the overlay. */ | |||
@Output() overlayKeydown = new EventEmitter<KeyboardEvent>(); | |||
|
|||
/** Emits when there are mouse events that are targeted at the overlay. */ | |||
@Output() overlayClick = new EventEmitter<MouseEvent>(); |
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 clicks inside the overlay or outside the overlay? If it's the latter, I would rename this to outsideClick
* if any. It maintains a list of attached overlays to determine best suited overlay based | ||
* on event target and order of overlay opens. | ||
*/ | ||
export abstract class OverlayBaseDispatcher implements OnDestroy { |
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.
export abstract class OverlayBaseDispatcher implements OnDestroy { | |
export abstract class BaseOverlayDispatcher implements OnDestroy { |
src/cdk/overlay/overlay-ref.ts
Outdated
@@ -268,6 +305,11 @@ export class OverlayRef implements PortalOutlet, OverlayReference { | |||
return this._keydownEventsObservable; | |||
} | |||
|
|||
/** Gets an observable of mouse outside click events targeted to this overlay. */ | |||
clickEvents(): Observable<MouseEvent> { |
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 clicks inside or outside the overlay?
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 outsideClickEvents
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 a few last comments from me; @crisbeto should also take one last look
* if any. It maintains a list of attached overlays to determine best suited overlay based | ||
* on event target and order of overlay opens. | ||
*/ | ||
@Injectable({ providedIn: 'root' }) |
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.
Omit spaces in braces
src/cdk/overlay/overlay-ref.ts
Outdated
@@ -268,6 +305,11 @@ export class OverlayRef implements PortalOutlet, OverlayReference { | |||
return this._keydownEventsObservable; | |||
} | |||
|
|||
/** Gets an observable of mouse outside click events targeted to this overlay. */ | |||
outsideClickEvents(): Observable<MouseEvent> { |
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.
outsideClickEvents(): Observable<MouseEvent> { | |
outsidePointerEvents(): Observable<PointerEvent> { |
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.
With current implementation I cannot change the observable from Observable<MouseEvent>
to Observable<PointerEvent>
as I am handling click
event in the dispatcher. Should I try to switch to pointer event in the dispatcher, even know this is not supported on iOS pre 13.2?
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.
MouseEvent is probably fine, then
@@ -10,14 +10,14 @@ export * from './overlay-config'; | |||
export * from './position/connected-position'; | |||
export * from './scroll/index'; | |||
export * from './overlay-module'; | |||
export * from './dispatchers/index'; |
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 we actually exclude BaseOverlayDispatcher
from the public API?
We found a Contributor License Agreement for you (the sender of this pull request), but were unable to find agreements for all the commit author(s) or Co-authors. If you authored these, maybe you used a different email address in the git commits than was used to sign the CLA (login here to double check)? If these were authored by someone else, then they will need to sign a CLA as well, and confirm that they're okay with these being contributed to Google. ℹ️ Googlers: Go here for more info. |
Looks like |
I've already removed it in this commit :) |
A Googler has manually verified that the CLAs look good. (Googler, please make sure the reason for overriding the CLA status is clearly documented in these comments.) ℹ️ Googlers: Go here for more info. |
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
Looks good, just needs a rebase |
CLAs look good, thanks! ℹ️ Googlers: Go here for more info. |
@jelbourn I had some circular dependencies issues. To solve them I have updated Also one of the checks is failing. I think this is not because of my changes. |
Thanks for rebasing! Removing circular deps is always good. I ran this PR through Google's internal presubmit system last night and it looks like there are at least ~20 failures. I'll try to investigate those failures in the next week. |
@wnvko Thanks for having patience with us on this PR - we really appreciate the contribution and this change will actually be a big help for one of our upcoming features with menu. Would you mind rebasing it once again, and I'll prioritize getting this change in? |
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. |
Allow detaching of the overlay via
detachOnOutsideClick
property inOverlayConfig
. If set to true the overlay will determine if user performed outside click. If so overlay will detach itself.Additionally
excludeFromOutsideClick
property is added toOverlayConfig
. This is an array ofHTMLElements
. Developer may add elements to this array. If user clicks on any of these elements the click will not be considered outside click.