/**
* @file ds-text-input.js
* @summary A custom Web Component that wraps a native `<input>` element for text-based inputs.
* @description
* The `ds-text-input` component provides a styled and functional text input field.
* It mirrors common `<input>` attributes and properties, making it easy to use
* within forms while leveraging the design system's styling.
*
* @element ds-text-input
* @extends BaseComponent
*
* @attr {string} [type="text"] - The type of input (e.g., `text`, `email`, `password`, `number`, `tel`, `url`, `search`).
* @attr {string} value - The current value of the input.
* @attr {string} placeholder - A hint to the user of what can be entered in the input.
* @attr {boolean} disabled - If present, the input cannot be interacted with.
* @attr {boolean} readonly - If present, the input cannot be modified by the user.
* @attr {boolean} required - If present, the input must have a value before form submission.
* @attr {string} name - The name of the input, used when submitting form data.
* @attr {string} id - A unique identifier for the input, useful for associating with labels.
* @attr {string} [aria-label] - Defines a string value that labels the current element for accessibility purposes.
*
* @property {string} value - Gets or sets the current value of the input.
* @property {string} type - Gets or sets the type of the input.
* @property {boolean} disabled - Gets or sets the disabled state of the input.
* @property {boolean} readonly - Gets or sets the readonly state of the input.
* @property {boolean} required - Gets or sets the required state of the input.
*
* @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 text input -->
* <ds-text-input placeholder="Enter your name" id="username-input"></ds-text-input>
* <ds-label for="username-input">Username</ds-label>
*
* @example
* <!-- Password input that is required -->
* <ds-text-input type="password" required placeholder="Your password"></ds-text-input>
*
* @example
* <!-- Disabled email input with a pre-filled value -->
* <ds-text-input type="email" value="example@domain.com" disabled></ds-text-input>
*/
import BaseComponent from './base-component.js';
import { emit } from '../utils/emit.js';
class DsTextInput extends BaseComponent {
constructor() {
// ARIA config for ds-text-input
const ariaConfig = {
staticAriaAttributes: {},
dynamicAriaAttributes: [
'aria-label',
'aria-describedby',
'aria-required',
'aria-invalid',
'aria-autocomplete',
'aria-controls',
'aria-activedescendant'
],
requiredAriaAttributes: [],
referenceAttributes: ['aria-describedby', 'aria-controls', 'aria-activedescendant'],
tokenValidation: {
'aria-autocomplete': ['inline', 'list', 'both', 'none'],
'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">
<input id="input" part="input" type="text">
<slot></slot>
</div>
`;
super({
template: template.innerHTML,
targetSelector: 'input',
ariaConfig,
observedAttributes: ['type', 'value', 'placeholder', 'disabled', 'readonly', 'required', 'name', 'id']
});
this.input = this.shadowRoot.querySelector('input');
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 ['type', 'value', 'placeholder', 'disabled', 'readonly', 'required', 'name', 'id', 'aria-label', 'aria-describedby', 'aria-required', 'aria-invalid', 'aria-autocomplete', 'aria-controls', 'aria-activedescendant'];
}
attributeChangedCallback(name, oldValue, newValue) {
super.attributeChangedCallback(name, oldValue, newValue);
if (oldValue === newValue) return;
switch (name) {
case 'type':
this.input.type = newValue || 'text';
break;
case 'value':
this.input.value = newValue || '';
break;
case 'placeholder':
this.input.placeholder = newValue || '';
break;
case 'disabled':
this.input.disabled = this.hasAttribute('disabled');
break;
case 'readonly':
this.input.readOnly = this.hasAttribute('readonly');
break;
case 'required':
this.input.required = this.hasAttribute('required');
break;
case 'name':
this.input.name = newValue || '';
break;
case 'id':
this.input.id = newValue || '';
break;
}
}
get value() {
return this.input.value;
}
set value(val) {
const v = val ?? '';
if (this.input.value !== v) {
this.input.value = v;
}
this.setAttribute('value', v);
}
get type() {
return this.input.type;
}
set type(val) {
this.input.type = val;
}
get disabled() {
return this.input.disabled;
}
set disabled(val) {
this.input.disabled = val;
}
get readonly() {
return this.input.readOnly;
}
set readonly(val) {
this.input.readOnly = val;
}
get required() {
return this.input.required;
}
set required(val) {
this.input.required = val;
}
connectedCallback() {
if (super.connectedCallback) super.connectedCallback();
this.input.addEventListener('input', this._onInput);
this.input.addEventListener('change', this._onChange);
this.input.addEventListener('focus', this._onFocus);
this.input.addEventListener('blur', this._onBlur);
}
disconnectedCallback() {
if (super.disconnectedCallback) super.disconnectedCallback();
this.input.removeEventListener('input', this._onInput);
this.input.removeEventListener('change', this._onChange);
this.input.removeEventListener('focus', this._onFocus);
this.input.removeEventListener('blur', this._onBlur);
}
_onInput() {
this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
emit(this, 'ds-change', { value: this.input.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 }));
}
// ARIA property accessors
get ariaLabel() {
const value = this.input.getAttribute('aria-label');
return value === null ? null : value;
}
set ariaLabel(val) {
if (val === null || val === undefined) {
this.input.removeAttribute('aria-label');
} else {
this.input.setAttribute('aria-label', val);
}
}
get ariaDescribedBy() {
const value = this.input.getAttribute('aria-describedby');
return value === null ? null : value;
}
set ariaDescribedBy(val) {
if (val === null || val === undefined) {
this.input.removeAttribute('aria-describedby');
} else {
this.input.setAttribute('aria-describedby', val);
}
}
get ariaRequired() {
const value = this.input.getAttribute('aria-required');
return value === null ? null : value;
}
set ariaRequired(val) {
if (val === null || val === undefined) {
this.input.removeAttribute('aria-required');
} else {
this.input.setAttribute('aria-required', val);
}
}
get ariaInvalid() {
const value = this.input.getAttribute('aria-invalid');
return value === null ? null : value;
}
set ariaInvalid(val) {
if (val === null || val === undefined) {
this.input.removeAttribute('aria-invalid');
} else {
this.input.setAttribute('aria-invalid', val);
}
}
get ariaAutocomplete() {
const value = this.input.getAttribute('aria-autocomplete');
return value === null ? null : value;
}
set ariaAutocomplete(val) {
if (val === null || val === undefined) {
this.input.removeAttribute('aria-autocomplete');
} else {
this.input.setAttribute('aria-autocomplete', val);
}
}
get ariaControls() {
const value = this.input.getAttribute('aria-controls');
return value === null ? null : value;
}
set ariaControls(val) {
if (val === null || val === undefined) {
this.input.removeAttribute('aria-controls');
} else {
this.input.setAttribute('aria-controls', val);
}
}
get ariaActiveDescendant() {
const value = this.input.getAttribute('aria-activedescendant');
return value === null ? null : value;
}
set ariaActiveDescendant(val) {
if (val === null || val === undefined) {
this.input.removeAttribute('aria-activedescendant');
} else {
this.input.setAttribute('aria-activedescendant', val);
}
}
// Override validateARIA for text input–specific checks
validateARIA() {
const errors = super.validateARIA ? super.validateARIA() : [];
// Accessible name check - check host element's text content and ARIA attributes
const hostAriaLabel = this.getAttribute('aria-label');
const hostAriaLabelledBy = this.getAttribute('aria-labelledby');
const inputAriaLabel = this.input.getAttribute('aria-label');
const inputAriaLabelledBy = this.input.getAttribute('aria-labelledby');
const hasName = hostAriaLabel || hostAriaLabelledBy || inputAriaLabel || inputAriaLabelledBy;
if (!hasName) {
errors.push('Text input has no accessible name (aria-label or aria-labelledby required)');
}
// aria-invalid state management
if (this.input.hasAttribute('aria-invalid')) {
const val = this.input.getAttribute('aria-invalid');
if (!['true', 'false', 'grammar', 'spelling'].includes(val)) {
errors.push(`Invalid aria-invalid value: ${val}`);
}
}
// aria-describedby references
if (this.input.hasAttribute('aria-describedby')) {
const refError = this.checkAriaReferences('aria-describedby', this.input.getAttribute('aria-describedby'));
if (refError) errors.push(refError);
}
return errors;
}
}
// Register the custom element
if (!customElements.get('ds-text-input')) {
customElements.define('ds-text-input', DsTextInput);
}
// Export for use in other modules
export default DsTextInput;