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

Should popover/dialog show/hide when already shown/hidden throw? #9045

Closed
jakearchibald opened this issue Mar 17, 2023 · 34 comments · Fixed by #9142
Closed

Should popover/dialog show/hide when already shown/hidden throw? #9045

jakearchibald opened this issue Mar 17, 2023 · 34 comments · Fixed by #9142
Labels
topic: dialog The <dialog> element. topic: popover The popover attribute and friends

Comments

@jakearchibald
Copy link
Contributor

jakearchibald commented Mar 17, 2023

Current state

  • close() on a closed <dialog>.
  • show() on an open <dialog>, unless that dialog is in the popover showing state.

…do not throw.

Whereas:

  • showModal() on an open dialog
  • show() on an open dialog that is also in the popover showing state.
  • showPopover() on an open popover
  • hidePopover() on a closed popover

…throw an error.

So the current state of things is that dialog is inconsistent with itself, and popover is inconsistent with dialog.

Elsewhere on the platform

Looking at other cases of hide/remove/delete in the web platform, none of these throw:

  • new Set().delete('foo')
  • ('hello').replace('world', '')
  • [].pop()
  • document.createElement('div').removeAttribute('foo')
  • document.createElement('div').remove()
  • document.createElement('div').classList.remove('foo')
  • document.createElement('div').removeEventListener(() => {})
  • document.createElement('div').classList.replace('foo', 'bar') (although it does return false)

Counter examples:

  • el.removeChild(otherEl) throws if otherEl is not a child of el.
  • el.removeAttributeNode(attributeNode) throws if attributeNode is not an attribute node of el.
  • document.exitFullscreen() throws if no element is fullscreen.

removeChild and removeAttributeNode are pretty old APIs that developers tend to avoid in favour of friendlier equivalents.

exitFullscreen is the most interesting example, since it's related to top level.

Looking at 'add' cases:

  • new Set(['foo', 'bar']).add('foo')
  • el.classList.add('foo')
  • el.addEventListener(callback)

All behave set-like. As in, if the item is already in the set, it doesn't throw, and it doesn't change the order of items. It's a no-op.

el.requestFullscreen(options) on an already-fullscreen element will adapt to changes in options, but it will not fire a fullscreenchange event.

el.append(otherEl) will remove otherEl from its parent, and add otherEl as the last child of el. This happens even if otherEl is already the last child of el, and it's observable in a bunch of ways, including mutation observers. But this is specifically 'append', not 'add'.

The current mindset in the frameworks world is to let developer declare the state they want, and the framework figures out what needs to change to get to that state. None of the frameworks throw if the developer re-declares the current state.

Proposal for <dialog>

It's pretty weird that show() followed by showModal() will throw, whereas showModal() followed by show() will no-op.

Option 1: 'show' throws if already shown

show() or showModal() followed by show() or showModal() will throw.

This is at least consistent between the two methods, but it doesn't seem to fit with the majority of the platform. You could say the behaviour here is justified in being unusual due to the different ways a dialog can be shown, but that wouldn't be consistent with requestFullscreen(options) either.

Option 2: 'show' is a no-op if already shown

With show() or showModal() followed by show() or showModal(), the second call will be a no-op.

This is at least consistent between the two methods, but it seems weird that showModal() would no-op resulting in a not-modal dialog.

Option 3: Update the type of 'show'

show() followed by showModal() will 'upgrade' the dialog to a modal dialog.

showModal() followed by show() will 'downgrade' the dialog to a non-modal dialog.

This feels consistent with how requestFullscreen(options) will react to changes in options even if the element is already fullscreen.

Option 4: showModal() is a more specific version of show()

show() followed by showModal() will 'upgrade' the dialog to a modal dialog.

showModal() followed by show() is a no-op, because the dialog is already shown.

This seems less flexible, but it feels like it makes sense. We could add showModeless() in future to do the more specific thing, or add an option like show({ mode: 'modeless' }).

Proposal for popover

I think we should match the majority of the platform, and open-when-already-open, and close-when-already-closed, should not throw.

Option 1: Second showPopover() moves popover to the top

This could be achieved by hiding then re-showing the popover, which would be like el.append(). If the developer is calling showPopover(), it seems like they feel this popover is important at this time, which suggests move-to-top is the right thing to do.

If this makes sense, showModal on <dialog> should do the same.

Option 2: Second showPopover() is a no-op

That's consistent with most of the platform.

Proposal for popover on <dialog>

Option 1: <dialog> cannot popover

Similar to requestFullscreen, calling showPopover() on a <dialog> will always throw, even if the dialog isn't open. That removes the overlap between these two features, and it's consistent with fullscreen.

