Skip to content

[Feature] Combobox: animate the popover open and close - #516

Open
tvq wants to merge 2 commits into
ruby-ui:mainfrom
tvq:fix_combobox
Open

[Feature] Combobox: animate the popover open and close#516
tvq wants to merge 2 commits into
ruby-ui:mainfrom
tvq:fix_combobox

Conversation

@tvq

@tvq tvq commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

The Combobox popover opened and closed in a hard cut. It now animates in and out like the other overlays.

What changed

  • ComboboxPopover gets the data-state enter/exit classes from [Bug Fix] Overlays: play the exit animation before hiding #506, duration-100 and the data-side slide-in — parity with shadcn/ui's Combobox.
  • The popover becomes popover="manual". A native auto popover's light dismiss hides it directly and the hide beforetoggle is not cancelable, so no exit could ever play. The controller now owns every close path (trigger, outside click, Escape, radio selection) through closePopover(), which waits for the exit animation before hidePopover() — the [Bug Fix] Overlays: play the exit animation before hiding #506 block, copied unchanged. Positioning keeps running until the popover is hidden, so it does not drift while fading.
  • Escape works with focus anywhere (before: only inside the popover). Outside click and Escape are window actions on the root, guarded for a Combobox rendered without a popover.
  • Dropped role="popover" (not an ARIA role). API and examples unchanged.

Tested

  • cd gem && bundle exec rake
  • Headless Chrome over the docs examples (single + multiple): open/close via trigger, outside click, Escape (trigger / search input / body), radio selection, reopen mid-exit, close mid-open, positioning during the fade, flip → data-side=top, keyboard navigation and filtering.

Dialog and AlertDialog get their exit animations in sibling PRs.

🤖 Generated with Claude Code


Summary by cubic

Animates the Combobox popover open and close instead of a hard cut. The controller now owns every close path (trigger, outside click, Escape, radio selection) and waits for the exit animation before hiding the popover.

New Features

  • Switches to popover="manual" so a single close path can wait for the exit animation.
  • Adds data-state enter/exit classes, duration-100, and data-side slide-in for parity with shadcn/ui.
  • Keeps positioning running until the popover is hidden, so it doesn't drift during the fade.
  • Removes the non-ARIA role="popover" and adds window-level outside-click and Escape handlers, guarded for comboboxes without a popover.

Written for commit 2b26f6b. Summary will update on new commits.

Review in cubic

@tvq
tvq requested a review from cirdes as a code owner August 28, 2026 18:58
tvq and others added 2 commits August 28, 2026 21:00
The popover had no enter/exit animation, and as a native `auto` popover
its outside-click and Escape dismissal ran inside the browser: the hide
`beforetoggle` is not cancelable, so there was no point at which an exit
could play.

Switch it to popover="manual" (still top layer, showPopover/hidePopover
and the toggle event) and route every close path — trigger, outside
click, Escape, radio selection — through closePopover(). data-state
drives the tw-animate-css enter/exit keyframes as in ruby-ui#506; the exit
block is copied unchanged, and afterExit() calls hidePopover() and stops
autoUpdate only then, so the panel keeps following the trigger while it
fades. data-side follows Floating UI's resolved placement so the
slide-in comes from the trigger's side.

Outside click and Escape are window-level actions on the root: clicking
a non-focusable part of the popover moves focus to body, and Escape only
prevents default while the popover is showing. Both guard
hasPopoverTarget, since a Combobox can render without a popover.
Restarting positioning stops the previous autoUpdate first (focusin and
click on the input trigger used to leak one).

Drop role="popover" (not an ARIA role) and add duration-100 plus the
directional slide-in classes for parity with shadcn/ui's Combobox.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found and verified against the latest diff

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="gem/lib/ruby_ui/combobox/combobox_controller.js">

<violation number="1" location="gem/lib/ruby_ui/combobox/combobox_controller.js:259">
P3: The `computePosition().then` callback here makes unconditional async writes to `this.popoverTarget` — including the newly added `dataset.side` — with no `isConnected`/run guard. The sibling popover_controller.js (source of this pattern) guards with `if (!content.isConnected) return;` and run-bound teardown precisely so a stale or detached run cannot overwrite position or write to a removed node. Add an `isConnected` check before writing, and consider using the same run-scoped teardown.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

top: `${y}px`,
});
// flip() can resolve to the opposite side, so the slide-in direction follows the resolved value.
this.popoverTarget.dataset.side = placement.split("-")[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The computePosition().then callback here makes unconditional async writes to this.popoverTarget — including the newly added dataset.side — with no isConnected/run guard. The sibling popover_controller.js (source of this pattern) guards with if (!content.isConnected) return; and run-bound teardown precisely so a stale or detached run cannot overwrite position or write to a removed node. Add an isConnected check before writing, and consider using the same run-scoped teardown.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At gem/lib/ruby_ui/combobox/combobox_controller.js, line 259:

<comment>The `computePosition().then` callback here makes unconditional async writes to `this.popoverTarget` — including the newly added `dataset.side` — with no `isConnected`/run guard. The sibling popover_controller.js (source of this pattern) guards with `if (!content.isConnected) return;` and run-bound teardown precisely so a stale or detached run cannot overwrite position or write to a removed node. Add an `isConnected` check before writing, and consider using the same run-scoped teardown.</comment>

<file context>
@@ -174,19 +244,30 @@ export default class extends Controller {
           top: `${y}px`,
         });
+        // flip() can resolve to the opposite side, so the slide-in direction follows the resolved value.
+        this.popoverTarget.dataset.side = placement.split("-")[0]
       });
     });
</file context>

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant