diff --git a/packages/widgets/src/dockpanel.ts b/packages/widgets/src/dockpanel.ts index 6226ed44a..ffd519f98 100644 --- a/packages/widgets/src/dockpanel.ts +++ b/packages/widgets/src/dockpanel.ts @@ -57,6 +57,9 @@ export class DockPanel extends Widget { if (options.addButtonEnabled !== undefined) { this._addButtonEnabled = options.addButtonEnabled; } + if (options.tabScrollingEnabled !== undefined) { + this._tabScrollingEnabled = options.tabScrollingEnabled; + } // Toggle the CSS mode attribute. this.dataset['mode'] = this._mode; @@ -258,6 +261,23 @@ export class DockPanel extends Widget { }); } + /** + * Whether scrolling of tabs in tab bars is enabled. + */ + get tabScrollingEnabled(): boolean { + return this._tabScrollingEnabled; + } + + /** + * Set whether the add buttons for each tab bar are enabled. + */ + set tabScrollingEnabled(value: boolean) { + this._tabScrollingEnabled = value; + each(this.tabBars(), tabbar => { + tabbar.scrollingEnabled = value; + }); + } + /** * Whether the dock panel is empty. */ @@ -938,6 +958,7 @@ export class DockPanel extends Widget { tabBar.tabsMovable = this._tabsMovable; tabBar.allowDeselect = false; tabBar.addButtonEnabled = this._addButtonEnabled; + tabBar.scrollingEnabled = this._tabScrollingEnabled; tabBar.removeBehavior = 'select-previous-tab'; tabBar.insertBehavior = 'select-tab-if-needed'; @@ -1083,6 +1104,7 @@ export class DockPanel extends Widget { private _tabsMovable: boolean = true; private _tabsConstrained: boolean = false; private _addButtonEnabled: boolean = false; + private _tabScrollingEnabled: boolean = false; private _pressData: Private.IPressData | null = null; private _layoutModified = new Signal(this); @@ -1165,6 +1187,13 @@ export namespace DockPanel { * The default is `'false'`. */ addButtonEnabled?: boolean; + + /** + * Enable scrolling in each of the dock panel's tab bars. + * + * The default is `'false'`. + */ + tabScrollingEnabled?: boolean; } /** diff --git a/packages/widgets/src/tabbar.ts b/packages/widgets/src/tabbar.ts index 56744b479..c307bbb2c 100644 --- a/packages/widgets/src/tabbar.ts +++ b/packages/widgets/src/tabbar.ts @@ -353,6 +353,28 @@ export class TabBar extends Widget { } } + /** + * Whether scrolling is enabled. + */ + get scrollingEnabled(): boolean { + return this._scrollingEnabled; + } + + set scrollingEnabled(value: boolean) { + // Do nothing if the value does not change. + if (this._scrollingEnabled === value) { + return; + } + + this._scrollingEnabled = value; + if (value) { + this.node.classList.add('lm-mod-scrollable'); + } else { + this.node.classList.add('lm-mod-scrollable'); + } + this.maybeSwitchScrollButtons(); + } + /** * A read-only array of the titles in the tab bar. */ @@ -374,6 +396,20 @@ export class TabBar extends Widget { )[0] as HTMLUListElement; } + /** + * The tab bar content wrapper node. + * + * #### Notes + * This is the node which the content node and enables scrolling. + * + * Modifying this node directly can lead to undefined behavior. + */ + get contentWrapperNode(): HTMLUListElement { + return this.node.getElementsByClassName( + 'lm-TabBar-wrapper' + )[0] as HTMLUListElement; + } + /** * The tab bar add button node. * @@ -388,6 +424,18 @@ export class TabBar extends Widget { )[0] as HTMLDivElement; } + get scrollBeforeButtonNode(): HTMLDivElement { + return this.node.getElementsByClassName( + 'lm-TabBar-scrollBeforeButton' + )[0] as HTMLDivElement; + } + + get scrollAfterButtonNode(): HTMLDivElement { + return this.node.getElementsByClassName( + 'lm-TabBar-scrollAfterButton' + )[0] as HTMLDivElement; + } + /** * Add a tab to the end of the tab bar. * @@ -618,6 +666,9 @@ export class TabBar extends Widget { event.preventDefault(); event.stopPropagation(); break; + case 'scroll': + this._evtScroll(event); + break; } } @@ -628,6 +679,7 @@ export class TabBar extends Widget { this.node.addEventListener('mousedown', this); // this.node.addEventListener('pointerdown', this); this.node.addEventListener('dblclick', this); + this.contentNode.addEventListener('scroll', this); } /** @@ -637,6 +689,7 @@ export class TabBar extends Widget { this.node.removeEventListener('mousedown', this); // this.node.removeEventListener('pointerdown', this); this.node.removeEventListener('dblclick', this); + this.contentNode.removeEventListener('scroll', this); this._releaseMouse(); } @@ -655,6 +708,80 @@ export class TabBar extends Widget { content[i] = renderer.renderTab({ title, current, zIndex }); } VirtualDOM.render(content, this.contentNode); + this.maybeSwitchScrollButtons(); + } + + protected onResize(msg: Widget.ResizeMessage): void { + super.onResize(msg); + this.maybeSwitchScrollButtons(); + } + + protected maybeSwitchScrollButtons() { + const scrollBefore = this.scrollBeforeButtonNode; + const scrollAfter = this.scrollAfterButtonNode; + const state = this._scrollState; + + if (this.scrollingEnabled && state.totalSize > state.displayedSize) { + // show both buttons + scrollBefore.classList.remove('lm-mod-hidden'); + scrollAfter.classList.remove('lm-mod-hidden'); + } else { + // hide both buttons + scrollBefore.classList.add('lm-mod-hidden'); + scrollAfter.classList.add('lm-mod-hidden'); + } + this.updateScrollingHints(state); + } + + /** + * Adjust data reflecting the ability to scroll in each direction. + */ + protected updateScrollingHints(scrollState: Private.IScrollState) { + const wrapper = this.contentWrapperNode; + + if (!this.scrollingEnabled) { + delete wrapper.dataset['canScroll']; + return; + } + + const canScrollBefore = scrollState.position != 0; + const canScrollAfter = + scrollState.position != scrollState.totalSize - scrollState.displayedSize; + + if (canScrollBefore && canScrollAfter) { + wrapper.dataset['canScroll'] = 'both'; + } else if (canScrollBefore) { + wrapper.dataset['canScroll'] = 'before'; + } else if (canScrollAfter) { + wrapper.dataset['canScroll'] = 'after'; + } else { + delete wrapper.dataset['canScroll']; + } + } + + private get _scrollState(): Private.IScrollState { + const content = this.contentNode; + const contentRect = content.getBoundingClientRect(); + const isHorizontal = this.orientation === 'horizontal'; + const contentSize = isHorizontal ? contentRect.width : contentRect.height; + const scrollTotal = isHorizontal + ? content.scrollWidth + : content.scrollHeight; + const scroll = Math.round( + isHorizontal ? content.scrollLeft : content.scrollTop + ); + return { + displayedSize: Math.round(contentSize), + totalSize: scrollTotal, + position: scroll + }; + } + + /** + * Handle the `'dblclick'` event for the tab bar. + */ + private _evtScroll(event: Event): void { + this.updateScrollingHints(this._scrollState); } /** @@ -734,6 +861,52 @@ export class TabBar extends Widget { } } + protected beginScrolling(direction: '-' | '+') { + const initialRate = 5; + const rateIncrease = 1; + const maxRate = 20; + const intervalHandle = setInterval(() => { + if (!this._scrollData) { + this.stopScrolling(); + return; + } + const rate = this._scrollData.rate; + const direction = this._scrollData.scrollDirection; + const change = (direction == '+' ? 1 : -1) * rate; + if (this.orientation == 'horizontal') { + this.contentNode.scrollLeft += change; + } else { + this.contentNode.scrollTop += change; + } + this._scrollData.rate = Math.min( + this._scrollData.rate + rateIncrease, + maxRate + ); + const state = this._scrollState; + if ( + (direction == '-' && state.position == 0) || + (direction == '+' && + state.totalSize == state.position + state.displayedSize) + ) { + this.stopScrolling(); + } + }, 50); + this._scrollData = { + timerHandle: intervalHandle, + scrollDirection: direction, + rate: initialRate + }; + } + + protected stopScrolling() { + if (this._scrollData) { + clearInterval(this._scrollData.timerHandle); + } + this._scrollData = null; + const state = this._scrollState; + this.updateScrollingHints(state); + } + /** * Handle the `'mousedown'` event for the tab bar. */ @@ -753,6 +926,19 @@ export class TabBar extends Widget { this.addButtonEnabled && this.addButtonNode.contains(event.target as HTMLElement); + let scrollBeforeButtonClicked = + this.scrollingEnabled && + this.scrollBeforeButtonNode.contains(event.target as HTMLElement); + + let scrollAfterButtonClicked = + this.scrollingEnabled && + this.scrollAfterButtonNode.contains(event.target as HTMLElement); + + const anyButtonClicked = + addButtonClicked || scrollAfterButtonClicked || scrollBeforeButtonClicked; + + const contentNode = this.contentNode; + // Lookup the tab nodes. let tabs = this.contentNode.children; @@ -761,8 +947,8 @@ export class TabBar extends Widget { 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) { + // Do nothing if the press is not on a tab or any of the buttons. + if (index === -1 && !anyButtonClicked) { return; } @@ -776,6 +962,10 @@ export class TabBar extends Widget { index: index, pressX: event.clientX, pressY: event.clientY, + initialScrollPosition: + this.orientation == 'horizontal' + ? contentNode.scrollLeft + : contentNode.scrollTop, tabPos: -1, tabSize: -1, tabPressPos: -1, @@ -796,6 +986,10 @@ export class TabBar extends Widget { if (event.button === 1 || addButtonClicked) { return; } + if (scrollBeforeButtonClicked || scrollAfterButtonClicked) { + this.beginScrolling(scrollBeforeButtonClicked ? '-' : '+'); + return; + } // Do nothing else if the close icon is clicked. let icon = tabs[index].querySelector(this.renderer.closeIconSelector); @@ -902,8 +1096,24 @@ export class TabBar extends Widget { } } + let overBeforeScrollButton = + this.scrollingEnabled && + this.scrollBeforeButtonNode.contains(event.target as HTMLElement); + + let overAfterScrollButton = + this.scrollingEnabled && + this.scrollAfterButtonNode.contains(event.target as HTMLElement); + + if (overBeforeScrollButton || overAfterScrollButton) { + // Start scrolling if the mouse is over scroll buttons + this.beginScrolling(overBeforeScrollButton ? '-' : '+'); + } else { + // Stop scrolling if mouse is not over scroll buttons + this.stopScrolling(); + } + // Update the positions of the tabs. - Private.layoutTabs(tabs, data, event, this._orientation); + Private.layoutTabs(tabs, data, event, this._orientation, this._scrollState); } /** @@ -938,6 +1148,10 @@ export class TabBar extends Widget { // Clear the drag data. this._dragData = null; + if (this._scrollData) { + this.stopScrolling(); + return; + } // Handle clicking the add button. let addButtonClicked = this.addButtonEnabled && @@ -989,7 +1203,7 @@ export class TabBar extends Widget { } // Position the tab at its final resting position. - Private.finalizeTabPosition(data, this._orientation); + Private.finalizeTabPosition(data, this._orientation, this._scrollState); // Remove the dragging class from the tab so it can be transitioned. data.tab.classList.remove('lm-mod-dragging'); @@ -1241,7 +1455,9 @@ export class TabBar extends Widget { private _titlesEditable: boolean = false; private _previousTitle: Title | null = null; private _dragData: Private.IDragData | null = null; + private _scrollData: Private.IScrollData | null = null; private _addButtonEnabled: boolean = false; + private _scrollingEnabled: boolean = false; private _tabMoved = new Signal>(this); private _currentChanged = new Signal>( this @@ -1776,6 +1992,33 @@ namespace Private { */ export const DETACH_THRESHOLD = 20; + /** + * A struct which holds the scroll data for a tab bar. + */ + export interface IScrollData { + timerHandle: number; + scrollDirection: '+' | '-'; + rate: number; + } + + /** + * A struct which holds the scroll state for a tab bar. + */ + export interface IScrollState { + /** + * The size of the container where scrolling occurs (the visible part of the total). + */ + displayedSize: number; + /** + * The total size of the content to be scrolled. + */ + totalSize: number; + /** + * The current position (offset) of the scroll state. + */ + position: number; + } + /** * A struct which holds the drag data for a tab bar. */ @@ -1795,6 +2038,11 @@ namespace Private { */ pressX: number; + /** + * The initial scroll position + */ + initialScrollPosition: number; + /** * The mouse press client Y position. */ @@ -1890,16 +2138,35 @@ namespace Private { */ export function createNode(): HTMLDivElement { let node = document.createElement('div'); + let scrollBefore = document.createElement('div'); + scrollBefore.className = + 'lm-TabBar-button lm-TabBar-scrollButton lm-TabBar-scrollBeforeButton lm-mod-hidden'; + scrollBefore.setAttribute('role', 'button'); + scrollBefore.innerText = '<'; + let scrollAfter = document.createElement('div'); + scrollAfter.className = + 'lm-TabBar-button lm-TabBar-scrollButton lm-TabBar-scrollAfterButton lm-mod-hidden'; + scrollAfter.setAttribute('role', 'button'); + scrollAfter.innerText = '>'; + node.appendChild(scrollBefore); + let content = document.createElement('ul'); content.setAttribute('role', 'tablist'); content.className = 'lm-TabBar-content'; /* */ content.classList.add('p-TabBar-content'); /* */ - node.appendChild(content); + + let wrapper = document.createElement('div'); + wrapper.className = 'lm-TabBar-wrapper'; + wrapper.appendChild(content); + node.appendChild(wrapper); + + node.appendChild(scrollAfter); let add = document.createElement('div'); - add.className = 'lm-TabBar-addButton lm-mod-hidden'; + add.setAttribute('role', 'button'); + add.className = 'lm-TabBar-button lm-TabBar-addButton lm-mod-hidden'; node.appendChild(add); return node; } @@ -1976,23 +2243,24 @@ namespace Private { tabs: HTMLCollection, data: IDragData, event: MouseEvent, - orientation: TabBar.Orientation + orientation: TabBar.Orientation, + scrollState: IScrollState ): void { // Compute the orientation-sensitive values. let pressPos: number; let localPos: number; let clientPos: number; - let clientSize: number; + const clientSize = scrollState.totalSize; + const scrollShift = scrollState.position - data.initialScrollPosition; + if (orientation === 'horizontal') { - pressPos = data.pressX; - localPos = event.clientX - data.contentRect!.left; + pressPos = data.pressX - scrollShift; + localPos = event.clientX - data.contentRect!.left + scrollState.position; clientPos = event.clientX; - clientSize = data.contentRect!.width; } else { - pressPos = data.pressY; - localPos = event.clientY - data.contentRect!.top; + pressPos = data.pressY - scrollShift; + localPos = event.clientY - data.contentRect!.top + scrollState.position; clientPos = event.clientY; - clientSize = data.contentRect!.height; } // Compute the target data. @@ -2034,15 +2302,10 @@ namespace Private { */ export function finalizeTabPosition( data: IDragData, - orientation: TabBar.Orientation + orientation: TabBar.Orientation, + scrollState: IScrollState ): void { - // Compute the orientation-sensitive client size. - let clientSize: number; - if (orientation === 'horizontal') { - clientSize = data.contentRect!.width; - } else { - clientSize = data.contentRect!.height; - } + const clientSize = scrollState.totalSize; // Compute the ideal final tab position. let ideal: number; diff --git a/packages/widgets/style/tabbar.css b/packages/widgets/style/tabbar.css index 6f64eab47..ab34de433 100644 --- a/packages/widgets/style/tabbar.css +++ b/packages/widgets/style/tabbar.css @@ -42,16 +42,16 @@ } /* */ -.p-TabBar[data-orientation='horizontal'] > .p-TabBar-content, +.p-TabBar[data-orientation='horizontal'] > .lm-TabBar-wrapper > .p-TabBar-content, /* */ -.lm-TabBar[data-orientation='horizontal'] > .lm-TabBar-content { +.lm-TabBar[data-orientation='horizontal'] > .lm-TabBar-wrapper > .lm-TabBar-content { flex-direction: row; } /* */ -.p-TabBar[data-orientation='vertical'] > .p-TabBar-content, +.p-TabBar[data-orientation='vertical'] > .lm-TabBar-wrapper > .p-TabBar-content, /* */ -.lm-TabBar[data-orientation='vertical'] > .lm-TabBar-content { +.lm-TabBar[data-orientation='vertical'] > .lm-TabBar-wrapper > .lm-TabBar-content { flex-direction: column; } @@ -133,3 +133,127 @@ box-sizing: border-box; background: inherit; } + +.lm-TabBar-wrapper { + /* Remove wrapper from DOM when scrolling is not enabled */ + display: contents; + --lm-tabbar-scrollshadow-end: rgba(0, 0, 0, 0.3); + position: relative; +} + +.lm-TabBar.lm-mod-scrollable .lm-TabBar-wrapper { + /* Keep wrapper in DOM when scrolling is enabled */ + display: block; +} + +.lm-TabBar-content { + scrollbar-width: none; +} + +.lm-TabBar-content::-webkit-scrollbar { + display: none; +} + +.lm-TabBar[data-orientation='horizontal'].lm-mod-scrollable + > .lm-TabBar-wrapper { + overflow-x: hidden; + overflow-y: hidden; +} + +.lm-TabBar[data-orientation='vertical'].lm-mod-scrollable > .lm-TabBar-wrapper { + overflow-x: hidden; + overflow-y: auto; +} + +.lm-TabBar > .lm-TabBar-wrapper > .lm-TabBar-content { + width: 100%; +} + +.lm-TabBar[data-orientation='horizontal'].lm-mod-scrollable + > .lm-TabBar-wrapper + > .lm-TabBar-content { + overflow-x: scroll; + overflow-y: hidden; +} + +.lm-TabBar[data-orientation='vertical'].lm-mod-scrollable + > .lm-TabBar-wrapper + > .lm-TabBar-content { + overflow-x: hidden; + overflow-y: scroll; +} + +.lm-TabBar-wrapper::before, +.lm-TabBar-wrapper::after { + content: ''; + z-index: 10000; + display: none; + position: absolute; +} + +.lm-TabBar-wrapper[data-can-scroll='before']::before { + display: block; +} + +.lm-TabBar-wrapper[data-can-scroll='after']::after { + display: block; +} + +.lm-TabBar-wrapper[data-can-scroll='both']::before, +.lm-TabBar-wrapper[data-can-scroll='both']::after { + display: block; +} + +.lm-TabBar[data-orientation='horizontal'] > .lm-TabBar-wrapper::before, +.lm-TabBar[data-orientation='horizontal'] > .lm-TabBar-wrapper::after { + width: 5px; + height: 100%; + top: 0; +} + +.lm-TabBar[data-orientation='horizontal'] > .lm-TabBar-wrapper::before { + background: linear-gradient( + to left, + rgba(0, 0, 0, 0), + var(--lm-tabbar-scrollshadow-end) + ); + left: 0; +} + +.lm-TabBar[data-orientation='horizontal'] > .lm-TabBar-wrapper::after { + background: linear-gradient( + to right, + rgba(0, 0, 0, 0), + var(--lm-tabbar-scrollshadow-end) + ); + right: 0; +} + +.lm-TabBar[data-orientation='vertical'] > .lm-TabBar-wrapper::before, +.lm-TabBar[data-orientation='vertical'] > .lm-TabBar-wrapper::after { + height: 5px; + width: 100%; + left: 0; +} + +.lm-TabBar[data-orientation='vertical'] > .lm-TabBar-wrapper::before { + background: linear-gradient( + to top, + rgba(0, 0, 0, 0), + var(--lm-tabbar-scrollshadow-end) + ); + top: 0; +} + +.lm-TabBar[data-orientation='vertical'] > .lm-TabBar-wrapper::after { + background: linear-gradient( + to bottom, + rgba(0, 0, 0, 0), + var(--lm-tabbar-scrollshadow-end) + ); + bottom: 0; +} + +.lm-TabBar[data-orientation='vertical'] > .lm-TabBar-scrollButton { + transform: rotate(90deg); +}