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
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { BaseDropdown, BaseDropdownItem } from '../../partials';
import { createNodesFromTemplate } from '../../utils/dom';

export enum DropdownMultiInputAction {
Check = 'check',
Uncheck = 'uncheck',
}

export class DropdownMultiInput extends BaseDropdown {
private _sourceInputNode: HTMLSelectElement;
private _value: string[];
Copy link
Contributor

Choose a reason for hiding this comment

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

I think at the end of our frontend discussion we decided to skip _ for private class properties


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

const _sourceInputNode = this._sourceNode.querySelector<HTMLSelectElement>('select');

if (!_sourceInputNode) {
throw new Error('DropdownMultiInput: Required elements are missing in the container.');
}

this._sourceInputNode = _sourceInputNode;
this._value = this.getSelectedValuesFromSource();

this.onItemClick = this.onItemClick.bind(this);
}

protected getSelectedValuesFromSource(): string[] {
const selectedValues = Array.from(this._sourceInputNode.selectedOptions).map((option) => option.value);

return selectedValues;
}

protected isSelected(id: string): boolean {
return this._value.includes(id);
}

protected setSource() {
this._sourceInputNode.innerHTML = '';

this._itemsMap.forEach((item) => {
const option = document.createElement('option');

option.value = item.id;
option.textContent = item.label;

if (this._value.includes(item.id)) {
option.selected = true;
}

this._sourceInputNode.appendChild(option);
});

this.setValues(this.getSelectedValuesFromSource());
}

protected setSourceValue(id: string, actionPerformed: DropdownMultiInputAction) {
const optionNode = this._sourceInputNode.querySelector<HTMLOptionElement>(`option[value="${id}"]`);

if (!optionNode) {
return;
}

optionNode.selected = actionPerformed === DropdownMultiInputAction.Check;
}

protected setSelectedItem(id: string, actionPerformed: DropdownMultiInputAction) {
const listItemNode = this._itemsContainerNode.querySelector<HTMLLIElement>(`.ids-dropdown__item[data-id="${id}"]`);
const checkboxNode = listItemNode?.querySelector<HTMLInputElement>('.ids-input--checkbox');

if (!checkboxNode) {
return;
}

checkboxNode.checked = actionPerformed === DropdownMultiInputAction.Check;
}

protected setSelectionInfo(values: string[]) {
const items = values.map((value) => this.getItemById(value)).filter((item): item is BaseDropdownItem => item !== undefined);

if (items.length) {
// TODO: implement OverflowList when merged
this._selectionInfoItemsNode.textContent = items.map(({ label }) => label).join(', ');
this._selectionInfoItemsNode.removeAttribute('hidden');
this._placeholderNode.setAttribute('hidden', '');
} else {
this._selectionInfoItemsNode.textContent = '';
this._selectionInfoItemsNode.setAttribute('hidden', '');
this._placeholderNode.removeAttribute('hidden');
}
}

public getItemContent(item: BaseDropdownItem, listItem: HTMLLIElement): NodeListOf<ChildNode> | string {
const placeholders = {
'{{ id }}': item.id,
'{{ label }}': item.label,
};

const itemContent = createNodesFromTemplate(listItem.innerHTML, placeholders);

return itemContent instanceof NodeList ? itemContent : item.label;
}

public setItems(items: BaseDropdownItem[]) {
super.setItems(items);

const tempValue = this._value;

this._value = [];

this.setValues(tempValue);
}

public setValues(values: string[]) {
values.forEach((value) => {
this.setValue(value);
});
}

public setValue(value: string) {
const isSelected = this.isSelected(value);
const nextValue = isSelected ? this._value.filter((iteratedValue) => iteratedValue !== value) : [...this._value, value];
const actionPerformed = isSelected ? DropdownMultiInputAction.Uncheck : DropdownMultiInputAction.Check;

this.setSourceValue(value, actionPerformed);
this.setSelectedItem(value, actionPerformed);
this.setSelectionInfo(nextValue);

this._value = nextValue;
}

public onItemClick = (event: MouseEvent) => {
if (event.currentTarget instanceof HTMLLIElement) {
const { id } = event.currentTarget.dataset;

if (!id) {
return;
}

this.setValue(id);
}
};
}
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './dropdown_multi_input';
export * from './dropdown_single_input';
14 changes: 11 additions & 3 deletions src/bundle/Resources/public/ts/init_components.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { CheckboxInput, CheckboxesListField } from './components/checkbox';
import { DropdownMultiInput, DropdownSingleInput } from './components/dropdown';
import { InputTextField, InputTextInput } from './components/input_text';
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<HTMLDivElement>('.ids-accordion:not([data-ids-custom-init])');
Expand Down Expand Up @@ -37,9 +37,17 @@ checkboxesFieldContainers.forEach((checkboxesFieldContainer: HTMLDivElement) =>
checkboxesFieldInstance.init();
});

const dropdownContainers = document.querySelectorAll<HTMLDivElement>('.ids-dropdown:not([data-ids-custom-init])');
const dropdownMultiContainers = document.querySelectorAll<HTMLDivElement>('.ids-dropdown--multi:not([data-ids-custom-init])');

