Skip to content

Commit 8c6c272

Browse files
committed
IBX-10853: Toggle
1 parent 7350356 commit 8c6c272

File tree

9 files changed

+336
-0
lines changed

9 files changed

+336
-0
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: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
if (isChecked) {
39+
this.toggleLabelNode.textContent = this.labels.on;
40+
} else {
41+
this.toggleLabelNode.textContent = this.labels.off;
42+
}
43+
}
44+
45+
protected initWidgets(): void {
46+
this.widgetNode.addEventListener('click', () => {
47+
this._inputElement.focus();
48+
this._inputElement.checked = !this._inputElement.checked;
49+
this._inputElement.dispatchEvent(new Event('change', { bubbles: true }));
50+
});
51+
}
52+
53+
protected initInputEvents(): void {
54+
this._inputElement.addEventListener('focus', () => {
55+
this._container.classList.add('ids-toggle--focused');
56+
});
57+
58+
this._inputElement.addEventListener('blur', () => {
59+
this._container.classList.remove('ids-toggle--focused');
60+
});
61+
62+
this._inputElement.addEventListener('change', () => {
63+
const changeEvent = new CustomEvent(ToggleButtonInput.EVENTS.CHANGE, {
64+
bubbles: true,
65+
detail: this._inputElement.checked,
66+
});
67+
68+
this.updateLabel();
69+
this._container.classList.toggle('ids-toggle--checked', this._inputElement.checked);
70+
this._container.dispatchEvent(changeEvent);
71+
});
72+
}
73+
74+
public init() {
75+
super.init();
76+
77+
this.initInputEvents();
78+
this.initWidgets();
79+
}
80+
}

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { CheckboxInput, CheckboxesListField } from './components/checkbox';
22
import { InputTextField, InputTextInput } from './components/input_text';
3+
import { ToggleButtonField, ToggleButtonInput } from './components/toggle_button';
34
import { Accordion } from './components/accordion';
45
import { AltRadioInput } from './components/alt_radio/alt_radio_input';
56
import { DropdownSingleInput } from './components/dropdown/dropdown_single_input';
@@ -59,3 +60,19 @@ inputTextContainers.forEach((inputTextContainer: HTMLDivElement) => {
5960

6061
inputTextInstance.init();
6162
});
63+
64+
const toggleButtonFieldContainers = document.querySelectorAll<HTMLDivElement>('.ids-toggle-field:not([data-ids-custom-init])');
65+
66+
toggleButtonFieldContainers.forEach((toggleButtonFieldContainer: HTMLDivElement) => {
67+
const toggleButtonFieldInstance = new ToggleButtonField(toggleButtonFieldContainer);
68+
69+
toggleButtonFieldInstance.init();
70+
});
71+
72+
const toggleButtonContainers = document.querySelectorAll<HTMLDivElement>('.ids-toggle:not([data-ids-custom-init])');
73+
74+
toggleButtonContainers.forEach((toggleButtonContainer: HTMLDivElement) => {
75+
const toggleButtonInstance = new ToggleButtonInput(toggleButtonContainer);
76+
77+
toggleButtonInstance.init();
78+
});
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>
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
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\ToggleButton;
10+
11+
use Ibexa\DesignSystemTwig\Twig\Components\AbstractField;
12+
use Symfony\Component\OptionsResolver\Options;
13+
use Symfony\Component\OptionsResolver\OptionsResolver;
14+
use Symfony\Component\Uid\Uuid;
15+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
16+
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
17+
18+
/**
19+
* @phpstan-type AttrMap array<string, scalar>
20+
*/
21+
#[AsTwigComponent('ibexa:toggle_button:field')]
22+
final class Field extends AbstractField
23+
{
24+
/** @var non-empty-string */
25+
public string $id; // TODO: maybe move to AbstractField?
26+
27+
/** @var AttrMap */
28+
#[ExposeInTemplate(name: 'input', getter: 'getInput')]
29+
public array $input = [];
30+
31+
public string $type = 'toggle';
32+
33+
public string $value = '';
34+
35+
/**
36+
* @return AttrMap
37+
*/
38+
public function getLabelExtra(): array
39+
{
40+
return $this->labelExtra + ['for' => $this->id, 'required' => $this->required];
41+
}
42+
43+
/**
44+
* @return AttrMap
45+
*/
46+
public function getInput(): array
47+
{
48+
return $this->input + [
49+
'id' => $this->id,
50+
'name' => $this->name,
51+
'required' => $this->required,
52+
'value' => $this->value,
53+
'data-ids-custom-init' => 'true',
54+
];
55+
}
56+
57+
protected function configurePropsResolver(OptionsResolver $resolver): void
58+
{
59+
$resolver->setDefaults([
60+
'id' => (string)Uuid::v7(),
61+
'input' => [],
62+
]);
63+
$resolver->setAllowedTypes('input', 'array');
64+
$resolver->setNormalizer('input', static function (Options $options, array $attributes) {
65+
return self::assertForbidden($attributes, ['id', 'name', 'required', 'value'], 'input');
66+
});
67+
$resolver->setRequired(['name']);
68+
$resolver->setAllowedTypes('id', ['null', 'string']);
69+
$resolver->setDefaults(['value' => '']);
70+
$resolver->setAllowedTypes('value', 'string');
71+
}
72+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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\ToggleButton;
10+
11+
use JMS\TranslationBundle\Annotation\Desc;
12+
use Ibexa\DesignSystemTwig\Twig\Components\AbstractChoiceInput;
13+
use Symfony\Component\OptionsResolver\OptionsResolver;
14+
use Symfony\Component\Uid\Uuid;
15+
use Symfony\Contracts\Translation\TranslatorInterface;
16+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
17+
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
18+
19+
#[AsTwigComponent('ibexa:toggle_button:input')]
20+
final class Input extends AbstractChoiceInput
21+
{
22+
private const string TRANSLATION_DOMAIN = 'ibexa_design_system_twig';
23+
24+
public string $offLabel = '';
25+
26+
public string $onLabel = '';
27+
28+
public string $id = '';
29+
30+
public function __construct(private readonly TranslatorInterface $translator)
31+
{
32+
}
33+
34+
protected function configurePropsResolver(OptionsResolver $resolver): void
35+
{
36+
$resolver
37+
->define('offLabel')
38+
->allowedTypes('string')
39+
->default(
40+
$this->translator->trans(
41+
/** @Desc("Off") */
42+
'ids.toggle.label.off',
43+
[],
44+
self::TRANSLATION_DOMAIN
45+
)
46+
);
47+
$resolver
48+
->define('onLabel')
49+
->allowedTypes('string')
50+
->default(
51+
$this->translator->trans(
52+
/** @Desc("On") */
53+
'ids.toggle.label.on',
54+
[],
55+
self::TRANSLATION_DOMAIN
56+
)
57+
);
58+
$resolver
59+
->define('id')
60+
->allowedTypes('string')
61+
->default((string)Uuid::v7());
62+
}
63+
64+
#[ExposeInTemplate('type')]
65+
public function getType(): string
66+
{
67+
return '';
68+
}
69+
}

0 commit comments

Comments
 (0)