diff --git a/eslint.config.mjs b/eslint.config.mjs index 4f75d81f..7d987c70 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,3 +1,11 @@ import getIbexaConfig from '@ibexa/eslint-config/eslint'; -export default getIbexaConfig({ react: false }); +export default [ + ...getIbexaConfig({ react: false }), + { + files: ['**/*.ts'], + rules: { + '@typescript-eslint/unbound-method': 'off', + }, + }, +]; diff --git a/src/bundle/Resources/public/ts/components/checkbox/checkboxes_list_field.ts b/src/bundle/Resources/public/ts/components/checkbox/checkboxes_list_field.ts new file mode 100644 index 00000000..0c6b1c55 --- /dev/null +++ b/src/bundle/Resources/public/ts/components/checkbox/checkboxes_list_field.ts @@ -0,0 +1,100 @@ +import { BaseInputsList } from '../../partials'; + +export enum CheckboxesListFieldAction { + Check = 'check', + Uncheck = 'uncheck', +} + +export class CheckboxesListField extends BaseInputsList { + private _itemsContainer: HTMLDivElement; + + static EVENTS = { + ...BaseInputsList.EVENTS, + CHANGE: 'ids:checkboxes-list-field:change', + }; + + constructor(container: HTMLDivElement) { + super(container); + + const itemsContainer = container.querySelector('.ids-choice-inputs-list__items'); + + if (!itemsContainer) { + throw new Error('CheckboxesListField: Required elements are missing in the container.'); + } + + this._itemsContainer = itemsContainer; + + this.onItemChange = this.onItemChange.bind(this); + } + + getItemsCheckboxes() { + const itemsCheckboxes = [ + ...this._itemsContainer.querySelectorAll('.ids-choice-input-field .ids-input--checkbox'), + ]; + + return itemsCheckboxes; + } + + getValue(): string[] { + const itemsCheckboxes = this.getItemsCheckboxes(); + const checkedValues = itemsCheckboxes.reduce((acc: string[], checkbox) => { + if (checkbox.checked) { + acc.push(checkbox.value); + } + + return acc; + }, []); + + return checkedValues; + } + + onItemChange(event: Event) { + if (!(event.target instanceof HTMLInputElement)) { + return; + } + + const item = event.target; + const nextValue = this.getValue(); + const actionPerformed = item.checked ? CheckboxesListFieldAction.Check : CheckboxesListFieldAction.Uncheck; + + this.onChange(nextValue, item.value, actionPerformed); + } + + onChange(nextValue: string[], itemValue: string, actionPerformed: CheckboxesListFieldAction) { + const changeEvent = new CustomEvent(CheckboxesListField.EVENTS.CHANGE, { + bubbles: true, + detail: [nextValue, itemValue, actionPerformed], + }); + + this._container.dispatchEvent(changeEvent); + } + + initCheckboxes() { + const itemsCheckboxes = this.getItemsCheckboxes(); + + itemsCheckboxes.forEach((checkbox) => { + checkbox.addEventListener('change', this.onItemChange, false); + }); + } + + unbindCheckboxes() { + const itemsCheckboxes = this.getItemsCheckboxes(); + + itemsCheckboxes.forEach((checkbox) => { + checkbox.removeEventListener('change', this.onItemChange, false); + }); + } + + reinit() { + super.reinit(); + + this.unbindCheckboxes(); + this.initCheckboxes(); + } + + init() { + super.init(); + + this.initCheckboxes(); + } +} diff --git a/src/bundle/Resources/public/ts/components/checkbox/index.ts b/src/bundle/Resources/public/ts/components/checkbox/index.ts index da3d51d8..51692c74 100644 --- a/src/bundle/Resources/public/ts/components/checkbox/index.ts +++ b/src/bundle/Resources/public/ts/components/checkbox/index.ts @@ -1 +1,2 @@ export * from './checkbox_input'; +export * from './checkboxes_list_field'; diff --git a/src/bundle/Resources/public/ts/init_components.ts b/src/bundle/Resources/public/ts/init_components.ts index 498497a7..80c32bef 100644 --- a/src/bundle/Resources/public/ts/init_components.ts +++ b/src/bundle/Resources/public/ts/init_components.ts @@ -1,7 +1,7 @@ +import { CheckboxInput, CheckboxesListField } from './components/checkbox'; import { InputTextField, InputTextInput } from './components/input_text'; import { Accordion } from './components/accordion'; import { AltRadioInput } from './components/alt_radio/alt_radio_input'; -import { CheckboxInput } from './components/checkbox'; const accordionContainers = document.querySelectorAll('.ids-accordion:not([data-ids-custom-init])'); @@ -27,6 +27,14 @@ checkboxContainers.forEach((checkboxContainer: HTMLDivElement) => { checkboxInstance.init(); }); +const checkboxesFieldContainers = document.querySelectorAll('.ids-field.ids-field--list:not([data-ids-custom-init])'); + +checkboxesFieldContainers.forEach((checkboxesFieldContainer: HTMLDivElement) => { + const checkboxesFieldInstance = new CheckboxesListField(checkboxesFieldContainer); + + checkboxesFieldInstance.init(); +}); + const fieldInputTextContainers = document.querySelectorAll('.ids-field--input-text:not([data-ids-custom-init])'); fieldInputTextContainers.forEach((fieldInputTextContainer: HTMLDivElement) => { diff --git a/src/bundle/Resources/public/ts/partials/base.ts b/src/bundle/Resources/public/ts/partials/base.ts index 2f8d19d5..7c695f2a 100644 --- a/src/bundle/Resources/public/ts/partials/base.ts +++ b/src/bundle/Resources/public/ts/partials/base.ts @@ -17,6 +17,10 @@ export abstract class Base { return this._container; } + reinit() { + // to be overridden in subclasses if needed + } + init() { this._container.setAttribute('data-ids-initialized', 'true'); diff --git a/src/bundle/Resources/views/themes/standard/design_system/components/checkbox/input.html.twig b/src/bundle/Resources/views/themes/standard/design_system/components/checkbox/input.html.twig index 31cb5d0b..52be1baf 100644 --- a/src/bundle/Resources/views/themes/standard/design_system/components/checkbox/input.html.twig +++ b/src/bundle/Resources/views/themes/standard/design_system/components/checkbox/input.html.twig @@ -21,6 +21,7 @@ name, required, type, + value, }) }} /> diff --git a/src/bundle/Resources/views/themes/standard/design_system/components/checkbox/list_field.html.twig b/src/bundle/Resources/views/themes/standard/design_system/components/checkbox/list_field.html.twig new file mode 100644 index 00000000..ca77ca08 --- /dev/null +++ b/src/bundle/Resources/views/themes/standard/design_system/components/checkbox/list_field.html.twig @@ -0,0 +1,9 @@ +{% extends '@IbexaDesignSystemTwig/themes/standard/design_system/partials/base_inputs_list.html.twig' %} + +{% set class = html_classes('ids-checkboxes-list-field', attributes.render('class') ?? '') %} + +{% block item %} + + {{ item.label }} + +{% endblock item %} diff --git a/src/lib/Twig/Components/AbstractChoiceInput.php b/src/lib/Twig/Components/AbstractChoiceInput.php index c25d9c39..3cbe35ee 100644 --- a/src/lib/Twig/Components/AbstractChoiceInput.php +++ b/src/lib/Twig/Components/AbstractChoiceInput.php @@ -29,7 +29,7 @@ abstract class AbstractChoiceInput public string $size = 'medium'; - protected ?string $value = null; + public ?string $value = null; /** * @param array $props @@ -79,12 +79,6 @@ public function validate(array $props): array return $resolver->resolve($props) + $props; } - #[ExposeInTemplate('value')] - protected function getValue(): ?string - { - return null; - } - abstract protected function configurePropsResolver(OptionsResolver $resolver): void; abstract public function getType(): string; diff --git a/src/lib/Twig/Components/AbstractField.php b/src/lib/Twig/Components/AbstractField.php index 14b457db..dfea47d7 100644 --- a/src/lib/Twig/Components/AbstractField.php +++ b/src/lib/Twig/Components/AbstractField.php @@ -32,8 +32,6 @@ abstract class AbstractField public bool $required = false; - public string $value = ''; - /** * @param array $props * @@ -49,7 +47,6 @@ public function validate(array $props): array 'labelExtra' => [], 'helperTextExtra' => [], 'required' => false, - 'value' => '', ]); $resolver->setRequired(['name']); @@ -58,7 +55,6 @@ public function validate(array $props): array $resolver->setAllowedTypes('labelExtra', 'array'); $resolver->setAllowedTypes('helperTextExtra', 'array'); $resolver->setAllowedTypes('required', 'bool'); - $resolver->setAllowedTypes('value', 'string'); $resolver->setNormalizer('labelExtra', static function (Options $options, array $attributes) { return self::assertForbidden($attributes, ['for', 'required'], 'labelExtra'); diff --git a/src/lib/Twig/Components/Checkbox/ListField.php b/src/lib/Twig/Components/Checkbox/ListField.php new file mode 100644 index 00000000..86b0b0eb --- /dev/null +++ b/src/lib/Twig/Components/Checkbox/ListField.php @@ -0,0 +1,52 @@ + + */ +#[AsTwigComponent('ibexa:checkbox:list_field')] +final class ListField extends AbstractField +{ + use ListFieldTrait; + + /** @var array */ + public array $value = []; + + /** + * @param CheckboxItem $item + * + * @return CheckboxItem + */ + protected function modifyListItem(array $item): array + { + $item['checked'] = in_array($item['value'], $this->value, true); + + return $item; + } + + protected function configurePropsResolver(OptionsResolver $resolver): void + { + $this->validateListFieldProps($resolver); + + // TODO: check if items are valid according to Checkbox/Field component + $resolver->setDefaults(['value' => []]); + $resolver->setAllowedTypes('value', 'array'); + } +} diff --git a/src/lib/Twig/Components/InputText/Field.php b/src/lib/Twig/Components/InputText/Field.php index bb75faf6..0429eb3b 100644 --- a/src/lib/Twig/Components/InputText/Field.php +++ b/src/lib/Twig/Components/InputText/Field.php @@ -29,6 +29,8 @@ final class Field extends AbstractField public string $type = 'input-text'; + public string $value = ''; + /** * @return AttrMap */ @@ -63,5 +65,7 @@ protected function configurePropsResolver(OptionsResolver $resolver): void }); $resolver->setRequired(['name']); $resolver->setAllowedTypes('id', ['null', 'string']); + $resolver->setDefaults(['value' => '']); + $resolver->setAllowedTypes('value', 'string'); } } diff --git a/src/lib/Twig/Components/LabelledChoiceInputTrait.php b/src/lib/Twig/Components/LabelledChoiceInputTrait.php index 9af03847..7f4962cd 100644 --- a/src/lib/Twig/Components/LabelledChoiceInputTrait.php +++ b/src/lib/Twig/Components/LabelledChoiceInputTrait.php @@ -57,9 +57,7 @@ public function getInput(): array 'disabled' => $this->disabled, 'error' => $this->error, 'required' => $this->required, - 'value' => $this->getValue(), + 'value' => $this->value, ]; } - - abstract protected function getValue(): ?string; } diff --git a/src/lib/Twig/Components/ListFieldTrait.php b/src/lib/Twig/Components/ListFieldTrait.php new file mode 100644 index 00000000..434e889e --- /dev/null +++ b/src/lib/Twig/Components/ListFieldTrait.php @@ -0,0 +1,66 @@ + + */ +trait ListFieldTrait +{ + public const string VERTICAL = 'vertical'; + public const string HORIZONTAL = 'horizontal'; + + public string $direction = 'vertical'; + + /** @var ListItems */ + #[ExposeInTemplate(name: 'items', getter: 'getItems')] + public array $items = []; + + /** + * @return ListItems + */ + public function getItems(): array + { + return array_map(function (array $item): array { + $listItem = $item + ['name' => $this->name, 'required' => $this->required]; + + return $this->modifyListItem($listItem); + }, $this->items); + } + + /** + * @param ListItem $item + * + * @return ListItem + */ + protected function modifyListItem(array $item): array + { + return $item; + } + + protected function validateListFieldProps(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'items' => [], + ]); + $resolver->setAllowedTypes('items', 'array'); + + $resolver + ->define('direction') + ->allowedValues(self::VERTICAL, self::HORIZONTAL) + ->default(self::VERTICAL); + } +} diff --git a/src/lib/Twig/Components/RadioButton/ListField.php b/src/lib/Twig/Components/RadioButton/ListField.php index 4f4bd5cf..d235a9d9 100644 --- a/src/lib/Twig/Components/RadioButton/ListField.php +++ b/src/lib/Twig/Components/RadioButton/ListField.php @@ -9,9 +9,9 @@ namespace Ibexa\DesignSystemTwig\Twig\Components\RadioButton; use Ibexa\DesignSystemTwig\Twig\Components\AbstractField; +use Ibexa\DesignSystemTwig\Twig\Components\ListFieldTrait; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\UX\TwigComponent\Attribute\AsTwigComponent; -use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate; /** * @phpstan-type RadioButtonItem array{ @@ -24,31 +24,17 @@ #[AsTwigComponent('ibexa:radio_button:list_field')] final class ListField extends AbstractField { - public string $direction = 'vertical'; + use ListFieldTrait; - /** @var RadioButtonItems */ - #[ExposeInTemplate(name: 'items', getter: 'getItems')] - public array $items = []; - - /** @return RadioButtonItems */ - public function getItems(): array - { - return array_map(function ($item) { - return $item + ['name' => $this->name, 'required' => $this->required]; - }, $this->items); - } + public string $value = ''; protected function configurePropsResolver(OptionsResolver $resolver): void { - $resolver->setDefaults([ - 'items' => [], - ]); - $resolver->setAllowedTypes('items', 'array'); + $this->validateListFieldProps($resolver); + // TODO: check if items are valid according to RadioButton/Field component - $resolver - ->define('direction') - ->allowedValues('vertical', 'horizontal') - ->default('vertical'); + $resolver->setDefaults(['value' => '']); + $resolver->setAllowedTypes('value', 'string'); } } diff --git a/tests/integration/Twig/Components/InputText/FieldTest.php b/tests/integration/Twig/Components/InputText/FieldTest.php index 816f798a..1cef28f3 100644 --- a/tests/integration/Twig/Components/InputText/FieldTest.php +++ b/tests/integration/Twig/Components/InputText/FieldTest.php @@ -27,7 +27,6 @@ public function testMount(): void [ 'name' => 'title', 'id' => 'title', - 'value' => 'Hello', 'required' => true, 'labelExtra' => ['class' => 'u-mb-1'], 'helperTextExtra' => ['data-test' => 'help'], @@ -39,7 +38,6 @@ public function testMount(): void self::assertSame('title', $component->name, 'Prop "name" should be set on the component.'); self::assertSame('title', $component->id, 'Prop "id" should be set on the component.'); - self::assertSame('Hello', $component->value, 'Prop "value" should be set on the component.'); self::assertTrue($component->required, 'Prop "required" should be true.'); /** @var array $label */ @@ -53,7 +51,6 @@ public function testMount(): void self::assertSame('title', $input['id'] ?? null, 'Input "id" should be set from prop.'); self::assertSame('title', $input['name'] ?? null, 'Input "name" should be set from prop.'); self::assertTrue((bool)($input['required'] ?? false), 'Input should have required attribute.'); - self::assertSame('Hello', $input['value'] ?? null, 'Input "value" should pass through.'); self::assertSame('true', $input['data-ids-custom-init'] ?? null, 'Input should include data-ids-custom-init="true".'); self::assertSame('ids-input u-w-full', $input['class'] ?? null, 'Input class should be merged.'); self::assertSame('Type…', $input['placeholder'] ?? null, 'Input placeholder should be merged.'); diff --git a/tests/integration/Twig/Components/RadioButton/FieldTest.php b/tests/integration/Twig/Components/RadioButton/FieldTest.php index 446edb82..c2dcd936 100644 --- a/tests/integration/Twig/Components/RadioButton/FieldTest.php +++ b/tests/integration/Twig/Components/RadioButton/FieldTest.php @@ -38,7 +38,6 @@ public function testDefaultRenderProducesWrapperAndRadioInput(): void $wrapper = $this->getWrapper($crawler); $class = $this->getClassAttr($wrapper); - self::assertSame('foo', $wrapper->attr('value'), 'Wrapper "value" should be passed through.'); self::assertStringContainsString('ids-radio-button-field', $class, 'Wrapper should have "ids-radio-button-field" class.'); self::assertStringContainsString('My label', $this->getText($wrapper), 'Wrapper should render provided label content.');