diff --git a/src/bundle/Resources/public/ts/components/HelperText.ts b/src/bundle/Resources/public/ts/components/HelperText.ts new file mode 100644 index 0000000..46ad827 --- /dev/null +++ b/src/bundle/Resources/public/ts/components/HelperText.ts @@ -0,0 +1,85 @@ +import Base from '../shared/Base'; + +type IconsTypes = 'default' | 'error'; + +export default class HelperText extends Base { + private _iconWrapper: HTMLDivElement; + private _contentWrapper: HTMLDivElement; + private _iconsTemplates: Record = { + default: null, + error: null, + }; + + private _error = false; + private _message = ''; + private _defaultMessage: string; + + constructor(container: HTMLElement) { + super(container); + + const iconWrapper = container.querySelector('.ids-helper-text__icon-wrapper'); + const contentWrapper = container.querySelector('.ids-helper-text__content-wrapper'); + + if (!iconWrapper || !contentWrapper) { + throw new Error('HelperText: Required elements are missing in the container.'); + } + + this._iconWrapper = iconWrapper; + this._contentWrapper = contentWrapper; + this._defaultMessage = contentWrapper.textContent ?? ''; + + const defaultIconTemplate = iconWrapper.querySelector( + '.ids-helper-text__icon-template[data-ids-type="default"]', + ); + const errorIconTemplate = iconWrapper.querySelector('.ids-helper-text__icon-template[data-ids-type="error"]'); + + this._iconsTemplates = { + default: defaultIconTemplate?.content.cloneNode(true) ?? null, + error: errorIconTemplate?.content.cloneNode(true) ?? null, + }; + } + + set defaultMessage(value: string) { + this._defaultMessage = value; + } + + set error(value: boolean) { + if (this._error === value) { + return; + } + + this._error = value; + + this.container.classList.toggle('ids-helper-text--error', value); + + const iconElement = this._iconWrapper.querySelector('.ids-helper-text__icon'); + + if (!iconElement) { + return; + } + + const replacementIcon = value ? this._iconsTemplates.error : this._iconsTemplates.default; + + if (!replacementIcon) { + throw new Error(`HelperText: Icon template for type "${value ? 'error' : 'default'}" is missing.`); + } + + iconElement.replaceWith(replacementIcon.cloneNode(true)); + } + + set message(value: string) { + if (this._message === value) { + return; + } + + this._message = value; + + this._contentWrapper.textContent = value; + } + + changeToDefaultMessage() { + this.message = this._defaultMessage; + } +} + +export type HelperTextType = InstanceType; diff --git a/src/bundle/Resources/public/ts/components/Label.ts b/src/bundle/Resources/public/ts/components/Label.ts new file mode 100644 index 0000000..e68c2f5 --- /dev/null +++ b/src/bundle/Resources/public/ts/components/Label.ts @@ -0,0 +1,16 @@ +import Base from '../shared/Base'; + +export default class Label extends Base { + private _error = false; + + set error(value: boolean) { + this._error = value; + this.container.classList.toggle('ids-label--error', value); + } + + get error(): boolean { + return this._error; + } +} + +export type LabelType = InstanceType; diff --git a/src/bundle/Resources/public/ts/components/formControls/InputText.ts b/src/bundle/Resources/public/ts/components/formControls/InputText.ts new file mode 100644 index 0000000..c3b9d13 --- /dev/null +++ b/src/bundle/Resources/public/ts/components/formControls/InputText.ts @@ -0,0 +1,110 @@ +import HelperText, { HelperTextType } from '../HelperText'; +import InputText, { InputTextType } from '../inputs/InputText'; +import Label, { LabelType } from '../Label'; +import Base from '../../shared/Base'; + +import ValidatorManager, { ValidatorManagerType } from '../../validators/ValidatorManager'; +import IsEmptyStringValidator from '../../validators/IsEmptyStringValidator'; + +export default class FormControlInputText extends Base { + private _labelInstance: LabelType | null = null; + private _inputTextInstance: InputTextType; + private _helperTextInstance: HelperTextType | null = null; + private _validatorManager: ValidatorManagerType; + private _error = false; + private _errorMessage = ''; + + constructor(container: HTMLDivElement) { + super(container); + + const inputTextContainer = container.querySelector('.ids-input-text'); + + if (!inputTextContainer) { + throw new Error('FormControlInputText: Required elements are missing in the container.'); + } + + const labelContainer = container.querySelector('.ids-label'); + + if (labelContainer) { + this._labelInstance = new Label(labelContainer); + } + + const helperTextContainer = container.querySelector('.ids-helper-text'); + + if (helperTextContainer) { + this._helperTextInstance = new HelperText(helperTextContainer); + } + + this._inputTextInstance = new InputText(inputTextContainer); + this._validatorManager = new ValidatorManager(); + + if (this._inputTextInstance.required) { + const isEmptyStringValidator = new IsEmptyStringValidator(); + + this._validatorManager.addValidator(isEmptyStringValidator); + } + } + + set error(value) { + if (this._error === value) { + return; + } + + this._error = value; + this._inputTextInstance.error = value; + + if (this._labelInstance) { + this._labelInstance.error = value; + } + + if (this._helperTextInstance) { + this._helperTextInstance.error = value; + } + } + + get error(): boolean { + return this._error; + } + + set errorMessage(value: string) { + if (this._errorMessage === value) { + return; + } + + this._errorMessage = value; + + if (this._error && this._helperTextInstance) { + this._helperTextInstance.message = value; + } + + if (!this._error && this._helperTextInstance) { + this._helperTextInstance.changeToDefaultMessage(); + } + } + + initChildren() { + this._labelInstance?.init(); + this._inputTextInstance.init(); + this._helperTextInstance?.init(); + } + + initInputListeners() { + this._inputTextInstance.inputElement.addEventListener('input', ({ currentTarget }) => { + if (!(currentTarget instanceof HTMLInputElement)) { + throw new Error('FormControlInputText: Current target is not an HTMLInputElement.'); + } + + const validatorData = this._validatorManager.validate(currentTarget.value); + + this.error = !validatorData.isValid; + this.errorMessage = validatorData.messages.join(', '); + }); + } + + init() { + super.init(); + + this.initChildren(); + this.initInputListeners(); + } +} diff --git a/src/bundle/Resources/public/ts/components/inputs/InputText.ts b/src/bundle/Resources/public/ts/components/inputs/InputText.ts index fa82d14..a15e4c1 100644 --- a/src/bundle/Resources/public/ts/components/inputs/InputText.ts +++ b/src/bundle/Resources/public/ts/components/inputs/InputText.ts @@ -1,9 +1,15 @@ import Base from '../../shared/Base'; -export default class InpuText extends Base { +export { BASE_EVENTS } from '../../shared/Base'; + +export enum INPUT_TEXT_EVENTS { + CLEARED = 'ids:component:input-text:cleared', +} +export default class InputText extends Base { private _inputElement: HTMLInputElement; private _actionsElement: HTMLDivElement; private _clearBtnElement: HTMLButtonElement; + private _error = false; constructor(container: HTMLDivElement) { super(container); @@ -21,6 +27,24 @@ export default class InpuText extends Base { this._clearBtnElement = clearBtnElement; } + get inputElement(): HTMLInputElement { + return this._inputElement; + } + + get required(): boolean { + return this._inputElement.required; + } + + set error(value) { + this._inputElement.classList.toggle('ids-input--error', value); + + this._error = value; + } + + get error(): boolean { + return this._error; + } + private _updateInputPadding() { const actionsWidth = this._actionsElement.offsetWidth; @@ -56,6 +80,7 @@ export default class InpuText extends Base { event.stopPropagation(); this.changeValue(''); + this.container.dispatchEvent(new Event(INPUT_TEXT_EVENTS.CLEARED)); }); } @@ -67,3 +92,5 @@ export default class InpuText extends Base { this._updateInputPadding(); } } + +export type InputTextType = InstanceType; diff --git a/src/bundle/Resources/public/ts/init_components.ts b/src/bundle/Resources/public/ts/init_components.ts index bbfa94d..0fa8151 100644 --- a/src/bundle/Resources/public/ts/init_components.ts +++ b/src/bundle/Resources/public/ts/init_components.ts @@ -1,4 +1,5 @@ import Accordion from './components/accordion'; +import FormControlInputText from './components/formControls/InputText'; import InputText from './components/inputs/InputText'; const accordionContainers = document.querySelectorAll('.ids-accordion:not([custom-init])'); @@ -9,10 +10,20 @@ accordionContainers.forEach((accordionContainer: HTMLDivElement) => { accordionInstance.init(); }); -const inputTextContainers = document.querySelectorAll('.ids-input-text:not([custom-init])'); +const inputTextContainers = document.querySelectorAll('.ids-input-text:not([data-ids-custom-init])'); inputTextContainers.forEach((inputTextContainer: HTMLDivElement) => { const inputTextInstance = new InputText(inputTextContainer); inputTextInstance.init(); }); + +const formControlInputTextContainers = document.querySelectorAll( + '.ids-form-control--input-text:not([data-ids-custom-init])', +); + +formControlInputTextContainers.forEach((inputTextContainer: HTMLDivElement) => { + const inputTextInstance = new FormControlInputText(inputTextContainer); + + inputTextInstance.init(); +}); diff --git a/src/bundle/Resources/public/ts/shared/Base.ts b/src/bundle/Resources/public/ts/shared/Base.ts index 16c8fbc..42315b2 100644 --- a/src/bundle/Resources/public/ts/shared/Base.ts +++ b/src/bundle/Resources/public/ts/shared/Base.ts @@ -1,15 +1,23 @@ import { setInstance } from '../helpers/object.instances'; +export enum BASE_EVENTS { + INITIALIZED = 'ids:component:initialized', +} + export default abstract class Base { - container: HTMLElement; + private _container: HTMLElement; constructor(container: HTMLElement) { - this.container = container; + this._container = container; setInstance(container, this); } + get container(): HTMLElement { + return this._container; + } + init() { - this.container.dispatchEvent(new CustomEvent('ids:component:initialized', { detail: { component: this } })); + this._container.dispatchEvent(new CustomEvent('ids:component:initialized', { detail: { component: this } })); } } diff --git a/src/bundle/Resources/public/ts/validators/BaseValidator.ts b/src/bundle/Resources/public/ts/validators/BaseValidator.ts new file mode 100644 index 0000000..cb1a41a --- /dev/null +++ b/src/bundle/Resources/public/ts/validators/BaseValidator.ts @@ -0,0 +1,7 @@ +export default abstract class BaseValidator { + abstract getErrorMessage(): string; + + abstract validate(_value: unknown): boolean; +} + +export type BaseValidatorType = typeof BaseValidator; diff --git a/src/bundle/Resources/public/ts/validators/IsEmptyStringValidator.ts b/src/bundle/Resources/public/ts/validators/IsEmptyStringValidator.ts new file mode 100644 index 0000000..0bef19b --- /dev/null +++ b/src/bundle/Resources/public/ts/validators/IsEmptyStringValidator.ts @@ -0,0 +1,11 @@ +import BaseValidator from './BaseValidator'; + +export default class IsEmptyStringValidator extends BaseValidator { + getErrorMessage(): string { + return /*@Desc("This field cannot be empty.")*/ 'ibexa.validators.is_empty_string'; // TODO: Use translation service when available + } + + validate(value: string): boolean { + return value.trim() !== ''; + } +} diff --git a/src/bundle/Resources/public/ts/validators/ValidatorManager.ts b/src/bundle/Resources/public/ts/validators/ValidatorManager.ts new file mode 100644 index 0000000..b8e3db3 --- /dev/null +++ b/src/bundle/Resources/public/ts/validators/ValidatorManager.ts @@ -0,0 +1,27 @@ +import BaseValidator from './BaseValidator'; + +export default class ValidatorManager { + private _validators: BaseValidator[]; + + constructor(validators: BaseValidator[] = []) { + this._validators = validators; + } + + addValidator(validator: BaseValidator): void { + this._validators.push(validator); + } + + removeValidator(validator: BaseValidator): void { + this._validators = this._validators.filter((savedValidator) => savedValidator !== validator); + } + + validate(value: unknown) { + const errors = this._validators + .filter((validator: BaseValidator) => !validator.validate(value)) + .map((validator: BaseValidator) => validator.getErrorMessage()); + + return { isValid: !errors.length, messages: errors }; + } +} + +export type ValidatorManagerType = InstanceType; diff --git a/src/bundle/Resources/views/themes/standard/design_system/components/HelperText.html.twig b/src/bundle/Resources/views/themes/standard/design_system/components/HelperText.html.twig index 486651e..9920f59 100644 --- a/src/bundle/Resources/views/themes/standard/design_system/components/HelperText.html.twig +++ b/src/bundle/Resources/views/themes/standard/design_system/components/HelperText.html.twig @@ -26,6 +26,13 @@
{% block icon_inner %} + + + {{ icon_text|default('') }} {% endblock icon_inner %}
{% endblock icon_outer %} diff --git a/src/bundle/Resources/views/themes/standard/design_system/components/formControls/InputText.html.twig b/src/bundle/Resources/views/themes/standard/design_system/components/formControls/InputText.html.twig new file mode 100644 index 0000000..ad6bcd1 --- /dev/null +++ b/src/bundle/Resources/views/themes/standard/design_system/components/formControls/InputText.html.twig @@ -0,0 +1,5 @@ +{% extends '@IbexaDesignSystemTwig/themes/standard/design_system/partials/base_form_control.html.twig' %} + +{% block content %} + +{% endblock content %} diff --git a/src/bundle/Resources/views/themes/standard/design_system/components/inputs/InputText.html.twig b/src/bundle/Resources/views/themes/standard/design_system/components/inputs/InputText.html.twig index 54a711a..e0d4932 100644 --- a/src/bundle/Resources/views/themes/standard/design_system/components/inputs/InputText.html.twig +++ b/src/bundle/Resources/views/themes/standard/design_system/components/inputs/InputText.html.twig @@ -1,4 +1,4 @@ -{% set value = attributes.only('value') %} +{% set value = attributes.render('value') %} {% set input_classes = html_cva( @@ -40,7 +40,7 @@ } ] %} -
+
+ {% if block('label') is defined and block('label') is not empty %} +
+ + {{ block(outerBlocks.label) }} + +
+ {% endif %} + +
+ {{ block('content') }} +
+ + {% if block('helper_text') is defined %} +
+ + {{ block(outerBlocks.helper_text) }} + +
+ {% endif %} +
diff --git a/src/lib/Twig/Components/formControls/InputText.php b/src/lib/Twig/Components/formControls/InputText.php new file mode 100644 index 0000000..2d13a2c --- /dev/null +++ b/src/lib/Twig/Components/formControls/InputText.php @@ -0,0 +1,82 @@ + $props + */ + #[PreMount] + public function validate(array $props): array + { + $resolver = new OptionsResolver(); + $resolver->setIgnoreUndefined(true); + $resolver + ->define('name') + ->required() + ->allowedTypes('string'); + $resolver + ->define('id') + ->required() + ->allowedTypes('string'); + $resolver + ->define('label_extra') + ->allowedTypes('array') + ->default([]); + $resolver + ->define('helper_text_extra') + ->allowedTypes('array') + ->default([]); + $resolver + ->setOptions('input', function (OptionsResolver $inputResolver): void { + $inputResolver->setIgnoreUndefined(true); + $inputResolver + ->define('required') + ->allowedTypes('bool') + ->default(false); + }); + $resolver + ->define('value') + ->allowedTypes('string') + ->default(''); + + return array_replace_recursive($resolver->resolve($props), $props); + } + + /** + * @param array $data + */ + #[PostMount] + public function setSharedProps(array $data): array + { + $this->label_extra['for'] = $this->id; + $this->label_extra['required'] = $this->input['required']; + $this->input['id'] = $this->id; + $this->input['name'] = $this->name; + $this->input['value'] = $this->value; + $this->input['data-ids-custom-init'] = 'true'; + + return $data; + } +} diff --git a/src/lib/Twig/Components/inputs/InputText.php b/src/lib/Twig/Components/inputs/InputText.php index c8d3a75..08b0842 100644 --- a/src/lib/Twig/Components/inputs/InputText.php +++ b/src/lib/Twig/Components/inputs/InputText.php @@ -11,10 +11,13 @@ use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; use Symfony\UX\TwigComponent\Attribute\PreMount; +use Symfony\UX\TwigComponent\Attribute\PostMount; #[AsTwigComponent] final class InputText { + public string $id; + public string $name; public string $type = 'text'; public string $size = 'medium'; public bool $disabled = false; @@ -29,6 +32,12 @@ public function validate(array $props): array { $resolver = new OptionsResolver(); $resolver->setIgnoreUndefined(true); + $resolver + ->define('id') + ->allowedTypes('string'); + $resolver + ->define('name') + ->allowedTypes('string'); $resolver ->define('type') ->allowedValues('text', 'password', 'email', 'number', 'tel', 'search', 'url') @@ -50,6 +59,42 @@ public function validate(array $props): array ->allowedTypes('bool') ->default(false); + if (!isset($props['name'])) { + $resolver->setRequired('id'); + } + + if (!isset($props['id'])) { + $resolver->setRequired('name'); + } + + if (isset($props['id']) || isset($props['name'])) { + $resolver->setDefaults([ + 'id' => '', + 'name' => '', + ]); + } + return $resolver->resolve($props) + $props; } + + /** + * @param array $data + */ + #[PostMount] + public function setInputAttrs(array $data): array + { + if (!empty($this->id)) { + $data['id'] = $this->id; + } + + if (!empty($this->name)) { + $data['name'] = $this->name; + } + + $data['type'] = $this->type; + $data['disabled'] = $this->disabled; + $data['required'] = $this->required; + + return $data; + } }