@v4fire/client
Version:
V4Fire client core library
571 lines (453 loc) • 12.3 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-list/README.md]]
* @packageDocumentation
*/
//#if demo
import 'models/demo/list';
//#endif
import symbolGenerator from 'core/symbol';
import SyncPromise from 'core/promise/sync';
import { isAbsURL } from 'core/url';
import { derive } from 'core/functools/trait';
import iVisible from 'traits/i-visible/i-visible';
import iWidth from 'traits/i-width/i-width';
import iItems, { IterationKey } from 'traits/i-items/i-items';
import iActiveItems, { Active } from 'traits/i-active-items/i-active-items';
import iData, { component, prop, field, system, computed, watch, hook, ModsDecl } from 'super/i-data/i-data';
import type { Item, Items } from 'base/b-list/interface';
export * from 'super/i-data/i-data';
export * from 'base/b-list/interface';
export const
$$ = symbolGenerator();
interface bList extends Trait<typeof iActiveItems> {}
/**
* Component to create a list of tabs/links
*/
class bList extends iData implements iVisible, iWidth, iActiveItems {
/** @see [[iVisible.hideIfOffline]] */
readonly hideIfOffline: boolean = false;
/** @see [[iActiveItems.Active]] */
readonly Active!: Active;
/** @see [[iItems.Item]] */
readonly Item!: Item;
/** @see [[iItems.Items]] */
readonly Items!: Array<this['Item']>;
/** @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'];
/**
* Type of the list' root tag
*/
readonly listTag: string = 'ul';
/**
* Type of list' element tags
*/
readonly listElTag: string = 'li';
/**
* An initial component active item/s.
* If the component is switched to the `multiple` mode, you can pass an array or Set to define several active items.
*/
readonly activeProp?: unknown[] | this['Active'];
/**
* If true, then all items without the `href` option will automatically generate a link by using `value`
* and other props
*/
readonly autoHref: boolean = false;
/**
* If true, the component supports a feature of multiple active items
*/
readonly multiple: boolean = false;
/**
* If true, the active item can be unset by using another click to it.
* By default, if the component is switched to the `multiple` mode, this value is set to `true`,
* otherwise to `false`.
*/
readonly cancelable?: boolean;
/**
* Initial additional attributes are provided to an "internal" (native) list tag
*/
readonly attrsProp?: Dictionary;
/**
* A map of the item indexes and their values
*/
indexes!: Dictionary;
/**
* A map of the item values and their indexes
*/
values!: Map<unknown, number>;
/**
* A map of the item values and their descriptors
*/
valueItems!: Map<unknown, this['Item']>;
/** @see [[iActiveItems.activeStore]] */
<bList>((o) => iActiveItems.linkActiveStore(o))
activeStore!: this['Active'];
/**
* Additional attributes are provided to an "internal" (native) list tag
* @see [[bList.attrsProp]]
*/
get attrs(): Dictionary {
const
attrs = {...this.attrsProp};
if (this.items.some((el) => el.href === undefined)) {
attrs.role = 'tablist';
attrs['aria-multiselectable'] = this.multiple;
}
return attrs;
}
/**
* List of component items
* @see [[bList.itemsProp]]
*/
get items(): this['Items'] {
return <this['Items']>this.field.get('itemsStore');
}
/**
* Sets a new list of component items
* @see [[bList.items]]
*/
set items(value: this['Items']) {
this.field.set('itemsStore', value);
}
/** @see [[iActiveItems.active] */
get active(): this['Active'] {
return iActiveItems.getActive(this);
}
static override readonly mods: ModsDecl = {
...iVisible.mods,
...iWidth.mods,
hideLabels: [
'true',
['false']
]
};
/**
* Store of component items
* @see [[bList.items]]
*/
<bList>((o) => o.sync.link<Items>((val) => {
if (o.dataProvider != null) {
return <CanUndef<Items>>o.itemsStore ?? [];
}
return o.normalizeItems(val);
}))
protected itemsStore!: this['Items'];
/** @see [[iActiveItems.prototype.activeElement] */
get activeElement(): CanPromise<CanArray<HTMLAnchorElement> | null> {
const
{active} = this;
const getEl = (value) => {
const
id = this.values.get(value);
if (id != null) {
return this.block?.element<HTMLAnchorElement>('link', {id}) ?? null;
}
return null;
};
return this.waitStatus('ready', () => {
if (this.multiple) {
return Object.isSet(active) ? [...active].flatMap((val) => getEl(val) ?? []) : [];
}
return getEl(active);
});
}
/** @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
res = iActiveItems.setActive(this, value);
if (!res) {
return res;
}
const
{block: $b} = this;
if ($b != null) {
const
id = this.values.get(value),
linkEl = id != null ? $b.element('link', {id}) : null;
if (!this.multiple || unsetPrevious) {
const
previousLinkEls = $b.elements('link', {active: true});
for (let i = 0; i < previousLinkEls.length; i++) {
const
previousLinkEl = previousLinkEls[i];
if (previousLinkEl !== linkEl) {
$b.setElMod(previousLinkEl, 'link', 'active', false);
if (previousLinkEl.hasAttribute('aria-selected')) {
previousLinkEl.setAttribute('aria-selected', 'false');
}
}
}
}
SyncPromise.resolve(this.activeElement).then((selectedElement) => {
const
els = Array.concat([], selectedElement);
for (let i = 0; i < els.length; i++) {
const el = els[i];
$b.setElMod(el, 'link', 'active', true);
if (el.hasAttribute('aria-selected')) {
el.setAttribute('aria-selected', 'true');
}
}
}, stderr);
}
return res;
}
/** @see [[iActiveItems.prototype.unsetActive] */
unsetActive(value: unknown): boolean {
const
{activeElement, block: $b} = this;
const
res = iActiveItems.unsetActive(this, value);
if (!res) {
return res;
}
if ($b != null) {
SyncPromise.resolve(activeElement).then((activeElement) => {
const
els = Array.concat([], activeElement);
for (let i = 0; i < els.length; i++) {
const
el = els[i],
id = el.getAttribute('data-id'),
itemValue = this.indexes[String(id)];
if (itemValue == null) {
continue;
}
const needChangeMod = this.multiple && Object.isSet(value) ?
value.has(itemValue) :
value === itemValue;
if (needChangeMod) {
$b.setElMod(el, 'link', 'active', false);
if (el.hasAttribute('aria-selected')) {
el.setAttribute('aria-selected', 'false');
}
}
}
}, stderr);
}
return res;
}
protected override initRemoteData(): CanUndef<CanPromise<this['Items'] | Dictionary>> {
if (!this.db) {
return;
}
const
val = this.convertDBToComponent(this.db);
if (Object.isDictionary(val)) {
return Promise.all(this.state.set(val)).then(() => val);
}
if (Object.isArray(val)) {
this.items = this.normalizeItems(<this['Items']>val);
}
return this.items;
}
protected override initBaseAPI(): void {
super.initBaseAPI();
const
i = this.instance;
this.isActive = i.isActive.bind(this);
this.setActive = i.setActive.bind(this);
this.normalizeItems = i.normalizeItems.bind(this);
}
/**
* Initializes component values
* @param itemsChanged - true, if the method is invoked after items changed
*/
protected initComponentValues(itemsChanged: boolean = false): void {
this.values = new Map();
this.valueItems = new Map();
this.indexes = {};
const
{active} = this;
let
hasActive = false,
activeItem;
for (let i = 0; i < this.items.length; i++) {
const
item = this.items[i],
val = item.value;
this.values.set(val, i);
this.valueItems.set(val, item);
this.indexes[i] = val;
if (item.value === active) {
hasActive = true;
}
if (item.active) {
activeItem = item;
}
}
if (!hasActive) {
if (itemsChanged && active != null) {
this.field.set('activeStore', undefined);
}
if (activeItem != null) {
iActiveItems.initItem(this, activeItem);
}
}
}
/**
* Normalizes the specified items and returns it
* @param items
*/
protected normalizeItems(items: CanUndef<this['Items']>): this['Items'] {
const
normalizedItems = <this['Items']>[];
if (items == null) {
return normalizedItems;
}
for (let i = 0; i < items.length; i++) {
const
item = items[i];
let
{value, href} = item;
if (value === undefined) {
value = href ?? i;
}
const needAutoHref =
href === undefined &&
value !== undefined &&
this.autoHref;
if (needAutoHref) {
href = String(value);
if (!isAbsURL.test(href) && !href.startsWith('/') && !href.startsWith('#')) {
href = `#${href}`;
}
}
const
classes = this.provide.hintClasses(item.hintPos).concat(item.classes ?? []),
attrs = {...item.attrs};
if (href === undefined) {
attrs.role = 'tab';
}
normalizedItems.push({
...item,
attrs,
classes,
value,
href
});
}
return normalizedItems;
}
/**
* 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;
return Object.isFunction(op) ?
op(item, i, {
key: this.getItemKey(item, i),
ctx: this
}) :
op ?? {};
}
/**
* 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.emit('itemsChange', items);
}
}
/** @see [[iItems.getItemKey]] */
protected getItemKey(item: this['Item'], i: number): CanUndef<IterationKey> {
return iItems.getItemKey(this, item, i);
}
/**
* Returns href for item. Is used at the rendering stage
*
* @param item
*/
protected getHref(item: Item): CanUndef<string> {
return item.href;
}
protected override initModEvents(): void {
super.initModEvents();
iVisible.initModEvents(this);
}
protected override onAddData(data: unknown): void {
Object.assign(this.db, this.convertDataToDB(data));
}
protected override onUpdData(data: unknown): void {
Object.assign(this.db, this.convertDataToDB(data));
}
protected override onDelData(data: unknown): void {
Object.assign(this.db, this.convertDataToDB(data));
}
/**
* Handler: click to some item element
*
* @param e
* @emits `actionChange(active: this['Active'])`
*/
protected onItemClick(e: Event): void {
const
target = <Element>e.delegateTarget,
id = Number(target.getAttribute('data-id'));
this.toggleActive(this.indexes[id]);
this.emit('actionChange', this.active);
}
}
export default bList;