diff --git a/src/bundle/Resources/public/ts/components/alt_radio/alt_radio_input.ts b/src/bundle/Resources/public/ts/components/alt_radio/alt_radio_input.ts index f047e94f..9ad15b82 100644 --- a/src/bundle/Resources/public/ts/components/alt_radio/alt_radio_input.ts +++ b/src/bundle/Resources/public/ts/components/alt_radio/alt_radio_input.ts @@ -1,12 +1,18 @@ import { Base } from '../../partials'; +export interface AltRadioInputOptions { + onTileClick?: (event: MouseEvent, inputId: string) => void; +} + export class AltRadioInput extends Base { - private _inputElement: HTMLInputElement; - private _tileElement: HTMLDivElement; + private inputElement: HTMLInputElement; + private tileElement: HTMLDivElement; + private onTileClick?: (event: MouseEvent, inputId: string) => void; - private _isFocused = false; + private isChecked = false; + private isFocused = false; - constructor(container: HTMLDivElement) { + constructor(container: HTMLDivElement, { onTileClick }: AltRadioInputOptions = {}) { super(container); const inputElement = this._container.querySelector('.ids-alt-radio__source .ids-input'); @@ -16,55 +22,67 @@ export class AltRadioInput extends Base { throw new Error('AltRadio: Required elements are missing in the container.'); } - this._inputElement = inputElement; - this._tileElement = tileElement; + this.inputElement = inputElement; + this.tileElement = tileElement; + + this.onTileClick = onTileClick; } setFocus(nextIsFocused: boolean) { - if (this._isFocused === nextIsFocused) { + if (this.isFocused === nextIsFocused) { return; } - this._isFocused = nextIsFocused; + this.isFocused = nextIsFocused; - this._tileElement.classList.toggle('ids-alt-radio__tile--focused', nextIsFocused); + this.tileElement.classList.toggle('ids-alt-radio__tile--focused', nextIsFocused); if (nextIsFocused) { - this._inputElement.focus(); + this.inputElement.focus(); } else { - this._inputElement.blur(); + this.inputElement.blur(); } } setError(value: boolean) { - this._tileElement.classList.toggle('ids-alt-radio__tile--error', value); + this.tileElement.classList.toggle('ids-alt-radio__tile--error', value); } getInputElement(): HTMLInputElement { - return this._inputElement; + return this.inputElement; + } + + toggleChecked(value?: boolean) { + const isChecked = value ?? !this.isChecked; + + this.isChecked = isChecked; + this.inputElement.checked = isChecked; + this.tileElement.classList.toggle('ids-alt-radio__tile--checked', isChecked); } initInputListeners() { - this._inputElement.addEventListener('focus', () => { + this.inputElement.addEventListener('focus', () => { this.setFocus(true); }); - this._inputElement.addEventListener('blur', () => { + this.inputElement.addEventListener('blur', () => { this.setFocus(false); }); - this._inputElement.addEventListener('input', () => { - this._tileElement.classList.toggle('ids-alt-radio__tile--checked', this._inputElement.checked); + this.inputElement.addEventListener('input', () => { + this.toggleChecked(); }); } initTileBtn() { - this._tileElement.addEventListener('click', (event) => { + this.tileElement.addEventListener('click', (event) => { event.preventDefault(); event.stopPropagation(); - this._inputElement.focus(); - this._inputElement.click(); + this.inputElement.focus(); + this.inputElement.click(); + + this.onTileClick?.(event, this.inputElement.id); }); } diff --git a/src/bundle/Resources/public/ts/components/alt_radio/alt_radios_list_field.ts b/src/bundle/Resources/public/ts/components/alt_radio/alt_radios_list_field.ts new file mode 100644 index 00000000..6c514189 --- /dev/null +++ b/src/bundle/Resources/public/ts/components/alt_radio/alt_radios_list_field.ts @@ -0,0 +1,87 @@ +import { AltRadioInput } from './alt_radio_input'; +import { BaseInputsList } from '../../partials'; + +export enum AltRadiosListFieldAction { + Check = 'check', + Uncheck = 'uncheck', +} + +export class AltRadiosListField extends BaseInputsList { + private itemsContainer: HTMLDivElement; + private itemsMap = new Map(); + protected value?: string; + + static EVENTS = { + ...BaseInputsList.EVENTS, + CHANGE: 'ids:alt-radio-list-field:change', + }; + + constructor(container: HTMLDivElement) { + super(container); + + const itemsContainer = container.querySelector('.ids-choice-inputs-list__items'); + + if (!itemsContainer) { + throw new Error('AltRadiosListField: Required elements are missing in the container.'); + } + + this.itemsContainer = itemsContainer; + + this.onItemClick = this.onItemClick.bind(this); + + this.saveItemsInstancesToMap(); + } + + protected saveItemsInstancesToMap() { + const itemsButtons = this.getItemsButtons(); + + this.itemsMap.clear(); + + itemsButtons.forEach((button) => { + const buttonInstance = new AltRadioInput(button, { onTileClick: this.onItemClick }); + const buttonId = buttonInstance.getInputElement().id; + + this.itemsMap.set(buttonId, buttonInstance); + }); + } + + getItemsButtons() { + const itemsButtons = [...this.itemsContainer.querySelectorAll('.ids-alt-radio')]; + + return itemsButtons; + } + + protected onItemClick(_event: MouseEvent, itemValue: string) { + if (this.value === itemValue) { + return; + } + + const changeEvent = new CustomEvent(AltRadiosListField.EVENTS.CHANGE, { + bubbles: true, + detail: itemValue, + }); + + if (this.value) { + const currentValueInstance = this.itemsMap.get(this.value); + + if (currentValueInstance) { + currentValueInstance.toggleChecked(false); + } + } + + this.value = itemValue; + this._container.dispatchEvent(changeEvent); + } + + protected initButtons() { + this.itemsMap.forEach((itemInstance) => { + itemInstance.init(); + }); + } + + public init() { + super.init(); + + this.initButtons(); + } +} diff --git a/src/bundle/Resources/public/ts/components/alt_radio/index.ts b/src/bundle/Resources/public/ts/components/alt_radio/index.ts index 0ca1f37a..16f4a591 100644 --- a/src/bundle/Resources/public/ts/components/alt_radio/index.ts +++ b/src/bundle/Resources/public/ts/components/alt_radio/index.ts @@ -1 +1,2 @@ export * from './alt_radio_input'; +export * from './alt_radios_list_field'; diff --git a/src/bundle/Resources/public/ts/init_components.ts b/src/bundle/Resources/public/ts/init_components.ts index 17b37edb..b6fa274e 100644 --- a/src/bundle/Resources/public/ts/init_components.ts +++ b/src/bundle/Resources/public/ts/init_components.ts @@ -1,9 +1,11 @@ +import { AltRadioInput, AltRadiosListField } from './components/alt_radio'; import { CheckboxInput, CheckboxesListField } from './components/checkbox'; import { DropdownMultiInput, DropdownSingleInput } from './components/dropdown'; import { InputTextField, InputTextInput } from './components/input_text'; import { ToggleButtonField, ToggleButtonInput } from './components/toggle_button'; import { Accordion } from './components/accordion'; import { AltRadioInput } from './components/alt_radio/alt_radio_input'; +import { DropdownSingleInput } from './components/dropdown/dropdown_single_input'; import { OverflowList } from './components/overflow_list'; const accordionContainers = document.querySelectorAll('.ids-accordion:not([data-ids-custom-init])'); @@ -22,6 +24,14 @@ altRadioContainers.forEach((altRadioContainer: HTMLDivElement) => { altRadioInstance.init(); }); +const altRadiosListContainers = document.querySelectorAll('.ids-alt-radio-list-field:not([data-ids-custom-init])'); + +altRadiosListContainers.forEach((altRadiosListContainer: HTMLDivElement) => { + const altRadiosListInstance = new AltRadiosListField(altRadiosListContainer); + + altRadiosListInstance.init(); +}); + const checkboxContainers = document.querySelectorAll('.ids-checkbox:not([data-ids-custom-init])'); checkboxContainers.forEach((checkboxContainer: HTMLDivElement) => { @@ -30,7 +40,7 @@ checkboxContainers.forEach((checkboxContainer: HTMLDivElement) => { checkboxInstance.init(); }); -const checkboxesFieldContainers = document.querySelectorAll('.ids-field.ids-field--list:not([data-ids-custom-init])'); +const checkboxesFieldContainers = document.querySelectorAll('.ids-checkboxes-list-field:not([data-ids-custom-init])'); checkboxesFieldContainers.forEach((checkboxesFieldContainer: HTMLDivElement) => { const checkboxesFieldInstance = new CheckboxesListField(checkboxesFieldContainer); diff --git a/src/bundle/Resources/views/themes/standard/design_system/components/alt_radio/input.html.twig b/src/bundle/Resources/views/themes/standard/design_system/components/alt_radio/input.html.twig index 8e992b85..37fa9e82 100644 --- a/src/bundle/Resources/views/themes/standard/design_system/components/alt_radio/input.html.twig +++ b/src/bundle/Resources/views/themes/standard/design_system/components/alt_radio/input.html.twig @@ -10,12 +10,14 @@ tile_class, ) %} +{% set custom_init = attributes.render('data-ids-custom-init') is not null %} -
+
diff --git a/src/bundle/Resources/views/themes/standard/design_system/components/alt_radio/list_field.html.twig b/src/bundle/Resources/views/themes/standard/design_system/components/alt_radio/list_field.html.twig new file mode 100644 index 00000000..30b4b129 --- /dev/null +++ b/src/bundle/Resources/views/themes/standard/design_system/components/alt_radio/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-alt-radio-list-field', attributes.render('class') ?? '') %} + +{% block item %} + + {{ item.label }} + +{% endblock item %} diff --git a/src/lib/Twig/Components/AltRadio/ListField.php b/src/lib/Twig/Components/AltRadio/ListField.php new file mode 100644 index 00000000..f3d6ed68 --- /dev/null +++ b/src/lib/Twig/Components/AltRadio/ListField.php @@ -0,0 +1,58 @@ +, + * label_attributes?: array, + * inputWrapperClassName?: string, + * labelClassName?: string, + * name?: string, + * required?: bool + * } + * @phpstan-type AltRadioItems list + */ +#[AsTwigComponent('ibexa:alt_radio:list_field')] +final class ListField extends AbstractField +{ + use ListFieldTrait; + + public string $value = ''; + + protected function configurePropsResolver(OptionsResolver $resolver): void + { + $this->validateListFieldProps($resolver); + + $resolver->setDefault('direction', self::HORIZONTAL); + $resolver->setDefault('value', ''); + $resolver->setAllowedTypes('value', 'string'); + } + + protected function configureListFieldItemOptions(OptionsResolver $itemsResolver): void + { + $itemsResolver + ->define('tileClass') + ->allowedTypes('string') + ->default(''); + + $itemsResolver->setDefault('disabled', false); + $itemsResolver->setDefault('attributes', []); + } +} diff --git a/src/lib/Twig/Components/ListFieldTrait.php b/src/lib/Twig/Components/ListFieldTrait.php index 324ece04..1bc216c7 100644 --- a/src/lib/Twig/Components/ListFieldTrait.php +++ b/src/lib/Twig/Components/ListFieldTrait.php @@ -66,7 +66,7 @@ protected function validateListFieldProps(OptionsResolver $resolver): void ->default([]) ->allowedTypes('array'); - $resolver->setOptions('items', static function (OptionsResolver $itemsResolver): void { + $resolver->setOptions('items', function (OptionsResolver $itemsResolver): void { $itemsResolver->setPrototype(true); $itemsResolver ->define('id') @@ -111,6 +111,8 @@ protected function validateListFieldProps(OptionsResolver $resolver): void $itemsResolver ->define('required') ->allowedTypes('bool'); + + $this->configureListFieldItemOptions($itemsResolver); }); $resolver @@ -118,4 +120,9 @@ protected function validateListFieldProps(OptionsResolver $resolver): void ->allowedValues(self::VERTICAL, self::HORIZONTAL) ->default(self::VERTICAL); } + + protected function configureListFieldItemOptions(OptionsResolver $itemsResolver): void + { + // Intentionally left blank; consuming components override to extend item option definitions. + } } diff --git a/tests/integration/Twig/Components/AltRadio/ListFieldTest.php b/tests/integration/Twig/Components/AltRadio/ListFieldTest.php new file mode 100644 index 00000000..e3f0f4af --- /dev/null +++ b/tests/integration/Twig/Components/AltRadio/ListFieldTest.php @@ -0,0 +1,161 @@ +mountTwigComponent(ListField::class, $this->baseProps()); + + self::assertInstanceOf( + ListField::class, + $component, + 'Component should mount as AltRadio\\ListField.' + ); + } + + public function testDefaultRenderProducesWrapperAndItems(): void + { + $crawler = $this->renderTwigComponent( + ListField::class, + $this->baseProps() + )->crawler(); + + $wrapper = $this->getWrapper($crawler); + $classes = $this->getClassAttr($wrapper); + + self::assertStringContainsString('ids-field', $classes, 'Wrapper should include "ids-field".'); + self::assertStringContainsString('ids-field--list', $classes, 'Wrapper should include "ids-field--list".'); + self::assertStringContainsString('ids-alt-radio-list-field', $classes, 'Wrapper should include alt radio modifier.'); + + $items = $crawler->filter('.ids-choice-inputs-list__items .ids-alt-radio'); + self::assertSame(2, $items->count(), 'Should render exactly two alt radio items.'); + + $firstTile = $this->getTile($items->eq(0)); + $secondTile = $this->getTile($items->eq(1)); + + self::assertStringContainsString('Pick A', $this->getText($firstTile), 'First tile should render its label.'); + self::assertStringContainsString('Pick B', $this->getText($secondTile), 'Second tile should render its label.'); + } + + public function testPerItemTileClassAndDisabledFlagAreForwarded(): void + { + $props = $this->baseProps(); + $props['items'][0]['tileClass'] = 'is-featured'; + $props['items'][0]['disabled'] = true; + + $crawler = $this->renderTwigComponent( + ListField::class, + $props + )->crawler(); + + $items = $crawler->filter('.ids-choice-inputs-list__items .ids-alt-radio'); + $firstAltRadio = $items->eq(0); + $firstTile = $this->getTile($firstAltRadio); + + self::assertStringContainsString( + 'is-featured', + $this->getClassAttr($firstTile), + 'Custom tile class should be merged onto the tile element.' + ); + + $firstInput = $this->getAltRadioInput($firstAltRadio); + self::assertNotNull( + $firstInput->attr('disabled'), + 'Disabled=true on the item should render native "disabled" attribute.' + ); + } + + public function testInvalidTileClassTypeCausesResolverErrorOnMount(): void + { + $this->expectException(InvalidOptionsException::class); + + $this->mountTwigComponent(ListField::class, $this->baseProps([ + 'items' => [ + [ + 'id' => 'opt-a', + 'value' => 'A', + 'label' => 'Pick A', + 'tileClass' => ['not-a-string'], + ], + ], + ])); + } + + /** + * @param array $overrides + * + * @return array + */ + private function baseProps(array $overrides = []): array + { + return array_replace([ + 'name' => 'group', + 'items' => [ + [ + 'id' => 'opt-a', + 'value' => 'A', + 'label' => 'Pick A', + ], + [ + 'id' => 'opt-b', + 'value' => 'B', + 'label' => 'Pick B', + ], + ], + ], $overrides); + } + + private function getWrapper(Crawler $crawler): Crawler + { + $node = $crawler->filter('.ids-field')->first(); + self::assertGreaterThan(0, $node->count(), 'Wrapper ".ids-field" should be present.'); + + return $node; + } + + private function getAltRadioInput(Crawler $scope): Crawler + { + $node = $scope->filter('.ids-alt-radio__source > input')->first(); + self::assertGreaterThan( + 0, + $node->count(), + 'Alt radio input should be present under ".ids-alt-radio__source > input".' + ); + + return $node; + } + + private function getTile(Crawler $scope): Crawler + { + $node = $scope->filter('.ids-alt-radio__tile')->first(); + self::assertGreaterThan(0, $node->count(), 'Tile ".ids-alt-radio__tile" should be present.'); + + return $node; + } + + private function getClassAttr(Crawler $node): string + { + return (string) $node->attr('class'); + } + + private function getText(Crawler $node): string + { + return trim($node->text('')); + } +}