Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"symfony/event-dispatcher": "^7.2",
"symfony/http-foundation": "^7.2",
"symfony/http-kernel": "^7.2",
"symfony/uid": "^7.3",
"symfony/ux-twig-component": "^2.27",
"symfony/yaml": "^7.2",
"twig/html-extra": "^3.20"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './toggle_button_field';
export * from './toggle_button_input';
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Base } from '../../partials';
import { HelperText } from '../helper_text';
import { Label } from '../label';
import { ToggleButtonInput } from './toggle_button_input';

export class ToggleButtonField extends Base {
private helperTextInstance: HelperText | null = null;
private inputInstance: ToggleButtonInput;
private labelInstance: Label | null = null;

constructor(container: HTMLDivElement) {
super(container);

const inputContainer = container.querySelector<HTMLDivElement>('.ids-toggle');

if (!inputContainer) {
throw new Error('ToggleButtonField: Input container is missing in the container.');
}

const labelContainer = container.querySelector<HTMLDivElement>('.ids-label');

if (labelContainer) {
this.labelInstance = new Label(labelContainer);
}

const helperTextContainer = container.querySelector<HTMLDivElement>('.ids-helper-text');

if (helperTextContainer) {
this.helperTextInstance = new HelperText(helperTextContainer);
}

this.inputInstance = new ToggleButtonInput(inputContainer);
}

initChildren(): void {
this.labelInstance?.init();
this.inputInstance.init();
this.helperTextInstance?.init();
}

init(): void {
super.init();

this.initChildren();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { BaseChoiceInput } from '../../partials';

export class ToggleButtonInput extends BaseChoiceInput {
private labels: { on: string; off: string };
private widgetNode: HTMLDivElement;
private toggleLabelNode: HTMLLabelElement;

static EVENTS = {
...BaseChoiceInput.EVENTS,
CHANGE: 'ids:toggle-button-input:change',
};

constructor(container: HTMLDivElement) {
super(container);

const widgetNode = this._container.querySelector<HTMLDivElement>('.ids-toggle__widget');
const toggleLabelNode = this._container.querySelector<HTMLLabelElement>('.ids-toggle__label');

if (!widgetNode || !toggleLabelNode) {
throw new Error('ToggleButtonInput: Required elements are missing in the container.');
}

const labelOn = toggleLabelNode.getAttribute('data-ids-label-on');
const labelOff = toggleLabelNode.getAttribute('data-ids-label-off');

if (!labelOn || !labelOff) {
throw new Error('ToggleButtonInput: Toggle labels are missing in label attributes.');
}

this.labels = { off: labelOff, on: labelOn };
this.widgetNode = widgetNode;
this.toggleLabelNode = toggleLabelNode;
}

protected updateLabel(): void {
const isChecked = this._inputElement.checked;

this.toggleLabelNode.textContent = isChecked ? this.labels.on : this.labels.off;
}

protected initWidgets(): void {
this.widgetNode.addEventListener('click', () => {
this._inputElement.focus();
this._inputElement.checked = !this._inputElement.checked;
this._inputElement.dispatchEvent(new Event('change', { bubbles: true }));
});
}

protected initInputEvents(): void {
this._inputElement.addEventListener('focus', () => {
this._container.classList.add('ids-toggle--focused');
});

this._inputElement.addEventListener('blur', () => {
this._container.classList.remove('ids-toggle--focused');
});

this._inputElement.addEventListener('change', () => {
const changeEvent = new CustomEvent(ToggleButtonInput.EVENTS.CHANGE, {
bubbles: true,
detail: this._inputElement.checked,
});

this.updateLabel();
this._container.classList.toggle('ids-toggle--checked', this._inputElement.checked);
this._container.dispatchEvent(changeEvent);
});
}

public init() {
super.init();

this.initInputEvents();
this.initWidgets();
}
}
17 changes: 17 additions & 0 deletions src/bundle/Resources/public/ts/init_components.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { CheckboxInput, CheckboxesListField } from './components/checkbox';
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';
Expand Down Expand Up @@ -68,3 +69,19 @@ overflowListContainers.forEach((overflowListContainer: HTMLDivElement) => {

overflowListInstance.init();
});

const toggleButtonFieldContainers = document.querySelectorAll<HTMLDivElement>('.ids-toggle-field:not([data-ids-custom-init])');

toggleButtonFieldContainers.forEach((toggleButtonFieldContainer: HTMLDivElement) => {
const toggleButtonFieldInstance = new ToggleButtonField(toggleButtonFieldContainer);

toggleButtonFieldInstance.init();
});

const toggleButtonContainers = document.querySelectorAll<HTMLDivElement>('.ids-toggle:not([data-ids-custom-init])');

toggleButtonContainers.forEach((toggleButtonContainer: HTMLDivElement) => {
const toggleButtonInstance = new ToggleButtonInput(toggleButtonContainer);

toggleButtonInstance.init();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{% extends '@IbexaDesignSystemTwig/themes/standard/design_system/partials/base_field.html.twig' %}

{% set class = html_classes('ids-toggle-field', attributes.render('class') ?? '') %}

{% block content %}
<twig:ibexa:toggle_button:input {{ ...input }} />
{% endblock content %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{% set component_classes =
html_cva(
base: html_classes(
'ids-toggle',
{
'ids-toggle--checked': checked,
'ids-toggle--disabled': disabled,
}
),
variants: {
size: {
medium: 'ids-toggle--medium',
small: 'ids-toggle--small'
}
}
)
%}

<div class="{{ component_classes.apply({ size }, attributes.render('class')) }}" {{ attributes }}>
<div class="ids-toggle__source">
<twig:ibexa:checkbox:input
:id="id"
:name="name"
:value="value"
:checked="checked"
:disabled="disabled"
:required="required"
data-ids-custom-init="1"
/>
</div>
<div class="ids-toggle__widget" role="button">
<div class="ids-toggle__indicator"></div>
</div>
<twig:ibexa:choice_input_label
class="ids-toggle__label"
:for="id"
:data-ids-label-on="onLabel"
:data-ids-label-off="offLabel"
>
{{ checked ? onLabel : offLabel }}
</twig:ibexa:choice_input_label>
</div>
8 changes: 4 additions & 4 deletions src/lib/Twig/Components/AbstractField.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,18 @@
use Symfony\UX\TwigComponent\Attribute\PreMount;

/**
* @phpstan-type AttrMap array<string, scalar>
* @phpstan-type AttributeMap array<string, scalar>
*/
abstract class AbstractField
{
/** @var non-empty-string */
public string $name;

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

/** @var AttrMap */
/** @var AttributeMap */
#[ExposeInTemplate('helper_text_extra')]
public array $helperTextExtra = [];

Expand Down Expand Up @@ -66,7 +66,7 @@ public function validate(array $props): array
}

/**
* @return AttrMap
* @return AttributeMap
*/
public function getLabelExtra(): array
{
Expand Down
96 changes: 96 additions & 0 deletions src/lib/Twig/Components/AbstractSingleInputField.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\DesignSystemTwig\Twig\Components;

use Symfony\Component\OptionsResolver\Options;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\PropertyAccess\Exception\InvalidTypeException;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;

/**
* @phpstan-type AttributeMap array<string, scalar>
*/
abstract class AbstractSingleInputField extends AbstractField
{
/** @var non-empty-string */
public string $id;

/** @var AttributeMap */
#[ExposeInTemplate(name: 'input', getter: 'getInput')]
public array $input = [];

public string $value = '';

/**
* @return AttributeMap
*/
public function getLabelExtra(): array
{
return $this->labelExtra + ['for' => $this->id, 'required' => $this->required];
}

/**
* @return AttributeMap
*/
public function getInput(): array
{
return $this->input + [
'id' => $this->id,
'name' => $this->name,
'required' => $this->required,
'value' => $this->value,
'data-ids-custom-init' => 'true',
];
}

protected function configureSingleInputFieldOptions(
OptionsResolver $resolver,
?callable $idFactory,
string $defaultValue = ''
): void {
$resolver
->define('id')
->allowedTypes('null', 'string')
->default(null)
->normalize(static function (Options $options, ?string $id) use ($idFactory): string {
if (null !== $id) {
if ('' === trim($id)) {
throw new InvalidTypeException('non-empty-string', 'string', 'id');
}

return $id;
}

if (null === $idFactory) {
throw new InvalidTypeException('string', 'NULL', 'id');
}

$value = $idFactory();

if ('' === trim($value)) {
throw new InvalidTypeException('non-empty-string', 'string', 'id');
}

return $value;
});

$resolver
->define('input')
->allowedTypes('array')
->default([])
->normalize(static function (Options $options, array $attributes): array {
return self::assertForbidden($attributes, ['id', 'name', 'required', 'value'], 'input');
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This call shows that this trait depends on something else. It can only ever be used as part of AbstractField.

});

$resolver
->define('value')
->allowedTypes('string')
->default($defaultValue);
}
}
Loading