Skip to content

Commit 7a6f675

Browse files
IBX-10850: AltRadio List Field (#64)
Co-authored-by: mikolaj <[email protected]>
1 parent a2841b5 commit 7a6f675

File tree

9 files changed

+376
-23
lines changed

9 files changed

+376
-23
lines changed

src/bundle/Resources/public/ts/components/alt_radio/alt_radio_input.ts

Lines changed: 38 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
import { Base } from '../../partials';
22

3+
export interface AltRadioInputOptions {
4+
onTileClick?: (event: MouseEvent, inputId: string) => void;
5+
}
6+
37
export class AltRadioInput extends Base {
4-
private _inputElement: HTMLInputElement;
5-
private _tileElement: HTMLDivElement;
8+
private inputElement: HTMLInputElement;
9+
private tileElement: HTMLDivElement;
10+
private onTileClick?: (event: MouseEvent, inputId: string) => void;
611

7-
private _isFocused = false;
12+
private isChecked = false;
13+
private isFocused = false;
814

9-
constructor(container: HTMLDivElement) {
15+
constructor(container: HTMLDivElement, { onTileClick }: AltRadioInputOptions = {}) {
1016
super(container);
1117

1218
const inputElement = this._container.querySelector<HTMLInputElement>('.ids-alt-radio__source .ids-input');
@@ -16,55 +22,67 @@ export class AltRadioInput extends Base {
1622
throw new Error('AltRadio: Required elements are missing in the container.');
1723
}
1824

19-
this._inputElement = inputElement;
20-
this._tileElement = tileElement;
25+
this.inputElement = inputElement;
26+
this.tileElement = tileElement;
27+
28+
this.onTileClick = onTileClick;
2129
}
2230

2331
setFocus(nextIsFocused: boolean) {
24-
if (this._isFocused === nextIsFocused) {
32+
if (this.isFocused === nextIsFocused) {
2533
return;
2634
}
2735

28-
this._isFocused = nextIsFocused;
36+
this.isFocused = nextIsFocused;
2937

30-
this._tileElement.classList.toggle('ids-alt-radio__tile--focused', nextIsFocused);
38+
this.tileElement.classList.toggle('ids-alt-radio__tile--focused', nextIsFocused);
3139

3240
if (nextIsFocused) {
33-
this._inputElement.focus();
41+
this.inputElement.focus();
3442
} else {
35-
this._inputElement.blur();
43+
this.inputElement.blur();
3644
}
3745
}
3846

3947
setError(value: boolean) {
40-
this._tileElement.classList.toggle('ids-alt-radio__tile--error', value);
48+
this.tileElement.classList.toggle('ids-alt-radio__tile--error', value);
4149
}
4250

4351
getInputElement(): HTMLInputElement {
44-
return this._inputElement;
52+
return this.inputElement;
53+
}
54+
55+
toggleChecked(value?: boolean) {
56+
const isChecked = value ?? !this.isChecked;
57+
58+
this.isChecked = isChecked;
59+
this.inputElement.checked = isChecked;
60+
this.tileElement.classList.toggle('ids-alt-radio__tile--checked', isChecked);
4561
}
4662

4763
initInputListeners() {
48-
this._inputElement.addEventListener('focus', () => {
64+
this.inputElement.addEventListener('focus', () => {
4965
this.setFocus(true);
5066
});
5167

52-
this._inputElement.addEventListener('blur', () => {
68+
this.inputElement.addEventListener('blur', () => {
5369
this.setFocus(false);
5470
});
5571

56-
this._inputElement.addEventListener('input', () => {
57-
this._tileElement.classList.toggle('ids-alt-radio__tile--checked', this._inputElement.checked);
72+
this.inputElement.addEventListener('input', () => {
73+
this.toggleChecked();
5874
});
5975
}
6076

6177
initTileBtn() {
62-
this._tileElement.addEventListener('click', (event) => {
78+
this.tileElement.addEventListener('click', (event) => {
6379
event.preventDefault();
6480
event.stopPropagation();
6581

66-
this._inputElement.focus();
67-
this._inputElement.click();
82+
this.inputElement.focus();
83+
this.inputElement.click();
84+
85+
this.onTileClick?.(event, this.inputElement.id);
6886
});
6987
}
7088

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { AltRadioInput } from './alt_radio_input';
2+
import { BaseInputsList } from '../../partials';
3+
4+
export enum AltRadiosListFieldAction {
5+
Check = 'check',
6+
Uncheck = 'uncheck',
7+
}
8+
9+
export class AltRadiosListField extends BaseInputsList<string> {
10+
private itemsContainer: HTMLDivElement;
11+
private itemsMap = new Map<string, AltRadioInput>();
12+
protected value?: string;
13+
14+
static EVENTS = {
15+
...BaseInputsList.EVENTS,
16+
CHANGE: 'ids:alt-radio-list-field:change',
17+
};
18+
19+
constructor(container: HTMLDivElement) {
20+
super(container);
21+
22+
const itemsContainer = container.querySelector<HTMLDivElement>('.ids-choice-inputs-list__items');
23+
24+
if (!itemsContainer) {
25+
throw new Error('AltRadiosListField: Required elements are missing in the container.');
26+
}
27+
28+
this.itemsContainer = itemsContainer;
29+
30+
this.onItemClick = this.onItemClick.bind(this);
31+
32+
this.saveItemsInstancesToMap();
33+
}
34+
35+
protected saveItemsInstancesToMap() {
36+
const itemsButtons = this.getItemsButtons();
37+
38+
this.itemsMap.clear();
39+
40+
itemsButtons.forEach((button) => {
41+
const buttonInstance = new AltRadioInput(button, { onTileClick: this.onItemClick });
42+
const buttonId = buttonInstance.getInputElement().id;
43+
44+
this.itemsMap.set(buttonId, buttonInstance);
45+
});
46+
}
47+
48+
getItemsButtons() {
49+
const itemsButtons = [...this.itemsContainer.querySelectorAll<HTMLDivElement>('.ids-alt-radio')];
50+
51+
return itemsButtons;
52+
}
53+
54+
protected onItemClick(_event: MouseEvent, itemValue: string) {
55+
if (this.value === itemValue) {
56+
return;
57+
}
58+
59+
const changeEvent = new CustomEvent(AltRadiosListField.EVENTS.CHANGE, {
60+
bubbles: true,
61+
detail: itemValue,
62+
});
63+
64+
if (this.value) {
65+
const currentValueInstance = this.itemsMap.get(this.value);
66+
67+
if (currentValueInstance) {
68+
currentValueInstance.toggleChecked(false);
69+
}
70+
}
71+
72+
this.value = itemValue;
73+
this._container.dispatchEvent(changeEvent);
74+
}
75+
76+
protected initButtons() {
77+
this.itemsMap.forEach((itemInstance) => {
78+
itemInstance.init();
79+
});
80+
}
81+
82+
public init() {
83+
super.init();
84+
85+
this.initButtons();
86+
}
87+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './alt_radio_input';
2+
export * from './alt_radios_list_field';

src/bundle/Resources/public/ts/init_components.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { AltRadioInput, AltRadiosListField } from './components/alt_radio';
12
import { CheckboxInput, CheckboxesListField } from './components/checkbox';
23
import { DropdownMultiInput, DropdownSingleInput } from './components/dropdown';
34
import { InputTextField, InputTextInput } from './components/input_text';
45
import { ToggleButtonField, ToggleButtonInput } from './components/toggle_button';
56
import { Accordion } from './components/accordion';
67
import { AltRadioInput } from './components/alt_radio/alt_radio_input';
8+
import { DropdownSingleInput } from './components/dropdown/dropdown_single_input';
79
import { OverflowList } from './components/overflow_list';
810

911
const accordionContainers = document.querySelectorAll<HTMLDivElement>('.ids-accordion:not([data-ids-custom-init])');
@@ -22,6 +24,14 @@ altRadioContainers.forEach((altRadioContainer: HTMLDivElement) => {
2224
altRadioInstance.init();
2325
});
2426

27+
const altRadiosListContainers = document.querySelectorAll<HTMLDivElement>('.ids-alt-radio-list-field:not([data-ids-custom-init])');
28+
29+
altRadiosListContainers.forEach((altRadiosListContainer: HTMLDivElement) => {
30+
const altRadiosListInstance = new AltRadiosListField(altRadiosListContainer);
31+
32+
altRadiosListInstance.init();
33+
});
34+
2535
const checkboxContainers = document.querySelectorAll<HTMLDivElement>('.ids-checkbox:not([data-ids-custom-init])');
2636

2737
checkboxContainers.forEach((checkboxContainer: HTMLDivElement) => {
@@ -30,7 +40,7 @@ checkboxContainers.forEach((checkboxContainer: HTMLDivElement) => {
3040
checkboxInstance.init();
3141
});
3242

33-
const checkboxesFieldContainers = document.querySelectorAll<HTMLDivElement>('.ids-field.ids-field--list:not([data-ids-custom-init])');
43+
const checkboxesFieldContainers = document.querySelectorAll<HTMLDivElement>('.ids-checkboxes-list-field:not([data-ids-custom-init])');
3444

3545
checkboxesFieldContainers.forEach((checkboxesFieldContainer: HTMLDivElement) => {
3646
const checkboxesFieldInstance = new CheckboxesListField(checkboxesFieldContainer);

src/bundle/Resources/views/themes/standard/design_system/components/alt_radio/input.html.twig

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,14 @@
1010
tile_class,
1111
)
1212
%}
13+
{% set custom_init = attributes.render('data-ids-custom-init') is not null %}
1314

14-
<div class="{{ component_classes }}">
15+
<div class="{{ component_classes }}"{{ custom_init ? ' data-ids-custom-init' : '' }}>
1516
<div class="ids-alt-radio__source">
1617
<input
1718
class="ids-input ids-input--radio"
1819
type="radio"
20+
name="{{ name }}"
1921
{{ attributes.defaults({ checked, disabled, required }) }}
2022
/>
2123
</div>
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{% extends '@IbexaDesignSystemTwig/themes/standard/design_system/partials/base_inputs_list.html.twig' %}
2+
3+
{% set class = html_classes('ids-alt-radio-list-field', attributes.render('class') ?? '') %}
4+
5+
{% block item %}
6+
<twig:ibexa:alt_radio:input {{ ...item }} data-ids-custom-init="true">
7+
{{ item.label }}
8+
</twig:ibexa:alt_radio:input>
9+
{% endblock item %}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
/**
4+
* @copyright Copyright (C) Ibexa AS. All rights reserved.
5+
* @license For full copyright and license information view LICENSE file distributed with this source code.
6+
*/
7+
declare(strict_types=1);
8+
9+
namespace Ibexa\DesignSystemTwig\Twig\Components\AltRadio;
10+
11+
use Ibexa\DesignSystemTwig\Twig\Components\AbstractField;
12+
use Ibexa\DesignSystemTwig\Twig\Components\ListFieldTrait;
13+
use Symfony\Component\OptionsResolver\OptionsResolver;
14+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
15+
16+
/**
17+
* @phpstan-type AltRadioItem array{
18+
* id: non-empty-string,
19+
* value: string|int,
20+
* label: string,
21+
* disabled?: bool,
22+
* tileClass?: string,
23+
* attributes?: array<string, mixed>,
24+
* label_attributes?: array<string, mixed>,
25+
* inputWrapperClassName?: string,
26+
* labelClassName?: string,
27+
* name?: string,
28+
* required?: bool
29+
* }
30+
* @phpstan-type AltRadioItems list<AltRadioItem>
31+
*/
32+
#[AsTwigComponent('ibexa:alt_radio:list_field')]
33+
final class ListField extends AbstractField
34+
{
35+
use ListFieldTrait;
36+
37+
public string $value = '';
38+
39+
protected function configurePropsResolver(OptionsResolver $resolver): void
40+
{
41+
$this->validateListFieldProps($resolver);
42+
43+
$resolver->setDefault('direction', self::HORIZONTAL);
44+
$resolver->setDefault('value', '');
45+
$resolver->setAllowedTypes('value', 'string');
46+
}
47+
48+
protected function configureListFieldItemOptions(OptionsResolver $itemsResolver): void
49+
{
50+
$itemsResolver
51+
->define('tileClass')
52+
->allowedTypes('string')
53+
->default('');
54+
55+
$itemsResolver->setDefault('disabled', false);
56+
$itemsResolver->setDefault('attributes', []);
57+
}
58+
}

src/lib/Twig/Components/ListFieldTrait.php

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ protected function validateListFieldProps(OptionsResolver $resolver): void
6666
->default([])
6767
->allowedTypes('array');
6868

69-
$resolver->setOptions('items', static function (OptionsResolver $itemsResolver): void {
69+
$resolver->setOptions('items', function (OptionsResolver $itemsResolver): void {
7070
$itemsResolver->setPrototype(true);
7171
$itemsResolver
7272
->define('id')
@@ -111,11 +111,18 @@ protected function validateListFieldProps(OptionsResolver $resolver): void
111111
$itemsResolver
112112
->define('required')
113113
->allowedTypes('bool');
114+
115+
$this->configureListFieldItemOptions($itemsResolver);
114116
});
115117

116118
$resolver
117119
->define('direction')
118120
->allowedValues(self::VERTICAL, self::HORIZONTAL)
119121
->default(self::VERTICAL);
120122
}
123+
124+
protected function configureListFieldItemOptions(OptionsResolver $itemsResolver): void
125+
{
126+
// Intentionally left blank; consuming components override to extend item option definitions.
127+
}
121128
}

0 commit comments

Comments
 (0)