docs/stack/Stack.js
import { VNode } from 'snabbdom/vnode';
import h from 'snabbdom/h';
import { Type, Inject, Injector } from '../di';
import {
RenderableInjector,
Renderable,
RemoveChildArgs,
AddChildArgs,
ConfiguredRenderable,
BaseModificationArgs
} from '../dom';
import { BeforeDestroyEvent, BusEvent, Subject, Observable } from '../events';
import { ConfigurationRef, ContainerRef, RenderableArg, XYDirection } from '../common';
import { XYItemContainer, XYItemContainerConfig, Row, Column } from '../XYContainer';
import { StackHeader, StackHeaderConfigArgs } from './StackHeader';
import { TabCloseEvent } from './TabCloseEvent';
import { TabSelectionEvent } from './TabSelectionEvent';
import { TabDragEvent } from './TabDragEvent';
import { StackItemCloseEvent } from './StackItemCloseEvent';
import { StackItemContainer, StackItemContainerConfig } from './StackItemContainer';
import { RootInjector } from '../RootInjector';
import { StackTab } from './StackTab';
import { clamp, get, isNumber, propEq } from '../utils';
import { StackRegion } from './common';
import { RenderableConfigArgs, RenderableConfig } from '../dom';
import { StackControl, CloseStackControl } from './controls';
export interface StackConfig extends RenderableConfig {
children: StackItemContainerConfig[];
startIndex: number;
direction: XYDirection;
reverse: boolean;
header: StackHeaderConfigArgs|null;
controls: RenderableArg<StackControl>[];
}
export interface StackConfigArgs extends RenderableConfigArgs {
children: StackItemContainerConfig[];
startIndex?: number;
direction?: XYDirection;
reverse?: boolean;
header?: StackHeaderConfigArgs;
controls?: RenderableArg<StackControl>[];
}
/**
* A Renderable that renders the Renderable items in a tabbable format.
* @export
* @class Stack
* @extends {Renderable}
*/
export class Stack extends Renderable {
private _direction: XYDirection;
private _header: StackHeader;
private _activeIndex: number = 0;
protected _contentItems: StackItemContainer[] = [];
protected _config: StackConfig;
/**
* Creates an instance of Stack.
* @param {StackConfigArgs} _config
* @param {Renderable} _container
*/
constructor(
@Inject(ConfigurationRef) _config: StackConfigArgs,
@Inject(ContainerRef) protected _container: Renderable
) {
super();
this._config = Object.assign({
controls: [],
header: null,
children: [],
startIndex: 0,
direction: XYDirection.X,
reverse: false
}, _config);
}
/**
* The direction of the stack.
* @readonly
* @type {XYDirection}
*/
get direction(): XYDirection {
return this._config && this._config.direction != null ? this._config.direction : XYDirection.X;
}
/**
* Whether the stack is horizontal.
* @readonly
* @type {boolean}
*/
get isHorizontal(): boolean {
return this.direction === XYDirection.X;
}
/**
* Whether the stack is reversed
* @readonly
* @type {boolean}
*/
get isReversed(): boolean {
return Boolean(this._config.reverse === true);
}
get width(): number {
return this._container.width;
}
get height(): number {
return this._container.height;
}
/**
* Whether this stack is considered closable.
* @readonly
* @type {boolean}
*/
get isCloseable(): boolean {
return this._contentItems.every(propEq('closeable', true));
}
/**
* The active content item index.
* @readonly
* @type {number}
*/
get activeIndex(): number {
return this._activeIndex;
}
/**
* The header associated with this stack.
* @readonly
* @type {StackHeader}
*/
get header(): StackHeader {
return this._header;
}
/**
* Gets the content items.
* @readonly
* @type {StackItemContainer[]}
*/
get items(): StackItemContainer[] {
return this._contentItems;
}
/**
* @listens {TabCloseEvent}
* @listens {TabSelectionEvent}
* @listens {TabDragEvent}
*/
initialize(): void {
super.initialize();
this._header = this.createChild(new ConfiguredRenderable(StackHeader, this._config ? this._config.header : null));
this._header.subscribe(TabCloseEvent, this._onTabClose.bind(this));
this._header.subscribe(TabSelectionEvent, this._onTabSelect.bind(this));
this._header.subscribe(TabDragEvent, this._onTabDrag.bind(this));
this._config.children.forEach(child => {
this.addChild(this.createChildItem(child), { render: false, resize: false });
});
if (!ConfiguredRenderable.inList(this._config.controls, CloseStackControl)) {
this._config.controls.push(CloseStackControl);
}
this._config.controls.forEach(control => this.addControl(control));
this._setActiveIndex(this._config.startIndex);
}
render(): VNode {
return h(`div.ug-layout__stack`, {
key: this.uid,
class: {
'ug-layout__stack-y': !this.isHorizontal,
'ug-layout__stack-reverse': this.isReversed
}
}, [
this._header.render(),
...this._contentItems.map(item => item.render())
]);
}
/**
* Creates a child {@link StackItemContainer}.
* @param {StackItemContainerConfig} config
* @returns {StackItemContainer}
*/
createChildItem(config: StackItemContainerConfig): StackItemContainer {
return this.createChild(new ConfiguredRenderable(StackItemContainer, config));
}
/**
* Adds a header control to this stack.
* @param {RenderableArg<StackControl>} control
*/
addControl(control: RenderableArg<StackControl>): void {
if (this._header) {
this._header.addControl(control);
}
}
/**
* Adds a child renderable. If the renderable being added is not a StackItemContainer
* then one is created and the item is wrapped in it.
* @param {Renderable} item
* @param {AddChildArgs} [options={}]
*/
addChild(item: Renderable, options: AddChildArgs = {}): void {
const { index } = options;
let container: StackItemContainer;
if (!(item instanceof StackItemContainer)) {
container = this.createChildItem({ use: item });
} else {
container = item;
}
const tab = this._header.addTab({
title: container.title,
maxSize: get<number>(this._config, 'header.maxTabSize', 200),
}, {
index,
render: false,
resize: false
});
super.addChild(item, options);
}
/**
* Removes a child item along with the tab associated with.
* @param {StackItemContainer} item
* @param {RemoveChildArgs} [options={}]
* @returns {void}
*/
removeChild(item: StackItemContainer, options: RemoveChildArgs = {}): void {
const { render = true } = options;
const index = this._contentItems.indexOf(item);
if (index === -1) {
return;
}
const tab = this._header.getAtIndex(index);
if (tab) {
this._header.removeChild(tab, Object.assign({}, options, { render: false }));
}
super.removeChild(item, Object.assign({}, options, { render: false }));
this._setActiveIndex(this._activeIndex);
if (render) {
this._renderer.render();
}
}
/**
* Sets the active item at a specific index.
* @param {number} [index]
* @param {BaseModificationArgs} [args={}]
*/
setActiveIndex(index?: number, args: BaseModificationArgs = {}): void {
const { render = true } = args;
this._setActiveIndex(index);
if (render) {
this._renderer.render();
}
}
/**
* Removes an item a specified index.
* @param {number} index
* @param {RemoveChildArgs} [options={}]
*/
removeAtIndex(index: number, options: RemoveChildArgs = {}): void {
const item = this.getAtIndex(index);
if (item) {
this.removeChild(item as StackItemContainer, options);
}
}
/**
* Closes the stack if all items are closeable.
* @emits {BeforeDestroyEvent} Event that can prevent this action.
* @returns {void}
*/
close(): void {
if (!this.isCloseable) {
return;
}
const event = new BeforeDestroyEvent(this);
this.emitDown(event);
event.results().subscribe(() => {
if (this._container) {
this._container.removeChild(this);
} else {
this.destroy();
}
});
}
destroy(): void {
this._header.destroy();
super.destroy();
}
getChildren(): Renderable[] {
return [
...this._contentItems,
this._header
];
}
/**
* Gets the index of a tab.
* @param {StackTab} tab
* @returns {number}
*/
getIndexOfTab(tab: StackTab): number {
return this._header.getIndexOf(tab);
}
/**
* Determines whether an item is the action item container.
* @param {StackItemContainer} container
* @returns {boolean}
*/
isActiveContainer(container: StackItemContainer): boolean {
return this.getIndexOf(container) === this.activeIndex;
}
/**
* Determines whether a tab is the active tab.
* @param {StackTab} tab
* @returns {boolean}
*/
isActiveTab(tab: StackTab): boolean {
return this._header.getIndexOf(tab) === this.activeIndex;
}
/**
* Gets a tab at a specified index.
* @param {number} index
* @returns {(StackTab|null)}
*/
getTabAtIndex(index: number): StackTab|null {
return this._header.getAtIndex(index) as StackTab|null;
}
/**
* Sets the given container as the active item.
* @param {StackItemContainer} container
*/
setActiveContainer(container: StackItemContainer): void {
this.setActiveIndex(this.getIndexOf(container));
}
/**
* Sets the active item from the given tab.
* @param {StackTab} tab
*/
setActiveTab(tab: StackTab): void {
this.setActiveIndex(this.getIndexOfTab(tab));
}
/**
* Handles a renderable being dropped on the stack within a certain region.
* @param {StackRegion} region The region the item was dropped in.
* @param {Renderable} item The item being dropped.
*/
handleItemDrop(region: StackRegion, item: Renderable): void {
if (
this._container instanceof XYItemContainer
&& (((region === StackRegion.EAST || region === StackRegion.WEST) && this._container.isRow)
|| ((region === StackRegion.NORTH || region === StackRegion.SOUTH) && !this._container.isRow))
) {
// If this stack belongs to a Row/Column and the item is being dropped in a region that flows with that container
// then it can be dropped in without creating the XYItemContainer.
const containerIndex = this._container.container.getIndexOf(this._container);
const index = region === StackRegion.NORTH || region === StackRegion.WEST
? containerIndex
: containerIndex + 1;
this._container.addChild(item, { index, render: false, resize: false });
this._container.ratio = <number>this._container.ratio * 0.5;
this._container.container.resize();
} else {
// Create a Row/Column and replace this stack with it, while adding the stack as an item.
const container = this._createContainerFromRegion(region);
const index = region === StackRegion.NORTH || region === StackRegion.WEST ? 0 : -1;
this._container.replaceChild(this, container, { destroy: false, render: false });
container.addChild(this, { render: false });
container.addChild(item, { render: false, index });
}
this._renderer.render();
}
private _createContainerFromRegion(region: StackRegion): Row|Column {
const RowOrColumn = this._getContainerDropType(region);
return this.createChild<Row|Column>(new ConfiguredRenderable(RowOrColumn as Type<Row|Column>, {}), [
{ provide: ContainerRef, useValue: null }
]);
}
private _getContainerDropType(region: StackRegion): typeof Row|typeof Column {
return region === StackRegion.EAST || region === StackRegion.WEST ? Row : Column;
}
private _onTabClose(e: TabCloseEvent): void {
const container = this.getAtIndex(this.getIndexOfTab(e.target)) as StackItemContainer|null;
if (container) {
const event = e.delegate(StackItemCloseEvent, container);
this._eventBus.next(event);
event.results().subscribe(() => {
this.removeChild(container);
});
}
}
private _onTabSelect(e: TabSelectionEvent): void {
this.setActiveTab(e.target);
}
private _onTabDrag(e: TabDragEvent): void {
const item = this.getAtIndex(this.getIndexOfTab(e.target)) as StackItemContainer|null;
if (item) {
this.removeChild(item, { destroy: false });
}
}
private _setActiveIndex(index?: number): void {
if (!isNumber(index)) {
return;
}
this._activeIndex = clamp(index, 0, Math.max(this._contentItems.length - 1, 0));
}
static configure(config: StackConfigArgs): ConfiguredRenderable<Stack> {
return new ConfiguredRenderable(Stack, config);
}
}