Keep tab widths frozen when closing tabs#793
Keep tab widths frozen when closing tabs#793MUFFANUJ wants to merge 2 commits intojupyterlab:mainfrom
Conversation
krassowski
left a comment
There was a problem hiding this comment.
I like this, I think this is quite desirable. I see that both Firefox and Chromium based browsers implement this, as do tab bars in GNOME/GTK based applications (I think that's Adw.TabBar).
While right now I don't see a reason why someone would want to opt-out of this behaviour*, we could consider making it customizable the way removeBehavior is? Or maybe it's better kept to a future PR if someone actually asks for a way to opt-out so that we don't expand API unnecessarily.
packages/widgets/src/tabbar.ts
Outdated
| // Remove the unfreezing class after the transition completes. | ||
| setTimeout(() => { | ||
| this.removeClass('lm-mod-unfreezing'); | ||
| }, 150); |
There was a problem hiding this comment.
That's a minor issue but this introduces a tight coupling between CSS and JS - downstreams cannot increase transition speed with CSS alone. I could see this either way:
- a) we drop the class and just set the style via JS; this is slightly more performant than adding/removing classes; maybe we could make the timeout configurable; the downside is we need to add new config options to public API on JS side.
- b) we use
transitionendevent to remove the class once transition completed, whatever time it was set to. - c) we don't special case this and just have the transition always enabled
There was a problem hiding this comment.
I would probably prefer (b) it feels more aligned with the lumino design but I wonder if other folks have thoughts on it.
There was a problem hiding this comment.
I feel we could do (b)
I agree. Since this matches common behavior in Firefox, Chromium, and GNOME tab bars, it makes sense as a default. Personally, I feel we can ship it as-is and gather feedback. If there’s real demand for opting out, we can add configurability later instead of expanding the API prematurely. |
There was a problem hiding this comment.
Pull request overview
Implements “Chrome-like” tab closing behavior in the Lumino TabBar by freezing remaining tab widths when a tab is closed via the close icon, then restoring natural sizing once the pointer leaves the tab bar.
Changes:
- Freeze remaining tab widths on close-icon initiated tab close; unfreeze on
pointerleave. - Add an unfreezing CSS class to animate width restoration.
- Add unit tests covering icon-initiated close vs programmatic close behavior.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| packages/widgets/src/tabbar.ts | Adds width-freezing logic, pointerleave handling, and unfreezing transition lifecycle. |
| packages/widgets/style/tabbar.css | Adds a width transition for tabs while the unfreezing modifier class is present. |
| packages/widgets/tests/src/tabbar.spec.ts | Adds tests validating freezing is only triggered by close-icon interaction and is cleared on pointerleave. |
Comments suppressed due to low confidence (3)
packages/widgets/src/tabbar.ts:525
removeTabAtmutates_frozenTabWidthsbefore verifying that the index is valid / a title was actually removed. For out-of-range indices this method is documented as a no-op, but with_tabSizeFrozenset it will still capture widths and leave_frozenTabWidthspopulated, which can later be applied on an unrelated update. Consider checking bounds (ortitleexistence) before capturing widths, or clearing_frozenTabWidthson the early-return path.
removeTabAt(index: number): void {
if (this._tabSizeFrozen) {
// Capture the widths of all tabs that will remain.
let tabs = this.contentNode.children;
this._frozenTabWidths = [];
for (let i = 0, n = tabs.length; i < n; ++i) {
if (i !== index) {
this._frozenTabWidths.push((tabs[i] as HTMLElement).offsetWidth);
}
}
}
// Release the mouse before making any changes.
this._releaseMouse();
// Remove the title from the array.
let title = ArrayExt.removeAt(this._titles, index);
// Bail if the index is out of range.
if (!title) {
return;
}
packages/widgets/src/tabbar.ts:701
- In
onUpdateRequest, when_tabSizeFrozenis true and_frozenTabWidthsis present but length-mismatched, the code falls back to freezing widths viaoffsetWidthbut never clears_frozenTabWidths. If a mismatch ever occurs (e.g., due to an out-of-range remove), the stale array will persist and keep the widget in an inconsistent state. Consider clearing_frozenTabWidthsin the fallback branch (or ensuring it can never be set to a mismatched length).
if (this._tabSizeFrozen) {
let tabs = this.contentNode.children;
if (
this._frozenTabWidths &&
this._frozenTabWidths.length === tabs.length
) {
for (let i = 0, n = tabs.length; i < n; ++i) {
(
tabs[i] as HTMLElement
).style.width = `${this._frozenTabWidths[i]}px`;
}
this._frozenTabWidths = null;
} else {
for (let i = 0, n = tabs.length; i < n; ++i) {
let tab = tabs[i] as HTMLElement;
tab.style.width = `${tab.offsetWidth}px`;
}
}
}
packages/widgets/src/tabbar.ts:1427
_evtPointerLeaverelies solely on atransitionendevent to removelm-mod-unfreezingand detach the listener. If the width doesn’t actually change (or if a subsequentupdate()/VirtualDOM render prevents the transition from starting),transitionendmay never fire, leaving the class and listener attached indefinitely. Consider adding a timeout fallback (e.g., slightly longer than the CSS duration) and/or avoiding the immediateupdate()so the transition can reliably run.
// Add the unfreezing class to enable a smooth width transition.
this.addClass('lm-mod-unfreezing');
// Clear the inline width on all tabs, triggering the CSS transition.
let tabs = this.contentNode.children;
if (tabs.length === 0) {
this.removeClass('lm-mod-unfreezing');
} else {
const onTransitionEnd = (event: Event) => {
if ((event as TransitionEvent).propertyName === 'width') {
this.removeClass('lm-mod-unfreezing');
this.node.removeEventListener('transitionend', onTransitionEnd);
}
};
this.node.addEventListener('transitionend', onTransitionEnd);
}
for (let i = 0, n = tabs.length; i < n; ++i) {
(tabs[i] as HTMLElement).style.width = '';
}
// Schedule an update to re-render the tabs at natural size.
this.update();
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
krassowski
left a comment
There was a problem hiding this comment.
Note: 150ms looks good to me, though I will note that both Chrome and GTK (adw) use 200ms.
| this._frozenTabWidths = null; | ||
|
|
||
| // Add the unfreezing class to enable a smooth width transition. | ||
| this.addClass('lm-mod-unfreezing'); |
There was a problem hiding this comment.
Testing on https://lumino--793.org.readthedocs.build/en/793/examples/dockpanel/index.html:
- the width pins get applied in styles but the width does not get pinned because the flex rule takes priority
- this never gets removed because
transitionendnever fires due to above
Screencast.From.2026-02-24.09-31-57.mp4
flex-basis: 125px rule takes precedence over any width set. I guess JupyterLab overrides it.
lumino/packages/default-theme/style/tabbar.css
Lines 27 to 36 in 4997384
Not sure how to solve it, will require some experimentation maybe using either flex-basis or width or both etc.
| this.node.removeEventListener('transitionend', onTransitionEnd); | ||
| } | ||
| }; | ||
| this.node.addEventListener('transitionend', onTransitionEnd); |
There was a problem hiding this comment.
- we should probably also handle
transitioncanceland add a longer timeout of last resort (possibly after say 2 seconds). - if we are adding multiple event listeners/timer we need to track the invocation number to avoid removing the class if a newer timer was or clear previous timer events.
fixes jupyterlab/jupyterlab#10514
This pull request brings Chrome-like tab closing behavior to the TabBar. When a user clicks the close icon (x) on a tab, the remaining tabs temporarily maintain their current widths rather than immediately reflowing to fill the empty space. This ensures that the next tab's close button lands directly under the user's cursor. Once the user moves their cursor away from the tab bar, the tabs smoothly transition back to their natural sizes.
Before
Screen.Recording.2026-02-22.at.1.10.24.AM.mov
After
Screen.Recording.2026-02-22.at.12.56.34.AM.mov