@v4fire/client
Version:
V4Fire client core library
781 lines (626 loc) • 17 kB
text/typescript
/*!
* V4Fire Client Core
* https://github.com/V4Fire/Client
*
* Released under the MIT license
* https://github.com/V4Fire/Client/blob/master/LICENSE
*/
/**
* [[include:base/b-tree/README.md]]
* @packageDocumentation
*/
//#if demo
import 'models/demo/nested-list';
//#endif
import symbolGenerator from 'core/symbol';
import SyncPromise from 'core/promise/sync';
import { derive } from 'core/functools/trait';
import iItems from 'traits/i-items/i-items';
import iActiveItems, { IterationKey } from 'traits/i-active-items/i-active-items';
import iData, {
component,
prop,
field,
system,
computed,
hook,
wait,
watch,
TaskParams,
TaskI
} from 'super/i-data/i-data';
import type { Item, Items, RenderFilter } from 'base/b-tree/interface';
export * from 'super/i-data/i-data';
export * from 'base/b-tree/interface';
export const
$$ = symbolGenerator();
interface bTree extends Trait<typeof iActiveItems> {}
class bTree extends iData implements iActiveItems {
/** @see [[iItems.Item]] */
readonly Item!: Item;
/** @see [[iItems.Items]] */
readonly Items!: Items;
/** @see [[iActiveItems.Active]] */
readonly Active!: iActiveItems['Active'];
/** @see [[iItems.items]] */
readonly itemsProp: this['Items'] = [];
/** @see [[iItems.item]] */
readonly item?: iItems['item'];
/** @see [[iItems.itemKey]] */
readonly itemKey?: iItems['itemKey'];
/** @see [[iItems.itemProps]] */
readonly itemProps?: iItems['itemProps'];
/**
* A common filter to render items via `asyncRender`.
* It is used to optimize the process of rendering items.
*
* @see [[AsyncRender.iterate]]
* @see [[TaskFilter]]
*/
readonly renderFilter!: RenderFilter;
/**
* A filter to render nested items via `asyncRender`.
* It is used to optimize the process of rendering child items.
*
* @see [[AsyncRender.iterate]]
* @see [[TaskFilter]]
*/
readonly nestedRenderFilter?: RenderFilter;
/**
* Number of chunks to render via `asyncRender`
*/
readonly renderChunks: number = 5;
/**
* If true, then all nested elements are folded by default
*/
readonly folded: boolean = true;
/**
* Link to the top level component (internal parameter)
*/
readonly top?: bTree;
/**
* Component nesting level (internal parameter)
*/
readonly level: number = 0;
/** @see [[iActiveItems.activeProp]] */
readonly activeProp?: iActiveItems['activeProp'];
/** @see [[iActiveItems.multiple]] */
readonly multiple: boolean = false;
/** @see [[iActiveItems.cancelable]] */
readonly cancelable?: boolean;
/** @see [[iItems.items]] */
<bTree>((o) => o.sync.link<Items>((val) => {
if (o.dataProvider != null) {
return <CanUndef<Items>>o.items ?? [];
}
return o.normalizeItems(val);
}))
items!: this['Items'];
/**
* @see [[iActiveItems.activeStore]]
* @see [[iActiveItems.syncActiveStore]]
*/
<bTree>((o) => iActiveItems.linkActiveStore(o))
activeStore!: iActiveItems['activeStore'];
/**
* A map of the item indexes and their values
*/
indexes!: Dictionary;
/**
* A map of the item values and their indexes
*/
valueIndexes!: Map<unknown, number>;
/**
* A map of the item values and their descriptors
*/
valueItems!: Map<unknown, this['Item']>;
/** @inheritDoc */
protected override readonly $refs!: {
children?: bTree[];
};
/**
* Parameters for async render tasks
*/
protected get renderTaskParams(): TaskParams {
return {
filter: this.renderFilter.bind(this, this)
};
}
/**
* Props for recursively inserted tree components
*/
protected get nestedTreeProps(): Dictionary {
const
{nestedRenderFilter} = this;
const
isRootLvl = this.level === 0,
renderFilter = Object.isFunction(nestedRenderFilter) ? nestedRenderFilter : this.renderFilter;
const opts = {
level: this.level + 1,
top: isRootLvl ? this : this.top,
multiple: this.multiple,
classes: this.classes,
renderChunks: this.renderChunks,
activeProp: this.active,
nestedRenderFilter,
renderFilter
};
if (this.$listeners.fold) {
opts['@fold'] = this.$listeners.fold;
}
return opts;
}
/** @see [[iActiveItems.prototype.active] */
get active(): iActiveItems['active'] {
return iActiveItems.getActive(this.top ?? this);
}
/** @see [[iActiveItems.prototype.activeElement] */
get activeElement(): iActiveItems['activeElement'] {
const
ctx = this.top ?? this;
return this.waitStatus('ready', () => {
if (ctx.multiple) {
if (!Object.isSet(this.active)) {
return [];
}
return [...this.active].flatMap((val) => this.findItemElement(val) ?? []);
}
return this.findItemElement(this.active);
});
}
/**
* Returns an iterator over the tree items based on the given arguments.
* The iterator returns pairs of elements `[Tree item, The bTree instance associated with the element]`.
*
* @param [ctx] - a context to start iteration, the top-level tree by default
* @param [opts] - additional options
*/
traverse(
ctx: bTree = this.top ?? this,
opts: { deep: boolean } = {deep: true}
): IterableIterator<[this['Item'], bTree]> {
const
children = ctx.$refs.children ?? [],
iter = createIter();
return {
[Symbol.iterator]() {
return this;
},
next: iter.next.bind(iter)
};
function* createIter() {
for (const item of ctx.items) {
yield [item, ctx];
}
if (opts.deep) {
for (const child of children) {
yield* child.traverse(child);
}
}
}
}
/**
* Folds the specified item.
* If the method is called without an element passed, all tree sibling elements will be folded.
*
* @param [value]
*/
fold(value?: unknown): Promise<boolean> {
if (arguments.length === 0) {
const
values: Array<Promise<boolean>> = [];
for (const [item] of this.traverse(this, {deep: false})) {
values.push(this.fold(item.value));
}
return SyncPromise.all(values)
.then((res) => res.some((value) => value === true));
}
return this.toggleFold(value, true);
}
/**
* Unfolds the specified item.
* If method is called on nested item, all parent items will be unfolded.
* If the method is called without an element passed, all tree sibling elements will be unfolded.
*
* @param [value]
*/
unfold(value?: unknown): Promise<boolean> {
const
values: Array<Promise<boolean>> = [];
if (arguments.length === 0) {
for (const [item] of this.traverse(this, {deep: false})) {
if (!this.hasChildren(item)) {
continue;
}
values.push(this.unfold(item.value));
}
} else {
const
ctx = this.top ?? this,
item = this.valueItems.get(value);
if (item != null && this.hasChildren(item)) {
values.push(ctx.toggleFold(value, false));
}
let
{parentValue} = item ?? {};
while (parentValue != null) {
const
parent = this.valueItems.get(parentValue);
if (parent != null) {
values.push(ctx.toggleFold(parent.value, false));
parentValue = parent.parentValue;
} else {
parentValue = null;
}
}
}
return SyncPromise.all(values)
.then((res) => res.some((value) => value === true));
}
/**
* Toggles the passed item fold value
*
* @param value
* @param [folded] - if value is not passed the current state will be toggled
* @emits `fold(target: HTMLElement, item: `[[Item]]`, value: boolean)`
*/
toggleFold(value: unknown, folded?: boolean): Promise<boolean> {
const
ctx = this.top ?? this;
const
oldVal = this.getFoldedMod(value) === 'true',
newVal = folded ?? !oldVal;
const
el = ctx.findItemElement(value),
item = this.valueItems.get(value);
if (oldVal !== newVal && el != null && item != null && this.hasChildren(item)) {
this.block?.setElMod(el, 'node', 'folded', newVal);
ctx.emit('fold', el, item, newVal);
return SyncPromise.resolve(true);
}
return SyncPromise.resolve(false);
}
isActive(value: unknown): boolean {
return iActiveItems.isActive(this.top ?? this, value);
}
/** @see [[iActiveItems.prototype.getItemByValue]] */
getItemByValue(value: Item['value']): CanUndef<Item> {
return this.valueItems.get(value);
}
/** @see [[iActiveItems.prototype.setActive]] */
setActive(value: this['Active'], unsetPrevious: boolean = false): boolean {
const
ctx = this.top ?? this;
if (!iActiveItems.setActive(ctx, value, unsetPrevious)) {
return false;
}
void ctx.unfold(value);
const {
$el,
block: $b
} = ctx;
if ($el != null && $b != null) {
if (!ctx.multiple || unsetPrevious) {
const
previousNodes = $el.querySelectorAll(`.${$b.getFullElName('node', 'active', true)}`);
previousNodes.forEach((previousNode) => {
if (!this.isActive(this.valueItems.get(previousNode.getAttribute('data-id')))) {
setActive(previousNode, false);
}
});
}
SyncPromise.resolve(this.activeElement).then((activeElement) => {
Array.concat([], activeElement).forEach((activeElement) => setActive(activeElement, true));
}).catch(stderr);
}
return true;
function setActive(el: Element, status: boolean) {
$b!.setElMod(el, 'node', 'active', status);
if (el.hasAttribute('aria-selected')) {
el.setAttribute('aria-selected', String(status));
}
}
}
unsetActive(value: this['Active']): boolean {
const
ctx = this.top ?? this;
if (!iActiveItems.unsetActive(ctx, value)) {
return false;
}
const {
$el,
block: $b
} = ctx;
if ($el != null && $b != null) {
const
previousNodes = $el.querySelectorAll(`.${$b.getFullElName('node', 'active', true)}`);
previousNodes.forEach((previousNode) => {
if (!this.isActive(this.valueItems.get(previousNode.getAttribute('data-id')))) {
$b.setElMod(previousNode, 'node', 'active', false);
if (previousNode.hasAttribute('aria-selected')) {
previousNode.setAttribute('aria-selected', 'false');
}
}
});
}
return true;
}
/** @see [[iActiveItems.prototype.toggleActive]] */
toggleActive(value: this['Active'], unsetPrevious?: boolean): this['Active'] {
return iActiveItems.toggleActive(this.top ?? this, value, unsetPrevious);
}
/** @see [[iItems.getItemKey]] */
protected getItemKey(item: this['Item'], i: number): CanUndef<IterationKey> {
return iItems.getItemKey(this, item, i);
}
protected override initRemoteData(): CanUndef<this['items']> {
if (!this.db) {
return;
}
const
val = this.convertDBToComponent<this['items']>(this.db);
if (Object.isArray(val)) {
return this.items = this.normalizeItems(val);
}
return this.items;
}
/**
* True, if specified item has children
* @param item
*/
protected hasChildren(item: this['Item']): boolean {
return Object.size(item.children?.length) > 0;
}
/**
* Returns a dictionary with props for the specified item
*
* @param item
* @param i - position index
*/
protected getItemProps(item: this['Item'], i: number): Dictionary {
const
op = this.itemProps,
props = Object.reject(item, ['value', 'parentValue', 'children', 'folded']);
if (op == null) {
return props;
}
return Object.isFunction(op) ?
op(item, i, {
key: this.getItemKey(item, i),
ctx: this,
...props
}) :
Object.assign(props, op);
}
/**
* Returns a dictionary with props for the specified item
* @param item
*/
protected getFoldProps(item: this['Item']): Dictionary {
return {
'@click': this.onFoldClick.bind(this, item)
};
}
/**
* Returns a value of the `folded` property from the specified item
* @param item
*/
protected getFoldedPropValue(item: this['Item']): boolean {
if (item.folded != null) {
return item.folded;
}
return this.top?.folded ?? this.folded;
}
/**
* Returns a value of the `folded` modifier from an element by the specified identifier
* @param value
*/
protected getFoldedMod(value: unknown): CanUndef<string> {
const
target = this.findItemElement(value);
if (target == null) {
return;
}
return this.block?.getElMod(target, 'node', 'folded');
}
/**
* Searches an HTML element by the specified item value and returns it
* @param value
*/
protected findItemElement(value: unknown): HTMLElement | null {
const
ctx = this.top ?? this,
id = this.valueIndexes.get(value);
if (id == null) {
return null;
}
return ctx.$el?.querySelector(`[data-id="${id}"]`) ?? null;
}
/**
* Synchronization of items
*
* @param items
* @param oldItems
* @emits `itemsChange(value: this['Items'])`
*/
protected syncItemsWatcher(items: this['Items'], oldItems?: this['Items']): void {
if (!Object.fastCompare(items, oldItems)) {
this.initComponentValues(oldItems != null);
this.async.setImmediate(() => this.emit('itemsChange', items), {label: $$.syncItemsWatcher});
}
}
/**
* Initializes component values
* @param [itemsChanged] - true, if the method is invoked after items changed
*/
protected initComponentValues(itemsChanged: boolean = false): void {
const
that = this,
{active} = this;
let
hasActive = false,
activeItem;
if (this.top == null) {
this.indexes = {};
this.valueIndexes = new Map();
this.valueItems = new Map();
traverse(this.field.get<this['Items']>('items'));
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!hasActive) {
if (itemsChanged && active != null) {
this.field.set('activeStore', undefined);
}
if (activeItem != null) {
iActiveItems.initItem(this, activeItem);
}
}
} else {
Object.defineProperty(this, 'indexes', {
enumerable: true,
configurable: true,
get: () => this.top?.indexes
});
Object.defineProperty(this, 'valueIndexes', {
enumerable: true,
configurable: true,
get: () => this.top?.valueIndexes
});
Object.defineProperty(this, 'valueItems', {
enumerable: true,
configurable: true,
get: () => this.top?.valueItems
});
}
function traverse(items?: Items) {
items?.forEach((item) => {
const
{value} = item;
if (that.valueIndexes.has(value)) {
return;
}
const
id = that.valueIndexes.size;
that.indexes[id] = value;
that.valueIndexes.set(value, id);
that.valueItems.set(value, item);
if (item.value === active) {
hasActive = true;
}
if (item.active) {
activeItem = item;
}
if (Object.isArray(item.children)) {
traverse(item.children);
}
});
}
}
/**
* Normalizes the specified items and returns it
* @param [items]
*/
protected normalizeItems(items: this['Items'] = []): this['Items'] {
const
that = this;
let
i = -1;
items = Object.fastClone(items);
items.forEach((el) => normalize(el));
return items;
function normalize(item: bTree['Item'], parentValue?: unknown) {
i++;
if (item.value === undefined) {
item.value = i;
}
if (!('parentValue' in item)) {
item.parentValue = parentValue;
if (Object.isArray(item.children)) {
if (that.isActive(item.value)) {
item.folded = false;
}
for (const el of item.children) {
if (normalize(el, item.value)) {
item.folded = false;
break;
}
}
}
}
return that.isActive(item.value) || item.folded === false;
}
}
/**
* Handler: fold element has been clicked
* @param item
*/
protected onFoldClick(item: this['Item']): void {
void this.toggleFold(item.value);
}
/**
* Handler: click to some item element
*
* @param e
* @emits `actionChange(active: this['Active'])`
*/
<bTree>({
path: '?$el:click',
wrapper: (o, cb) => o.dom.delegateElement('node', cb)
})
protected onItemClick(e: Event): void {
e.stopPropagation();
let
target = <Element>e.target;
if (target.matches(this.block!.getElSelector('fold'))) {
return;
}
target = <Element>e.delegateTarget;
const
id = target.getAttribute('data-id');
if (id != null) {
this.toggleActive(this.indexes[id]);
}
(this.top ?? this).emit('actionChange', this.active);
}
}
export default bTree;