UNPKG

@lumino/widgets

Version:
1,853 lines (1,612 loc) 58.9 kB
// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. /*----------------------------------------------------------------------------- | Copyright (c) 2014-2017, PhosphorJS Contributors | | Distributed under the terms of the BSD 3-Clause License. | | The full license is in the file LICENSE, distributed with this software. |----------------------------------------------------------------------------*/ import { ArrayExt } from '@lumino/algorithm'; import { IDisposable } from '@lumino/disposable'; import { ElementExt } from '@lumino/domutils'; import { Drag } from '@lumino/dragdrop'; import { Message, MessageLoop } from '@lumino/messaging'; import { ISignal, Signal } from '@lumino/signaling'; import { ElementARIAAttrs, ElementBaseAttrs, ElementDataset, ElementInlineStyle, h, VirtualDOM, VirtualElement } from '@lumino/virtualdom'; import { Title } from './title'; import { Widget } from './widget'; const ARROW_KEYS = [ 'ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'Home', 'End' ]; /** * A widget which displays titles as a single row or column of tabs. * * #### Notes * If CSS transforms are used to rotate nodes for vertically oriented * text, then tab dragging will not work correctly. The `tabsMovable` * property should be set to `false` when rotating nodes from CSS. */ export class TabBar<T> extends Widget { /** * Construct a new tab bar. * * @param options - The options for initializing the tab bar. */ constructor(options: TabBar.IOptions<T> = {}) { super({ node: Private.createNode() }); this.addClass('lm-TabBar'); this.contentNode.setAttribute('role', 'tablist'); this.setFlag(Widget.Flag.DisallowLayout); this._document = options.document || document; this.tabsMovable = options.tabsMovable || false; this.titlesEditable = options.titlesEditable || false; this.allowDeselect = options.allowDeselect || false; this.addButtonEnabled = options.addButtonEnabled || false; this.insertBehavior = options.insertBehavior || 'select-tab-if-needed'; this.name = options.name || ''; this.orientation = options.orientation || 'horizontal'; this.removeBehavior = options.removeBehavior || 'select-tab-after'; this.renderer = options.renderer || TabBar.defaultRenderer; } /** * Dispose of the resources held by the widget. */ dispose(): void { this._releaseMouse(); this._titles.length = 0; this._previousTitle = null; super.dispose(); } /** * A signal emitted when the current tab is changed. * * #### Notes * This signal is emitted when the currently selected tab is changed * either through user or programmatic interaction. * * Notably, this signal is not emitted when the index of the current * tab changes due to tabs being inserted, removed, or moved. It is * only emitted when the actual current tab node is changed. */ get currentChanged(): ISignal<this, TabBar.ICurrentChangedArgs<T>> { return this._currentChanged; } /** * A signal emitted when a tab is moved by the user. * * #### Notes * This signal is emitted when a tab is moved by user interaction. * * This signal is not emitted when a tab is moved programmatically. */ get tabMoved(): ISignal<this, TabBar.ITabMovedArgs<T>> { return this._tabMoved; } /** * A signal emitted when a tab is clicked by the user. * * #### Notes * If the clicked tab is not the current tab, the clicked tab will be * made current and the `currentChanged` signal will be emitted first. * * This signal is emitted even if the clicked tab is the current tab. */ get tabActivateRequested(): ISignal< this, TabBar.ITabActivateRequestedArgs<T> > { return this._tabActivateRequested; } /** * A signal emitted when the tab bar add button is clicked. */ get addRequested(): ISignal<this, void> { return this._addRequested; } /** * A signal emitted when a tab close icon is clicked. * * #### Notes * This signal is not emitted unless the tab title is `closable`. */ get tabCloseRequested(): ISignal<this, TabBar.ITabCloseRequestedArgs<T>> { return this._tabCloseRequested; } /** * A signal emitted when a tab is dragged beyond the detach threshold. * * #### Notes * This signal is emitted when the user drags a tab with the mouse, * and mouse is dragged beyond the detach threshold. * * The consumer of the signal should call `releaseMouse` and remove * the tab in order to complete the detach. * * This signal is only emitted once per drag cycle. */ get tabDetachRequested(): ISignal<this, TabBar.ITabDetachRequestedArgs<T>> { return this._tabDetachRequested; } /** * The renderer used by the tab bar. */ readonly renderer: TabBar.IRenderer<T>; /** * The document to use with the tab bar. * * The default is the global `document` instance. */ get document(): Document | ShadowRoot { return this._document; } /** * Whether the tabs are movable by the user. * * #### Notes * Tabs can always be moved programmatically. */ tabsMovable: boolean; /** * Whether the titles can be user-edited. * */ get titlesEditable(): boolean { return this._titlesEditable; } /** * Set whether titles can be user edited. * */ set titlesEditable(value: boolean) { this._titlesEditable = value; } /** * Whether a tab can be deselected by the user. * * #### Notes * Tabs can be always be deselected programmatically. */ allowDeselect: boolean; /** * The selection behavior when inserting a tab. */ insertBehavior: TabBar.InsertBehavior; /** * The selection behavior when removing a tab. */ removeBehavior: TabBar.RemoveBehavior; /** * Get the currently selected title. * * #### Notes * This will be `null` if no tab is selected. */ get currentTitle(): Title<T> | null { return this._titles[this._currentIndex] || null; } /** * Set the currently selected title. * * #### Notes * If the title does not exist, the title will be set to `null`. */ set currentTitle(value: Title<T> | null) { this.currentIndex = value ? this._titles.indexOf(value) : -1; } /** * Get the index of the currently selected tab. * * #### Notes * This will be `-1` if no tab is selected. */ get currentIndex(): number { return this._currentIndex; } /** * Set the index of the currently selected tab. * * #### Notes * If the value is out of range, the index will be set to `-1`. */ set currentIndex(value: number) { // Adjust for an out of range index. if (value < 0 || value >= this._titles.length) { value = -1; } // Bail early if the index will not change. if (this._currentIndex === value) { return; } // Look up the previous index and title. let pi = this._currentIndex; let pt = this._titles[pi] || null; // Look up the current index and title. let ci = value; let ct = this._titles[ci] || null; // Update the current index and previous title. this._currentIndex = ci; this._previousTitle = pt; // Schedule an update of the tabs. this.update(); // Emit the current changed signal. this._currentChanged.emit({ previousIndex: pi, previousTitle: pt, currentIndex: ci, currentTitle: ct }); } /** * Get the name of the tab bar. */ get name(): string { return this._name; } /** * Set the name of the tab bar. */ set name(value: string) { this._name = value; if (value) { this.contentNode.setAttribute('aria-label', value); } else { this.contentNode.removeAttribute('aria-label'); } } /** * Get the orientation of the tab bar. * * #### Notes * This controls whether the tabs are arranged in a row or column. */ get orientation(): TabBar.Orientation { return this._orientation; } /** * Set the orientation of the tab bar. * * #### Notes * This controls whether the tabs are arranged in a row or column. */ set orientation(value: TabBar.Orientation) { // Do nothing if the orientation does not change. if (this._orientation === value) { return; } // Release the mouse before making any changes. this._releaseMouse(); // Toggle the orientation values. this._orientation = value; this.dataset['orientation'] = value; this.contentNode.setAttribute('aria-orientation', value); } /** * Whether the add button is enabled. */ get addButtonEnabled(): boolean { return this._addButtonEnabled; } /** * Set whether the add button is enabled. */ set addButtonEnabled(value: boolean) { // Do nothing if the value does not change. if (this._addButtonEnabled === value) { return; } this._addButtonEnabled = value; if (value) { this.addButtonNode.classList.remove('lm-mod-hidden'); } else { this.addButtonNode.classList.add('lm-mod-hidden'); } } /** * A read-only array of the titles in the tab bar. */ get titles(): ReadonlyArray<Title<T>> { return this._titles; } /** * The tab bar content node. * * #### Notes * This is the node which holds the tab nodes. * * Modifying this node directly can lead to undefined behavior. */ get contentNode(): HTMLUListElement { return this.node.getElementsByClassName( 'lm-TabBar-content' )[0] as HTMLUListElement; } /** * The tab bar add button node. * * #### Notes * This is the node which holds the add button. * * Modifying this node directly can lead to undefined behavior. */ get addButtonNode(): HTMLDivElement { return this.node.getElementsByClassName( 'lm-TabBar-addButton' )[0] as HTMLDivElement; } /** * Add a tab to the end of the tab bar. * * @param value - The title which holds the data for the tab, * or an options object to convert to a title. * * @returns The title object added to the tab bar. * * #### Notes * If the title is already added to the tab bar, it will be moved. */ addTab(value: Title<T> | Title.IOptions<T>): Title<T> { return this.insertTab(this._titles.length, value); } /** * Insert a tab into the tab bar at the specified index. * * @param index - The index at which to insert the tab. * * @param value - The title which holds the data for the tab, * or an options object to convert to a title. * * @returns The title object added to the tab bar. * * #### Notes * The index will be clamped to the bounds of the tabs. * * If the title is already added to the tab bar, it will be moved. */ insertTab(index: number, value: Title<T> | Title.IOptions<T>): Title<T> { // Release the mouse before making any changes. this._releaseMouse(); // Coerce the value to a title. let title = Private.asTitle(value); // Look up the index of the title. let i = this._titles.indexOf(title); // Clamp the insert index to the array bounds. let j = Math.max(0, Math.min(index, this._titles.length)); // If the title is not in the array, insert it. if (i === -1) { // Insert the title into the array. ArrayExt.insert(this._titles, j, title); // Connect to the title changed signal. title.changed.connect(this._onTitleChanged, this); // Schedule an update of the tabs. this.update(); // Adjust the current index for the insert. this._adjustCurrentForInsert(j, title); // Return the title added to the tab bar. return title; } // Otherwise, the title exists in the array and should be moved. // Adjust the index if the location is at the end of the array. if (j === this._titles.length) { j--; } // Bail if there is no effective move. if (i === j) { return title; } // Move the title to the new location. ArrayExt.move(this._titles, i, j); // Schedule an update of the tabs. this.update(); // Adjust the current index for the move. this._adjustCurrentForMove(i, j); // Return the title added to the tab bar. return title; } /** * Remove a tab from the tab bar. * * @param title - The title for the tab to remove. * * #### Notes * This is a no-op if the title is not in the tab bar. */ removeTab(title: Title<T>): void { this.removeTabAt(this._titles.indexOf(title)); } /** * Remove the tab at a given index from the tab bar. * * @param index - The index of the tab to remove. * * #### Notes * This is a no-op if the index is out of range. */ removeTabAt(index: number): void { // 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; } // Disconnect from the title changed signal. title.changed.disconnect(this._onTitleChanged, this); // Clear the previous title if it's being removed. if (title === this._previousTitle) { this._previousTitle = null; } // Schedule an update of the tabs. this.update(); // Adjust the current index for the remove. this._adjustCurrentForRemove(index, title); } /** * Remove all tabs from the tab bar. */ clearTabs(): void { // Bail if there is nothing to remove. if (this._titles.length === 0) { return; } // Release the mouse before making any changes. this._releaseMouse(); // Disconnect from the title changed signals. for (let title of this._titles) { title.changed.disconnect(this._onTitleChanged, this); } // Get the current index and title. let pi = this.currentIndex; let pt = this.currentTitle; // Reset the current index and previous title. this._currentIndex = -1; this._previousTitle = null; // Clear the title array. this._titles.length = 0; // Schedule an update of the tabs. this.update(); // If no tab was selected, there's nothing else to do. if (pi === -1) { return; } // Emit the current changed signal. this._currentChanged.emit({ previousIndex: pi, previousTitle: pt, currentIndex: -1, currentTitle: null }); } /** * Release the mouse and restore the non-dragged tab positions. * * #### Notes * This will cause the tab bar to stop handling mouse events and to * restore the tabs to their non-dragged positions. */ releaseMouse(): void { this._releaseMouse(); } /** * Handle the DOM events for the tab bar. * * @param event - The DOM event sent to the tab bar. * * #### Notes * This method implements the DOM `EventListener` interface and is * called in response to events on the tab bar's DOM node. * * This should not be called directly by user code. */ handleEvent(event: Event): void { switch (event.type) { case 'pointerdown': this._evtPointerDown(event as PointerEvent); break; case 'pointermove': this._evtPointerMove(event as PointerEvent); break; case 'pointerup': this._evtPointerUp(event as PointerEvent); break; case 'dblclick': this._evtDblClick(event as MouseEvent); break; case 'keydown': event.eventPhase === Event.CAPTURING_PHASE ? this._evtKeyDownCapturing(event as KeyboardEvent) : this._evtKeyDown(event as KeyboardEvent); break; case 'contextmenu': event.preventDefault(); event.stopPropagation(); break; } } /** * A message handler invoked on a `'before-attach'` message. */ protected onBeforeAttach(msg: Message): void { this.node.addEventListener('pointerdown', this); this.node.addEventListener('dblclick', this); this.node.addEventListener('keydown', this); } /** * A message handler invoked on an `'after-detach'` message. */ protected onAfterDetach(msg: Message): void { this.node.removeEventListener('pointerdown', this); this.node.removeEventListener('dblclick', this); this.node.removeEventListener('keydown', this); this._releaseMouse(); } /** * A message handler invoked on an `'update-request'` message. */ protected onUpdateRequest(msg: Message): void { let titles = this._titles; let renderer = this.renderer; let currentTitle = this.currentTitle; let content = new Array<VirtualElement>(titles.length); // Keep the tabindex="0" attribute to the tab which handled it before the update. // If the add button handles it, no need to do anything. If no element of the tab // bar handles it, set it on the current or the first tab to ensure one element // handles it after update. const tabHandlingTabindex = this._getCurrentTabindex() ?? (this._currentIndex > -1 ? this._currentIndex : 0); for (let i = 0, n = titles.length; i < n; ++i) { let title = titles[i]; let current = title === currentTitle; let zIndex = current ? n : n - i - 1; let tabIndex = tabHandlingTabindex === i ? 0 : -1; content[i] = renderer.renderTab({ title, current, zIndex, tabIndex }); } VirtualDOM.render(content, this.contentNode); } /** * Get the index of the tab which handles tabindex="0". * If the add button handles tabindex="0", -1 is returned. * If none of the previous handles tabindex="0", null is returned. */ private _getCurrentTabindex(): number | null { let index = null; const elemTabindex = this.contentNode.querySelector('li[tabindex="0"]'); if (elemTabindex) { index = [...this.contentNode.children].indexOf(elemTabindex); } else if ( this._addButtonEnabled && this.addButtonNode.getAttribute('tabindex') === '0' ) { index = -1; } return index; } /** * Handle the `'dblclick'` event for the tab bar. */ private _evtDblClick(event: MouseEvent): void { // Do nothing if titles are not editable if (!this.titlesEditable) { return; } let tabs = this.contentNode.children; // Find the index of the targeted tab. let index = ArrayExt.findFirstIndex(tabs, tab => { return ElementExt.hitTest(tab, event.clientX, event.clientY); }); // Do nothing if the press is not on a tab. if (index === -1) { return; } let title = this.titles[index]; let label = tabs[index].querySelector('.lm-TabBar-tabLabel') as HTMLElement; if (label && label.contains(event.target as HTMLElement)) { let value = title.label || ''; // Clear the label element let oldValue = label.innerHTML; label.innerHTML = ''; let input = document.createElement('input'); input.classList.add('lm-TabBar-tabInput'); input.value = value; label.appendChild(input); let onblur = () => { input.removeEventListener('blur', onblur); label.innerHTML = oldValue; this.node.addEventListener('keydown', this); }; input.addEventListener('dblclick', (event: Event) => event.stopPropagation() ); input.addEventListener('blur', onblur); input.addEventListener('keydown', (event: KeyboardEvent) => { if (event.key === 'Enter') { if (input.value !== '') { title.label = title.caption = input.value; } onblur(); } else if (event.key === 'Escape') { onblur(); } }); this.node.removeEventListener('keydown', this); input.select(); input.focus(); if (label.children.length > 0) { (label.children[0] as HTMLElement).focus(); } } } /** * Handle the `'keydown'` event for the tab bar at capturing phase. */ private _evtKeyDownCapturing(event: KeyboardEvent): void { if (event.eventPhase !== Event.CAPTURING_PHASE) { return; } // Stop all input events during drag. event.preventDefault(); event.stopPropagation(); // Release the mouse if `Escape` is pressed. if (event.key === 'Escape') { this._releaseMouse(); } } /** * Handle the `'keydown'` event for the tab bar at target phase. */ private _evtKeyDown(event: KeyboardEvent): void { // Allow for navigation using tab key if (event.key === 'Tab' || event.eventPhase === Event.CAPTURING_PHASE) { return; } // Check if Enter or Spacebar key has been pressed and open that tab if ( event.key === 'Enter' || event.key === 'Spacebar' || event.key === ' ' ) { // Get focus element that is in focus by the tab key const focusedElement = document.activeElement; // Test first if the focus is on the add button node if ( this.addButtonEnabled && this.addButtonNode.contains(focusedElement) ) { event.preventDefault(); event.stopPropagation(); this._addRequested.emit(); } else { const index = ArrayExt.findFirstIndex(this.contentNode.children, tab => tab.contains(focusedElement) ); if (index >= 0) { event.preventDefault(); event.stopPropagation(); this.currentIndex = index; } } // Handle the arrow keys to switch tabs. } else if (ARROW_KEYS.includes(event.key)) { // Create a list of all focusable elements in the tab bar. const focusable: Element[] = [...this.contentNode.children]; if (this.addButtonEnabled) { focusable.push(this.addButtonNode); } // If the tab bar contains only one element, nothing to do. if (focusable.length <= 1) { return; } event.preventDefault(); event.stopPropagation(); // Get the current focused element. let focusedIndex = focusable.indexOf(document.activeElement as Element); if (focusedIndex === -1) { focusedIndex = this._currentIndex; } // Find the next element to focus on. let nextFocused: Element | null | undefined; if ( (event.key === 'ArrowRight' && this._orientation === 'horizontal') || (event.key === 'ArrowDown' && this._orientation === 'vertical') ) { nextFocused = focusable[focusedIndex + 1] ?? focusable[0]; } else if ( (event.key === 'ArrowLeft' && this._orientation === 'horizontal') || (event.key === 'ArrowUp' && this._orientation === 'vertical') ) { nextFocused = focusable[focusedIndex - 1] ?? focusable[focusable.length - 1]; } else if (event.key === 'Home') { nextFocused = focusable[0]; } else if (event.key === 'End') { nextFocused = focusable[focusable.length - 1]; } // Change the focused element and the tabindex value. if (nextFocused) { focusable[focusedIndex]?.setAttribute('tabindex', '-1'); nextFocused?.setAttribute('tabindex', '0'); (nextFocused as HTMLElement).focus(); } } } /** * Handle the `'pointerdown'` event for the tab bar. */ private _evtPointerDown(event: PointerEvent | MouseEvent): void { // Do nothing if it's not a left or middle mouse press. if (event.button !== 0 && event.button !== 1) { return; } // Do nothing if a drag is in progress. if (this._dragData) { return; } // Do nothing if a title editable input was clicked. if ( (event.target as HTMLElement).classList.contains('lm-TabBar-tabInput') ) { return; } // Check if the add button was clicked. let addButtonClicked = this.addButtonEnabled && this.addButtonNode.contains(event.target as HTMLElement); // Lookup the tab nodes. let tabs = this.contentNode.children; // Find the index of the pressed tab. let index = ArrayExt.findFirstIndex(tabs, tab => { return ElementExt.hitTest(tab, event.clientX, event.clientY); }); // Do nothing if the press is not on a tab or the add button. if (index === -1 && !addButtonClicked) { return; } // Pressing on a tab stops the event propagation. event.preventDefault(); event.stopPropagation(); // Initialize the non-measured parts of the drag data. this._dragData = { tab: tabs[index] as HTMLElement, index: index, pressX: event.clientX, pressY: event.clientY, tabPos: -1, tabSize: -1, tabPressPos: -1, targetIndex: -1, tabLayout: null, contentRect: null, override: null, dragActive: false, dragAborted: false, detachRequested: false }; // Add the document pointer up listener. this.document.addEventListener('pointerup', this, true); // Do nothing else if the middle button or add button is clicked. if (event.button === 1 || addButtonClicked) { return; } // Do nothing else if the close icon is clicked. let icon = tabs[index].querySelector(this.renderer.closeIconSelector); if (icon && icon.contains(event.target as HTMLElement)) { return; } // Add the extra listeners if the tabs are movable. if (this.tabsMovable) { this.document.addEventListener('pointermove', this, true); this.document.addEventListener('keydown', this, true); this.document.addEventListener('contextmenu', this, true); } // Update the current index as appropriate. if (this.allowDeselect && this.currentIndex === index) { this.currentIndex = -1; } else { this.currentIndex = index; } // Do nothing else if there is no current tab. if (this.currentIndex === -1) { return; } // Emit the tab activate request signal. this._tabActivateRequested.emit({ index: this.currentIndex, title: this.currentTitle! }); } /** * Handle the `'pointermove'` event for the tab bar. */ private _evtPointerMove(event: PointerEvent | MouseEvent): void { // Do nothing if no drag is in progress. let data = this._dragData; if (!data) { return; } // Suppress the event during a drag. event.preventDefault(); event.stopPropagation(); // Lookup the tab nodes. let tabs = this.contentNode.children; // Bail early if the drag threshold has not been met. if (!data.dragActive && !Private.dragExceeded(data, event)) { return; } // Activate the drag if necessary. if (!data.dragActive) { // Fill in the rest of the drag data measurements. let tabRect = data.tab.getBoundingClientRect(); if (this._orientation === 'horizontal') { data.tabPos = data.tab.offsetLeft; data.tabSize = tabRect.width; data.tabPressPos = data.pressX - tabRect.left; } else { data.tabPos = data.tab.offsetTop; data.tabSize = tabRect.height; data.tabPressPos = data.pressY - tabRect.top; } data.tabPressOffset = { x: data.pressX - tabRect.left, y: data.pressY - tabRect.top }; data.tabLayout = Private.snapTabLayout(tabs, this._orientation); data.contentRect = this.contentNode.getBoundingClientRect(); data.override = Drag.overrideCursor('default'); // Add the dragging style classes. data.tab.classList.add('lm-mod-dragging'); this.addClass('lm-mod-dragging'); // Mark the drag as active. data.dragActive = true; } // Emit the detach requested signal if the threshold is exceeded. if (!data.detachRequested && Private.detachExceeded(data, event)) { // Only emit the signal once per drag cycle. data.detachRequested = true; // Setup the arguments for the signal. let index = data.index; let clientX = event.clientX; let clientY = event.clientY; let tab = tabs[index] as HTMLElement; let title = this._titles[index]; // Emit the tab detach requested signal. this._tabDetachRequested.emit({ index, title, tab, clientX, clientY, offset: data.tabPressOffset }); // Bail if the signal handler aborted the drag. if (data.dragAborted) { return; } } // Update the positions of the tabs. Private.layoutTabs(tabs, data, event, this._orientation); } /** * Handle the `'pointerup'` event for the document. */ private _evtPointerUp(event: PointerEvent | MouseEvent): void { // Do nothing if it's not a left or middle mouse release. if (event.button !== 0 && event.button !== 1) { return; } // Do nothing if no drag is in progress. const data = this._dragData; if (!data) { return; } // Stop the event propagation. event.preventDefault(); event.stopPropagation(); // Remove the extra mouse event listeners. this.document.removeEventListener('pointermove', this, true); this.document.removeEventListener('pointerup', this, true); this.document.removeEventListener('keydown', this, true); this.document.removeEventListener('contextmenu', this, true); // Handle a release when the drag is not active. if (!data.dragActive) { // Clear the drag data. this._dragData = null; // Handle clicking the add button. let addButtonClicked = this.addButtonEnabled && this.addButtonNode.contains(event.target as HTMLElement); if (addButtonClicked) { this._addRequested.emit(undefined); return; } // Lookup the tab nodes. let tabs = this.contentNode.children; // Find the index of the released tab. let index = ArrayExt.findFirstIndex(tabs, tab => { return ElementExt.hitTest(tab, event.clientX, event.clientY); }); // Do nothing if the release is not on the original pressed tab. if (index !== data.index) { return; } // Ignore the release if the title is not closable. let title = this._titles[index]; if (!title.closable) { return; } // Emit the close requested signal if the middle button is released. if (event.button === 1) { this._tabCloseRequested.emit({ index, title }); return; } // Emit the close requested signal if the close icon was released. let icon = tabs[index].querySelector(this.renderer.closeIconSelector); if (icon && icon.contains(event.target as HTMLElement)) { this._tabCloseRequested.emit({ index, title }); return; } // Otherwise, there is nothing left to do. return; } // Do nothing if the left button is not released. if (event.button !== 0) { return; } // Position the tab at its final resting position. Private.finalizeTabPosition(data, this._orientation); // Remove the dragging class from the tab so it can be transitioned. data.tab.classList.remove('lm-mod-dragging'); // Parse the transition duration for releasing the tab. let duration = Private.parseTransitionDuration(data.tab); // Complete the release on a timer to allow the tab to transition. setTimeout(() => { // Do nothing if the drag has been aborted. if (data.dragAborted) { return; } // Clear the drag data reference. this._dragData = null; // Reset the positions of the tabs. Private.resetTabPositions(this.contentNode.children, this._orientation); // Clear the cursor grab. data.override!.dispose(); // Remove the remaining dragging style. this.removeClass('lm-mod-dragging'); // If the tab was not moved, there is nothing else to do. let i = data.index; let j = data.targetIndex; if (j === -1 || i === j) { return; } // Move the title to the new locations. ArrayExt.move(this._titles, i, j); // Adjust the current index for the move. this._adjustCurrentForMove(i, j); // Emit the tab moved signal. this._tabMoved.emit({ fromIndex: i, toIndex: j, title: this._titles[j] }); // Update the tabs immediately to prevent flicker. MessageLoop.sendMessage(this, Widget.Msg.UpdateRequest); }, duration); } /** * Release the mouse and restore the non-dragged tab positions. */ private _releaseMouse(): void { // Do nothing if no drag is in progress. let data = this._dragData; if (!data) { return; } // Clear the drag data reference. this._dragData = null; // Remove the extra document event listeners. this.document.removeEventListener('pointermove', this, true); this.document.removeEventListener('pointerup', this, true); this.document.removeEventListener('keydown', this, true); this.document.removeEventListener('contextmenu', this, true); // Indicate the drag has been aborted. This allows the mouse // event handlers to return early when the drag is canceled. data.dragAborted = true; // If the drag is not active, there's nothing more to do. if (!data.dragActive) { return; } // Reset the tabs to their non-dragged positions. Private.resetTabPositions(this.contentNode.children, this._orientation); // Clear the cursor override. data.override!.dispose(); // Clear the dragging style classes. data.tab.classList.remove('lm-mod-dragging'); this.removeClass('lm-mod-dragging'); } /** * Adjust the current index for a tab insert operation. * * This method accounts for the tab bar's insertion behavior when * adjusting the current index and emitting the changed signal. */ private _adjustCurrentForInsert(i: number, title: Title<T>): void { // Lookup commonly used variables. let ct = this.currentTitle; let ci = this._currentIndex; let bh = this.insertBehavior; // TODO: do we need to do an update to update the aria-selected attribute? // Handle the behavior where the new tab is always selected, // or the behavior where the new tab is selected if needed. if (bh === 'select-tab' || (bh === 'select-tab-if-needed' && ci === -1)) { this._currentIndex = i; this._previousTitle = ct; this._currentChanged.emit({ previousIndex: ci, previousTitle: ct, currentIndex: i, currentTitle: title }); return; } // Otherwise, silently adjust the current index if needed. if (ci >= i) { this._currentIndex++; } } /** * Adjust the current index for a tab move operation. * * This method will not cause the actual current tab to change. * It silently adjusts the index to account for the given move. */ private _adjustCurrentForMove(i: number, j: number): void { if (this._currentIndex === i) { this._currentIndex = j; } else if (this._currentIndex < i && this._currentIndex >= j) { this._currentIndex++; } else if (this._currentIndex > i && this._currentIndex <= j) { this._currentIndex--; } } /** * Adjust the current index for a tab remove operation. * * This method accounts for the tab bar's remove behavior when * adjusting the current index and emitting the changed signal. */ private _adjustCurrentForRemove(i: number, title: Title<T>): void { // Lookup commonly used variables. let ci = this._currentIndex; let bh = this.removeBehavior; // Silently adjust the index if the current tab is not removed. if (ci !== i) { if (ci > i) { this._currentIndex--; } return; } // TODO: do we need to do an update to adjust the aria-selected value? // No tab gets selected if the tab bar is empty. if (this._titles.length === 0) { this._currentIndex = -1; this._currentChanged.emit({ previousIndex: i, previousTitle: title, currentIndex: -1, currentTitle: null }); return; } // Handle behavior where the next sibling tab is selected. if (bh === 'select-tab-after') { this._currentIndex = Math.min(i, this._titles.length - 1); this._currentChanged.emit({ previousIndex: i, previousTitle: title, currentIndex: this._currentIndex, currentTitle: this.currentTitle }); return; } // Handle behavior where the previous sibling tab is selected. if (bh === 'select-tab-before') { this._currentIndex = Math.max(0, i - 1); this._currentChanged.emit({ previousIndex: i, previousTitle: title, currentIndex: this._currentIndex, currentTitle: this.currentTitle }); return; } // Handle behavior where the previous history tab is selected. if (bh === 'select-previous-tab') { if (this._previousTitle) { this._currentIndex = this._titles.indexOf(this._previousTitle); this._previousTitle = null; } else { this._currentIndex = Math.min(i, this._titles.length - 1); } this._currentChanged.emit({ previousIndex: i, previousTitle: title, currentIndex: this._currentIndex, currentTitle: this.currentTitle }); return; } // Otherwise, no tab gets selected. this._currentIndex = -1; this._currentChanged.emit({ previousIndex: i, previousTitle: title, currentIndex: -1, currentTitle: null }); } /** * Handle the `changed` signal of a title object. */ private _onTitleChanged(sender: Title<T>): void { this.update(); } private _name: string; private _currentIndex = -1; private _titles: Title<T>[] = []; private _orientation: TabBar.Orientation; private _document: Document | ShadowRoot; private _titlesEditable: boolean = false; private _previousTitle: Title<T> | null = null; private _dragData: Private.IDragData | null = null; private _addButtonEnabled: boolean = false; private _tabMoved = new Signal<this, TabBar.ITabMovedArgs<T>>(this); private _currentChanged = new Signal<this, TabBar.ICurrentChangedArgs<T>>( this ); private _addRequested = new Signal<this, void>(this); private _tabCloseRequested = new Signal< this, TabBar.ITabCloseRequestedArgs<T> >(this); private _tabDetachRequested = new Signal< this, TabBar.ITabDetachRequestedArgs<T> >(this); private _tabActivateRequested = new Signal< this, TabBar.ITabActivateRequestedArgs<T> >(this); } /** * The namespace for the `TabBar` class statics. */ export namespace TabBar { /** * A type alias for a tab bar orientation. */ export type Orientation = | /** * The tabs are arranged in a single row, left-to-right. * * The tab text orientation is horizontal. */ 'horizontal' /** * The tabs are arranged in a single column, top-to-bottom. * * The tab text orientation is horizontal. */ | 'vertical'; /** * A type alias for the selection behavior on tab insert. */ export type InsertBehavior = | /** * The selected tab will not be changed. */ 'none' /** * The inserted tab will be selected. */ | 'select-tab' /** * The inserted tab will be selected if the current tab is null. */ | 'select-tab-if-needed'; /** * A type alias for the selection behavior on tab remove. */ export type RemoveBehavior = | /** * No tab will be selected. */ 'none' /** * The tab after the removed tab will be selected if possible. */ | 'select-tab-after' /** * The tab before the removed tab will be selected if possible. */ | 'select-tab-before' /** * The previously selected tab will be selected if possible. */ | 'select-previous-tab'; /** * An options object for creating a tab bar. */ export interface IOptions<T> { /** * The document to use with the tab bar. * * The default is the global `document` instance. */ document?: Document | ShadowRoot; /** * Name of the tab bar. * * This is used for accessibility reasons. The default is the empty string. */ name?: string; /** * The layout orientation of the tab bar. * * The default is `horizontal`. */ orientation?: TabBar.Orientation; /** * Whether the tabs are movable by the user. * * The default is `false`. */ tabsMovable?: boolean; /** * Whether a tab can be deselected by the user. * * The default is `false`. */ allowDeselect?: boolean; /** * Whether the titles can be directly edited by the user. * * The default is `false`. */ titlesEditable?: boolean; /** * Whether the add button is enabled. * * The default is `false`. */ addButtonEnabled?: boolean; /** * The selection behavior when inserting a tab. * * The default is `'select-tab-if-needed'`. */ insertBehavior?: TabBar.InsertBehavior; /** * The selection behavior when removing a tab. * * The default is `'select-tab-after'`. */ removeBehavior?: TabBar.RemoveBehavior; /** * A renderer to use with the tab bar. * * The default is a shared renderer instance. */ renderer?: IRenderer<T>; } /** * The arguments object for the `currentChanged` signal. */ export interface ICurrentChangedArgs<T> { /** * The previously selected index. */ readonly previousIndex: number; /** * The previously selected title. */ readonly previousTitle: Title<T> | null; /** * The currently selected index. */ readonly currentIndex: number; /** * The currently selected title. */ readonly currentTitle: Title<T> | null; } /** * The arguments object for the `tabMoved` signal. */ export interface ITabMovedArgs<T> { /** * The previous index of the tab. */ readonly fromIndex: number; /** * The current index of the tab. */ readonly toIndex: number; /** * The title for the tab. */ readonly title: Title<T>; } /** * The arguments object for the `tabActivateRequested` signal. */ export interface ITabActivateRequestedArgs<T> { /** * The index of the tab to activate. */ readonly index: number; /** * The title for the tab. */ readonly title: Title<T>; } /** * The arguments object for the `tabCloseRequested` signal. */ export interface ITabCloseRequestedArgs<T> { /** * The index of the tab to close. */ readonly index: number; /** * The title for the tab. */ readonly title: Title<T>; } /** * The arguments object for the `tabDetachRequested` signal. */ export interface ITabDetachRequestedArgs<T> { /** * The index of the tab to detach. */ readonly index: number; /** * The title for the tab. */ readonly title: Title<T>; /** * The node representing the tab. */ readonly tab: HTMLElement; /** * The current client X position of the mouse. */ readonly clientX: number; /** * The current client Y position of the mouse. */ readonly clientY: number; /** * The mouse position in the tab coordinate. */ readonly offset?: { x: number; y: number }; } /** * An object which holds the data to render a tab. */ export interface IRenderData<T> { /** * The title associated with the tab. */ readonly title: Title<T>; /** * Whether the tab is the current tab. */ readonly current: boolean; /** * The z-index for the tab. */ readonly zIndex: number; /** * The tabindex value for the tab. */ readonly tabIndex?: number; } /** * A renderer for use with a tab bar. */ export interface IRenderer<T> { /** * A selector which matches the close icon node in a tab. */ readonly closeIconSelector: string; /** * Render the virtual element for a tab. * * @param data - The data to use for rendering the tab. * * @returns A virtual element representing the tab. */ renderTab(data: IRenderData<T>): VirtualElement; } /** * The default implementation of `IRenderer`. * * #### Notes * Subclasses are free to reimplement rendering methods as needed. */ export class Renderer implements IRenderer<any> { constructor() { this._uuid = ++Renderer._nInstance; } /** * A selector which matches the close icon node in a tab. */ readonly closeIconSelector = '.lm-TabBar-tabCloseIcon'; /** * Render the virtual element for a tab. * * @param data - The data to use for rendering the tab. * * @returns A virtual element representing the tab. */ renderTab(data: IRenderData<any>): VirtualElement { let title = data.title.caption; let key = this.createTabKey(data); let id = key; let style = this.createTabStyle(data); let className = this.createTabClass(data); let dataset = this.createTabDataset(data); let aria = this.createTabARIA(data); if (data.title.closable) { return h.li( { id, key, className, title, style, dataset, ...aria }, this.renderIcon(data), this.renderLabel(data), this.renderCloseIcon(data) ); } else { return h.li( { id, key, className, title, style, dataset, ...aria }, this.renderIcon(data), this.renderLabel(data) ); } } /** * Render the icon element for a tab. * * @param data - The data to use for rendering the tab. * * @returns A virtual element representing the tab icon. */ renderIcon(data: IRenderData<any>): VirtualElement { const { title } = data; let className = this.createIconClass(data); // If title.icon is undefined, it will be ignored. return h.div({ className }, title.icon!, title.iconLabel); } /** * Render the label element for a tab. * * @param data - The data to use for rendering the tab. * * @returns A virtual element representing the tab label. */ renderLabel(data: IRenderData<any>): VirtualElement { return h.div({ className: 'lm-TabBar-tabLabel' }, data.title.label); } /** * Render the close icon element for a tab. * * @param data - The data to use for rendering the tab. * * @returns A virtual element representing the tab close icon. */ renderCloseIcon(data: IRenderData<any>): VirtualElement { return h.div({ className: 'lm-TabBar-tabCloseIcon' }); } /** * Create a unique render key for the tab. * * @param data - The data to use for the tab. * * @returns The unique render key for the tab. * * #### Notes * This method caches the key against the tab title the first time * the key is generated. This enables efficient rendering of moved * tabs and avoids subtle hover style artifacts. */ createTabKey(data: IRenderData<any>): string { let key = this._tabKeys.get(data.title); if (key === undefined) { key = `tab-key-${this._uuid}-${this._tabID++}`; this._tabKeys.set(data.title, key); } return key; } /** * Create the inline style object for a tab. * * @param data - The data to use for the tab. * * @returns The inline style data for the tab. */ createTabStyle(data: IRenderData<any>): ElementInlineStyle { return { zIndex: `${data.zIndex}` }; } /** * Create the class name for the tab. * * @param data - The data to use for the tab. * * @returns The full class name for the tab. */ createTabClass(data: IRenderData<any>): string { let name = 'lm-TabBar-tab'; if (data.title.className) { name += ` ${data.title.className}`; } if (data.title.closable) { name += ' lm-mod-closable'; } if (data.current) { name += ' lm-mod-current'; } return name; } /** * Create the dataset for a tab. * * @param data - The data to use for the tab. * * @returns The dataset for the tab. */ createTabDataset(data: IRenderData<any>): ElementDataset { return data.title.dataset; } /** * Create the ARIA attributes for a tab. * * @param data - The data to use for the tab. * * @returns The ARIA attributes for the tab. */ createTabARIA(data: IRenderData<any>): ElementARIAAttrs | ElementBaseAttrs { return { role: 'tab', 'aria-selected': data.current.toString(), tabindex: `${data.tabIndex ?? '-1'}` }; } /** * Create the class name for the tab icon. * * @param data - The data to use for the tab. * * @returns The full class name for the tab icon. */ createIconClass(data: IRenderData<any>): string { let name = 'lm-TabBar-tabIcon'; let extra = data.title.iconClass; return extra ? `${name} ${extra}` : name; } private static _nInstance = 0; private readonly _uuid: number; private _tabID = 0; private _tabKeys = new WeakMap<Title<any>, s