Skip to content

fix(selector): overlay the trigger only when the selected option covers it (#5004) - #5010

Closed
cixzhang wants to merge 1 commit into
mainfrom
fix/selector-overlay-invariant
Closed

fix(selector): overlay the trigger only when the selected option covers it (#5004)#5010
cixzhang wants to merge 1 commit into
mainfrom
fix/selector-overlay-invariant

Conversation

@cixzhang

@cixzhang cixzhang commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Closes #5004.

The bug

The selected-item overlay paints the menu on top of its own trigger, so whichever option lands over the trigger is what a press on the trigger commits. That is harmless in exactly one case — when the option sitting there is the one already selected, the press re-commits the current value and reads as a dismissal (this is what a native <select> does).

Two states break that invariant, and both were live. Measured in Chrome 151 against main, pressing the trigger of an open menu at its own coordinates:

state option painted over the trigger value before → after
placeholder (no selection) Apple Pick one…Apple
selection past the fold, trigger near the viewport top Option 31 Option 38Option 31
selection aligned (healthy overlay) Banana BananaBanana

Identical with mouse and with touch. The second row is the worse one: an existing value is silently replaced by a different one.

It is geometry, not a popover race

Worth stating because it is the obvious suspect. Instrumenting the second press: click at the trigger, then the light-dismiss toggle 15 ms later — the toggle always arrives after the click, so the "closed by light dismiss, then re-opened by the click" race that DropdownMenu and Popover guard with a 50 ms window never fires here. Nothing in the timing needs fixing.

The rule

Use the overlay only when the option under the trigger is the selected one. Nothing selected means no such option; a viewport clamp that slides the list can put a different one there. In both cases the menu opens below with the standard --spacing-1 clearance — the same geometry every other Astryx menu uses, and the trigger stays uncovered, so click-to-close works.

useSelectedItemOffset now reports whether the target row still covers the anchor after clamping (it already computed everything needed); the tolerance is the one deliberate optical pixel the row is shifted by. Where the invariant holds, the measured offset is unchanged.

How this sits with #4976

Orthogonal, and this does not pre-empt that decision. #4976 decides what the default placement should be; this fixes the overlay itself, which survives there as the opt-in hasSelectedItemOverlay — carrying both failures with it. Whichever lands first, the other rebases onto the same few lines.

Risk

Placeholder-state Selectors — the state every form starts in — now open below with a gap instead of over the trigger. That is a visible change, and a subset of the one #4976 proposes for all Selectors. Clamped menus open below rather than pinning to the viewport edge. useSelectedItemOffset is exported from the package; its return type gains a field, which is additive.

Adjacent, not fixed here

  • A one-pixel sliver survives in the healthy overlay. The control box measures [396, 428] against the selected option's [395, 427] — that bottom pixel row hits the next option. It comes from the deliberate 1 px optical offset, so straightening it is a visual-alignment call, not a geometry bug.
  • WCAG 2.4.11 still applies to overlay mode: the trigger keeps DOM focus and the menu covers it. Inherent to the affordance; noted in #5004.

Test plan

Unit — two new tests, both written red-first (they fail on unmodified Selector.tsx/hooks.ts): no selection, and a clamp that slides another option over the trigger. The overlay geometry tests were asserting the clamped case as correct behavior, so they now run against rects where the alignment actually lands (-61px), which is what they were meant to cover. packages/core/src/Selector: 120 passing. Full packages/core: 6310 passing — the one failure is the Table 500-row perf budget, which flakes under full parallel load on this laptop and passes in isolation.

Real Chromium (Playwright against local Storybook, mouse and touch, pressing the trigger of an open menu at its own coordinates):

story before after
Default (placeholder) commits Apple closes, value untouched
clamped selection commits Option 31 closes, value untouched
Pre-Selected (healthy overlay) closes, value untouched unchanged — still overlays
placement="above" / hasSearch / MultiSelector closes unchanged

Repotsc --noEmit clean, strict ESLint clean, pnpm check:repo green.

…rs it (#5004)

The selected-item overlay puts the menu on top of its own trigger, so the
option painted there is what a press on the trigger commits. That is only
harmless while the option under the trigger is the selected one. With no
selection the first option takes that spot, and the viewport clamp can slide
an unrelated option there — in both cases a press meant to dismiss the menu
silently changes the value.

Fall back to the standard below placement whenever the invariant does not
hold, which also restores click-to-close for those states.
@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
astryx Ready Ready Preview Aug 14, 2026 2:26am

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

Modified Components

Selector (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 1513 -
Complexity N/A Very High (157) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.7KB 1.2KB

Accessibility Audit

Status: No accessibility violations detected.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

github-actions Bot added a commit that referenced this pull request Aug 14, 2026
@cixzhang

Copy link
Copy Markdown
Contributor Author

Parking this: the reported symptom is the trigger press closing and immediately reopening the popup — a light-dismiss/click race, not the overlay geometry — and that is fixed in #5018, which also stops the clear and status buttons on a trigger from dismissing the popup they belong to.

The geometry finding here still stands and is documented in #5004: with the selected-item overlay, the option painted over the trigger is what a press on the trigger commits, so a placeholder Selector can be given a value by someone trying to dismiss the menu. Where that lands depends on the default-placement decision in #4976, so it should not be settled by a drive-by fix from me. The branch stays if we want it back.

@cixzhang cixzhang closed this Aug 14, 2026
@github-actions
github-actions Bot deleted the fix/selector-overlay-invariant branch August 14, 2026 06:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Selector: the overlay menu covers its own trigger — clicking it commits the option on top instead of closing

1 participant