Option 2: Avoid both features being active at the same time

  • If show() is not a no-op, then it should throw if the element is in the popover showing state.
  • If showModal() is not a no-op, then it should throw if the element is in the popover showing state.
  • If showPopover() is not a no-op, then it should throw if the element is a dialog, and open.

I'm not sure this complexity is worth it, and it's inconsistent with requestFullscreen.

@jakearchibald jakearchibald added topic: dialog The <dialog> element. topic: popover The popover attribute and friends labels Mar 17, 2023
@annevk
Copy link
Member

annevk commented Mar 17, 2023

cc @nt1m @josepharhar @mfreed7

@keithamus
Copy link
Contributor

I’m inclined to agree - at least with nooping. Guarding these calls is a little unergonomic as iirc the only way to check if a popover is open is via .matches(':open'). With <dialog> one can check open but that’s not present on popovers.

I wonder if it makes sense to alias dialogs show/hide to showPopover/hidePopover? Since it enacts the same mechanics iirc?

@jakearchibald
Copy link
Contributor Author

I wonder if it makes sense to alias dialogs show/hide to showPopover/hidePopover? Since it enacts the same mechanics iirc?

The algorithms are pretty different, so I don't think that's possible.

@josepharhar
Copy link
Contributor

Option 1: 'show' throws if already shown
show() or showModal() followed by show() or showModal() will throw.

This sounds good to me, I wonder why show() isn't doing that. I'm also more supportive of modifying show() than showModal().

Option 1: cannot popover
Similar to requestFullscreen, calling showPopover() on a will always throw, even if the dialog isn't open. That removes the overlap between these two features, and it's consistent with fullscreen.

This sounds good to me, especially if thats what requestFullscreen does. I don't think that popover on dialog elements is a use case that makes any sense.

Option 2: Second showPopover() is a no-op
That's consistent with most of the platform.

But calling dialog.showModal() a second time also throws an exception, I don't see why we can't do the same thing. If a majority of some APIs from the DOM spec don't throw exceptions when they could, I don't find that very motivating.

I’m inclined to agree - at least with nooping. Guarding these calls is a little unergonomic as iirc the only way to check if a popover is open is via .matches(':open'). With one can check open but that’s not present on popovers.

I guess that's a good point. If yall really want to make it noop then I won't stop you.

I wonder if it makes sense to alias dialogs show/hide to showPopover/hidePopover? Since it enacts the same mechanics iirc?

Yeah I don't think this is a good idea since we are defining showPopover/hidePopover on all HTML elements regardless of the presence of the popover attribute. If we renamed/aliased them to show/hide, then every element would have a show and hide method even if it isn't a popover which would be confusing. It would even conflict with dialog's show method.

@jakearchibald
Copy link
Contributor Author

@josepharhar

Option 1: 'show' throws if already shown
show() or showModal() followed by show() or showModal() will throw.

This sounds good to me, I wonder why show() isn't doing that. I'm also more supportive of modifying show() than showModal().

Can you provide some detail around why you're happy with that option, given the inconsistency with most of the web platform?

@keithamus
Copy link
Contributor

If we renamed/aliased them to show/hide, then every element would have a show and hide method even if it isn't a popover which would be confusing.

I more meant that for <dialog> elements show() and showPopover() would do the same thing. It was not a proposal to add show() to HTMLElement.

@josepharhar
Copy link
Contributor

Can you provide some detail around why you're happy with that option, given the inconsistency with most of the web platform?

showModal() serves more of a purpose than show(), and there is even a consideration to deprecate/remove show(): #7707 (comment)
Modifying show() is also less work than modifying showModal(), close(), showPopover(), and hidePopover().

I found the dialog element APIs more motivating than DOM APIs. How about looking at some more HTML APIs?

  • HTMLInputElement.showPicker doesn't throw if it's already open, so theres one point for no-oping.
  • HTMLDetailsElement doesn't have a method to open or close, it's just controlled by the attribute, which obviously doesn't throw when you tell it to open twice.

I couldn't think of any other examples.

el.requestFullscreen(options) on an already-fullscreen element will adapt to changes in options, but it will not fire a fullscreenchange event.

Ah, I missed this detail that requestFullscreen does not throw when the element is already fullscreen.

I guess it makes sense to make them not throw. It sounds like you're in favor of making dialog.show(), dialog.showModal(), dialog.close(), element.showPopover(), and element.hidePopover() never throw based on whether they are already open or closed?

@josepharhar
Copy link
Contributor

I more meant that for elements show() and showPopover() would do the same thing. It was not a proposal to add show() to HTMLElement.

