golden-layout
Version:
A multi-screen javascript Layout manager
282 lines (240 loc) • 12 kB
text/typescript
import { AssertError } from '../errors/internal-error';
import { ComponentItem } from '../items/component-item';
import { LayoutManager } from '../layout-manager';
import { DomConstants } from '../utils/dom-constants';
import { DragListener } from '../utils/drag-listener';
import { numberToPixels, pixelsToNumber } from '../utils/utils';
import { Tab } from './tab';
/** @internal */
export class TabsContainer {
// There is one tab per ComponentItem in stack. However they may not be ordered the same
private readonly _tabs: Tab[] = [];
private readonly _dropdownElement: HTMLElement;
private readonly _element: HTMLElement;
private _lastVisibleTabIndex = -1;
private _dropdownActive = false;
get tabs(): Tab[] { return this._tabs; }
get tabCount(): number { return this._tabs.length; }
get lastVisibleTabIndex(): number { return this._lastVisibleTabIndex; }
get element(): HTMLElement { return this._element; }
get dropdownElement(): HTMLElement { return this._dropdownElement; }
get dropdownActive(): boolean { return this._dropdownActive; }
constructor(private _layoutManager: LayoutManager,
private _componentRemoveEvent: TabsContainer.ComponentItemRemoveEvent,
private _componentFocusEvent: TabsContainer.ComponentItemFocusEvent,
private _componentDragStartEvent: TabsContainer.ComponentItemDragStartEvent,
private _dropdownActiveChangedEvent: TabsContainer.DropdownActiveChangedEvent,
) {
this._element = document.createElement('section');
this._element.classList.add(DomConstants.ClassName.Tabs);
this._dropdownElement = document.createElement('section');
this._dropdownElement.classList.add(DomConstants.ClassName.TabDropdownList);
this._dropdownElement.style.display = 'none';
}
destroy(): void {
for (let i = 0; i < this._tabs.length; i++) {
this._tabs[i].destroy();
}
}
/**
* Creates a new tab and associates it with a contentItem
* @param index - The position of the tab
*/
createTab(componentItem: ComponentItem, index: number): void {
//If there's already a tab relating to the
//content item, don't do anything
for (let i = 0; i < this._tabs.length; i++) {
if (this._tabs[i].componentItem === componentItem) {
return;
}
}
const tab = new Tab(this._layoutManager,
componentItem,
(item) => this.handleTabCloseEvent(item),
(item) => this.handleTabFocusEvent(item),
(x, y, dragListener, item) => this.handleTabDragStartEvent(x, y, dragListener, item));
if (index === undefined) {
index = this._tabs.length;
}
this._tabs.splice(index, 0, tab);
if (index < this._element.childNodes.length) {
this._element.insertBefore(tab.element, this._element.childNodes[index]);
} else {
this._element.appendChild(tab.element);
}
}
removeTab(componentItem: ComponentItem): void {
// componentItem cannot be ActiveComponentItem
for (let i = 0; i < this._tabs.length; i++) {
if (this._tabs[i].componentItem === componentItem) {
const tab = this._tabs[i];
tab.destroy();
this._tabs.splice(i, 1);
return;
}
}
throw new Error('contentItem is not controlled by this header');
}
processActiveComponentChanged(newActiveComponentItem: ComponentItem): void {
let activeIndex = -1;
for (let i = 0; i < this._tabs.length; i++) {
const isActive = this._tabs[i].componentItem === newActiveComponentItem;
this._tabs[i].setActive(isActive);
if (isActive) {
activeIndex = i;
}
}
if (activeIndex < 0) {
throw new AssertError('HSACI56632');
} else {
if (this._layoutManager.layoutConfig.settings.reorderOnTabMenuClick) {
/**
* If the tab selected was in the dropdown, move everything down one to make way for this one to be the first.
* This will make sure the most used tabs stay visible.
*/
if (this._lastVisibleTabIndex !== -1 && activeIndex > this._lastVisibleTabIndex) {
const activeTab = this._tabs[activeIndex];
for (let j = activeIndex; j > 0; j--) {
this._tabs[j] = this._tabs[j - 1];
}
this._tabs[0] = activeTab;
// updateTabSizes will always be called after this and it will reposition tab elements
}
}
}
}
/**
* Pushes the tabs to the tab dropdown if the available space is not sufficient
*/
updateTabSizes(availableWidth: number, activeComponentItem: ComponentItem | undefined): void {
let dropDownActive = false;
const success = this.tryUpdateTabSizes(dropDownActive, availableWidth, activeComponentItem);
if (!success) {
dropDownActive = true;
// this will always succeed
this.tryUpdateTabSizes(dropDownActive, availableWidth, activeComponentItem)
}
if (dropDownActive !== this._dropdownActive) {
this._dropdownActive = dropDownActive;
this._dropdownActiveChangedEvent();
}
}
tryUpdateTabSizes(dropdownActive: boolean, availableWidth: number, activeComponentItem: ComponentItem | undefined): boolean {
if (this._tabs.length > 0) {
if (activeComponentItem === undefined) {
throw new Error('non-empty tabs must have active component item');
}
let cumulativeTabWidth = 0;
let tabOverlapAllowanceExceeded = false;
const tabOverlapAllowance = this._layoutManager.layoutConfig.settings.tabOverlapAllowance;
const activeIndex = this._tabs.indexOf(activeComponentItem.tab);
const activeTab = this._tabs[activeIndex];
this._lastVisibleTabIndex = -1;
for (let i = 0; i < this._tabs.length; i++) {
const tabElement = this._tabs[i].element;
//Put the tab in the tabContainer so its true width can be checked
if (tabElement.parentElement !== this._element) {
this._element.appendChild(tabElement);
}
const tabMarginRightPixels = getComputedStyle(activeTab.element).marginRight;
const tabMarginRight = pixelsToNumber(tabMarginRightPixels);
const tabWidth = tabElement.offsetWidth + tabMarginRight;
cumulativeTabWidth += tabWidth;
//Include the active tab's width if it isn't already
//This is to ensure there is room to show the active tab
let visibleTabWidth = 0;
if (activeIndex <= i) {
visibleTabWidth = cumulativeTabWidth;
} else {
const activeTabMarginRightPixels = getComputedStyle(activeTab.element).marginRight;
const activeTabMarginRight = pixelsToNumber(activeTabMarginRightPixels);
visibleTabWidth = cumulativeTabWidth + activeTab.element.offsetWidth + activeTabMarginRight;
}
// If the tabs won't fit, check the overlap allowance.
if (visibleTabWidth > availableWidth) {
//Once allowance is exceeded, all remaining tabs go to menu.
if (!tabOverlapAllowanceExceeded) {
//No overlap for first tab or active tab
//Overlap spreads among non-active, non-first tabs
let overlap: number;
if (activeIndex > 0 && activeIndex <= i) {
overlap = (visibleTabWidth - availableWidth) / (i - 1);
} else {
overlap = (visibleTabWidth - availableWidth) / i;
}
//Check overlap against allowance.
if (overlap < tabOverlapAllowance) {
for (let j = 0; j <= i; j++) {
const marginLeft = (j !== activeIndex && j !== 0) ? '-' + numberToPixels(overlap) : '';
this._tabs[j].element.style.zIndex = numberToPixels(i - j);
this._tabs[j].element.style.marginLeft = marginLeft;
}
this._lastVisibleTabIndex = i;
if (tabElement.parentElement !== this._element) {
this._element.appendChild(tabElement);
}
} else {
tabOverlapAllowanceExceeded = true;
}
} else if (i === activeIndex) {
//Active tab should show even if allowance exceeded. (We left room.)
tabElement.style.zIndex = 'auto';
tabElement.style.marginLeft = '';
if (tabElement.parentElement !== this._element) {
this._element.appendChild(tabElement);
}
}
if (tabOverlapAllowanceExceeded && i !== activeIndex) {
if (dropdownActive) {
//Tab menu already shown, so we just add to it.
tabElement.style.zIndex = 'auto';
tabElement.style.marginLeft = '';
if (tabElement.parentElement !== this._dropdownElement) {
this._dropdownElement.appendChild(tabElement);
}
} else {
//We now know the tab menu must be shown, so we have to recalculate everything.
return false;
}
}
} else {
this._lastVisibleTabIndex = i;
tabElement.style.zIndex = 'auto';
tabElement.style.marginLeft = '';
if (tabElement.parentElement !== this._element) {
this._element.appendChild(tabElement);
}
}
}
}
return true;
}
/**
* Shows drop down for additional tabs when there are too many to display.
*/
showAdditionalTabsDropdown(): void {
this._dropdownElement.style.display = '';
}
/**
* Hides drop down for additional tabs when there are too many to display.
*/
hideAdditionalTabsDropdown(): void {
this._dropdownElement.style.display = 'none';
}
private handleTabCloseEvent(componentItem: ComponentItem) {
this._componentRemoveEvent(componentItem);
}
private handleTabFocusEvent(componentItem: ComponentItem) {
this._componentFocusEvent(componentItem);
}
private handleTabDragStartEvent(x: number, y: number, dragListener: DragListener, componentItem: ComponentItem) {
this._componentDragStartEvent(x, y, dragListener, componentItem);
}
}
/** @internal */
export namespace TabsContainer {
export type ComponentItemRemoveEvent = (this: void, componentItem: ComponentItem) => void;
export type ComponentItemFocusEvent = (this: void, componentItem: ComponentItem) => void;
export type ComponentItemDragStartEvent = (this: void, x: number, y: number, dragListener: DragListener, componentItem: ComponentItem) => void;
export type DropdownActiveChangedEvent = (this: void) => void;
}