UNPKG

@v4fire/client

Version:

V4Fire client core library

451 lines (366 loc) • 9.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-textarea/README.md]] * @packageDocumentation */ //#if demo import 'models/demo/input'; //#endif import symbolGenerator from 'core/symbol'; import SyncPromise from 'core/promise/sync'; import iInputText, { component, prop, system, computed, hook, wait, watch, TextValidators, ValidatorsDecl } from 'super/i-input-text/i-input-text'; import type { Value, FormValue } from 'form/b-textarea/interface'; export * from 'super/i-input-text/i-input-text'; export * from 'form/b-textarea/interface'; export { Value, FormValue }; export const $$ = symbolGenerator(); /** * Component to create a form textarea */ @component({ functional: { dataProvider: undefined } }) export default class bTextarea extends iInputText { override readonly Value!: Value; override readonly FormValue!: FormValue; override readonly rootTag: string = 'span'; @prop({type: String, required: false}) override readonly valueProp?: this['Value']; @prop({type: String, required: false}) override readonly defaultProp?: this['Value']; /** * How many rows need to add to extend the textarea height when it can't fit the entire content without * showing a scrollbar. The value of one row is equal to `line-height` of the textarea or `font-size`. */ @prop(Number) readonly extRowCount: number = 1; override get value(): this['Value'] { return this.field.get<this['Value']>('valueStore')!; } override set value(value: this['Value']) { this.text = value; this.field.set('valueStore', this.text); } override get default(): this['Value'] { return this.defaultProp != null ? String(this.defaultProp) : ''; } /** * Textarea height */ get height(): CanPromise<number> { return this.waitStatus('ready', () => { const {input} = this.$refs; return input.scrollHeight + <number>this.borderHeight - <number>this.paddingHeight; }); } /** * The maximum textarea height */ get maxHeight(): CanPromise<number> { return this.waitStatus('ready', () => { const s = getComputedStyle(this.$refs.input); return this.parse(s.maxHeight) + <number>this.borderHeight - <number>this.paddingHeight; }); } /** * Height of a newline. * It depends on `line-height/font-size` of the textarea. */ get newlineHeight(): CanPromise<number> { return this.waitStatus('ready', () => { const s = getComputedStyle(this.$refs.input), lineHeight = parseFloat(s.lineHeight); return isNaN(lineHeight) ? parseFloat(s.fontSize) : lineHeight; }); } /** * Number of remaining characters that the component can contain */ @computed({dependencies: ['value']}) get limit(): CanUndef<number> { if (this.maxLength === undefined) { return undefined; } const val = this.maxLength - this.value.length; return val >= 0 ? val : 0; } static override validators: ValidatorsDecl = { ...iInputText.validators, ...TextValidators }; @system() protected override valueStore!: this['Value']; @system({ after: 'valueStore', init: (o) => o.sync.link((text) => { o.watch('valueProp', {label: $$.textStore}, () => { const label = { label: $$.textStoreToValueStore }; o.watch('valueStore', label, (v) => { o.async.clearAll(label); return link(v); }); }); return link(Object.cast(o.valueProp)); function link(textFromValue: CanUndef<string>): string { const resolvedText = textFromValue === undefined ? text ?? o.field.get('valueStore') : textFromValue, str = resolvedText !== undefined ? String(resolvedText) : ''; if (o.isNotRegular) { o.waitStatus('ready', {label: $$.textStoreSync}).then(() => o.text = str).catch(stderr); } else if (o.hook === 'updated') { o.text = str; } return str; } }) }) protected override textStore!: string; /** * The minimum textarea height */ @system() protected minHeight: number = 0; protected override readonly $refs!: iInputText['$refs'] & { input: HTMLTextAreaElement; }; /** * Sum of the textarea `border-top-width` and `border-bottom-width` */ protected get borderHeight(): CanPromise<number> { return this.waitStatus('ready', () => { const s = getComputedStyle(this.$refs.input); return this.parse(s.borderBottomWidth) + this.parse(s.borderTopWidth); }); } /** * Sum of the textarea `padding-top` and `padding-bottom` */ protected get paddingHeight(): CanPromise<number> { return this.waitStatus('ready', () => { const s = getComputedStyle(this.$refs.input); return this.parse(s.paddingTop) + this.parse(s.paddingBottom); }); } override clear(): Promise<boolean> { const v = this.value; void this.clearText(); if (v !== '') { this.async.clearAll({group: 'validation'}); void this.removeMod('valid'); this.emit('clear', this.value); return SyncPromise.resolve(true); } return SyncPromise.resolve(false); } /** * Updates the textarea height to show its content without showing a scrollbar. * The method returns a new height value. */ @wait('ready', {defer: true, label: $$.calcHeight}) fitHeight(): Promise<CanUndef<number>> { const { $refs: {input}, value: {length} } = this; if (input.scrollHeight <= input.clientHeight) { if (input.clientHeight > this.minHeight && (this.prevValue ?? '').length > length) { return Promise.resolve(this.minimizeHeight()); } return Promise.resolve(undefined); } const height = <number>this.height, maxHeight = <number>this.maxHeight, newlineHeight = <number>this.newlineHeight; const newHeight = height + (this.extRowCount - 1) * newlineHeight, fixedNewHeight = newHeight < maxHeight ? newHeight : maxHeight; input.style.height = fixedNewHeight.px; return Promise.resolve(fixedNewHeight); } /** * Initializes the textarea height */ @hook('mounted') protected async initHeight(): Promise<void> { await this.nextTick(); await this.dom.putInStream(async () => { this.minHeight = this.$refs.input.clientHeight; await this.fitHeight(); }); } /** * Minimizes the textarea height. * The method returns a new height value. */ @wait('ready', {defer: true, label: $$.minimize}) protected minimizeHeight(): Promise<number> { const { minHeight, $refs: {input} } = this; const maxHeight = <number>this.maxHeight; let newHeight = <number>this.getTextHeight(); if (newHeight < minHeight) { newHeight = minHeight; } else if (newHeight > maxHeight) { newHeight = maxHeight; } input.style.height = this.value !== '' ? newHeight.px : ''; return SyncPromise.resolve(newHeight); } /** * Returns height of textarea' text content */ @wait('ready', {label: $$.getTextHeight}) protected getTextHeight(): CanPromise<number> { const {input} = this.$refs; if (this.$el == null || this.block == null) { return 0; } const tmp = <HTMLElement>this.$el.cloneNode(true), tmpInput = <HTMLTextAreaElement>tmp.querySelector(this.block.getElSelector('input')); tmpInput.value = input.value; Object.assign(tmpInput.style, { width: input.clientWidth.px, height: 'auto' }); Object.assign(tmp.style, { position: 'absolute', top: 0, left: 0, 'z-index': -1 }); document.body.appendChild(tmp); const height = tmpInput.scrollHeight + <number>this.borderHeight; tmp.remove(); return height; } /** * Parses the specified value as a number and returns it or `0` * (if the parsing is failed) * * @param value */ protected parse(value: string): number { const v = parseFloat(value); return isNaN(v) ? 0 : v; } /** * Synchronization for the `text` field */ @watch({path: 'valueStore', immediate: true}) protected async syncValueStoreWatcher(): Promise<void> { await this.fitHeight(); } /** * Synchronization of the `limit` slot */ @watch('value') protected syncLimitSlotWatcher(): void { if (this.isNotRegular) { return; } if (this.vdom.getSlot('limit') != null) { void this.forceUpdate(); } } /** * Handler: updating of a limit warning * @param el */ protected onLimitUpdate(el: Element): void { const { block, compiledMask, messageHelpers, limit, maxLength } = this; if ( block == null || compiledMask != null || messageHelpers !== true || limit == null || maxLength == null ) { return; } if (limit > maxLength / 1.5) { block.setElMod(el, 'limit', 'hidden', true); } else { block.setElMod(el, 'limit', 'hidden', false); block.setElMod(el, 'limit', 'warning', limit < maxLength / 3); el.innerHTML = this.t('Characters left: {limit}', {limit}); } } /** * Handler: updating of a component text value */ @watch({path: 'textStore', immediate: true}) @hook('beforeDataCreate') protected onTextUpdate(): void { this.field.set('valueStore', this.text); } /** * Handler: manual editing of a component text value * @emits `actionChange(value: this['Value'])` */ protected onEdit(): void { if (this.compiledMask != null) { return; } this.value = this.$refs.input.value; this.field.set('textStore', this.value); this.emit('actionChange', this.value); } protected override onMaskInput(): Promise<boolean> { return super.onMaskInput().then((res) => { if (res) { this.emit('actionChange', this.value); } return res; }); } protected override onMaskKeyPress(e: KeyboardEvent): boolean { if (super.onMaskKeyPress(e)) { this.emit('actionChange', this.value); return true; } return false; } protected override onMaskDelete(e: KeyboardEvent): boolean { if (super.onMaskDelete(e)) { this.emit('actionChange', this.value); return true; } return false; } }