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 CFTheme,
cfThemeContext,
type ComponentSize,
defaultTheme,
} from "../theme-context.ts";
import { type CellHandle } from "@commonfabric/runtime-client";
import { stringSchema } from "@commonfabric/runner/schemas";
import { createStringCellController } from "../../core/cell-controller.ts";
import { createFormFieldController } from "../../core/form-field-controller.ts";
export type TimingStrategy = "immediate" | "debounce" | "throttle" | "blur";
/**
* CFTextarea - Multi-line text input with support for auto-resize, various states, and reactive data binding
*
* @element cf-textarea
*
* @attr {string} placeholder - Placeholder text
* @attr {string|CellHandle} value - Textarea value (supports both plain string and CellHandle)
* @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 cf-input - Fired on input with detail: { value, oldValue, name }
* @fires cf-change - Fired on change with detail: { value, oldValue, name }
* @fires cf-focus - Fired on focus with detail: { value, name }
* @fires cf-blur - Fired on blur with detail: { value, name }
* @fires cf-keydown - Fired on keydown with detail: { key, value, shiftKey, ctrlKey, metaKey, altKey, name }
* @fires cf-submit - Fired on Ctrl/Cmd+Enter with detail: { value, name }
*
* @example
*
*
* @example
*
*
*
* @example
*
*
*/
export class CFTextarea extends BaseElement {
static formAssociated = true;
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" },
size: { type: String, reflect: true },
};
declare placeholder: string;
declare value: CellHandle | 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;
declare size: ComponentSize;
static override styles = css`
:host {
--cf-textarea-color-background: var(--cf-theme-color-background, #ffffff);
--cf-textarea-color-text: var(--cf-theme-color-text, #0f172a);
--cf-textarea-color-border: var(--cf-theme-color-border, #e2e8f0);
--cf-textarea-color-primary: var(--cf-theme-color-primary, #3b82f6);
--cf-textarea-color-error: var(--cf-theme-color-error, #dc2626);
--cf-textarea-color-surface: var(--cf-theme-color-surface, #f1f5f9);
--cf-textarea-color-text-muted: var(--cf-theme-color-text-muted, #64748b);
--cf-textarea-color-placeholder: #94a3b8;
--cf-textarea-border-radius: var(--cf-theme-border-radius, 0.375rem);
--cf-textarea-font-family: var(--cf-theme-font-family, inherit);
--cf-textarea-animation-duration: var(--cf-theme-animation-duration, 150ms);
/* Default color values if not provided */
--background: var(--cf-textarea-color-background, #ffffff);
--foreground: var(--cf-textarea-color-text, #0f172a);
--border: var(--cf-textarea-color-border, #e2e8f0);
--ring: var(--cf-textarea-color-primary, #3b82f6);
--destructive: var(--cf-textarea-color-error, #dc2626);
--muted: var(--cf-textarea-color-surface, #f1f5f9);
--muted-foreground: var(--cf-textarea-color-text-muted, #64748b);
--placeholder: var(--cf-textarea-color-placeholder, #94a3b8);
/* Textarea dimensions — default size md */
--textarea-padding-x: var(--cf-size-md-padding-h, 8px);
--textarea-padding-y: var(--cf-size-md-padding-v, 8px);
--textarea-font-size: var(--cf-size-md-font-size, 12px);
--textarea-line-height: var(--cf-size-md-line-height, 16px);
--textarea-border-radius: var(--cf-size-md-radius, 8px);
--textarea-min-height: 5rem;
display: block;
width: 100%;
}
:host([size="xs"]) {
--textarea-padding-x: var(--cf-size-xs-padding-h, 4px);
--textarea-padding-y: var(--cf-size-xs-padding-v, 2px);
--textarea-font-size: var(--cf-size-xs-font-size, 9px);
--textarea-line-height: var(--cf-size-xs-line-height, 12px);
--textarea-border-radius: var(--cf-size-xs-radius, 4px);
}
:host([size="sm"]) {
--textarea-padding-x: var(--cf-size-sm-padding-h, 6px);
--textarea-padding-y: var(--cf-size-sm-padding-v, 4px);
--textarea-font-size: var(--cf-size-sm-font-size, 11px);
--textarea-line-height: var(--cf-size-sm-line-height, 16px);
--textarea-border-radius: var(--cf-size-sm-radius, 5px);
}
:host([size="lg"]) {
--textarea-padding-x: var(--cf-size-lg-padding-h, 12px);
--textarea-padding-y: var(--cf-size-lg-padding-v, 8px);
--textarea-font-size: var(--cf-size-lg-font-size, 16px);
--textarea-line-height: var(--cf-size-lg-line-height, 20px);
--textarea-border-radius: var(--cf-size-lg-radius, 9px);
}
:host([size="xl"]) {
--textarea-padding-x: var(--cf-size-xl-padding-h, 16px);
--textarea-padding-y: var(--cf-size-xl-padding-v, 12px);
--textarea-font-size: var(--cf-size-xl-font-size, 18px);
--textarea-line-height: var(--cf-size-xl-line-height, 24px);
--textarea-border-radius: var(--cf-size-xl-radius, 10px);
}
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(--cf-textarea-font-family, inherit);
color: var(--foreground);
background-color: var(--background);
border: 1px solid var(--border);
border-radius: var(--textarea-border-radius);
transition: all var(--cf-textarea-animation-duration, 150ms)
var(--cf-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(--cf-textarea-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(--cf-textarea-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(--cf-textarea-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(--cf-textarea-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: cfThemeContext, subscribe: true })
@property({ attribute: false })
accessor theme: CFTheme = defaultTheme;
#internals: ElementInternals;
private _generatedAriaLabel: string | null = null;
// Cache + initial setup
private _textarea: HTMLTextAreaElement | null = null;
private _cellController = createStringCellController(this, {
timing: {
strategy: "debounce",
delay: 300,
},
});
// Form field controller handles buffering when in cf-form context
private _formField = createFormFieldController(this, {
cellController: this._cellController,
validate: () => ({
valid: this.checkValidity(),
message: this.validationMessage,
}),
});
constructor() {
super();
this.#internals = this.attachInternals();
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;
this.size = "md";
this.addEventListener("focus", this._forwardFocusToTextarea);
}
private getValue(): string {
return this._formField.getValue();
}
private setValue(newValue: string): void {
this._formField.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, stringSchema);
// 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);
// Register with form after binding is complete
this._formField.register(this.name);
if (this.autofocus) {
this.textarea?.focus();
}
// Store initial height for auto-resize
if (this.autoResize && this.textarea) {
this._minHeight = this.textarea.scrollHeight;
this.adjustHeight();
}
this._updateAccessibilityAttributes();
}
override connectedCallback() {
// Set host attributes before super triggers rendering.
// Cannot be in the constructor — the custom element spec forbids
// setAttribute during construction.
if (!this.hasAttribute("role")) {
this.setAttribute("role", "textbox");
}
if (!this.hasAttribute("exportparts")) {
this.setAttribute("exportparts", "textarea");
}
super.connectedCallback();
this._updateAccessibilityAttributes();
}
override disconnectedCallback() {
super.disconnectedCallback();
// Controllers handle cleanup automatically via ReactiveController
}
override willUpdate(
changedProperties: Map,
) {
super.willUpdate(changedProperties);
// Bind value in willUpdate (before render) to avoid extra render cycle
if (changedProperties.has("value")) {
// Bind the new cell first so getValue() returns the new value
this._cellController.bind(this.value, stringSchema);
// Then clear buffer - this captures the new cell's value as baseline for reset/dirty
this._formField.clearBuffer();
}
}
override updated(
changedProperties: Map,
) {
super.updated(changedProperties);
// 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";
}
}
if (
changedProperties.has("disabled") ||
changedProperties.has("readonly") ||
changedProperties.has("required") ||
changedProperties.has("error") ||
changedProperties.has("placeholder") ||
changedProperties.has("value")
) {
this._updateAccessibilityAttributes();
}
}
override render() {
const resizeStyle = this.resize === "none" || this.autoResize
? "resize: none;"
: `resize: ${this.resize};`;
// The host carries the ARIA role and tabindex. The inner textarea is
// removed from sequential tab order; host focus forwards here so
// typing and selection work. Avoid delegatesFocus: it can make the
// shadow control appear to be the active tab stop instead of the host
// that owns the ARIA surface.
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("cf-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("cf-change", {
value: textarea.value,
oldValue,
name: this.name,
});
}
private _handleFocus(_event: Event) {
this._cellController.onFocus();
this.emit("cf-focus", {
value: this.getValue(),
name: this.name,
});
}
private _handleBlur(_event: Event) {
this._cellController.onBlur();
this.emit("cf-blur", {
value: this.getValue(),
name: this.name,
});
}
private _handleKeyDown(event: KeyboardEvent) {
this.emit("cf-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("cf-submit", {
value: this.getValue(),
name: this.name,
});
}
}
private _forwardFocusToTextarea = () => {
if (this.disabled) return;
this.textarea?.focus();
};
private _updateAccessibilityAttributes() {
// Respect author-provided role; only set our generated role when none exists
if (!this.hasAttribute("role")) {
this.setAttribute("role", "textbox");
}
if (!this.hasAttribute("exportparts")) {
this.setAttribute("exportparts", "textarea");
}
this.tabIndex = this.disabled ? -1 : 0;
this.setAttribute("aria-disabled", String(this.disabled));
this.setAttribute("aria-readonly", String(this.readonly));
this.setAttribute("aria-required", String(this.required));
this._updateGeneratedAriaLabel();
// Read .validity.valid directly to avoid firing the 'invalid' event.
const nativeValid = this.textarea?.validity?.valid ?? true;
this.setAttribute(
"aria-invalid",
String(this.error || !nativeValid),
);
this._syncInternals();
}
/** Sync value and validity to ElementInternals for native form participation. */
private _syncInternals() {
this.#internals.setFormValue(this.getValue());
if (this.textarea) {
this.#internals.setValidity(
this.textarea.validity,
this.textarea.validationMessage,
this.textarea,
);
}
}
private _updateGeneratedAriaLabel() {
const ariaLabel = this.getAttribute("aria-label");
const hasAuthorProvidedName = this.hasAttribute("aria-labelledby") ||
(ariaLabel !== null && ariaLabel !== this._generatedAriaLabel);
if (hasAuthorProvidedName) {
this._generatedAriaLabel = null;
return;
}
if (this.placeholder) {
this.setAttribute("aria-label", this.placeholder);
this._generatedAriaLabel = this.placeholder;
return;
}
if (
this._generatedAriaLabel !== null &&
ariaLabel === this._generatedAriaLabel
) {
this.removeAttribute("aria-label");
this._generatedAriaLabel = null;
}
}
/**
* 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`;
}
override focus(options?: FocusOptions): void {
if (this.disabled) return;
this.textarea?.focus(options);
}
/**
* 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);
}
}