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:
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:
There are two options to improve the UX here:
- Change the vertical position of the menu ("flip") so that it appears to open up above the triggering element instead of down below it
- 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:
- Always remain "attached" to their triggering element
- Always have an appropriate width (e.g. in most cases, menus are the width of their triggering element)
- Always be fully visible within the viewport and not extend past it
- 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.