Skip to content

Commit a2841b5

Browse files
IBX-10853: Toggle (#65)
Co-authored-by: mikolaj <[email protected]>
1 parent c943784 commit a2841b5

File tree

12 files changed

+392
-53
lines changed

12 files changed

+392
-53
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"symfony/event-dispatcher": "^7.2",
1616
"symfony/http-foundation": "^7.2",
1717
"symfony/http-kernel": "^7.2",
18+
"symfony/uid": "^7.3",
1819
"symfony/ux-twig-component": "^2.27",
1920
"symfony/yaml": "^7.2",
2021
"twig/html-extra": "^3.20"
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './toggle_button_field';
2+
export * from './toggle_button_input';
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Base } from '../../partials';
2+
import { HelperText } from '../helper_text';
3+
import { Label } from '../label';
4+
import { ToggleButtonInput } from './toggle_button_input';
5+
6+
export class ToggleButtonField extends Base {
7+
private helperTextInstance: HelperText | null = null;
8+
private inputInstance: ToggleButtonInput;
9+
private labelInstance: Label | null = null;
10+
11+
constructor(container: HTMLDivElement) {
12+
super(container);
13+
14+
const inputContainer = container.querySelector<HTMLDivElement>('.ids-toggle');
15+
16+
if (!inputContainer) {
17+
throw new Error('ToggleButtonField: Input container is missing in the container.');
18+
}
19+
20+
const labelContainer = container.querySelector<HTMLDivElement>('.ids-label');
21+
22+
if (labelContainer) {
23+
this.labelInstance = new Label(labelContainer);
24+
}
25+
26+
const helperTextContainer = container.querySelector<HTMLDivElement>('.ids-helper-text');
27+
28+
if (helperTextContainer) {
29+
this.helperTextInstance = new HelperText(helperTextContainer);
30+
}
31+
32+
this.inputInstance = new ToggleButtonInput(inputContainer);
33+
}
34+
35+
initChildren(): void {
36+
this.labelInstance?.init();
37+
this.inputInstance.init();
38+
this.helperTextInstance?.init();
39+
}
40+
41+
init(): void {
42+
super.init();
43+
44+
this.initChildren();
45+
}
46+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { BaseChoiceInput } from '../../partials';
2+
3+
export class ToggleButtonInput extends BaseChoiceInput {
4+
private labels: { on: string; off: string };
5+
private widgetNode: HTMLDivElement;
6+
private toggleLabelNode: HTMLLabelElement;
7+
8+
static EVENTS = {
9+
...BaseChoiceInput.EVENTS,
10+
CHANGE: 'ids:toggle-button-input:change',
11+
};
12+
13+
constructor(container: HTMLDivElement) {
14+
super(container);
15+
16+
const widgetNode = this._container.querySelector<HTMLDivElement>('.ids-toggle__widget');
17+
const toggleLabelNode = this._container.querySelector<HTMLLabelElement>('.ids-toggle__label');
18+
19+
if (!widgetNode || !toggleLabelNode) {
20+
throw new Error('ToggleButtonInput: Required elements are missing in the container.');
21+
}
22+
23+
const labelOn = toggleLabelNode.getAttribute('data-ids-label-on');
24+
const labelOff = toggleLabelNode.getAttribute('data-ids-label-off');
25+
26+
if (!labelOn || !labelOff) {
27+
throw new Error('ToggleButtonInput: Toggle labels are missing in label attributes.');
28+
}
29+
30+
this.labels = { off: labelOff, on: labelOn };
31+
this.widgetNode = widgetNode;
32+
this.toggleLabelNode = toggleLabelNode;
33+
}
34+
35+
protected updateLabel(): void {
36+
const isChecked = this._inputElement.checked;
37+
38+
this.toggleLabelNode.textContent = isChecked ? this.labels.on : this.labels.off;
39+
}
40+
41+
protected initWidgets(): void {
42+
this.widgetNode.addEventListener('click', () => {
43+
this._inputElement.focus();
44+
this._inputElement.checked = !this._inputElement.checked;
45+
this._inputElement.dispatchEvent(new Event('change', { bubbles: true }));
46+
});
47+
}
48+
49+
protected initInputEvents(): void {
50+
this._inputElement.addEventListener('focus', () => {
51+
this._container.classList.add('ids-toggle--focused');
52+
});
53+
54+
this._inputElement.addEventListener('blur', () => {
55+
this._container.classList.remove('ids-toggle--focused');
56+
});
57+
58+
this._inputElement.addEventListener('change', () => {
59+
const changeEvent = new CustomEvent(ToggleButtonInput.EVENTS.CHANGE, {
60+
bubbles: true,
61+
detail: this._inputElement.checked,
62+
});
63+
64+
this.updateLabel();
65+
this._container.classList.toggle('ids-toggle--checked', this._inputElement.checked);
66+
this._container.dispatchEvent(changeEvent);
67+
});
68+
}
69+
70+
public init() {
71+
super.init();
72+
73+
this.initInputEvents();
74+
this.initWidgets();
75+
}
76+
}

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { CheckboxInput, CheckboxesListField } from './components/checkbox';
22
import { DropdownMultiInput, DropdownSingleInput } from './components/dropdown';
33
import { InputTextField, InputTextInput } from './components/input_text';
4+
import { ToggleButtonField, ToggleButtonInput } from './components/toggle_button';
45
import { Accordion } from './components/accordion';
56
import { AltRadioInput } from './components/alt_radio/alt_radio_input';
67
import { OverflowList } from './components/overflow_list';
@@ -76,3 +77,19 @@ overflowListContainers.forEach((overflowListContainer: HTMLDivElement) => {
7677

7778
overflowListInstance.init();
7879
});
80+
81+
const toggleButtonFieldContainers = document.querySelectorAll<HTMLDivElement>('.ids-toggle-field:not([data-ids-custom-init])');
82+
83+
toggleButtonFieldContainers.forEach((toggleButtonFieldContainer: HTMLDivElement) => {
84+
const toggleButtonFieldInstance = new ToggleButtonField(toggleButtonFieldContainer);
85+
86+
toggleButtonFieldInstance.init();
87+
});
88+
89+
const toggleButtonContainers = document.querySelectorAll<HTMLDivElement>('.ids-toggle:not([data-ids-custom-init])');
90+
91+
toggleButtonContainers.forEach((toggleButtonContainer: HTMLDivElement) => {
92+
const toggleButtonInstance = new ToggleButtonInput(toggleButtonContainer);
93+
94+
toggleButtonInstance.init();
95+
});
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{% extends '@IbexaDesignSystemTwig/themes/standard/design_system/partials/base_field.html.twig' %}
2+
3+
{% set class = html_classes('ids-toggle-field', attributes.render('class') ?? '') %}
4+
5+
{% block content %}
6+
<twig:ibexa:toggle_button:input {{ ...input }} />
7+
{% endblock content %}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{% set component_classes =
2+
html_cva(
3+
base: html_classes(
4+
'ids-toggle',
5+
{
6+
'ids-toggle--checked': checked,
7+
'ids-toggle--disabled': disabled,
8+
}
9+
),
10+
variants: {
11+
size: {
12+
medium: 'ids-toggle--medium',
13+
small: 'ids-toggle--small'
14+
}
15+
}
16+
)
17+
%}
18+
19+
<div class="{{ component_classes.apply({ size }, attributes.render('class')) }}" {{ attributes }}>
20+
<div class="ids-toggle__source">
21+
<twig:ibexa:checkbox:input
22+
:id="id"
23+
:name="name"
24+
:value="value"
25+
:checked="checked"
26+
:disabled="disabled"
27+
:required="required"
28+
data-ids-custom-init="1"
29+
/>
30+
</div>
31+
<div class="ids-toggle__widget" role="button">
32+
<div class="ids-toggle__indicator"></div>
33+
</div>
34+
<twig:ibexa:choice_input_label
35+
class="ids-toggle__label"
36+
:for="id"
37+
:data-ids-label-on="onLabel"
38+
:data-ids-label-off="offLabel"
39+
>
40+
{{ checked ? onLabel : offLabel }}
41+
</twig:ibexa:choice_input_label>
42+
</div>

src/lib/Twig/Components/AbstractField.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,18 +15,18 @@
1515
use Symfony\UX\TwigComponent\Attribute\PreMount;
1616

1717
/**
18-
* @phpstan-type AttrMap array<string, scalar>
18+
* @phpstan-type AttributeMap array<string, scalar>
1919
*/
2020
abstract class AbstractField
2121
{
2222
/** @var non-empty-string */
2323
public string $name;
2424

25-
/** @var AttrMap */
25+
/** @var AttributeMap */
2626
#[ExposeInTemplate(name: 'label_extra', getter: 'getLabelExtra')]
2727
public array $labelExtra = [];
2828

29-
/** @var AttrMap */
29+
/** @var AttributeMap */
3030
#[ExposeInTemplate('helper_text_extra')]
3131
public array $helperTextExtra = [];
3232

@@ -66,7 +66,7 @@ public function validate(array $props): array
6666
}
6767

6868
/**
69-
* @return AttrMap
69+
* @return AttributeMap
7070
*/
7171
public function getLabelExtra(): array
7272
{
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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;
10+
11+
use Symfony\Component\OptionsResolver\Options;
12+
use Symfony\Component\OptionsResolver\OptionsResolver;
13+
use Symfony\Component\PropertyAccess\Exception\InvalidTypeException;
14+
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
15+
16+
/**
17+
* @phpstan-type AttributeMap array<string, scalar>
18+
*/
19+
abstract class AbstractSingleInputField extends AbstractField
20+
{
21+
/** @var non-empty-string */
22+
public string $id;
23+
24+
/** @var AttributeMap */
25+
#[ExposeInTemplate(name: 'input', getter: 'getInput')]
26+
public array $input = [];
27+
28+
public string $value = '';
29+
30+
/**
31+
* @return AttributeMap
32+
*/
33+
public function getLabelExtra(): array
34+
{
35+
return $this->labelExtra + ['for' => $this->id, 'required' => $this->required];
36+
}
37+
38+
/**
39+
* @return AttributeMap
40+
*/
41+
public function getInput(): array
42+
{
43+
return $this->input + [
44+
'id' => $this->id,
45+
'name' => $this->name,
46+
'required' => $this->required,
47+
'value' => $this->value,
48+
'data-ids-custom-init' => 'true',
49+
];
50+
}
51+
52+
protected function configureSingleInputFieldOptions(
53+
OptionsResolver $resolver,
54+
?callable $idFactory,
55+
string $defaultValue = ''
56+
): void {
57+
$resolver
58+
->define('id')
59+
->allowedTypes('null', 'string')
60+
->default(null)
61+
->normalize(static function (Options $options, ?string $id) use ($idFactory): string {
62+
if (null !== $id) {
63+
if ('' === trim($id)) {
64+
throw new InvalidTypeException('non-empty-string', 'string', 'id');
65+
}
66+
67+
return $id;
68+
}
69+
70+
if (null === $idFactory) {
71+
throw new InvalidTypeException('string', 'NULL', 'id');
72+
}
73+
74+
$value = $idFactory();
75+
76+
if ('' === trim($value)) {
77+
throw new InvalidTypeException('non-empty-string', 'string', 'id');
78+
}
79+
80+
return $value;
81+
});
82+
83+
$resolver
84+
->define('input')
85+
->allowedTypes('array')
86+
->default([])
87+
->normalize(static function (Options $options, array $attributes): array {
88+
return self::assertForbidden($attributes, ['id', 'name', 'required', 'value'], 'input');
89+
});
90+
91+
$resolver
92+
->define('value')
93+
->allowedTypes('string')
94+
->default($defaultValue);
95+
}
96+
}

0 commit comments

Comments
 (0)