import { css, html } from "lit"; import { ifDefined } from "lit/directives/if-defined.js"; import { property } from "lit/decorators.js"; import { BaseElement } from "../../core/base-element.ts"; import { consume } from "@lit/context"; import { applyThemeToElement, type CTTheme, defaultTheme, themeContext, } from "../theme-context.ts"; import { type Cell } from "@commontools/runner"; import { type InputTimingOptions } from "../../core/input-timing-controller.ts"; import { createStringCellController } from "../../core/cell-controller.ts"; /** * CTInput - Enhanced input field with support for various types, validation patterns, and reactive data binding * * @element ct-input * * @attr {string} type - Input type: "text" | "email" | "password" | "number" | "search" | "tel" | "url" | "date" | "time" | "datetime-local" | "month" | "week" | "color" | "file" | "range" | "hidden" * @attr {string} placeholder - Placeholder text * @attr {string|Cell} value - Input value (supports both plain string and Cell) * @attr {boolean} disabled - Whether the input is disabled * @attr {boolean} readonly - Whether the input is read-only * @attr {boolean} required - Whether the input is required * @attr {string} name - Name attribute for form submission * @attr {string|number} min - Minimum value (for number, date, range inputs) * @attr {string|number} max - Maximum value (for number, date, range inputs) * @attr {string|number} step - Step value (for number, range inputs) * @attr {string} pattern - Custom validation pattern (regex) * @attr {string} validationPattern - Predefined pattern: "email" | "url" | "tel-us" | "tel-intl" | "credit-card" | "zip-us" | "alphanumeric" | "letters" | "numbers" * @attr {string} autocomplete - Autocomplete hint * @attr {string} inputmode - Virtual keyboard mode: "none" | "text" | "decimal" | "numeric" | "tel" | "search" | "email" | "url" * @attr {number} size - Width of input in characters * @attr {boolean} multiple - Allow multiple files (file input only) * @attr {string} accept - File types to accept (file input only) * @attr {string} list - ID of datalist element for suggestions * @attr {string} spellcheck - Enable/disable spellcheck * @attr {boolean} showValidation - Show validation state visually * @attr {boolean} error - Manual error state override * @attr {string} timingStrategy - Input timing strategy: "immediate" | "debounce" | "throttle" | "blur" * @attr {number} timingDelay - Delay in milliseconds for debounce/throttle (default: 300) * * @fires ct-change - Fired when value changes (timing depends on strategy) with detail: { value, oldValue, name, files? } * @fires ct-focus - Fired on focus with detail: { value, name } * @fires ct-blur - Fired on blur with detail: { value, name } * @fires ct-keydown - Fired on keydown with detail: { key, value, shiftKey, ctrlKey, metaKey, altKey, name } * @fires ct-submit - Fired on Enter key with detail: { value, name } * @fires ct-invalid - Fired on validation failure with detail: { value, name, validationMessage, validity } * * @example * * * @example * * * @example * * * @example * * * * @example * * */ export type InputType = | "text" | "password" | "email" | "number" | "tel" | "url" | "search" | "date" | "time" | "datetime-local" | "month" | "week" | "color" | "file" | "range" | "hidden"; export type InputMode = | "none" | "text" | "decimal" | "numeric" | "tel" | "search" | "email" | "url"; // Common validation patterns for different input types export const INPUT_PATTERNS = { // Email pattern (basic validation) email: "[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", // URL pattern (http/https) url: "https?://.+", // US Phone pattern (various formats) "tel-us": "\\+?1?[-.]?\\(?([0-9]{3})\\)?[-.]?([0-9]{3})[-.]?([0-9]{4})", // International phone "tel-intl": "\\+?[0-9]{1,4}?[-.]?\\(?([0-9]{1,4})\\)?[-.]?([0-9]{1,4})[-.]?([0-9]{1,9})", // Credit card (basic - digits with optional spaces/dashes) "credit-card": "[0-9]{4}[-\\s]?[0-9]{4}[-\\s]?[0-9]{4}[-\\s]?[0-9]{4}", // ZIP code (US 5 or 9 digit) "zip-us": "[0-9]{5}(-[0-9]{4})?", // Alphanumeric only alphanumeric: "[a-zA-Z0-9]+", // Letters only letters: "[a-zA-Z]+", // Numbers only numbers: "[0-9]+", } as const; export class CTInput extends BaseElement { static override styles = css` :host { display: block; box-sizing: border-box; } *, *::before, *::after { box-sizing: inherit; } input { display: block; width: 100%; padding: 0.5rem 0.75rem; font-size: 0.875rem; line-height: 1.25rem; color: var(--ct-theme-color-text, #111827); background-color: var(--ct-theme-color-background, #ffffff); border: 1px solid var(--ct-theme-color-border, #e5e7eb); border-radius: var( --ct-theme-border-radius, var(--ct-border-radius-md, 0.375rem) ); transition: all var(--ct-theme-animation-duration, 150ms) var(--ct-transition-timing-ease); font-family: var(--ct-theme-font-family, inherit); } input::placeholder { color: var(--ct-theme-color-text-muted, #6b7280); } input:hover:not(:disabled):not(:focus) { border-color: var(--ct-theme-color-border, #d1d5db); } input:focus { outline: none; border-color: var(--ct-theme-color-primary, #3b82f6); box-shadow: 0 0 0 3px var(--ct-theme-color-primary, rgba(59, 130, 246, 0.15)); } input:disabled { cursor: not-allowed; opacity: 0.5; background-color: var(--ct-theme-color-surface, #f1f5f9); } input[readonly] { background-color: var(--ct-theme-color-surface, #f1f5f9); } input.error { border-color: var(--ct-theme-color-error, #dc2626); } input.error:focus { border-color: var(--ct-theme-color-error, #dc2626); box-shadow: 0 0 0 3px var(--ct-theme-color-error, rgba(220, 38, 38, 0.15)); } /* Remove spinner buttons from number inputs in Chrome/Safari/Edge */ input[type="number"]::-webkit-outer-spin-button, input[type="number"]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } /* Remove spinner from number inputs in Firefox */ input[type="number"] { -moz-appearance: textfield; } /* Style file input */ input[type="file"] { padding: 0.375rem 0.75rem; cursor: pointer; } input[type="file"]::file-selector-button { margin-right: 0.5rem; padding: 0.125rem 0.5rem; font-size: 0.75rem; font-weight: 500; color: var(--primary-foreground, hsl(0, 0%, 100%)); background-color: var(--primary, hsl(212, 100%, 47%)); border: none; border-radius: var(--radius-sm, 0.25rem); cursor: pointer; } input[type="file"]::file-selector-button:hover { background-color: var(--primary-hover, hsl(212, 100%, 42%)); } /* Date/time inputs */ input[type="date"], input[type="time"], input[type="datetime-local"], input[type="month"], input[type="week"] { cursor: pointer; } /* Color input */ input[type="color"] { padding: 0.25rem; cursor: pointer; } /* Search input */ input[type="search"]::-webkit-search-decoration, input[type="search"]::-webkit-search-cancel-button { -webkit-appearance: none; } /* Range input */ input[type="range"] { padding: 0.5rem 0; cursor: pointer; } input[type="range"]::-webkit-slider-track { width: 100%; height: 4px; background: var(--ct-theme-color-surface, #f1f5f9); border-radius: 2px; } input[type="range"]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 16px; height: 16px; background: var(--ct-theme-color-primary, #3b82f6); border-radius: 50%; cursor: pointer; } input[type="range"]::-moz-range-track { width: 100%; height: 4px; background: var(--ct-theme-color-surface, #f1f5f9); border-radius: 2px; } input[type="range"]::-moz-range-thumb { width: 16px; height: 16px; background: var(--ct-theme-color-primary, #3b82f6); border-radius: 50%; border: none; cursor: pointer; } /* Hidden input */ input[type="hidden"] { display: none; } /* Valid state (when showValidation is true) */ input:valid:not(:placeholder-shown) { border-color: var(--ct-theme-color-success, #16a34a); } input:valid:not(:placeholder-shown):focus { border-color: var(--ct-theme-color-success, #16a34a); box-shadow: 0 0 0 3px var(--ct-theme-color-success, rgba(22, 163, 74, 0.15)); } `; static override properties = { type: { type: String }, placeholder: { type: String }, value: { type: String }, disabled: { type: Boolean }, readonly: { type: Boolean }, error: { type: Boolean }, name: { type: String }, required: { type: Boolean }, autofocus: { type: Boolean }, autocomplete: { type: String }, min: { type: String }, max: { type: String }, step: { type: String }, pattern: { type: String }, maxlength: { type: String }, minlength: { type: String }, inputmode: { type: String }, size: { type: Number }, multiple: { type: Boolean }, accept: { type: String }, list: { type: String }, spellcheck: { type: Boolean }, validationPattern: { type: String }, showValidation: { type: Boolean }, timingStrategy: { type: String }, timingDelay: { type: Number }, }; declare type: InputType; declare placeholder: string; declare value: Cell | string; declare disabled: boolean; declare readonly: boolean; declare error: boolean; declare name: string; declare required: boolean; declare autofocus: boolean; declare autocomplete: string; declare min: string; declare max: string; declare step: string; declare pattern: string; declare maxlength: string; declare minlength: string; declare inputmode: InputMode; declare size: number; declare multiple: boolean; declare accept: string; declare list: string; declare spellcheck: boolean; declare validationPattern: keyof typeof INPUT_PATTERNS | ""; declare showValidation: boolean; declare timingStrategy: InputTimingOptions["strategy"]; declare timingDelay: number; private _changeGroup = crypto.randomUUID(); private _input: HTMLInputElement | null = null; private _cellController = createStringCellController(this, { timing: { strategy: "debounce", delay: 300, }, changeGroup: this._changeGroup, onChange: (newValue: string, oldValue: string) => { this.emit("ct-change", { value: newValue, oldValue, name: this.name, files: this.type === "file" ? this._input?.files : undefined, }); }, }); constructor() { super(); this.type = "text"; this.placeholder = ""; this.value = ""; this.disabled = false; this.readonly = false; this.error = false; this.name = ""; this.required = false; this.autofocus = false; this.autocomplete = ""; this.min = ""; this.max = ""; this.step = ""; this.pattern = ""; this.maxlength = ""; this.minlength = ""; this.inputmode = "text"; this.size = 0; this.multiple = false; this.accept = ""; this.list = ""; this.spellcheck = true; this.validationPattern = ""; this.showValidation = false; this.timingStrategy = "debounce"; this.timingDelay = 300; } // Theme consumption @consume({ context: themeContext, subscribe: true }) @property({ attribute: false }) // deno-lint-ignore no-explicit-any declare theme?: CTTheme; private get input(): HTMLInputElement | null { if (!this._input) { this._input = this.shadowRoot?.querySelector("input") || null; } return this._input; } private getValue(): string { return this._cellController.getValue(); } private setValue(newValue: string, _files?: FileList | null): void { // Store files reference for the onChange handler this._cellController.setValue(newValue); } private getPattern(): string { // Use custom pattern if provided if (this.pattern) { return this.pattern; } // Use validation pattern if specified if ( this.validationPattern && this.validationPattern in INPUT_PATTERNS ) { return INPUT_PATTERNS[this.validationPattern]; } // Use default patterns for specific types if (this.type === "email" && !this.pattern) { return INPUT_PATTERNS.email; } if (this.type === "url" && !this.pattern) { return INPUT_PATTERNS.url; } return ""; } private getInputMode(): InputMode { // Use explicit inputmode if provided if (this.inputmode && this.inputmode !== "text") { return this.inputmode; } // Return appropriate inputmode based on type switch (this.type) { case "email": return "email"; case "tel": return "tel"; case "url": return "url"; case "number": return "numeric"; case "search": return "search"; default: return "text"; } } private getValidationClass(): string { if (!this.showValidation) { return this.error ? "error" : ""; } // Check native validation const isValid = this.checkValidity(); return isValid ? "" : "error"; } override connectedCallback() { super.connectedCallback(); // CellController handles subscription automatically via ReactiveController } override disconnectedCallback() { super.disconnectedCallback(); // CellController handles cleanup automatically via ReactiveController } override willUpdate(changedProperties: Map) { super.willUpdate(changedProperties); // If the value property itself changed (e.g., switched to a different cell) if (changedProperties.has("value")) { // Bind the new value (Cell or plain) to the controller // This updates the internal reference so getValue() returns the correct value this._cellController.bind(this.value); } } override updated(changedProperties: Map) { super.updated(changedProperties); // If value changed, ensure the DOM input is synchronized if (changedProperties.has("value") && this.input) { const currentValue = this.getValue(); if (this.input.value !== currentValue) { this.input.value = currentValue; } } // Update timing controller if timing options changed if ( changedProperties.has("timingStrategy") || changedProperties.has("timingDelay") ) { this._cellController.updateTimingOptions({ strategy: this.timingStrategy, delay: this.timingDelay, }); } if (changedProperties.has("theme")) { applyThemeToElement(this, this.theme ?? defaultTheme); } } override firstUpdated() { // Cache the input element reference this._input = this.shadowRoot?.querySelector("input") || null; // Bind the initial value to the cell controller this._cellController.bind(this.value); // Update timing options to match current properties this._cellController.updateTimingOptions({ strategy: this.timingStrategy, delay: this.timingDelay, }); if (this.autofocus) { this._input?.focus(); } // Apply theme after first render applyThemeToElement(this, this.theme ?? defaultTheme); } override render() { const pattern = this.getPattern(); const inputMode = this.getInputMode(); const validationClass = this.getValidationClass(); // For file inputs, we can't set the value programmatically const inputValue = this.type === "file" ? undefined : this.getValue(); return html` `; } private _handleInput(event: Event) { const input = event.target as HTMLInputElement; const oldValue = this.getValue(); // For file inputs, we can't set the value programmatically if (this.type !== "file") { this.setValue(input.value, input.files); } else { // For file inputs, still emit the event with files this.setValue("", input.files); } // Emit ct-input event directly for non-cell interop this.emit("ct-input", { value: this.type === "file" ? "" : input.value, oldValue, name: this.name, files: this.type === "file" ? input.files : undefined, }); } private _handleChange(event: Event) { const input = event.target as HTMLInputElement; const oldValue = this.getValue(); // Change events use the same setValue logic as input events // The timing controller will determine when to actually emit if (this.type !== "file") { this.setValue(input.value, input.files); } else { this.setValue("", input.files); } // Emit ct-change event directly for non-cell interop // This ensures the event is emitted regardless of timing strategy this.emit("ct-change", { value: this.type === "file" ? "" : input.value, oldValue, name: this.name, files: this.type === "file" ? input.files : undefined, }); } private _handleFocus(_event: Event) { this._cellController.onFocus(); this.emit("ct-focus", { value: this.getValue(), name: this.name, }); } private _handleBlur(_event: Event) { this._cellController.onBlur(); this.emit("ct-blur", { value: this.getValue(), name: this.name, }); } private _handleKeyDown(event: KeyboardEvent) { this.emit("ct-keydown", { key: event.key, value: this.getValue(), shiftKey: event.shiftKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, altKey: event.altKey, name: this.name, }); // Special handling for Enter key if (event.key === "Enter") { this.emit("ct-submit", { value: this.getValue(), name: this.name, }); } } private _handleInvalid(event: Event) { event.preventDefault(); // Prevent browser's default validation UI const input = event.target as HTMLInputElement; this.emit("ct-invalid", { value: this.getValue(), name: this.name, validationMessage: input.validationMessage, validity: input.validity, }); // Update visual state if showValidation is enabled if (this.showValidation) { this.requestUpdate(); } } /** * Focus the input programmatically */ override focus(): void { this.input?.focus(); } /** * Blur the input programmatically */ override blur(): void { this.input?.blur(); } /** * Select all text in the input */ select(): void { this.input?.select(); } /** * Set selection range in the input */ setSelectionRange( start: number, end: number, direction?: "forward" | "backward" | "none", ): void { this.input?.setSelectionRange(start, end, direction); } /** * Check validity of the input */ checkValidity(): boolean { return this.input?.checkValidity() ?? true; } /** * Report validity of the input */ reportValidity(): boolean { return this.input?.reportValidity() ?? true; } /** * Get the validity state */ get validity(): ValidityState | undefined { return this.input?.validity; } /** * Get validation message */ get validationMessage(): string { return this.input?.validationMessage || ""; } /** * Set custom validity message */ setCustomValidity(message: string): void { this.input?.setCustomValidity(message); } } globalThis.customElements.define("ct-input", CTInput);