Skip to content

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

Merged
merged 1 commit into from
Jun 30, 2020
Merged

feat(overlay): detach on outside click #16611

merged 1 commit into from
Jun 30, 2020

Conversation

wnvko
Copy link
Contributor

@wnvko wnvko commented Jul 26, 2019

Allow detaching of the overlay via detachOnOutsideClick property in OverlayConfig. If set to true the overlay will determine if user performed outside click. If so overlay will detach itself.

Additionally excludeFromOutsideClick property is added to OverlayConfig. This is an array of HTMLElements. Developer may add elements to this array. If user clicks on any of these elements the click will not be considered outside click.

@googlebot googlebot added the cla: yes PR author has agreed to Google's Contributor License Agreement label Jul 26, 2019
@wnvko wnvko marked this pull request as ready for review July 29, 2019 11:27
@wnvko wnvko requested review from crisbeto and jelbourn as code owners July 29, 2019 11:27
Copy link
Member

@crisbeto crisbeto left a 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.

Aug-07-2019 11-28-02

/**
* Array of HTML elements clicking on which should not be considered as outside click
*/
excludeFromOutsideClick?: HTMLElement[] = [];
Copy link
Member

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.

Copy link
Contributor Author

@wnvko wnvko Dec 5, 2019

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:

  1. Document's body will recognize this as outside click and will force mat-menu to close.
  2. 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.

Copy link
Member

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?

Copy link
Contributor Author

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 {
Copy link
Member

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.

Copy link
Member

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?

Copy link
Contributor Author

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();
Copy link
Member

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.

Copy link
Member

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.

Copy link
Contributor Author

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.

@wnvko
Copy link
Contributor Author

wnvko commented Sep 25, 2019

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.

Aug-07-2019 11-28-02

I can solve this by forcing the documentElement to be "clickable" under iOS. This could be achieved by adding onclick attribute or by setting the cursor style to pointer. I can check if the OS is iOS and set the cursor. Of course, when all the overlays get closed I will restore the original cursor style. What do you think about such a fix?

@crisbeto
Copy link
Member

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?

@jelbourn
Copy link
Member

jelbourn commented Dec 3, 2019

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.

@wnvko
Copy link
Contributor Author

wnvko commented Dec 6, 2019

@crisbeto and @jelbourn regarding the iOS issue I am setting now the body's cursor style to pointer when overlayRef is added. As soon as last overlayRef is removed I am restoring original style.
I've tested this on iPad and iPhone both with iOS 12.3.1 and it works.
I also have tested this on Android 9 device, on my PC and on PC with touch screen - it was ok on all these devices.

@jelbourn
Copy link
Member

I tried this out locally just now- I set detachOnOutsideClick: true in the connected-overlay-demo, but clicking outside the panel doesn't dismiss the overlay. Am I missing something?

@wnvko
Copy link
Contributor Author

wnvko commented Jan 20, 2020

I tried this out locally just now- I set detachOnOutsideClick: true in the connected-overlay-demo, but clicking outside the panel doesn't dismiss the overlay. Am I missing something?

Now if detachOnOutsideClick is set to true OverlayRef's clickEvents will dispatch event on outside click. You should add, in connected-overlay-demo in openWithConfig something like this:

    this.overlayRef.clickEvents().subscribe(() => {
      this.close();
    });

In this way this is now working similarly to OverlayKeyboardDispatcher

@jelbourn
Copy link
Member

I see; I think the naming is currently misleading, then. detachOnOutsideClick would make me think the overlay will automatically do this. It's also not totally clear that clickEvents would include clicks outside the overlay, since the description says "observable of keydown events targeted to this overlay".

Would it make more sense to just have a stream of outsideClicks (similar to backdropClick) that the user can handle in the same manner?

@wnvko
Copy link
Contributor Author

wnvko commented Jan 27, 2020

The initial idea was setting the detachOnOutsideClick to automatically do that. Then we switched it to observable, which is better I think. As a result detachOnOutsideClick became obsolete. I will remove it now. I should update and the design doc, should not I?

@jelbourn
Copy link
Member

That sounds good

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.

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 {
Copy link
Member

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
Copy link
Member

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);
});
Copy link
Member

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];
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 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);
Copy link
Member

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[] = [];
Copy link
Member

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>();
Copy link
Member

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 {
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
export abstract class OverlayBaseDispatcher implements OnDestroy {
export abstract class BaseOverlayDispatcher implements OnDestroy {

@@ -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> {
Copy link
Member

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

definitely outsideClickEvents

@jelbourn jelbourn added the target: minor This PR is targeted for the next minor release label Jan 30, 2020
@Splaktar Splaktar changed the title Detach on outside click feature feat(overlay): detach on outside click Jan 30, 2020
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.

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' })
Copy link
Member

Choose a reason for hiding this comment

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

Omit spaces in braces

@@ -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> {
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
outsideClickEvents(): Observable<MouseEvent> {
outsidePointerEvents(): Observable<PointerEvent> {

Copy link
Contributor Author

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?

Copy link
Member

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';
Copy link
Member

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?

@googlebot
Copy link

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.
In order to pass this check, please resolve this problem and then comment @googlebot I fixed it.. If the bot doesn't comment, it means it doesn't think anything has changed.

ℹ️ Googlers: Go here for more info.

@googlebot googlebot added cla: no PR author must sign Google's Contributor License Agreement: https://opensource.google.com/docs/cla and removed cla: yes PR author has agreed to Google's Contributor License Agreement labels Jan 31, 2020
@jelbourn
Copy link
Member

Looks like BaseOverlayDispatcher still needs to be excluded from the public api

@wnvko
Copy link
Contributor Author

wnvko commented Feb 12, 2020

Looks like BaseOverlayDispatcher still needs to be excluded from the public api

I've already removed it in this commit :)

@jelbourn jelbourn added cla: yes PR author has agreed to Google's Contributor License Agreement and removed cla: no PR author must sign Google's Contributor License Agreement: https://opensource.google.com/docs/cla labels Feb 12, 2020
@googlebot
Copy link

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.

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

@jelbourn
Copy link
Member

Looks good, just needs a rebase

@googlebot
Copy link

CLAs look good, thanks!

ℹ️ Googlers: Go here for more info.

@jelbourn jelbourn added action: merge The PR is ready for merge by the caretaker and removed pr: needs rebase labels Feb 13, 2020
@wnvko wnvko requested review from andrewseguin, devversion, mmalerba and a team as code owners May 7, 2020 13:02
@wnvko
Copy link
Contributor Author

wnvko commented May 7, 2020

@jelbourn I had some circular dependencies issues. To solve them I have updated OverlayReference, as well as have removed one circular dependency from goldens/ts-circular-deps.json. I was wondering is this the right way, or should I leave OverlayReference as it was and add some more dependencies in goldens/ts-circular-deps.json.

Also one of the checks is failing. I think this is not because of my changes.

@Splaktar Splaktar added P3 An issue that is relevant to core functions, but does not impede progress. Important, but not urgent and removed needs rebase labels May 8, 2020
@jelbourn
Copy link
Member

jelbourn commented May 8, 2020

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.

@andrewseguin
Copy link
Contributor

@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?

@jelbourn jelbourn merged commit 210d054 into angular:master Jun 30, 2020
@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 Jul 31, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
action: merge The PR is ready for merge by the caretaker cla: yes PR author has agreed to Google's Contributor License Agreement P3 An issue that is relevant to core functions, but does not impede progress. Important, but not urgent target: minor This PR is targeted for the next minor release
Projects
None yet
Development

Successfully merging this pull request may close these issues.

6 participants