uicore-ts
Version:
UICore is a library to build native-like user interfaces using pure Typescript. No HTML is needed at all. Components are described as TS classes and all user interactions are handled explicitly. This library is strongly inspired by the UIKit framework tha
470 lines (370 loc) • 17.1 kB
text/typescript
import { UIColor } from "./UIColor"
import { UICore } from "./UICore"
import { nil, NO, ValueOf, YES } from "./UIObject"
import { UITextView } from "./UITextView"
import { UIView, UIViewAddControlEventTargetObject, UIViewBroadcastEvent } from "./UIView"
export class UITextField extends UITextView {
_placeholderTextKey?: string
_defaultPlaceholderText?: string
override _viewHTMLElement!: HTMLInputElement
// --- Native Autocomplete (HTML datalist) ---
_datalistElement?: HTMLDataListElement
_nativeAutocompleteData: string[] = []
_hasCommittedSelection: boolean = NO
/** Minimum characters required before showing autocomplete suggestions */
minCharactersForAutocomplete: number = 0
/**
* When YES, hides the datalist if the current text exactly matches
* a single autocomplete option (avoids showing redundant single suggestion).
* Default is YES for better UX.
*/
hideNativeAutocompleteOnExactMatch: boolean = YES
// --- Validation against autocomplete list ---
_validatesAgainstNativeAutocomplete: boolean = NO
_isValidAgainstNativeAutocomplete: boolean = YES
_validationInvalidBackgroundColor = UIColor.redColor.colorWithAlpha(0.5)
_validationInvalidBorderColor = UIColor.colorWithRGBA(200, 0, 0, 0.5)
static override controlEvent = Object.assign({}, UITextView.controlEvent, {
"TextChange": "TextChange",
"ValidationChange": "ValidationChange"
})
constructor(
elementID?: string,
viewHTMLElement = null,
type: string | ValueOf<typeof UITextView.type> = UITextView.type.textField
) {
super(elementID, type, viewHTMLElement)
this.textElementView.viewHTMLElement.setAttribute("type", "text")
this.backgroundColor = UIColor.transparentColor
this.addTargetForControlEvent(
UIView.controlEvent.PointerUpInside,
(sender, event) => sender.focus()
)
this.textElementView.viewHTMLElement.oninput = (event) => {
this._hasCommittedSelection = NO
this.sendControlEventForKey(UITextField.controlEvent.TextChange, event)
this._validateAgainstNativeAutocompleteIfNeeded()
this._updateDatalistVisibility()
}
this.textElementView.viewHTMLElement.onchange = (event) => {
this.sendControlEventForKey(UITextField.controlEvent.TextChange, event)
// Fires when the user commits a selection from the datalist (enter or click).
// Regular typing does not trigger onchange, only committing a value does.
if (this._datalistElement && this._nativeAutocompleteData.includes(this.text)) {
this._hasCommittedSelection = YES
this._updateDatalistVisibility()
}
// Validate on change (commit) when validation is enabled
this._validateAgainstNativeAutocompleteIfNeeded()
}
this.textElementView.controlEventTargetAccumulator.Blur = (sender, event) => {
// Final validation when leaving the field
this._validateAgainstNativeAutocompleteIfNeeded()
}
this.textElementView.style.webkitUserSelect = "text"
this.nativeSelectionEnabled = YES
this.pausesPointerEvents = NO
this.changesOften = YES
}
override get controlEventTargetAccumulator(): UIViewAddControlEventTargetObject<typeof UITextField> {
return (super.controlEventTargetAccumulator as any)
}
public override get viewHTMLElement() {
return this._viewHTMLElement
}
public override set text(text: string) {
this.textElementView.viewHTMLElement.value = text
// Re-validate when text is set programmatically
this._validateAgainstNativeAutocompleteIfNeeded()
this._updateDatalistVisibility()
}
public override get text(): string {
return this.textElementView.viewHTMLElement.value
}
public set placeholderText(text: string) {
this.textElementView.viewHTMLElement.placeholder = text
}
public get placeholderText(): string {
return this.textElementView.viewHTMLElement.placeholder
}
setPlaceholderText(key: string, defaultString: string) {
this._placeholderTextKey = key
this._defaultPlaceholderText = defaultString
const languageName = UICore.languageService.currentLanguageKey
this.placeholderText = UICore.languageService.stringForKey(key, languageName, defaultString, nil)
}
/**
* Controls whether the browser is allowed to autofill this field.
* Defaults to YES. Set to NO for sensitive fields such as passwords
* in registration or reset flows where autofill is undesirable.
*/
public get autocompleteEnabled(): boolean {
const value = this.textElementView.viewHTMLElement.getAttribute("autocomplete")
return value === null || value === "" || value === "on"
}
public set autocompleteEnabled(enabled: boolean) {
if (enabled) {
this.textElementView.viewHTMLElement.removeAttribute("autocomplete")
}
else {
const type = this.textElementView.viewHTMLElement.type
if (type === "password") {
// "new-password" prevents autofill; appending "one-time-code" suppresses
// Chrome's offer to save the password after submission
this.textElementView.viewHTMLElement.setAttribute("autocomplete", "new-password one-time-code")
}
else {
this.textElementView.viewHTMLElement.setAttribute("autocomplete", "off")
}
}
}
override didReceiveBroadcastEvent(event: UIViewBroadcastEvent) {
super.didReceiveBroadcastEvent(event)
if (event.name == UIView.broadcastEventName.LanguageChanged || event.name ==
UIView.broadcastEventName.AddedToViewTree) {
this._setPlaceholderFromKeyIfPossible()
}
}
override willMoveToSuperview(superview: UIView) {
super.willMoveToSuperview(superview)
this._setPlaceholderFromKeyIfPossible()
}
_setPlaceholderFromKeyIfPossible() {
if (this._placeholderTextKey && this._defaultPlaceholderText) {
this.setPlaceholderText(this._placeholderTextKey, this._defaultPlaceholderText)
}
}
public get isSecure(): boolean {
const result = (this.textElementView.viewHTMLElement.type == "password")
return result
}
public set isSecure(secure: boolean) {
let type = "text"
if (secure) {
type = "password"
}
this.textElementView.viewHTMLElement.type = type
}
// MARK: - Native Autocomplete Methods
/**
* Sets the data for native browser autocomplete using HTML datalist.
* Setting an empty array will remove the autocomplete functionality.
*
* @param data Array of strings to show as autocomplete suggestions
*/
public set nativeAutocompleteData(data: string[]) {
this._nativeAutocompleteData = data
this._hasCommittedSelection = NO
this._updateDatalist()
this._validateAgainstNativeAutocompleteIfNeeded()
this._updateDatalistVisibility()
}
public get nativeAutocompleteData(): string[] {
return this._nativeAutocompleteData
}
// MARK: - Validation Methods
/**
* When enabled, the text field will validate its content against the autocomplete list.
* Invalid values will trigger a ValidationChange event and can be checked via isValidAgainstAutocomplete.
*
* Empty text is always considered valid (use required field validation separately if needed).
*/
public set validatesAgainstNativeAutocomplete(validate: boolean) {
if (this._validatesAgainstNativeAutocomplete !== validate) {
this._validatesAgainstNativeAutocomplete = validate
this._validateAgainstNativeAutocompleteIfNeeded()
}
}
public get validatesAgainstNativeAutocomplete(): boolean {
return this._validatesAgainstNativeAutocomplete
}
/**
* Returns YES if the current text value is valid according to autocomplete validation.
* Always returns YES if validatesAgainstAutocomplete is disabled.
* Empty text is considered valid.
*/
public get isValidAgainstNativeAutocomplete(): boolean {
return this._isValidAgainstNativeAutocomplete
}
/**
* Background color to apply when validation fails.
* Set to nil to disable background color change on invalid state.
*/
public set validationInvalidBackgroundColor(color: UIColor) {
this._validationInvalidBackgroundColor = color
this._updateValidationVisualState()
}
public get validationInvalidBackgroundColor(): UIColor {
return this._validationInvalidBackgroundColor
}
/**
* Border color to apply when validation fails.
* Set to nil to disable border color change on invalid state.
*/
public set validationInvalidBorderColor(color: UIColor) {
this._validationInvalidBorderColor = color
this._updateValidationVisualState()
}
public get validationInvalidBorderColor(): UIColor {
return this._validationInvalidBorderColor
}
/**
* Validates the current text against the autocomplete list if validation is enabled.
* Updates the _isValidAgainstAutocomplete flag and fires ValidationChange event on state change.
*/
_validateAgainstNativeAutocompleteIfNeeded() {
if (!this._validatesAgainstNativeAutocomplete) {
this._setValidationState(YES)
return
}
const currentText = this.text
// Empty text is considered valid (use separate required validation if needed)
if (currentText.length === 0) {
this._setValidationState(YES)
return
}
const isValid = this._nativeAutocompleteData.includes(currentText)
this._setValidationState(isValid)
}
_setValidationState(isValid: boolean) {
const wasValid = this._isValidAgainstNativeAutocomplete
this._isValidAgainstNativeAutocomplete = isValid
// Update visual state
this._updateValidationVisualState()
// Fire event only on state change
if (wasValid !== isValid) {
this.sendControlEventForKey(UITextField.controlEvent.ValidationChange)
}
}
/**
* Updates the visual state of the text field based on validation status.
* Override this method to customize validation styling.
*/
_updateValidationVisualState() {
const inputElement = this.textElementView.viewHTMLElement
if (!this._validatesAgainstNativeAutocomplete || this._isValidAgainstNativeAutocomplete) {
// Restore normal state - clear validation-specific styles
inputElement.classList.remove("autocomplete-invalid")
// Reset to default colors if we had set validation colors
if (this._validationInvalidBackgroundColor) {
inputElement.style.removeProperty("background-color")
}
if (this._validationInvalidBorderColor) {
inputElement.style.removeProperty("border-color")
}
} else {
// Apply invalid state
inputElement.classList.add("autocomplete-invalid")
// Apply validation colors if set
if (this._validationInvalidBackgroundColor) {
inputElement.style.backgroundColor = this._validationInvalidBackgroundColor.stringValue
}
if (this._validationInvalidBorderColor) {
inputElement.style.borderColor = this._validationInvalidBorderColor.stringValue
}
}
}
/**
* Clears the text field if the current value is not in the autocomplete list.
* Useful for enforcing selection from the list only.
* @returns YES if the text was cleared (was invalid), NO otherwise
*/
public clearIfInvalid(): boolean {
if (this._validatesAgainstNativeAutocomplete && !this._isValidAgainstNativeAutocomplete && this.text.length > 0) {
this.text = ""
return YES
}
return NO
}
/**
* Returns a list of autocomplete options that match the current text (case-insensitive).
* Useful for implementing custom filtering or showing filtered results elsewhere.
*/
public getMatchingAutocompleteOptions(): string[] {
const currentText = this.text
if (currentText.length === 0) {
return [...this._nativeAutocompleteData]
}
return this._getFilteredAutocompleteOptions(currentText)
}
// MARK: - Datalist Management
_updateDatalist() {
// If no data, remove the datalist
if (this._nativeAutocompleteData.length === 0) {
if (this._datalistElement) {
this._datalistElement.remove()
this.textElementView.viewHTMLElement.removeAttribute("list")
this._datalistElement = undefined
}
return
}
// Create datalist if it doesn't exist
if (!this._datalistElement) {
const datalistId = this.elementID + "_datalist"
this._datalistElement = document.createElement("datalist")
this._datalistElement.id = datalistId
// Add datalist as a sibling to the text element within this view's container
this.viewHTMLElement.appendChild(this._datalistElement)
this.textElementView.viewHTMLElement.setAttribute("list", datalistId)
}
// Update the options
this._datalistElement.innerHTML = ""
this._nativeAutocompleteData.forEach(item => {
const option = document.createElement("option")
option.value = item
this._datalistElement!.appendChild(option)
})
}
_updateDatalistVisibility() {
if (!this._datalistElement) {
return
}
// After the user has picked a value from the list, hide suggestions until
// they start typing again. oninput clears _hasCommittedSelection, so this
// gate only holds for exactly as long as the selection stands untouched.
if (this._hasCommittedSelection) {
this.textElementView.viewHTMLElement.removeAttribute("list")
return
}
const currentText = this.text
// Check minimum character requirement
if (this.minCharactersForAutocomplete > 0 &&
currentText.length < this.minCharactersForAutocomplete) {
this.textElementView.viewHTMLElement.removeAttribute("list")
return
}
// Hide datalist when it would show only a single redundant option
if (this.hideNativeAutocompleteOnExactMatch && currentText.length > 0) {
// Count how many options would be offered (browser uses starts-with logic)
const matchingOptions = this._nativeAutocompleteData.filter(item =>
item.toLowerCase().startsWith(currentText.toLowerCase()) ||
currentText.toLowerCase().startsWith(item.toLowerCase())
)
// If only one option matches, it's redundant - hide the datalist
if (matchingOptions.length === 1) {
this.textElementView.viewHTMLElement.removeAttribute("list")
return
}
}
// Show the datalist
this.textElementView.viewHTMLElement.setAttribute("list", this._datalistElement.id)
}
/**
* Returns autocomplete options that match the given search text.
* Uses case-insensitive substring matching (consistent with browser behavior).
*/
_getFilteredAutocompleteOptions(searchText: string): string[] {
const searchLower = searchText.toLowerCase()
return this._nativeAutocompleteData.filter(item =>
item.toLowerCase().includes(searchLower)
)
}
override wasRemovedFromViewTree() {
super.wasRemovedFromViewTree()
// Clean up datalist element when text field is removed
if (this._datalistElement) {
this._datalistElement.remove()
this._datalistElement = undefined
}
}
}