Page MenuHomePhabricator

[SPIKE] Determine how to provide comprehensive solutions for overlay sizing and positioning
Closed, ResolvedPublic5 Estimated Story Points

Description

Background

Problem summary

Some components, notably Menu and the future Popup, have an element that need special sizing and positioning treatment, both when initially displayed and in reaction to changes and interactions like scroll and window resize. These elements sit above the rest of the UI ("float"), and must be properly positioned relative to their triggering element, and sized so that they are fully visible ("clipped").

Currently, the Menu component's root element is positioned absolutely at its natural vertical position relative to the container that wraps its triggering element (the Select handle or Lookup text input) and the Menu itself, with a left value of 0 (or right in RTL). The Menu is rendered in its natural place in the DOM.

Although we have no comprehensive sizing or positioning solution at this time, we have created a solution for menu components in dialogs.

Menu components in Dialogs

When the Dialog component was introduced, we knew that menus would need to be able to extend past the end of the Dialog, e.g. if you open a Select menu that is longer than the space it has before the edge of the dialog body:

image.png (922×1 px, 107 KB)

This works as long as the dialog body is not scrollable (see remaining bugs below) by doing the following in components that have menus:

  • Setting position: static on the root element, so that the menu will be positioned relative to the dialog backdrop instead
  • Finding the current width of the root element
  • Applying that width to the menu, plus left: auto for horizontal alignment with the parent component
  • Using ResizeObserver to recalculate that width when the root element is resized or the template ref changes
Remaining bugs
Menu positioning in scrollable dialogs

T344542 details a significant shortcoming of the above solution: when the dialog body is scrollable, the menu position does not change, so it becomes visually "detached" from its trigger element. See this comment for a video demonstration. The menu should "float" along with its triggering element on scroll.

Menu extends past the edge of the viewport

T344776 demonstrates what happens when you open a Select that is near the bottom of the viewport: the menu extends beyond the edge of the viewport, so you have to scroll to read it:

image.png (1×2 px, 800 KB)

There are two options to improve the UX here:

  1. Change the vertical position of the menu ("flip") so that it appears to open up above the triggering element instead of down below it
  2. Clip the menu so that it fits in the space available between the triggering element and the edge of the viewport

In cases like this, OOUI seems to prefer flipping menus but clipping popups.

Requirements

Menus and popups should:

  1. Always remain "attached" to their triggering element
  2. Always have an appropriate width (e.g. in most cases, menus are the width of their triggering element)
  3. Always be fully visible within the viewport and not extend past it
  4. Be able to extend past the bounds of their container

This should all work:

  • With the menu or popup attached in any direction (see the FloatableElement test on the OOUI dialog demo page)
  • With a static "footer" element under the menu or popup
  • With scrolling
  • With window resizing
  • In LTR and RTL

OOUI support

OOUI supports the above requirements through two mixins: ClippableElement and FloatableElement.

ClippableElement

Clippable elements can be automatically clipped to visible boundaries, first when the element is mounted to the DOM, then in response to scroll or resize. Any time the natural height changes, ClippableElement.clip() must be run to make sure the clippable element is still properly sized.

It's possible to have a clippable element and a statically-sized element (e.g. footer) inside a clippable container: when the clippable container is resized, the static element won't change, but the clippable element will.

Along with height, this mixin also sets a max-height to control for situations where calling ClippableElement.clip() isn't possible, e.g. if an element outside of this context changes.