Sorry I misinterpreted. So dialog.showPopover() would be the same as dialog.show()? Or dialog.showModal()...? It still sounds funny since dialogs are not popovers. Would the dialog element need the popover attribute? Would the presence of the popover attribute affect what dialog.showPopover() does?

@jakearchibald
Copy link
Contributor Author

Modifying show() is also less work than modifying showModal(), close(), showPopover(), and hidePopover().

I found the dialog element APIs more motivating than DOM APIs.

We shouldn't avoid doing the right thing because it's more work. And we shouldn't assume that <dialog> is doing the right thing just because it already exists.

It sounds like you're in favor of making dialog.show(), dialog.showModal(), dialog.close(), element.showPopover(), and element.hidePopover() never throw based on whether they are already open or closed?

Absolutely, since that's how most of the platform works. There are multiple options for how to do that though (options 2-4).

@keithamus
Copy link
Contributor

Would the dialog element need the popover attribute? Would the presence of the popover attribute affect what dialog.showPopover() does?

This raises another question which I don't think @jakearchibald's OP covers. Right now showPopover()/hidePopover() will throw if the element does not have either popover="manual" or popover="auto". What should the behaviour there be?

@mfreed7
Copy link
Contributor

mfreed7 commented Mar 17, 2023

Thanks for raising the detailed issue here, @jakearchibald. (Side note for others, this was briefly discussed on #9036.)

Ok, given the good case you've made for consistency with the rest of the platform, I vote for these choices:

  • <dialog> Option 4: showModal() is a more specific version of show()
  • Popover Option 2: Second showPopover() {and hidePopover()} is a no-op
  • <dialog popover> Option 2: Avoid both features being active at the same time

In terms of the current spec, I believe the only needed changes would be:

  • Remove the exception for showModal() on an already-open dialog. This becomes a no-op.
  • Remove the exception for showPopover() on an already-open popover. This becomes a no-op.
  • Remove the exception for hidePopover() on an already-closed popover. This becomes a no-op.

The behaviors for <dialog popover> listed for Option 2 are what the current spec says to do.

@mfreed7
Copy link
Contributor

mfreed7 commented Mar 17, 2023

This raises another question which I don't think @jakearchibald's OP covers. Right now showPopover()/hidePopover() will throw if the element does not have either popover="manual" or popover="auto". What should the behaviour there be?

These throw NotSupportedException because those operations aren't supported on something that isn't a popover. That feels consistent with other APIs, doesn't it? E.g. ElementInternals.form throws NotSupportedException if the CE isn't a FACE.

@nt1m
Copy link
Member

nt1m commented Mar 18, 2023

Option 4: showModal() is a more specific version of show()
show() followed by showModal() will 'upgrade' the dialog to a modal dialog.

I don't think that upgrading to a modal dialog is behavior that makes a lot of sense, this feels like it should be an exception. It also would be jarring visually due to modal/non-modal dialogs having different styles. Modal are fixed positioned, non-modal dialogs are absolute positioned, so if you've scrolled in the viewport to a point until the dialog is out of view, showModal() will suddenly make it pop into the view, which is strange.

Inversely downgrading also doesn't make sense either, due to the same opposite result.

Option 1: cannot popover

I slightly favor this option for its predictability and consistency with fullscreen, although that means we won't deprecate dialog.show() in favor <dialog popover> (if that's desired).

@jakearchibald
Copy link
Contributor Author

@nt1m

Modal are fixed positioned, non-modal dialogs are absolute positioned, so if you've scrolled in the viewport to a point until the dialog is out of view, showModal() will suddenly make it pop into the view, which is strange.

Surely the regular behaviour of showModal() is that it pops into view? I guess you didn't find the consistency with fullscreen compelling here?

Would you want it to throw only in the show then showModal case, or in all show then show cases?

@mfreed7
Copy link
Contributor

mfreed7 commented Mar 20, 2023

Option 1: cannot popover

I slightly favor this option for its predictability and consistency with fullscreen, although that means we won't deprecate dialog.show() in favor <dialog popover> (if that's desired).

I do disagree with this one. We discussed this at some length in OpenUI (mostly here, and somewhat here), and there is a very definite use case for <dialog popover>. I started with the ("easy") position that we should just not support this, but both developers and a11y folks pushed back. From developers, there's the desire to make "dialogs" that have popover behaviors. From a11y folks, there's a strong desire to avoid requiring role=dialog for normal use cases. Both of those point to supporting <dialog popover> as a natural way to make a <dialog...with...popover> behaviors.

I also don't quite understand how this particular case is confusing to developers:

