@v4fire/client
Version:
V4Fire client core library
451 lines (366 loc) • 9.9 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: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
*/
export default class bTextarea extends iInputText {
override readonly Value!: Value;
override readonly FormValue!: FormValue;
override readonly rootTag: string = 'span';
override readonly valueProp?: this['Value'];
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`.
*/
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
*/
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
};
protected override valueStore!: this['Value'];
protected override textStore!: string;
/**
* The minimum textarea height
*/
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.
*/
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
*/
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.
*/
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
*/
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
*/
protected async syncValueStoreWatcher(): Promise<void> {
await this.fitHeight();
}
/**
* Synchronization of the `limit` slot
*/
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
*/
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;
}
}