import type { VNode } from 'vue'; import type { DropdownButtonItemArgs } from '../dropdown-button/dropdown-button-item'; import type { FormItemWrapperArgs, MarginType } from '../form/form-item-wrapper'; import { Prop, toNative } from 'vue-facing-decorator'; import PowerduckState from '../../app/powerduck-state'; import TsxComponent, { Component } from '../../app/vuetsx'; import { PortalUtils } from '../../common/utils/utils'; import FormItemWrapper from '../form/form-item-wrapper'; interface TextBoxArgs extends Omit { value: string; placeholder?: string; textType?: TextBoxTextType; disabled?: boolean; autoCompleteText?: string; autoCompleteEnabled?: boolean; readOnly?: boolean; changed: (newValue: string) => void; maxLength?: number; updateMode?: 'input' | 'change'; nameAttr?: string; keyUp?: (e: KeyboardEvent) => void; keyDown?: (e: KeyboardEvent) => void; enterPressed?: (e: KeyboardEvent) => void; hidePasswordToggle?: boolean; /** FloatingField parity: `inputmode` hint (e.g. 'email', 'tel', 'numeric'). */ inputMode?: string; /** FloatingField parity: per-keystroke value transform (mask). Receives raw + previous, returns kept value. */ mask?: (raw: string, previous: string) => string; /** FloatingField parity: monospace value (OTP / codes). */ mono?: boolean; } export const enum TextBoxTextType { Text = 'text', Password = 'password', /** * Same DOM rendering as Password (type="password" — characters hidden), but * sets autocomplete="new-password" by default. Use for password fields that * should NOT trigger the browser's login-form heuristic — admin config fields * for third-party integration credentials, "set new password" forms, etc. * * The login form intentionally uses Password (with explicit autoCompleteText * "current-password") so the browser still autofills saved credentials. */ PasswordNew = 'password-new', Url = 'url', Email = 'email', Time = 'time', Phone = 'phone', } @Component class TextBoxComponent extends TsxComponent implements TextBoxArgs { @Prop() label!: string | VNode; @Prop() labelButtons!: DropdownButtonItemArgs[]; @Prop() subtitle!: string; @Prop() value!: string; @Prop() placeholder!: string; @Prop() cssClass!: string; @Prop() mandatory!: boolean; @Prop() disabled!: boolean; @Prop() wrap!: boolean; @Prop() hint: string; @Prop() appendIcon: string; @Prop() prependIcon: string; @Prop() maxWidth?: number; @Prop() nameAttr?: string; @Prop() marginType?: MarginType; @Prop() textType: TextBoxTextType; @Prop() readOnly?: boolean; @Prop() autoCompleteText?: string; @Prop() autoCompleteEnabled: boolean; @Prop() appendClicked: () => void; @Prop() prependClicked: () => void; @Prop() prependIconClicked: () => void; @Prop() appendIconClicked: () => void; @Prop() keyDown: (e: KeyboardEvent) => void; @Prop() keyUp: (e: KeyboardEvent) => void; @Prop() enterPressed: (e: KeyboardEvent) => void; @Prop() changed: (newValue: string) => void; @Prop() showClearValueButton!: boolean; @Prop() updateMode?: 'input' | 'change'; @Prop() maxLength: number; @Prop() hidePasswordToggle?: boolean; @Prop() inputMode?: string; @Prop() mask?: (raw: string, previous: string) => string; @Prop() mono?: boolean; isPasswordVisible: boolean = false; // Defer the "real" autocomplete token until the user actually focuses the field // (see getAutoCompleteText). Flips to true on first focus and stays true. autoCompleteActivated: boolean = false; mounted() { if (this.keyDown != null || this.enterPressed != null) { this.$el.querySelector('input').addEventListener('keydown', (e) => { this.keyDownHandler(e); }); } if (this.keyUp != null) { this.$el.querySelector('input').addEventListener('keyup', (e) => { this.keyUpHandler(e); }); } } raiseChangeEvent(e) { this.populateValidationDeclaration(); if (this.changed != null) { const newValue = e.target.value; this.changed(newValue); } } getTypeAttribute(): string { if (this.textType === TextBoxTextType.Password && this.isPasswordVisible) { return 'text'; } if (this.textType === TextBoxTextType.PasswordNew && this.isPasswordVisible) { return 'text'; } if (this.textType === TextBoxTextType.PasswordNew) { return 'password'; } return this.textType || 'text'; } togglePasswordVisibility() { this.isPasswordVisible = !this.isPasswordVisible; } get computedAppendIcon(): string { if ((this.textType === TextBoxTextType.Password || this.textType === TextBoxTextType.PasswordNew) && !this.hidePasswordToggle) { return this.isPasswordVisible ? 'fa fa-eye-slash' : 'fa fa-eye'; } return this.appendIcon; } get computedAppendIconClicked(): (() => void) { if ((this.textType === TextBoxTextType.Password || this.textType === TextBoxTextType.PasswordNew) && !this.hidePasswordToggle) { return () => this.togglePasswordVisibility(); } return this.appendIconClicked; } // Autocomplete is emitted in two phases so a browser can't offer autofill for a recognized // login field (username / email / current-password) purely because a modal took focus on // open — the behavior Safari exhibits (Chrome only offers on a real user gesture). Until the // user focuses the field we emit a suppressing token; on first focus (`activateAutoComplete`) // we restore the real token so password managers still pair and save on submit. Nothing here // auto-focuses, so the first focus is always user-initiated. getAutoCompleteText(): string { if (this.autoCompleteText) { return this.autoCompleteActivated ? this.autoCompleteText : this.getSuppressedAutoCompleteText(); } if (this.autoCompleteEnabled == false) { return this.getSuppressedAutoCompleteText(); } if (this.textType === TextBoxTextType.PasswordNew) { return 'new-password'; } return null; } // Browsers ignore autocomplete="off" for login fields, so on Chrome we use "new-password" // (which Chrome honors as "don't autofill saved logins"); on other engines "off" is enough // to keep the on-open AutoFill suggestion from appearing. private getSuppressedAutoCompleteText(): string { return PortalUtils.isBrowserChrome() ? 'new-password' : 'off'; } private activateAutoComplete(e: FocusEvent): void { if (this.autoCompleteActivated) { return; } this.autoCompleteActivated = true; // Vue re-renders asynchronously; set the attribute on the element synchronously too so the // real token is present during THIS focus and the password manager can offer autofill on // the first click (not only the next one). const el = e.target as HTMLInputElement; const token = this.getAutoCompleteText(); if (token != null) { el.setAttribute('autocomplete', token); } else { el.removeAttribute('autocomplete'); } } keyDownHandler(e: KeyboardEvent) { if (this.keyDown != null) { this.keyDown(e); } if (this.enterPressed != null && (e.keyCode == 13 || e.which == 13)) { this.raiseChangeEvent(e); this.enterPressed(e); e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); } } keyUpHandler(e: KeyboardEvent) { if (this.keyUp != null) { this.keyUp(e); } if (this.enterPressed != null && (e.keyCode == 13 || e.which == 13)) { this.raiseChangeEvent(e); this.enterPressed(e); e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); } } // FloatingField parity: optional per-keystroke input mask. When set, transform the raw // value, force-sync the DOM (so rejected chars vanish even when the masked result equals // the bound value), then emit the masked value on every keystroke. private handleInput(e: Event): void { const el = e.target as HTMLInputElement; if (this.mask != null) { const masked = this.mask(el.value, this.value ?? ''); if (el.value !== masked) { el.value = masked; } this.populateValidationDeclaration(); this.changed?.(masked); return; } if (this.updateMode == 'input') { this.raiseChangeEvent(e); } } private get errorId(): string { return `textbox-error-${this.$.uid}`; } render(h) { const hasError = this.hasValidationError; return ( this.raiseChangeEvent(e)} onInput={e => this.handleInput(e)} onFocus={e => this.activateAutoComplete(e)} class={this.mono === true ? `${PowerduckState.getFormControlCssClass()} is-mono` : PowerduckState.getFormControlCssClass()} inputmode={this.inputMode} placeholder={this.placeholder} autocomplete={this.getAutoCompleteText()} aria-invalid={hasError ? 'true' : undefined} aria-describedby={hasError ? this.errorId : undefined} aria-required={this.mandatory ? 'true' : undefined} /> ); } } const TextBox = toNative(TextBoxComponent); export default TextBox;