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

Add API for getting the visible bounding rect an element can be placed inside #6132

Open
keithamus opened this issue Mar 24, 2021 · 3 comments

Comments

@keithamus
Copy link
Member

keithamus commented Mar 24, 2021

Originally from whatwg/dom#964

I guess this is related to multiple parts of the spec, css-overflow-4, css-position-3

When designing a system for popovers, we came across a rather tricky area of the DOM: getting a computed rectangle of the range of visible pixels an element could be placed in, such that the top-left pixel is visible. I think the specs refer to this concept as a scrollport.

To achieve this is difficult because most DOM APIs to get these dimensions are either speculative, in that they will provide a "suggested" value, e.g. .style.height, or actualized, for example getBoundingClientRect() (which gets real pixels on the screen but doesn't account for overflow). The answer to the question "where can an element render and still be visible in the viewport" is somewhere in between, as it includes accidental complexity the CSS box model (borders are clipping, overflow only impacts positioned elements), API idiosyncrasies (document is not an element and so has no getBoundingClientRect(), document.body nor document.body.parentElement are overflow: visible by default, despite the window having a scrollbar).

The answer comes after much edge-case testing, the behaviour is something like:

  • Traverse up the tree until a "clipping" element is found
    • A clipping element is defined as an element which is overflow: scroll, and not position: static.
  • If a clipping element is not found, and we've traversed up to the body
    • document.body has specific edge cases. It is not a clipping element, but the window is, so enact specific behavior here.
  • Take the clipping elements bounding rect, and the border, to get the "non clipping area" of the containing element.
const element = document.querySelector('the-element-to-position')
// The top level clipping rect is the window. This is the largest possible container, if all elements between don't clip
const clippingRect = {top: 0, left: 0, right: window.innerWidth, bottom: window.innerHeight}
{
  let parent = element
  while (parent !== null && parent !== document.body) {
    const {overflow, position, borderLeftWidth, borderRightWidth, borderTopWidth, borderBottomWidth} = getComputedStyle(parent)
    const borderLeft = parseInt(borderLeftWidth, 10)
    const borderRight = parseInt(borderRightWidth, 10)
    const borderTop = parseInt(borderTopWidth, 10)
    const borderBottom = parseInt(borderBottomWidth, 10)
    if (
      overflow !== 'visible' && // Overflow needs to be checked as this is the rect which will clip rendering
      position !== 'static' // If a position is "static" then it does not "contain" elements, so `overflow` styles have no impact
    ) {
      const {top, left, width, height} = parent.getBoundingClientRect()
      clippingRect.top = top + borderTop // borders need to be accounted for as they clip
      clippingRect.left = left + borderLeft  // borders need to be accounted for as they clip
      clippingRect.right = width - borderLeft - borderRight // borders need to be accounted for as they clip
      clippingRect.bottom = height - borderTop - borderBottom // borders need to be accounted for as they clip
      break
    }
    parent = parent.parentElement
  }
}

This will get an absolute clipping rect, which gives the dimensions that an element is able to render into. It does not account for any positioned elements and how that may affect positioning.

There exists a ton of code in the wild for popups and their variants (toasts, tooltips, menus, dialogs) and all require this kind of code. Some fall back for detecting if the container sits outside the windows dimensions but this can cause all kinds of overflow bugs as soon as they're nested inside an overflow container.

This feels like something the browser would keep track of during a rendering path? Is it possible to expose this kind of "clipping rect" via a DOM API?

@Loirooriol
Copy link
Contributor

@keithamus
Copy link
Member Author

keithamus commented Mar 24, 2021

I think you're right, it looks a lot like Step 3 of that algo:

  1. While container is not the intersection root:
    1. If container is the document of a nested browsing context, update intersectionRect by clipping to the viewport of the document, and update container to be the browsing context container of container.
    2. Map intersectionRect to the coordinate space of container.
    3. If container has overflow clipping or a css clip-path property, update intersectionRect by applying container’s clip.
    4. If container is the root element of a browsing context, update container to be the browsing context’s document; otherwise, update container to be the containing block of container.

Perhaps this algo can be abstracted out into a method on the HTMLElement definition?

@T-Hugs
Copy link

T-Hugs commented Mar 24, 2021

I don't think the intersection rectangle is exactly what is needed to solve this problem. Furthermore, browsers are not consistent on how it is calculated. I couldn't find any implementation that calculates the intersection rectangle in a way that lets me discover the clipping rectangle.

Here is a CodePen that demonstrates one inconsistency. We have a scrollable container (the intersection root) that is 500px tall, with some top padding, a 100px tall element (pink) followed by a 2000px tall element (blue) that is the intersection target. As the container scrolls, we log out the height of the intersection rect. Clearly, when scrolled far enough, we should log 500, since the blue is filling the 500px-tall container, but instead, we log at most 400. The 100px of padding seems to get lost.

Chrome and Safari behave the same (modulo decimal precision). Up until recently, so did Firefox, but the latest Firefox works as expected. However, for another issue, change overflow: auto on the #outer element to overflow: visible. After making this change (and re-running the pen), you will see that the height of the intersection rectangle is always 300, even though the clipping rectangle's height is equivalent to window.innerHeight.

To @keithamus's point, getClippingRect would be a nice addition to Element, which would avoid having to set up an IntersectionObserver in the first place, just to have it fire once. The returned rectangle would contain all of the available space that the element has to render itself before getting clipped by an ancestor.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants