/**
* @file ds-textarea.js
* @summary A custom Web Component that wraps a native `<textarea>` element.
* @description
* The `ds-textarea` component provides a styled and functional textarea for multi-line text input.
* It supports various textarea attributes and properties while maintaining accessibility
* and proper event handling.
*
* @element ds-textarea
* @extends BaseComponent
*
* @attr {string} value - The current value of the textarea.
* @attr {string} placeholder - A hint to the user of what can be entered in the textarea.
* @attr {string} rows - The number of visible text lines in the textarea.
* @attr {string} cols - The visible width of the textarea in average character widths.
* @attr {boolean} disabled - If present, the textarea cannot be interacted with.
* @attr {boolean} readonly - If present, the textarea cannot be modified by the user.
* @attr {boolean} required - If present, the textarea must have a value before form submission.
* @attr {string} name - The name of the textarea, used when submitting form data.
* @attr {string} id - A unique identifier for the textarea, useful for associating with labels.
*
* @property {string} value - Gets or sets the current value of the textarea.
* @property {string} placeholder - Gets or sets the placeholder text of the textarea.
* @property {number} rows - Gets or sets the number of rows in the textarea.
* @property {number} cols - Gets or sets the number of columns in the textarea.
* @property {boolean} disabled - Gets or sets the disabled state of the textarea.
* @property {boolean} readonly - Gets or sets the readonly state of the textarea.
* @property {boolean} required - Gets or sets the required state of the textarea.
* @property {string} name - Gets or sets the name of the textarea.
*
* @fires input - re-emitted from host (bubbles, composed)
* @fires change - re-emitted from host (bubbles, composed)
* @fires focus - re-emitted from host (bubbles, composed)
* @fires blur - re-emitted from host (bubbles, composed)
* @fires ds-change - custom event with { detail: { value } }
*
* @example
* <!-- Basic textarea -->
* <ds-textarea placeholder="Enter your message" rows="4" cols="50"></ds-textarea>
*
* @example
* <!-- Required textarea with pre-filled value -->
* <ds-textarea value="Default text" required rows="6">Enter description</ds-textarea>
*
* @example
* <!-- Disabled textarea -->
* <ds-textarea value="Read-only content" disabled rows="3"></ds-textarea>
*/
import BaseComponent from './base-component.js';
import { emit } from '../utils/emit.js';
class DsTextarea extends BaseComponent {
constructor() {
// ARIA config for ds-textarea
const ariaConfig = {
staticAriaAttributes: {},
dynamicAriaAttributes: [
'aria-label',
'aria-describedby',
'aria-required',
'aria-invalid'
],
requiredAriaAttributes: [],
referenceAttributes: ['aria-describedby'],
tokenValidation: {
'aria-invalid': ['grammar', 'false', 'spelling', 'true']
}
};
const template = document.createElement('template');
template.innerHTML = `
<style>
@import url('/src/styles/styles.css');
:host {
display: block;
}
.wrapper {
width: 100%;
}
</style>
<div class="wrapper">
<textarea id="textarea" part="textarea">
<slot></slot>
</textarea>
</div>
`;
super({
template: template.innerHTML,
targetSelector: 'textarea',
ariaConfig,
observedAttributes: ['value', 'placeholder', 'rows', 'cols', 'disabled', 'readonly', 'required', 'name', 'id']
});
this.textarea = this.shadowRoot.querySelector('textarea');
this._onInput = this._onInput.bind(this);
this._onChange = this._onChange.bind(this);
this._onFocus = this._onFocus.bind(this);
this._onBlur = this._onBlur.bind(this);
}
static get observedAttributes() {
return ['value', 'placeholder', 'rows', 'cols', 'disabled', 'readonly', 'required', 'name', 'id', 'aria-label', 'aria-describedby', 'aria-required', 'aria-invalid'];
}
attributeChangedCallback(name, oldValue, newValue) {
super.attributeChangedCallback(name, oldValue, newValue);
if (oldValue === newValue) return;
switch (name) {
case 'value':
this.textarea.value = newValue || '';
break;
case 'placeholder':
this.textarea.placeholder = newValue || '';
break;
case 'rows':
this.textarea.rows = newValue || '';
break;
case 'cols':
this.textarea.cols = newValue || '';
break;
case 'disabled':
this.textarea.disabled = this.hasAttribute('disabled');
break;
case 'readonly':
this.textarea.readOnly = this.hasAttribute('readonly');
break;
case 'required':
this.textarea.required = this.hasAttribute('required');
break;
case 'name':
this.textarea.name = newValue || '';
break;
case 'id':
this.textarea.id = newValue || '';
break;
}
}
get value() {
return this.textarea.value;
}
set value(val) {
const v = val ?? '';
if (this.textarea.value !== v) {
this.textarea.value = v;
}
this.setAttribute('value', v);
}
get placeholder() {
return this.textarea.placeholder;
}
set placeholder(val) {
this.textarea.placeholder = val;
}
get rows() {
return this.textarea.rows;
}
set rows(val) {
this.textarea.rows = val;
}
get cols() {
return this.textarea.cols;
}
set cols(val) {
this.textarea.cols = val;
}
get disabled() {
return this.textarea.disabled;
}
set disabled(val) {
this.textarea.disabled = val;
}
get readonly() {
return this.textarea.readOnly;
}
set readonly(val) {
this.textarea.readOnly = val;
}
get required() {
return this.textarea.required;
}
set required(val) {
this.textarea.required = val;
}
connectedCallback() {
if (super.connectedCallback) super.connectedCallback();
this.textarea.addEventListener('input', this._onInput);
this.textarea.addEventListener('change', this._onChange);
this.textarea.addEventListener('focus', this._onFocus);
this.textarea.addEventListener('blur', this._onBlur);
}
disconnectedCallback() {
if (super.disconnectedCallback) super.disconnectedCallback();
this.textarea.removeEventListener('input', this._onInput);
this.textarea.removeEventListener('change', this._onChange);
this.textarea.removeEventListener('focus', this._onFocus);
this.textarea.removeEventListener('blur', this._onBlur);
}
_onInput() {
this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
emit(this, 'ds-change', { value: this.textarea.value });
}
_onChange() {
this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
}
_onFocus() {
this.dispatchEvent(new Event('focus', { bubbles: true, composed: true }));
}
_onBlur() {
this.dispatchEvent(new Event('blur', { bubbles: true, composed: true }));
}
get name() {
return this.textarea.name;
}
set name(val) {
this.textarea.name = val;
}
// ARIA property accessors
get ariaLabel() {
const value = this.textarea.getAttribute('aria-label');
return value === null ? null : value;
}
set ariaLabel(val) {
if (val === null || val === undefined) {
this.textarea.removeAttribute('aria-label');
} else {
this.textarea.setAttribute('aria-label', val);
}
}
get ariaDescribedBy() {
const value = this.textarea.getAttribute('aria-describedby');
return value === null ? null : value;
}
set ariaDescribedBy(val) {
if (val === null || val === undefined) {
this.textarea.removeAttribute('aria-describedby');
} else {
this.textarea.setAttribute('aria-describedby', val);
}
}
get ariaRequired() {
const value = this.textarea.getAttribute('aria-required');
return value === null ? null : value;
}
set ariaRequired(val) {
if (val === null || val === undefined) {
this.textarea.removeAttribute('aria-required');
} else {
this.textarea.setAttribute('aria-required', val);
}
}
get ariaInvalid() {
const value = this.textarea.getAttribute('aria-invalid');
return value === null ? null : value;
}
set ariaInvalid(val) {
if (val === null || val === undefined) {
this.textarea.removeAttribute('aria-invalid');
} else {
this.textarea.setAttribute('aria-invalid', val);
}
}
// Override validateARIA for textarea-specific checks
validateARIA() {
const errors = super.validateARIA ? super.validateARIA() : [];
const hostAriaLabel = this.getAttribute('aria-label');
const hostAriaLabelledBy = this.getAttribute('aria-labelledby');
const textareaAriaLabel = this.textarea.getAttribute('aria-label');
const textareaAriaLabelledBy = this.textarea.getAttribute('aria-labelledby');
const hasName = hostAriaLabel || hostAriaLabelledBy || textareaAriaLabel || textareaAriaLabelledBy;
if (!hasName) {
errors.push('Textarea has no accessible name (aria-label or aria-labelledby required)');
}
if (this.textarea.hasAttribute('aria-invalid')) {
const val = this.textarea.getAttribute('aria-invalid');
if (!['true', 'false', 'grammar', 'spelling'].includes(val)) {
errors.push(`Invalid aria-invalid value: ${val}`);
}
}
if (this.textarea.hasAttribute('aria-describedby')) {
const refError = this.checkAriaReferences('aria-describedby', this.textarea.getAttribute('aria-describedby'));
if (refError) errors.push(refError);
}
return errors;
}
}
// Register the custom element
if (!customElements.get('ds-textarea')) {
customElements.define('ds-textarea', DsTextarea);
}
// Export for use in other modules
export default DsTextarea;