Skip to content

Keep tab widths frozen when closing tabs#793

Open
MUFFANUJ wants to merge 2 commits intojupyterlab:mainfrom
MUFFANUJ:keepSizeOnClosing
Open

Keep tab widths frozen when closing tabs#793
MUFFANUJ wants to merge 2 commits intojupyterlab:mainfrom
MUFFANUJ:keepSizeOnClosing

Conversation

@MUFFANUJ
Copy link
Member

@MUFFANUJ MUFFANUJ commented Feb 21, 2026

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

Copy link
Member

@krassowski krassowski left a comment

Choose a reason for hiding this comment

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

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.

// Remove the unfreezing class after the transition completes.
setTimeout(() => {
this.removeClass('lm-mod-unfreezing');
}, 150);
Copy link
Member

Choose a reason for hiding this comment

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

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 transitionend event 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

Copy link
Member

Choose a reason for hiding this comment

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

I would probably prefer (b) it feels more aligned with the lumino design but I wonder if other folks have thoughts on it.

Copy link
Member Author

@MUFFANUJ MUFFANUJ Feb 21, 2026

Choose a reason for hiding this comment

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

I feel we could do (b)

@MUFFANUJ
Copy link
Member Author

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.

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.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

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

  • removeTabAt mutates _frozenTabWidths before 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 _tabSizeFrozen set it will still capture widths and leave _frozenTabWidths populated, which can later be applied on an unrelated update. Consider checking bounds (or title existence) before capturing widths, or clearing _frozenTabWidths on 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 _tabSizeFrozen is true and _frozenTabWidths is present but length-mismatched, the code falls back to freezing widths via offsetWidth but 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 _frozenTabWidths in 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

  • _evtPointerLeave relies solely on a transitionend event to remove lm-mod-unfreezing and detach the listener. If the width doesn’t actually change (or if a subsequent update()/VirtualDOM render prevents the transition from starting), transitionend may 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 immediate update() 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.

Copy link
Member

@krassowski krassowski left a comment

Choose a reason for hiding this comment

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

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');
Copy link
Member

Choose a reason for hiding this comment

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

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 transitionend never 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.

.lm-TabBar-tab {
padding: 0px 10px;
background: #e5e5e5;
border: 1px solid #c0c0c0;
border-bottom: none;
font:
12px Helvetica,
Arial,
sans-serif;
flex: 0 1 125px;

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);
Copy link
Member

Choose a reason for hiding this comment

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

  • we should probably also handle transitioncancel and 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.

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

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Keep size of Titlebar when pressing "x".

3 participants