Skip to content

Tabs inside Dialog swallows Escape: createSelectableCollection preventDefaults Escape even when disallowEmptySelection makes it a no-op #688

Description

@bianjiefilm

Describe the bug

When a Tabs component is composed inside a Dialog (a very common settings-dialog pattern), pressing Escape while a tab trigger has focus does not close the dialog. Since Dialog.Content's focus trap auto-focuses the selected tab trigger on open, the practical effect is that Escape appears completely broken the moment the dialog opens.

Root cause

createSelectableCollection's keydown handler (selection/create-selectable-collection.ts L320–327):

case "Escape":
    if (!e.defaultPrevented) {
        e.preventDefault();
        if (!access(mergedProps.disallowEmptySelection)) {
            manager.clearSelection();
        }
    }
    break;

e.preventDefault() is called unconditionally, even though TabsList hardcodes disallowEmptySelection: true (tabs/tabs-list.tsx L83), so the clearSelection() that the preventDefault() is guarding never runs — the call has no visible effect on the collection itself.

But the poisoned event.defaultPrevented then reaches DismissableLayer's document-level escape handler, which gates dismissal on !e.defaultPrevented (dismissable-layer/dismissable-layer.tsx) — so the dialog's onDismiss never fires. The layer believes a descendant meaningfully consumed Escape when nothing actually happened.

Both code paths above are current main as of this report; reproduced concretely on @kobalte/core@0.13.11.

Suggested fix

Only claim the event when the handler actually does something:

case "Escape":
    if (!e.defaultPrevented && !access(mergedProps.disallowEmptySelection)) {
        e.preventDefault();
        manager.clearSelection();
    }
    break;

(This also affects any other selectable collection rendered inside a dismissable layer whenever the Escape press ends up being a selection no-op.)

Minimal reproduction

import { Dialog, Tabs } from "@kobalte/core";

<Dialog.Root>
  <Dialog.Trigger>Open settings</Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Overlay />
    <Dialog.Content>
      <Tabs.Root orientation="vertical">
        <Tabs.List>
          <Tabs.Trigger value="a">Tab A</Tabs.Trigger>
          <Tabs.Trigger value="b">Tab B</Tabs.Trigger>
        </Tabs.List>
        <Tabs.Content value="a"></Tabs.Content>
        <Tabs.Content value="b"></Tabs.Content>
      </Tabs.Root>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>
  1. Open the dialog — the focus trap focuses the selected tab trigger.
  2. Press Escape.
  3. Expected: the dialog closes. Actual: nothing happens. (Clicking the overlay still closes it; Escape works again only if focus moves off the tab triggers first.)

Environment

  • @kobalte/core: 0.13.11 (bug also present in current main source)
  • solid-js: 1.x

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions