import { css, html } from "lit";
import { property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.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 { createStringCellController } from "../../core/cell-controller.ts";
export type TimingStrategy = "immediate" | "debounce" | "throttle" | "blur";
/**
* CTTextarea - Multi-line text input with support for auto-resize, various states, and reactive data binding
*
* @element ct-textarea
*
* @attr {string} placeholder - Placeholder text
* @attr {string|Cell} value - Textarea value (supports both plain string and Cell)
* @attr {boolean} disabled - Whether the textarea is disabled
* @attr {boolean} readonly - Whether the textarea is read-only
* @attr {boolean} required - Whether the textarea is required
* @attr {string} name - Name attribute for form submission
* @attr {number} rows - Number of visible text rows
* @attr {number} cols - Number of visible text columns
* @attr {number} maxlength - Maximum number of characters allowed
* @attr {boolean} auto-resize - Whether the textarea automatically resizes to fit content
* @attr {string} timingStrategy - Input timing strategy: "immediate" | "debounce" | "throttle" | "blur"
* @attr {number} timingDelay - Delay in milliseconds for debounce/throttle (default: 300)
*
* @fires ct-input - Fired on input with detail: { value, oldValue, name }
* @fires ct-change - Fired on change with detail: { value, oldValue, name }
* @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 Ctrl/Cmd+Enter with detail: { value, name }
*
* @example
*
*
* @example
*
*
*
* @example
*
*
*/
export class CTTextarea extends BaseElement {
static override properties = {
placeholder: { type: String },
value: { type: String },
disabled: { type: Boolean },
readonly: { type: Boolean },
error: { type: Boolean },
rows: { type: Number },
cols: { type: Number },
name: { type: String },
required: { type: Boolean },
autofocus: { type: Boolean },
maxlength: { type: String },
minlength: { type: String },
wrap: { type: String },
spellcheck: { type: Boolean },
autocomplete: { type: String },
resize: { type: String },
autoResize: { type: Boolean, attribute: "auto-resize" },
timingStrategy: { type: String, attribute: "timing-strategy" },
timingDelay: { type: Number, attribute: "timing-delay" },
};
declare placeholder: string;
declare value: Cell | string;
declare disabled: boolean;
declare readonly: boolean;
declare error: boolean;
declare rows: number;
declare cols: number;
declare name: string;
declare required: boolean;
declare autofocus: boolean;
declare maxlength: string;
declare minlength: string;
declare wrap: string;
declare spellcheck: boolean;
declare autocomplete: string;
declare resize: string;
declare autoResize: boolean;
declare timingStrategy: TimingStrategy;
declare timingDelay: number;
static override styles = css`
:host {
display: block;
width: 100%;
/* Default color values if not provided */
--background: var(--ct-theme-color-background, #ffffff);
--foreground: var(--ct-theme-color-text, #0f172a);
--border: var(--ct-theme-color-border, #e2e8f0);
--ring: var(--ct-theme-color-primary, #3b82f6);
--destructive: var(--ct-theme-color-error, #dc2626);
--muted: var(--ct-theme-color-surface, #f1f5f9);
--muted-foreground: var(--ct-theme-color-text-muted, #64748b);
--placeholder: var(--ct-theme-color-text-muted, #94a3b8);
/* Textarea dimensions */
--textarea-padding-x: 0.75rem;
--textarea-padding-y: 0.5rem;
--textarea-font-size: 0.875rem;
--textarea-line-height: 1.25rem;
--textarea-border-radius: var(--ct-theme-border-radius, 0.375rem);
--textarea-min-height: 5rem;
}
textarea {
all: unset;
box-sizing: border-box;
width: 100%;
min-height: var(--textarea-min-height);
padding: var(--textarea-padding-y) var(--textarea-padding-x);
font-size: var(--textarea-font-size);
line-height: var(--textarea-line-height);
font-family: var(--ct-theme-font-family, inherit);
color: var(--foreground);
background-color: var(--background);
border: 1px solid var(--border);
border-radius: var(--textarea-border-radius);
transition: all var(--ct-theme-animation-duration, 150ms)
var(--ct-transition-timing-ease);
display: block;
overflow: auto;
word-wrap: break-word;
white-space: pre-wrap;
}
/* Default resize behavior */
textarea {
resize: vertical;
}
/* Override resize when specified */
textarea[style*="resize: none"] {
resize: none !important;
}
textarea[style*="resize: horizontal"] {
resize: horizontal !important;
}
textarea[style*="resize: both"] {
resize: both !important;
}
textarea::placeholder {
color: var(--placeholder);
opacity: 1;
}
textarea::-webkit-input-placeholder {
color: var(--placeholder);
opacity: 1;
}
textarea::-moz-placeholder {
color: var(--placeholder);
opacity: 1;
}
textarea:-ms-input-placeholder {
color: var(--placeholder);
opacity: 1;
}
/* Focus state */
textarea:focus {
outline: 2px solid transparent;
outline-offset: 2px;
border-color: var(--ring);
box-shadow: 0 0 0 3px
var(--ct-theme-color-primary, rgba(59, 130, 246, 0.15));
}
textarea:focus-visible {
outline: 2px solid transparent;
outline-offset: 2px;
border-color: var(--ring);
box-shadow: 0 0 0 3px
var(--ct-theme-color-primary, rgba(59, 130, 246, 0.15));
}
/* Disabled state */
textarea:disabled {
cursor: not-allowed;
opacity: 0.5;
background-color: var(--muted);
resize: none;
}
/* Readonly state */
textarea:read-only {
background-color: var(--muted);
cursor: default;
}
/* Error state */
textarea.error {
border-color: var(--destructive);
}
textarea.error:focus,
textarea.error:focus-visible {
border-color: var(--destructive);
box-shadow: 0 0 0 3px var(--ct-theme-color-error, rgba(220, 38, 38, 0.1));
}
/* Scrollbar styling */
textarea::-webkit-scrollbar {
width: 0.5rem;
height: 0.5rem;
}
textarea::-webkit-scrollbar-track {
background-color: var(--muted);
border-radius: calc(var(--textarea-border-radius) * 0.5);
}
textarea::-webkit-scrollbar-thumb {
background-color: var(--border);
border-radius: calc(var(--textarea-border-radius) * 0.5);
transition: background-color var(--ct-theme-animation-duration, 150ms);
}
textarea::-webkit-scrollbar-thumb:hover {
background-color: var(--muted-foreground);
}
/* Firefox scrollbar styling */
textarea {
scrollbar-width: thin;
scrollbar-color: var(--border) var(--muted);
}
/* Autofill styles */
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus {
-webkit-text-fill-color: var(--foreground);
-webkit-box-shadow: 0 0 0px 1000px var(--muted) inset;
transition: background-color 5000s ease-in-out 0s;
}
/* Selection styles */
textarea::selection {
background-color: var(--ring);
color: var(--background);
opacity: 0.3;
}
textarea::-moz-selection {
background-color: var(--ring);
color: var(--background);
opacity: 0.3;
}
/* Auto-resize specific styles */
:host([auto-resize]) textarea {
overflow-y: hidden;
}
`;
// Theme consumption
@consume({ context: themeContext, subscribe: true })
@property({ attribute: false })
declare theme?: CTTheme;
// Cache + initial setup
private _changeGroup = crypto.randomUUID();
private _textarea: HTMLTextAreaElement | null = null;
private _cellController = createStringCellController(this, {
timing: {
strategy: "debounce",
delay: 300,
},
changeGroup: this._changeGroup,
});
constructor() {
super();
this.placeholder = "";
this.value = "";
this.disabled = false;
this.readonly = false;
this.error = false;
this.rows = 4;
this.cols = 50;
this.name = "";
this.required = false;
this.autofocus = false;
this.maxlength = "";
this.minlength = "";
this.wrap = "soft";
this.spellcheck = true;
this.autocomplete = "off";
this.resize = "vertical";
this.autoResize = false;
this.timingStrategy = "debounce";
this.timingDelay = 300;
}
private getValue(): string {
return this._cellController.getValue();
}
private setValue(newValue: string): void {
this._cellController.setValue(newValue);
}
get textarea(): HTMLTextAreaElement | null {
if (!this._textarea) {
this._textarea = this.shadowRoot?.querySelector("textarea") as
| HTMLTextAreaElement
| null;
}
return this._textarea;
}
private _minHeight = 0;
override firstUpdated() {
// Cache reference
this._textarea = this.shadowRoot?.querySelector("textarea") as
| HTMLTextAreaElement
| 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,
});
// Apply theme on mount
applyThemeToElement(this, this.theme ?? defaultTheme);
if (this.autofocus) {
this.textarea?.focus();
}
// Store initial height for auto-resize
if (this.autoResize && this.textarea) {
this._minHeight = this.textarea.scrollHeight;
this.adjustHeight();
}
}
override updated(
changedProperties: Map,
) {
super.updated(changedProperties);
if (changedProperties.has("value")) {
// Bind the new value (Cell or plain) to the controller
this._cellController.bind(this.value);
}
// Update timing options if they 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);
}
if (changedProperties.has("value") && this.autoResize) {
this.adjustHeight();
}
if (changedProperties.has("autoResize")) {
if (this.autoResize) {
this.resize = "none";
if (this.textarea) {
this._minHeight = this.textarea.scrollHeight;
this.adjustHeight();
}
} else {
this.resize = "vertical";
}
}
}
override render() {
const resizeStyle = this.resize === "none" || this.autoResize
? "resize: none;"
: `resize: ${this.resize};`;
return html`
`;
}
private _handleInput(event: Event) {
const textarea = event.target as HTMLTextAreaElement;
const oldValue = this.getValue();
this.setValue(textarea.value);
// Auto-resize if enabled
if (this.autoResize) {
this.adjustHeight();
}
// Emit custom input event
this.emit("ct-input", {
value: textarea.value,
oldValue,
name: this.name,
});
}
private _handleChange(event: Event) {
const textarea = event.target as HTMLTextAreaElement;
const oldValue = this.getValue();
// Emit custom change event
this.emit("ct-change", {
value: textarea.value,
oldValue,
name: this.name,
});
}
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 with modifiers
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
this.emit("ct-submit", {
value: this.getValue(),
name: this.name,
});
}
}
/**
* Adjust height for auto-resize functionality
*/
private adjustHeight(): void {
if (!this.textarea || !this.autoResize) return;
// Reset height to recalculate
(this.textarea as HTMLTextAreaElement).style.height = "auto";
// Set new height based on scrollHeight
const newHeight = Math.max(
this._minHeight,
(this.textarea as HTMLTextAreaElement).scrollHeight,
);
(this.textarea as HTMLTextAreaElement).style.height = `${newHeight}px`;
}
/**
* Focus the textarea programmatically
*/
override focus(): void {
this.textarea?.focus();
}
/**
* Blur the textarea programmatically
*/
override blur(): void {
this.textarea?.blur();
}
/**
* Select all text in the textarea
*/
select(): void {
this.textarea?.select();
}
/**
* Set selection range in the textarea
*/
setSelectionRange(
start: number,
end: number,
direction?: "forward" | "backward" | "none",
): void {
this.textarea?.setSelectionRange(start, end, direction);
}
/**
* Check validity of the textarea
*/
checkValidity(): boolean {
return this.textarea?.checkValidity() ?? true;
}
/**
* Report validity of the textarea
*/
reportValidity(): boolean {
return this.textarea?.reportValidity() ?? true;
}
/**
* Get the validity state
*/
get validity(): ValidityState | undefined {
return this.textarea?.validity;
}
/**
* Get validation message
*/
get validationMessage(): string {
return this.textarea?.validationMessage || "";
}
/**
* Set custom validity message
*/
setCustomValidity(message: string): void {
this.textarea?.setCustomValidity(message);
}
}
globalThis.customElements.define("ct-textarea", CTTextarea);