backbone-fractal
Version:
Lightweight composite views for Backbone
203 lines (174 loc) • 6.07 kB
text/typescript
import {
result,
isFunction,
isString,
isArray,
isUndefined,
compact,
map,
each,
extend,
ListIterator,
} from 'underscore';
import { eachRight } from './underscore-compat';
import { View, Model } from 'backbone';
export type Result<T> = T | { (): T };
export type Selector = NonNullable<string>;
export type NoSelector = false;
export type FallbackSelector = Selector | NoSelector;
export type JQElement = JQuery<Element>;
export type SafeInsertionMethod = 'append' | 'prepend';
export type RiskyInsertionMethod = 'after' | 'before' | 'replaceWith';
export type InsertionMethod = SafeInsertionMethod | RiskyInsertionMethod;
export interface RootSubViewDton {
view: string | Result<View>;
selector?: Result<NoSelector>;
method?: Result<SafeInsertionMethod>;
place?: string | Result<boolean>;
}
export interface SelectorSubViewDton {
view: string | Result<View>;
selector: Result<Selector>;
method?: Result<InsertionMethod>;
place?: string | Result<boolean>;
}
export type SubViewDescription = RootSubViewDton | SelectorSubViewDton;
export type SubView = string | Result<View | SubViewDescription>;
export interface SubViewIterator {
(view: View, selector: JQElement, method: InsertionMethod): void;
}
export interface IterationOptions {
placeOnly?: boolean;
reverse?: boolean;
}
const defaultIterationOptions: IterationOptions = {
placeOnly: false,
reverse: false,
};
export type _NormalizedSubView = [View, JQElement, InsertionMethod];
interface ContainerMap {
[selector: string]: JQElement;
}
export default class CompositeView<TModel extends Model = Model> extends View<TModel> {
defaultPlacement: SafeInsertionMethod;
_containers: ContainerMap;
render() {
this.beforeRender();
this.detachSubviews();
this._resetContainer();
this.placeSubviews();
this.afterRender();
return this;
}
beforeRender() { return this; }
renderContainer() { return this; }
afterRender() { return this; }
remove(): this {
this.clearSubviews();
delete this._containers;
super.remove();
return this;
}
/**
* Invariant: each subview must appear at most once in this list.
*/
subviews(): SubView[] {
return [];
}
clearSubviews(): this {
return this.forEachSubview(_removeSubview, { reverse: true });
}
detachSubviews(): this {
return this.forEachSubview(_detachSubview, { reverse: true });
}
placeSubviews(): this {
// No need to detach the subview first.
// Each subview is unique, so jQuery moves rather than copies it.
return this.forEachSubview(_placeSubview, { placeOnly: true });
}
forEachSubview(iteratee: SubViewIterator, options?: IterationOptions):this {
let _iteratee = args => iteratee.apply(this, args);
let { placeOnly, reverse } = options || defaultIterationOptions;
let iterator = reverse ? eachRight : each;
let list = this._getSubviews(placeOnly);
iterator(list, _iteratee);
return this;
}
_resetContainer(): this {
this._containers = {};
return this.renderContainer();
}
_getSubviews(applyChecks: boolean): _NormalizedSubView[] {
let normalize = this._normalizeSubview.bind(this, applyChecks);
if (!this._containers) this._resetContainer();
return compact(map(result(this, 'subviews'), normalize));
}
_normalizeSubview(applyChecks: boolean, sv: SubView): _NormalizedSubView | void {
sv = this._resolve(sv);
if (isUndefined(sv)) return null;
if (sv instanceof View) return [sv, this.$el, this.defaultPlacement];
let { view, selector, place } = sv;
if (applyChecks && !isUndefined(place) && !this._resolve(place)) {
return null;
}
view = this._resolve(view);
if (!(view instanceof View)) return null;
if (isFunction(selector)) {
selector = selector.call(this);
}
if (isString(selector)) {
let { method } = sv;
return this._normalizeSelectorSubview(view, selector, method);
} else {
let { method } = sv as RootSubViewDton;
return this._normalizeRootSubview(view, method);
}
}
_normalizeRootSubview(
view: View,
method: Result<SafeInsertionMethod>,
): _NormalizedSubView {
if (isFunction(method)) {
method = method.call(this) as SafeInsertionMethod;
}
if (method && method !== 'append' && method !== 'prepend') {
throw new TypeError(`Selector empty or attempting jQuery method ` +
`.${method}() on the root element of a CompositeView.`);
}
return [view, this.$el, method || this.defaultPlacement];
}
_normalizeSelectorSubview(
view: View,
selector: Selector,
method: Result<InsertionMethod>,
): _NormalizedSubView {
if (isFunction(method)) method = method.call(this) as InsertionMethod;
let container = this._containers[selector];
if (!container) {
this._containers[selector] = container = this.$(selector).first();
}
return [view, container, method || this.defaultPlacement];
}
_resolve<T>(handle: string | Result<T>): T {
if (isString(handle)) handle = result(this, handle) as Result<T>;
if (isFunction(handle)) handle = handle.call(this) as T;
return handle;
}
}
extend(CompositeView.prototype, {
defaultPlacement: 'append',
});
export function _removeSubview<V extends View>(view: V): void {
view.remove();
}
export function _detachSubview<V extends View>(view: V): void {
view.$el.detach();
}
export function _placeSubview<V extends View>(
subview: V,
container: JQElement,
method: InsertionMethod,
): void {
const insert = (container[method]);
insert.call(container, subview.el);
}