UNPKG

@v4fire/client

Version:

V4Fire client core library

571 lines (453 loc) • 12.3 kB
/*! * 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 */ @component({ functional: { dataProvider: undefined }, model: { prop: 'activeProp', event: 'onChange' } }) @derive(iActiveItems) class bList extends iData implements iVisible, iWidth, iActiveItems { /** @see [[iVisible.hideIfOffline]] */ @prop(Boolean) 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]] */ @prop(Array) readonly itemsProp: this['Items'] = []; /** @see [[iItems.item]] */ @prop({type: [String, Function], required: false}) readonly item?: iItems['item']; /** @see [[iItems.itemKey]] */ @prop({type: [String, Function], required: false}) readonly itemKey?: iItems['itemKey']; /** @see [[iItems.itemProps]] */ @prop({type: Function, required: false}) readonly itemProps?: iItems['itemProps']; /** * Type of the list' root tag */ @prop(String) readonly listTag: string = 'ul'; /** * Type of list' element tags */ @prop(String) 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. */ @prop({required: false}) 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 */ @prop(Boolean) readonly autoHref: boolean = false; /** * If true, the component supports a feature of multiple active items */ @prop(Boolean) 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`. */ @prop({type: Boolean, required: false}) readonly cancelable?: boolean; /** * Initial additional attributes are provided to an "internal" (native) list tag */ @prop({type: Object, required: false}) readonly attrsProp?: Dictionary; /** * A map of the item indexes and their values */ @system() indexes!: Dictionary; /** * A map of the item values and their indexes */ @system() values!: Map<unknown, number>; /** * A map of the item values and their descriptors */ @system() valueItems!: Map<unknown, this['Item']>; /** @see [[iActiveItems.activeStore]] */ @system<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]] */ @computed({dependencies: ['itemsStore']}) 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] */ @computed({cache: false}) 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]] */ @field<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] */ @computed({ cache: true, dependencies: ['active'] }) 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 */ @hook('beforeDataCreate') 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'])` */ @watch({path: 'itemsStore', immediate: true}) 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'])` */ @watch({ path: '?$el:click', wrapper: (o, cb) => o.dom.delegateElement('link', cb) }) 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;