facebook / facebook/astryx

ComplexSelector: a flyout nested in the popup is clipped by the content box, and there is no nested-layer affordance

Open
#4,985 1 comment 0 reactions 1 assignee Claimed by @cixzhang View on GitHub
component enhancement
Dominant language
TypeScript
Stars
13.1k
Forks
1.1k
Avg merge
1d 15h
Merged PRs (30d)
687

Description

### Problem

A flyout nested inside `ComplexSelector`'s popup — a "More…" side panel hanging off one row — is invisible. Not mispositioned: absent, with a horizontal scrollbar appearing under the menu instead.

The popup *is* in the top layer, which is why this reads as a paradox. The top layer settles paint order against the rest of the page; it does nothing for a descendant inside the popup, and the popup's own content box is a scroll container:

```js
// ComplexSelector.tsx
content: {
boxSizing: 'border-box',
maxHeight: 'min(480px, calc(100vh - 32px))',
overflow: 'auto',
padding: spacingVars['--spacing-3'],
},
```

`overflow: auto` clips every descendant regardless of stacking. So the child is not painted outside the menu; it is added to the scroller's scrollable area.

Measured in Chromium (Storybook, `placement="below"`, 900×560), a 240px panel absolutely positioned at `left: 100%` of its row:

| | value |
|---|---|
| panel rect | 246 → 486 (240px wide, on screen) |
| content box rect | 40 → 252 |
| `elementFromPoint` at the panel's centre | `HTML` — the page, not the panel |
| content box `scrollWidth` / `clientWidth` | 446 / 212 |

The panel occupies a real 240px box that hit-tests as the page behind it, and inflates the scroller by 234px. Layer element is `overflow: visible`; the clip is entirely the content box.

### Why this matters more than the layout bug

There is no submenu or nested-layer affordance on `ComplexSelector`, and no way to opt the content box out of its own scroll clip, so the escape is to promote the panel into the top layer yourself. In an app I looked at, that was `popover="auto"` plus `role="dialog"` plus hand-written flip/clamp positioning in a layout effect — about thirty lines re-implementing a positioning engine inside a component whose positioning engine is unreachable.

It shipped inaccessible, and predictably so. The panel announces itself as a dialog, but nothing moves focus into it when it opens, nothing traps focus inside it, and nothing returns focus to the row that opened it — the only trap in play is the selector's own, one level up, so Tab walks out of the "dialog" and back into the primary list while the dialog stays open. The trigger carries `aria-haspopup="dialog"` and `aria-expanded` but no `aria-controls`.

That is the part worth fixing upstream. A design system that owns the popup, the focus trap and the focus restore, and then leaves nesting to the consumer, is handing out a job that only looks easy: every consumer who needs a second level will hand-roll a dialog, and most of them will hand-roll it wrong.

### Repro

```tsx

{(_v, commit, close) => (


{rows.map(r => { commit(r); close(); }}>{r})}

setOpen(o => !o)}>More models ▸
{open && (



)}


)}

```

Expected: a panel beside the menu. Actual: nothing visible, plus a horizontal scrollbar. Swapping the panel to `popover="auto"` makes it appear — it is then its own top-layer element rather than a descendant of the scroller.

### Approaches

**A — a nested-layer primitive.** `ComplexSelector` (and `Selector`, `DropdownMenu`'s flyout already does its own version of this) exposes a submenu/panel that opens as a sibling layer anchored to a row, inheriting the parent's dismissal chain and owning its own focus contract. Most work, and the only option that stops consumers writing dialogs by hand.

**B — let the scroll clip be opted out of.** Move `max-height`/`overflow` onto an inner scroller, or accept them as props, so a popup that wants to host a nested layer can. Cheap, and enough to unblock; leaves the focus contract with the consumer, which is where the accessibility defects come from.

**C — document it.** At minimum the content box's `overflow: auto` deserves a line in the docs saying a nested layer must promote itself, because "it's in the top layer" is exactly the reasoning that makes people expect the absolute child to work.

A is what I'd want; B is a reasonable interim if it lands with C.

Related: #4804 (the popup surface has no non-StyleX styling hatch). Worth noting the two are the same shape from the consumer's side — `Popover`, `DropdownMenu` and `ContextMenu` all merge a consumer `className`/`style` onto their content box, while `ComplexSelector` and `Selector` accept only StyleX-typed `contentXstyle` there. Whatever we do for the popup surface should probably be decided for both components at once.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.