UNPKG

@v4fire/client

Version:

V4Fire client core library

986 lines (788 loc) • 19.9 kB
/*! * V4Fire Client Core * https://github.com/V4Fire/Client * * Released under the MIT license * https://github.com/V4Fire/Client/blob/master/LICENSE */ /** * [[include:form/b-select/README.md]] * @packageDocumentation */ //#if demo import 'models/demo/select'; //#endif import SyncPromise from 'core/promise/sync'; import { derive } from 'core/functools/trait'; import { is } from 'core/browser'; import iItems, { IterationKey } from 'traits/i-items/i-items'; import iOpenToggle, { CloseHelperEvents } from 'traits/i-open-toggle/i-open-toggle'; import iInputText, { component, prop, field, system, computed, p, hook, watch, ModsDecl, ModEvent, SetModEvent, UnsafeGetter, ValidatorsDecl, ValidatorParams, ValidatorResult } from 'super/i-input-text/i-input-text'; import * as on from 'form/b-select/modules/handlers'; import * as h from 'form/b-select/modules/helpers'; import { $$, openedSelect } from 'form/b-select/const'; import type { Value, FormValue, Item, Items, UnsafeBSelect } from 'form/b-select/interface'; export * from 'form/b-input/b-input'; export * from 'traits/i-open-toggle/i-open-toggle'; export * from 'form/b-select/const'; export * from 'form/b-select/interface'; export { $$, Value, FormValue }; interface bSelect extends Trait<typeof iOpenToggle> {} /** * Component to create a form select */ @component({ model: { prop: 'selectedProp', event: 'onChange' } }) @derive(iOpenToggle) class bSelect extends iInputText implements iOpenToggle, iItems { override readonly Value!: Value; override readonly FormValue!: FormValue; /** @see [[iItems.Item]] */ readonly Item!: Item; /** @see [[iItems.Items]] */ readonly Items!: Array<this['Item']>; override readonly rootTag: string = 'span'; override readonly valueProp?: unknown[] | this['Value']; /** @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], default: () => (item: Item) => item.value }) readonly itemKey!: iItems['itemKey']; /** @see [[iItems.itemProps]] */ @prop({type: Function, required: false}) readonly itemProps?: iItems['itemProps']; /** * If true, the component supports a feature of multiple selected items */ @prop(Boolean) readonly multiple: boolean = false; /** * If true, the component will use a native tag to show the select */ @prop(Boolean) readonly native: boolean = Object.isTruly(is.mobile); /** * Icon to show before the input * * @example * ``` * < b-select :preIcon = 'dropdown' | :items = myItems * ``` */ @prop({type: String, required: false}) readonly preIcon?: string; /** * Name of the used component to show `preIcon` * * @default `'b-icon'` * @example * ``` * < b-select :preIconComponent = 'b-my-icon' | :items = myItems * ``` */ @prop({type: String, required: false}) readonly preIconComponent?: string; /** * Tooltip text to show during hover the cursor on `preIcon` * * @example * ``` * < b-select & * :preIcon = 'dropdown' | * :preIconHint = 'Show variants' | * :items = myItems * . * ``` */ @prop({type: String, required: false}) readonly preIconHint?: string; /** * Tooltip position to show during hover the cursor on `preIcon` * * @see [[gHint]] * @example * ``` * < b-select & * :preIcon = 'dropdown' | * :preIconHint = 'Show variants' | * :preIconHintPos = 'bottom-right' | * :items = myItems * . * ``` */ @prop({type: String, required: false}) readonly preIconHintPos?: string; /** * Icon to show after the input * * @example * ``` * < b-select :icon = 'dropdown' | :items = myItems * ``` */ @prop({type: String, required: false}) readonly icon?: string; /** * Name of the used component to show `icon` * * @default `'b-icon'` * @example * ``` * < b-select :iconComponent = 'b-my-icon' | :items = myItems * ``` */ @prop({type: String, required: false}) readonly iconComponent?: string; /** * Tooltip text to show during hover the cursor on `icon` * * @example * ``` * < b-select & * :icon = 'dropdown' | * :iconHint = 'Show variants' | * :items = myItems * . * ``` */ @prop({type: String, required: false}) readonly iconHint?: string; /** * Tooltip position to show during hover the cursor on `icon` * * @see [[gHint]] * @example * ``` * < b-select & * :icon = 'dropdown' | * :iconHint = 'Show variants' | : * :iconHintPos = 'bottom-right' | * :items = myItems * . * ``` */ @prop({type: String, required: false}) readonly iconHintPos?: string; /** * A component to show "in-progress" state or * Boolean, if needed to show progress by slot or `b-progress-icon` * * @default `'b-progress-icon'` * @example * ``` * < b-select :progressIcon = 'b-my-progress-icon' | :items = myItems * ``` */ @prop({type: [String, Boolean], required: false}) readonly progressIcon?: string | boolean; override get unsafe(): UnsafeGetter<UnsafeBSelect<this>> { return Object.cast(this); } override get rootAttrs(): Dictionary { const attrs = { ...super['rootAttrsGetter']() }; if (!this.native) { Object.assign(attrs, { role: 'listbox', 'aria-multiselectable': this.multiple }); } return attrs; } override get value(): this['Value'] { const v = this.field.get('valueStore'); if (this.multiple) { if (Object.isSet(v) && v.size > 0) { return new Set(v); } return undefined; } return v; } override set value(value: this['Value']) { if (value === undefined) { this.unselectValue(this.value); } else { this.selectValue(value, true); void this.setScrollToMarkedOrSelectedItem(); } if (!this.multiple) { const item = this.indexes[String(this.values.get(value))]; this.text = item?.label ?? ''; } } override get default(): this['Value'] { const val = this.field.get('defaultProp'); if (this.multiple) { return new Set(Object.isSet(val) ? val : Array.concat([], val)); } return val; } override get formValue(): Promise<this['FormValue']> { const formValue = super['formValueGetter'](); return (async () => { const val = await formValue; if (this.multiple && Object.isSet(val)) { return [...val]; } return val; })(); } /** * List of component items or select options * @see [[bSelect.itemsProp]] */ get items(): this['Items'] { return <this['Items']>this.field.get('itemsStore'); } /** * Sets a new list of component items * @see [[bSelect.items]] */ set items(value: this['Items']) { this.field.set('itemsStore', value); } static override readonly mods: ModsDecl = { opened: [ ...iOpenToggle.mods.opened!, ['false'] ], native: [ 'true', 'false' ], multiple: [ 'true', 'false' ] }; static override validators: ValidatorsDecl<bSelect> = { //#if runtime has iInput/validators ...iInputText.validators, async required({msg, showMsg = true}: ValidatorParams): Promise<ValidatorResult<boolean>> { const val = await this.formValue; if (this.multiple ? Object.size(val) === 0 : val === undefined) { this.setValidationMsg(this.getValidatorMsg(false, msg, this.t`Required field`), showMsg); return false; } return true; } //#endif }; @system<bSelect>((o) => o.sync.link((val) => { val = o.resolveValue(val); if (val === undefined && o.hook === 'beforeDataCreate') { if (o.multiple) { if (Object.isSet(o.valueStore)) { return o.valueStore; } return new Set(Array.concat([], o.valueStore)); } return o.valueStore; } let newVal; if (o.multiple) { const objVal = new Set(Object.isSet(val) ? val : Array.concat([], val)); if (Object.fastCompare(objVal, o.valueStore)) { return o.valueStore; } newVal = objVal; } else { newVal = val; } o.selectValue(newVal); return newVal; })) protected override valueStore!: this['Value']; /** * Map of item indexes and their values */ @system() // @ts-ignore (type loop) protected indexes!: Dictionary<this['Item']>; /** * Map of item values and their indexes */ @system() protected values!: Map<unknown, number>; /** * Store of component items * @see [[bSelect.items]] */ @field<bSelect>((o) => o.sync.link<Items>((val) => { if (o.dataProvider != null) { return <CanUndef<Items>>o.itemsStore ?? []; } return o.normalizeItems(val); })) protected itemsStore!: this['Items']; protected override readonly $refs!: iInputText['$refs'] & { dropdown?: Element; }; /** * A link to the selected item element. * If the component is switched to the `multiple` mode, the getter will return an array of elements. */ @computed({ cache: true, dependencies: ['value'] }) protected get selectedElement(): CanPromise<CanUndef<CanArray<HTMLOptionElement>>> { return h.getSelectedElement(this); } /** * Handler: changing text of a component helper input */ @computed({cache: true}) protected get onTextChange(): Function { return this.async.debounce(on.textChange.bind(null, this), 200); } override reset(): Promise<boolean> { const compare = (a, b) => { if (this.multiple && Object.isSet(a) && Object.isSet(b)) { return Object.fastCompare([...a], [...b]); } return a === b; }; if (!compare(this.value, this.default)) { return super.reset(); } return SyncPromise.resolve(false); } /** * Returns true if the specified value is selected * @param value */ isSelected(value: unknown): boolean { const valueStore = this.field.get('valueStore'); if (this.multiple) { if (!Object.isSet(valueStore)) { return false; } return valueStore.has(value); } return value === valueStore; } /** * Selects an item by the specified value. * If the component is switched to the `multiple` mode, the method can take a `Set` object to set multiple items. * * @param value * @param [unselectPrevious] - true, if needed to unselect previous selected items * (works only with the `multiple` mode) */ selectValue(value: this['Value'], unselectPrevious: boolean = false): boolean { const valueStore = this.field.get('valueStore'); if (this.multiple) { if (!Object.isSet(valueStore)) { return false; } if (unselectPrevious) { valueStore.clear(); } let res = false; const set = (value) => { if (valueStore.has(value)) { return false; } valueStore.add(value); res = true; }; if (Object.isSet(value)) { Object.forEach(value, set); } else { set(value); } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!res) { return false; } } else if (valueStore === value) { return false; } else { this.field.set('valueStore', value); } const {block: $b} = this; if ($b == null) { return true; } const id = this.values.get(value), itemEl = id != null ? $b.element<HTMLOptionElement>('item', {id}) : null; if (!this.multiple || unselectPrevious) { const previousItemEls = $b.elements<HTMLOptionElement>('item', {selected: true}); for (let i = 0; i < previousItemEls.length; i++) { const previousItemEl = previousItemEls[i]; if (previousItemEl !== itemEl) { $b.setElMod(previousItemEl, 'item', 'selected', false); if (this.native) { previousItemEl.selected = false; } else { previousItemEl.setAttribute('aria-selected', 'false'); } } } } SyncPromise.resolve(this.selectedElement).then((selectedElement) => { const els = Array.concat([], selectedElement); for (let i = 0; i < els.length; i++) { const el = els[i]; $b.setElMod(el, 'item', 'selected', true); if (this.native) { el.selected = true; } else { el.setAttribute('aria-selected', 'true'); } } }).catch(stderr); return true; } /** * Removes selection from an item by the specified value. * If the component is switched to the `multiple` mode, the method can take a `Set` object to unset multiple items. * * @param value */ unselectValue(value: this['Value']): boolean { const valueStore = this.field.get('valueStore'); const {selectedElement} = this; if (this.multiple) { if (!Object.isSet(valueStore)) { return false; } let res = false; const unset = (value) => { if (!valueStore.has(value)) { return; } valueStore.delete(value); res = true; }; if (Object.isSet(value)) { Object.forEach(value, unset); } else { unset(value); } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!res) { return false; } } else if (valueStore !== value) { return false; } else { this.field.set('valueStore', undefined); } const {block: $b} = this; if ($b == null) { return true; } SyncPromise.resolve(selectedElement).then((selectedElement) => { const els = Array.concat([], selectedElement); for (let i = 0; i < els.length; i++) { const el = els[i], id = el.getAttribute('data-id'), item = this.indexes[String(id)]; if (item == null) { continue; } const needChangeMod = this.multiple && Object.isSet(value) ? value.has(item.value) : value === item.value; if (needChangeMod) { $b.setElMod(el, 'item', 'selected', false); if (this.native) { el.selected = false; } else { el.setAttribute('aria-selected', 'false'); } } } }).catch(stderr); return true; } /** * Toggles selection of an item by the specified value. * The methods return a new selected value/s. * * @param value * @param [unselectPrevious] - true, if needed to unselect previous selected items * (works only with the `multiple` mode) */ toggleValue(value: this['Value'], unselectPrevious: boolean = false): this['Value'] { const valueStore = this.field.get('valueStore'); if (this.multiple) { if (!Object.isSet(valueStore)) { return this.value; } const toggle = (value) => { if (valueStore.has(value)) { if (unselectPrevious) { this.unselectValue(this.value); } else { this.unselectValue(value); } return; } this.selectValue(value, unselectPrevious); }; if (Object.isSet(value)) { Object.forEach(value, toggle); } else { toggle(value); } } else if (valueStore !== value) { this.selectValue(value); } else { this.unselectValue(value); } return this.value; } /** @see [[iOpenToggle.open]] */ async open(...args: unknown[]): Promise<boolean> { if (this.multiple || this.native) { return false; } if (await iOpenToggle.open(this, ...args)) { await this.setScrollToMarkedOrSelectedItem(); return true; } return false; } /** @see [[iOpenToggle.open]] */ async close(...args: unknown[]): Promise<boolean> { if (this.native) { return false; } if (this.multiple || await iOpenToggle.close(this, ...args)) { const {block: $b} = this; if ($b != null) { const markedEl = $b.element('item', {marked: true}); if (markedEl != null) { $b.removeElMod(markedEl, 'item', 'marked'); } } return true; } return false; } /** @see [[iOpenToggle.onOpenedChange]] */ // eslint-disable-next-line @typescript-eslint/require-await async onOpenedChange(e: ModEvent | SetModEvent): Promise<void> { await on.openedChange(this, e); } /** * Sets the scroll position to the first marked or selected item */ protected setScrollToMarkedOrSelectedItem(): Promise<boolean> { return h.setScrollToMarkedOrSelectedItem(this); } protected override initBaseAPI(): void { super.initBaseAPI(); const i = this.instance; this.normalizeItems = i.normalizeItems.bind(this); this.selectValue = i.selectValue.bind(this); } /** @see [[iOpenToggle.initCloseHelpers]] */ @p({hook: 'beforeDataCreate', replace: false}) protected initCloseHelpers(events?: CloseHelperEvents): void { iOpenToggle.initCloseHelpers(this, events); } /** * Initializes component values */ @hook('beforeDataCreate') protected initComponentValues(): void { h.initComponentValues(this); } /** * Normalizes the specified items and returns it * @param items */ protected normalizeItems(items: CanUndef<this['Items']>): this['Items'] { return h.normalizeItems(items); } protected override normalizeAttrs(attrs: Dictionary = {}): Dictionary { attrs = super.normalizeAttrs(attrs); if (this.native) { attrs.multiple = this.multiple; } return attrs; } /** * 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 ?? {}; } /** @see [[iItems.getItemKey]] */ protected getItemKey(item: this['Item'], i: number): CanUndef<IterationKey> { return iItems.getItemKey(this, item, i); } /** * Synchronization of items * * @param items * @param oldItems * @emits `itemsChange(value: this['Items'])` */ @watch('itemsStore') protected syncItemsWatcher(items: this['Items'], oldItems: this['Items']): void { if (!Object.fastCompare(items, oldItems)) { this.initComponentValues(); this.emit('itemsChange', items); } } protected override initModEvents(): void { super.initModEvents(); this.sync.mod('native', 'native', Boolean); this.sync.mod('multiple', 'multiple', Boolean); this.sync.mod('opened', 'multiple', Boolean); } protected override beforeDestroy(): void { super.beforeDestroy(); if (openedSelect.link === this) { openedSelect.link = null; } } protected override onFocus(): void { super.onFocus(); void this.open(); } /** * Handler: clearing of a component value * @emits `actionChange(value: this['Value'])` */ protected async onClear(): Promise<void> { if (await this.clear()) { this.emit('actionChange', this.value); } } /** * Handler: value changing of a native component `<select>` * @emits `actionChange(value: this['Value'])` */ protected onNativeChange(): void { on.nativeChange(this); } protected override onMaskInput(): Promise<boolean> { return super.onMaskInput().then((res) => { if (res) { this.onTextChange(); } return res; }); } protected override onMaskKeyPress(e: KeyboardEvent): boolean { if (super.onMaskKeyPress(e)) { this.onTextChange(); return true; } return false; } protected override onMaskDelete(e: KeyboardEvent): boolean { if (super.onMaskDelete(e)) { this.onTextChange(); return true; } return false; } /** * Handler: typing text into a helper text input to search select options * * @param e * @emits `actionChange(value: this['Value'])` */ protected onSearchInput(e: InputEvent): void { on.searchInput(this, e); } /** * Handler: click to some item element * * @param itemEl * @emits `actionChange(value: this['Value'])` */ @watch({ field: '?$el:click', wrapper: (o, cb) => o.dom.delegateElement('item', (e: MouseEvent) => cb(e.delegateTarget)) }) protected onItemClick(itemEl: CanUndef<Element>): void { on.itemClick(this, itemEl); } /** * Handler: "navigation" over the select via "arrow" buttons * @param e */ protected onItemsNavigate(e: KeyboardEvent): void { void on.itemsNavigate(this, e); } } export default bSelect;