Pressing Escape is one of the most familiar keyboard interactions in a web application. It should dismiss the active dropdown, close a dialog, or cancel the current interaction.
But what happens when an input component prevents that keyboard event from reaching the modal that contains it?
That was the problem in GitLab UI's GlTokenSelector when used inside GlModal. Pressing Escape while the input had focus could fail to close the modal because the component stopped keyboard event propagation unconditionally.
In Pajamas MR !6678, addressing work item #3762, I fixed this behavior while preserving the expected two-step Escape interaction.
The Problem: Escape Never Reached the Modal
The issue was in:
packages/gitlab-ui/src/components/base/token_selector/token_selector.vue
The input used Vue's .stop event modifier:
@keydown.stop="$emit('keydown', $event)" Vue's .stop modifier calls event.stopPropagation() for every keydown event.
That means the component stopped not only ordinary keyboard events, but also Escape.
When GlTokenSelector was rendered inside GlModal, the event could not bubble to the parent modal's keyboard handler. As a result, the modal did not receive the Escape event it needed to close.
This created a keyboard accessibility problem: the expected keyboard interaction was blocked by an inner component.
Why Removing .stop Wasn't Enough
One possible fix would have been to remove .stop entirely:
@keydown="$emit('keydown', $event)" But that would introduce another problem.
GlTokenSelector has its own suggestions dropdown. When that dropdown is open, pressing Escape should dismiss the dropdown first—not close the entire modal behind it.
The expected interaction is:
- First
Escape: close the token suggestions dropdown. - Second
Escape: allow the event to reach the modal so it can close.
This is a small but important interaction detail. Nested UI components need to handle keyboard events according to which layer is currently active.
The solution therefore needed conditional event propagation rather than simply removing event isolation.
The Fix: Conditional Event Propagation
I separated the handling into two methods: handleEscape and handleKeydown.
1. Handle Escape According to Dropdown State
handleEscape(event) { if (this.dropdownIsOpen) { event?.stopPropagation?.(); } this.inputText = ''; this.closeDropdown(); }, The important condition is:
if (this.dropdownIsOpen) { event?.stopPropagation?.(); } When the suggestions dropdown is open, the component stops the Escape event from reaching the parent modal.
It then clears the input text and closes the dropdown.
When the dropdown is already closed, the method does not stop propagation. The event can continue upward to the modal.
2. Preserve Isolation for Other Keyboard Events
The general keydown handler now treats Escape differently from other keys:
handleKeydown(event) { if (event.key !== 'Escape' && event.key !== 'Esc') { event.stopPropagation?.(); } this.$emit('keydown', event); }, The input template uses the handler directly:
@keydown="handleKeydown" This preserves the previous event-isolation behavior for non-Escape keys while allowing Escape to propagate when the dropdown is closed.
The optional chaining also makes the calls defensive when the event or method is unavailable.
Understanding the Two-Step Escape Behavior
The resulting behavior can be summarized as follows:
| Current state | Key pressed | Event propagation | Expected result |
|---|---|---|---|
| Dropdown open | Escape | Stopped by the token selector | Dropdown closes; modal stays open |
| Dropdown closed | Escape | Allowed to bubble | Parent modal can close |
| Either state | Other key | Stopped by the token selector | Existing keyboard-event isolation is preserved |
This is the central idea behind the fix: the component should intercept Escape only when it has something of its own to dismiss.
Once the dropdown is closed, the parent component should be allowed to handle the same keyboard interaction.
Regression Tests
I updated:
packages/gitlab-ui/src/components/base/token_selector/token_selector.spec.js
The tests verify the conditional propagation behavior:
-
stopPropagationis called when the dropdown is open and Escape is handled. -
stopPropagationis not called when the dropdown is closed and Escape is handled. -
stopPropagationcontinues to be called for non-Escape keys.
These cases protect both sides of the interaction: the dropdown must remain independently dismissible, and the modal must still receive Escape when the dropdown is no longer open.
Changeset
I also added a patch changeset:
.changeset/token-selector-escape-propagation.md
This records the fix as a patch-level change for the GitLab UI package.
Engineering Takeaways
Event propagation is part of component behavior. Calling stopPropagation() unconditionally can unintentionally break keyboard interactions in parent components.
Nested overlays need predictable dismissal order. A dropdown inside a modal should generally handle its own dismissal before allowing the modal to close.
Removing event isolation is not always the right fix. Other keyboard events may still need to remain isolated. Conditional propagation is more precise than removing .stop everywhere.
Test both propagation and non-propagation paths. Testing only that Escape closes the dropdown would miss the modal regression. The closed-dropdown case is equally important.
Conclusion
The GlTokenSelector issue was caused by a small event modifier with a wider effect: unconditional propagation stopping prevented a parent modal from receiving Escape.
The fix introduced conditional handling so that an open dropdown consumes Escape, while a closed dropdown allows the event to reach its parent. Other keyboard events retain their existing isolation behavior.
This is a useful lesson when building reusable Vue components: keyboard events do not belong to only one layer of the UI. Their propagation behavior must account for the complete component hierarchy.
References
- Work item: design.gitlab.com#3762
- Merge Request: design.gitlab.com!6678
Author: Manish Kumar
GitLab: @manishpatel00