How it works:

  • The parent element runs ClippableElement.toggleClipping() to turn clipping on or off. When clipping is on, this method sets up some necessary references (like the closest scrollable container) and scroll and resize handlers, and runs .clip() for an initial resize. When clipping is off, those references and the event listeners are removed. For example, MenuSelectWidget turns clipping on when the menu is visible, and off when it is not.
  • The main clip() method figures out the clippable element's "scrollable container," which may be the body or html element, and gets its bounding client rect, accounting for:
    • Scrollbar gutter
    • A 7px buffer, meant to head off single-pixel miscalculations and allow room for drop shadows
  • Determines if there's anything inside the clippable container besides the clippable element and, if so, accounts for its dimensions
  • Extends the clippable element's rectangle to its maximum size, to account for the fact that it might have been clipped before
  • Figures out the allotted width and height, based on the max size of the clippable element and the viewport size
  • Determines if it needs to clip the width and/or height, based on whether the allotted sizes are different than the natural sizes.
  • If so, sets the sizes and max sizes, with code/reflows that account for browser bugs related to scrollbars

Directionality considerations:

  • The element is horizontally clipped on the side opposite its "anchor" edge
  • The scrollbar can be on either size depending on reading direction

FloatableElement

FloatableElements stick to a specified container, even when inserted elsewhere in the document (like an overlay). The element's position is automatically calculated and maintained on window resize or page scroll, and you can manually reposition when you make your own changes. FloatableElements can be attached to any side of their container (vertically: below, above, top, bottom, or center; horizontally: before, after, start, end, or center).

By default FloatableElements should not be out of view, but you can configure them to allow this.

How it works:

  • Similar to ClippableElement, the parent element runs FloatableElement.togglePositioning() to toggle positioning on or off. When on, this sets up data like the closest scrollable container, adds handlers for resize and scroll, and runs .position() for an initial reposition. When off, the data and event handlers are unset.
  • verticalPosition and horizontalPosition are passed in by the parent
  • The main .position() method does a bunch of checks to make sure it can/should run, then sets CSS positioning rules on the floatable element. It also runs this.clip() if the element is also clippable.
  • The positioning rules are calculated as follows:
    • Find the offsetParent and see if its scrollable horizontally and/or vertically
    • Calculate scrollbar widths
    • Find the position values of the container by looking at the offsetParent, seeing if its scrollable and accounting for scrollbar widths, and determining the container's relative position
    • Depending on the verticalPosition and horizontalPosition, set the floatable element's new position to align with the container
    • Account for scroll position and scrollbar gutter

Note that MenuSelectWidget chooses to "flip" the verticalPosition if the menu will be clipped.

Directionality considerations:

  • Start and end positions are flipped in RTL. This affects positioning and scrollbar offsets

Modern tools

The OOUI mixins described above were developed 10 years ago, before some of the tools listed below existed or had sufficient browser support. Some parts of the OOUI implementation could be swapped out for browser APIs, and we could consider using external libraries.

Browser APIs:

  • IntersectionObserver: A browser API that can detect when an element intersects with an ancestor element or the viewport. Codex already has a useIntersectionObserver composable that reports whether the provided template ref is visible.
  • ResizeObserver: A browser API that reports changes to an element's dimensions. Codex already has a useResizeObserver composable that reports changes to a provided template ref's dimensions.
  • (more?)

External libraries:

  • FloatingUI: A JavaScript library with Vue bindings that positions and resizes floating elements
  • (more?)

See Eric's demo of the FloatingUI library applied to the Select component, which fixes the "detached menu" bug documented above.


Conclusion

After studying this problem and reviewing the available options, the DST engineers recommend that Codex adopt the 3rd-party FloatingUI library to solve this problem.

Adding a 3rd-party runtime dependency to Codex is not something which we take on lightly, but the benefits appear to outweigh the downsides here.

Work to review and integrate this library into Codex will be tracked at https://phabricator.wikimedia.org/T346096.

Event Timeline

Restricted Application added a subscriber: Aklapper. · View Herald Transcript
AnneT set the point value for this task to 5.Aug 28 2023, 10:25 PM
AnneT moved this task from Inbox to Design-Systems-Sprint-7 on the Design-System-Team board.
CCiufo-WMF lowered the priority of this task from High to Medium.