dialogPopover.showPopover();
dialogPopover.showModal(); // Exception - please call hidePopover before you make it modal

@jakearchibald
Copy link
Contributor Author

Because it's hard to tell if an element is an active popover? It feels like detecting that isn't a first class part of the API.

@mfreed7
Copy link
Contributor

mfreed7 commented Mar 20, 2023

popover.matches(':open')

For a dialog-popover, I suppose you need to do

dialogPopover.matches(':open:not(:modal)')

@jakearchibald
Copy link
Contributor Author

Right, you have to go via CSS.

@mfreed7
Copy link
Contributor

mfreed7 commented Mar 20, 2023

We discussed adding some helpful JS sugar for that state, here. The conclusion was that we could add that, if it was helpful. But matches() is pretty easy and we should wait to see if there's a real need.

I guess the point is that you said "it's hard to tell", and I think the answer is "no, it's easy, just use matches()".

@jakearchibald
Copy link
Contributor Author

I guess it's a separate discussion, but .matches() isn't anywhere near as discoverable (thinking of things like autocomplete) as properties on an object.

As API designers we should be willing to tackle complexity ourselves, rather than pass it on to every developer that has to interact with our work.

@mfreed7
Copy link
Contributor

mfreed7 commented Mar 20, 2023

I guess it's a separate discussion, but .matches() isn't anywhere near as discoverable (thinking of things like autocomplete) as properties on an object.

As API designers we should be willing to tackle complexity ourselves, rather than pass it on to every developer that has to interact with our work.

Fair point. I don't actually think this was complex to add, it was just that the developers in the room didn't think it was something that needed a ton of attention. But if you feel differently, I'm certainly happy to propose a quick addition:

partial interface HTMLElement : Element {
  readonly attribute boolean popoverOpen;
};

The popoverOpen IDL attribute returns true if the element's popover visibility state is showing, or false otherwise.

I suppose if you follow this line of thought, you'd also want to propose an addition to the <dialog> spec to expose :modal without using matches() also.

@jakearchibald
Copy link
Contributor Author

The design of an equivalent for dialog would likely depend on the outcome of this issue. But hopefully these issues demonstrate that I'm not against improving dialog too.

@jakearchibald
Copy link
Contributor Author

Splitting the popoverOpen discussion out to #9054

@jakearchibald
Copy link
Contributor Author

For the call later today, the original post still serves as a summary.

@jakearchibald
Copy link
Contributor Author

Open UI resolutions:

  • Popover should not throw on show/hide when in that state. More discussion needed around "pop to top behaviour"
  • Still throw when trying to switch from open popover to open dialog. Although it's fine to hide a popover that's already hidden, but it's a shown dialog (and vice versa)
  • For dialog, throw when trying to switch shown modes, but don't throw if call is for the current state (eg hidden to hidden). Subject to webcompat testing

Minutes: https://www.w3.org/2023/03/23-openui-minutes.html#t03

@kizu
Copy link

kizu commented Mar 23, 2023

One use-case that I had for "pop to top behaviour" in my past practice — multiple tooltips. The most recent tooltip would always want to be “on top”, and I had situations, where we wanted to have a delay before the tooltip is hidden (a transition, for example), so the following situation could be possible:

  1. We hover an item A, showing the first tooltip.
  2. We hover over a nearby item B, showing the second tooltip on the top layer, keeping the old tooltip open for the specified delay.
  3. While the first tooltip is still shown, just on a lower layer, we hover back to the item A — because the second tooltip would also still be visible, we'd want the first tooltip to go on top of the second one.

Implementation-wise, just telling the tooltip to show and get it on top would be the easiest, and probably the expected behavior.

While this could be theoretically implemented in the future with plain CSS, using a display transition, making the tooltip be “hidden” immediately, I can see cases where developers would still want some reason use JS and keep the tooltip open, and I can also imagine cases for popovers, where we could want to keep multiple of them open, and switching between them when necessary.

@mfreed7
Copy link
Contributor

mfreed7 commented Mar 31, 2023

One use-case that I had for "pop to top behaviour" in my past practice — multiple tooltips...

So we're going to start discussing popover=hint (whose use case is tooltips) in OpenUI: openui/open-ui#617. In all of the discussions about popover=hint before, multiple tooltips were not a thing. That's what set hints apart from autos.

Open UI resolutions:

  • Popover should not throw on show/hide when in that state. More discussion needed around "pop to top behaviour"
  • Still throw when trying to switch from open popover to open dialog. Although it's fine to hide a popover that's already hidden, but it's a shown dialog (and vice versa)
  • For dialog, throw when trying to switch shown modes, but don't throw if call is for the current state (eg hidden to hidden). Subject to webcompat testing