dropdownContainers.forEach((dropdownContainer: HTMLDivElement) => {
dropdownMultiContainers.forEach((dropdownContainer: HTMLDivElement) => {
const dropdownInstance = new DropdownMultiInput(dropdownContainer);

dropdownInstance.init();
});

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

dropdownSingleContainers.forEach((dropdownContainer: HTMLDivElement) => {
const dropdownInstance = new DropdownSingleInput(dropdownContainer);

dropdownInstance.init();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,17 @@ export abstract class BaseDropdown extends Base {

listItem.dataset.id = item.id;
listItem.dataset.label = item.label;
listItem.textContent = this.getItemContent(item);

const itemContent = this.getItemContent(item, listItem);

if (itemContent instanceof NodeList) {
listItem.innerHTML = '';
Array.from(itemContent).forEach((childNode) => {
listItem.appendChild(childNode);
});
} else {
listItem.textContent = itemContent;
}

this._itemsNode.appendChild(listItem);
});
Expand All @@ -132,15 +142,12 @@ export abstract class BaseDropdown extends Base {
}
}

protected abstract setSourceValue(id: string): void;

protected abstract setSelectedItem(id: string): void;

protected abstract setSource(): void;

/******* Items management ********/

public getItemContent(item: BaseDropdownItem) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public getItemContent(item: BaseDropdownItem, _listItem: HTMLLIElement): NodeListOf<ChildNode> | string {
return item.label;
}

Expand Down
16 changes: 16 additions & 0 deletions src/bundle/Resources/public/ts/utils/dom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export const createNodesFromTemplate = (template: string, placeholders: Record<string, string>): NodeListOf<ChildNode> | null => {
const container = document.createElement('div');
let result = template;

Object.entries(placeholders).forEach(([placeholder, value]) => {
result = result.replaceAll(placeholder, value);
});

container.innerHTML = result;

if (container instanceof HTMLElement && container.childNodes.length > 0) {
return container.childNodes;
}

return null;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{% extends '@IbexaDesignSystemTwig/themes/standard/design_system/partials/base_dropdown.html.twig' %}

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

{% block source %}
<select multiple name="{{ name }}"{% if disabled %} disabled{% endif %}>
{% for item in items %}
<option value="{{ item.id }}"{% if item.id in value %} selected{% endif %}>{{ item.label }}</option>
{% endfor %}
</select>
{% endblock source %}

{% block item_content %}
<twig:ibexa:checkbox:input
name="{{ name }}-checkbox"
:checked="item.id in value"
:value="item.id"
data-ids-custom-init="true"
/>
{{ item.label }}
{% endblock item_content %}

{% block selected_items %}
{{ selected_items|map(item => item.label)|join(', ') }}
{% endblock selected_items %}
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,9 @@
</div>
{% block templates %}
{% set item_content %}
{{ block('item_content') is defined ? block('item_content') : '' }}
{% with { item: item_template_props } %}
{{ block('item_content') is defined ? block('item_content') : '' }}
{% endwith %}
{% endset %}

<template class="ids-dropdown__template" data-id="item">
Expand Down
17 changes: 17 additions & 0 deletions src/lib/Twig/Components/AbstractDropdown.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ abstract class AbstractDropdown
/** @var array<TDropdownItem> */
public array $items = [];

/** @var array<string> */
public array $itemTemplateProps = ['id', 'label'];

public string $placeholder;

#[ExposeInTemplate('max_visible_items')]
Expand Down Expand Up @@ -94,6 +97,20 @@ public function getIsSearchVisible(): bool
return count($this->items) > $this->maxVisibleItems;
}

/**
* @return array<string, string>
*/
#[ExposeInTemplate('item_template_props')]
public function getItemTemplateProps(): array
{
$itemPropsPatterns = array_map(
static fn (string $name): string => '{{ ' . $name . ' }}',
$this->itemTemplateProps
);

return array_combine($this->itemTemplateProps, $itemPropsPatterns);
}

abstract protected function configurePropsResolver(OptionsResolver $resolver): void;

/**
Expand Down
53 changes: 53 additions & 0 deletions src/lib/Twig/Components/DropdownMulti/Input.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?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\DropdownMulti;

use Ibexa\DesignSystemTwig\Twig\Components\AbstractDropdown;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;

#[AsTwigComponent('ibexa:dropdown_multi:input')]
final class Input extends AbstractDropdown
{
/** @var array<string> */
public array $value = [];

/**
* @return array<int, array{id: string, label: string}|null>
*/
#[ExposeInTemplate('selected_items')]
public function getSelectedItems(): array
{
$items = $this->items;

return array_map(
static function (string $id) use ($items): ?array {
return array_find($items, static function (array $item) use ($id): bool {
return $item['id'] === $id;
});
},
$this->value
);
}

#[ExposeInTemplate('is_empty')]
Copy link

Choose a reason for hiding this comment

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

what this fancy thing do?

Copy link
Contributor

Choose a reason for hiding this comment

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

#[ExposeInTemplate('is_empty')] comes from Symfony UX TwigComponent. It marks the isEmpty() method so that, when the component renders, its return value is automatically available in the Twig template as the variable is_empty. In other words, Twig templates using this component can just write {{ is_empty }} without calling the method explicitly; TwigComponent populates that context entry by running isEmpty().
Without #[ExposeInTemplate('is_empty')], the Twig template would only see the component’s public properties; isEmpty() would remain internal, so you’d have to replicate the empty-check logic in Twig or reference the component object directly (e.g., this.isEmpty()), which is discouraged.

public function isEmpty(): bool
{
return count($this->value) === 0;
}

protected function configurePropsResolver(OptionsResolver $resolver): void
{
$resolver
->define('value')
->allowedTypes('array')
->default([]);
}
}
Loading