Per my comments in T344542 (which I've merged into this task):

I think that we should look closely at https://floating-ui.com/ and either incorporate it into Codex or adapt some of that code as a Codex composable. The failing userscript example @Celenduin created over at https://test.wikipedia.org/wiki/User:Zvpunry/codexLookupMenuStuck.js can serve as a good test for whether or not we have implemented an adequate solution to this problem.

See https://gerrit.wikimedia.org/r/c/design/codex/+/953740 for an example of how we could solve some of these problems if we decide to rely on the FloatingUI 3rd-party library.

I spent a little more time investigating FloatingUI with an eye towards how much effort it would take us to re-implement some of this behavior ourselves.

Consider the scenario from @Celenduin's userscript. We have a Dialog which contains overflowing, scrollable content. Included in that content is a Select component, with a drop-down Menu. We want the Menu to pop out of the dialog, and we also want the menu to remain anchored to the Select component as the user scrolls up and down within the Dialog (until the Select is scrolled out of view, and at that point we probably want to ensure that the menu is hidden as well).

You could keep the current styles we use for dropdown Menus inside of Dialogs, where the positioning of the parent component (Select in this case) becomes static and the menu becomes absolutely positioned relative to the Dialog backdrop (which has fixed positioning and covers the entire viewport).

To ensure that the Menu appears properly connected to the Select component in this case, you could:

  1. add top: 0 and left: 0 to completely reset the Menu's position on the screen
  2. apply a CSS transform: translate( x, y ) rule where x is equal to getBoundingClientRect().x based on the Select component's handle template ref; y would be equal to getBoundingClientRect().y + getBoundingClientRect() + height (since we want the menu to appear below the Select element).

The problem is that you need to re-compute these values every time the user scrolls the page. You may also need to keep track of scroll events on ancestor elements. You probably will also want a ResizeObserver to handle resize events, as well as an IntersectionObserver to determine whether you should show the menu at all.

FloatingUI has a feature called autoUpate which handles all of this – here is the source code for reference.

I don't think we'd need to re-implement all of FloatingUI, but we probably would need to re-implement most/all of the autoUpdate feature in order to meet our acceptance criteria here.

Some notes from my experimentation:

  • Flipping and clipping work fairly well out of the box. I pushed a patchset to Eric's proof of concept using FloatingUI to implement a combination of the flip and size middleware that clips the menu to a certain min-height before flipping. However, we will need to refine this—the OOUI menu never flips on scroll, only initially, which I think is better UX than having the menu flip as you scroll.
  • There are some styling details we'll need to get right, e.g. handling border radiuses for a flipped menu
  • RTL: a Select outside of a Dialog in RTL works great. Inside of a Dialog, the horizontal transform on the menu is not working properly. The pixel value of the transform would work if the transform were to begin at the left edge of the viewport, but it's being applied on the right side, which means the menu is not visible as it's off the right edge of the page. Instead, a negative value equal to the transform-x value for LTR works perfectly. I haven't figured out yet what's causing this (it happens in VitePress and the dev sandbox, including when I bind floatingStyles to the style attribute of the menu rather than in the Less code).
  • I didn't get around to testing the following:
    • Horizontal clipping in RTL
    • Horizontal clipping + scrollbars
    • Stuff related to popups and not menu - different positions, arrow, autoPlacement
    • Enabling a clippable element with a static footer

That said, I think FloatingUI is an excellent library that's easily extensible, so I'm in favor of using it and extending it via middleware. I do think we should try to confirm that this library will meet our needs for a future Popup component, which has more options for positioning than menu, but our initial implementation need only cover menu.

egardner updated the task description. (Show Details)

See https://phabricator.wikimedia.org/T346096 for follow-up on the outcome of this spike.

Change 953740 had a related patch set uploaded (by Anne Tomasevich; author: Eric Gardner):

[design/codex@main] Combobox, Lookup, Select: Use FloatingUI

https://gerrit.wikimedia.org/r/953740