@josepharhar would it make sense to put up a PR with the above changes, and then we can discuss the details?

josepharhar added a commit to josepharhar/html that referenced this issue Apr 10, 2023
This patch makes the following changes:
- Don't throw exceptions when showPopover or hidePopover is called and
  the popover is already in the requested state.
- Don't throw exceptions when dialog.showModal, dialog.show, or
  dialog.close is called and the dialog is already in the
  requested state.
- Throw exceptions when trying to switch between modal and non-modal
  dialog modes via dialog.showModal or dialog.show.

Fixes whatwg#9045
@josepharhar
Copy link
Contributor

Here is a PR: #9142

Still throw when trying to switch from open popover to open dialog. Although it's fine to hide a popover that's already hidden, but it's a shown dialog (and vice versa)

Switching between modal and non-modal does not currently throw. My PR adds new exceptions to make it throw.

annevk pushed a commit that referenced this issue May 9, 2023
This patch makes the following changes:

- Don't throw exceptions when showPopover or hidePopover is called and the popover is already in the requested state.
- Don't throw exceptions when dialog.showModal, dialog.show, or dialog.close is called and the dialog is already in the requested state.
- Throw exceptions when trying to switch between modal and non-modal dialog modes via dialog.showModal or dialog.show.

Fixes #9045.
@josepharhar
Copy link
Contributor

#9142 as merged doesn't throw when hidePopover is called on a disconnected element, which @mfreed7 I think still wants with rationale here: #9142 (comment)

Should the following call to hidePopover throw an exception?

const popover = document.createElement('div');
popover.setAttribute('popover', 'auto');
// don't connect to document
popover.hidePopover();

@josepharhar josepharhar reopened this May 9, 2023
@nt1m
Copy link
Member

nt1m commented May 9, 2023

I think it might be more convenient for library users that we don't throw in this case? @jakearchibald is probably in a better position to answer though

@mfreed7
Copy link
Contributor

mfreed7 commented May 13, 2023

I think it might be more convenient for library users that we don't throw in this case? @jakearchibald is probably in a better position to answer though

Help me understand. When should we throw an exception? My concept of exceptions is that they're for exceptional situations, where the API can't do what you've asked it to do. It seems harder to code around an API that will just silently (or with a console warning) do nothing when the preconditions aren't met.

@nt1m
Copy link
Member

nt1m commented May 13, 2023

My concept of exceptions is that they're for exceptional situations, where the API can't do what you've asked it to do

In this case, the API can hide a popover that is already closed, doesn't matter if it's disconnected. You could also argue that the API can't hide a popover that is already closed, so I'm not sure this reasoning really works well here.

Since this issue was about thinking about developer ergonomics, I'm just pointing out there's a possibility that library users might call hidePopover() in some common render function, that may be called even when the popover is disconnected.

To be clear, I don't really have a strong opinion about this, I would rather leave this to someone who knows about how libraries are used out there to determine what is the best thing to do in this case.

@mfreed7
Copy link
Contributor

mfreed7 commented May 13, 2023

My concept of exceptions is that they're for exceptional situations, where the API can't do what you've asked it to do

In this case, the API can hide a popover that is already closed, doesn't matter if it's disconnected. You could also argue that the API can't hide a popover that is already closed, so I'm not sure this reasoning really works well here.

Since this issue was about thinking about developer ergonomics, I'm just pointing out there's a possibility that library users might call hidePopover() in some common render function, that may be called even when the popover is disconnected.

To be clear, I don't really have a strong opinion about this, I would rather leave this to someone who knows about how libraries are used out there to determine what is the best thing to do in this case.

Ahh - this helped my understanding. Thanks. I thought you wanted both hidePopover() and showPopover() to silently fail in this case. You just want hidePopover() not to throw when disconnected. As you said, that feels ok because the popover is already "hidden" in some sense. But we can keep throwing for showPopover() in this situation.

Sorry about that - I'm ok with that change. I believe Chromium currently throws, but I'll change that. Unless @josepharhar already has that in progress?

@nt1m
Copy link
Member

nt1m commented Jun 6, 2023

Closing this issue as I don't think there's anything else to do here? #9142 wrote this in a way that hiding disconnected popovers that were already hidden doesn't throw.

@josepharhar @mfreed7 Can you please double check and re-open the issue if needed?

@nt1m nt1m closed this as completed Jun 6, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
topic: dialog The <dialog> element. topic: popover The popover attribute and friends
Development

Successfully merging a pull request may close this issue.

7 participants