Skip to content

Commit 918df6d

Browse files
committed
IBX-7908: Multivalue dropdown
1 parent 7350356 commit 918df6d

File tree

9 files changed

+279
-10
lines changed

9 files changed

+279
-10
lines changed
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { BaseDropdown, BaseDropdownItem } from '../../partials';
2+
import { createNodesFromTemplate } from '../../utils/dom';
3+
4+
export enum DropdownMultiInputAction {
5+
Check = 'check',
6+
Uncheck = 'uncheck',
7+
}
8+
9+
export class DropdownMultiInput extends BaseDropdown {
10+
private _sourceInputNode: HTMLSelectElement;
11+
private _value: string[];
12+
13+
constructor(container: HTMLDivElement) {
14+
super(container);
15+
16+
const _sourceInputNode = this._sourceNode.querySelector<HTMLSelectElement>('select');
17+
18+
if (!_sourceInputNode) {
19+
throw new Error('DropdownMultiInput: Required elements are missing in the container.');
20+
}
21+
22+
this._sourceInputNode = _sourceInputNode;
23+
this._value = this.getSelectedValuesFromSource();
24+
25+
this.onItemClick = this.onItemClick.bind(this);
26+
}
27+
28+
protected getSelectedValuesFromSource(): string[] {
29+
const selectedValues = Array.from(this._sourceInputNode.selectedOptions).map((option) => option.value);
30+
31+
return selectedValues;
32+
}
33+
34+
protected isSelected(id: string): boolean {
35+
return this._value.includes(id);
36+
}
37+
38+
protected setSource() {
39+
this._sourceInputNode.innerHTML = '';
40+
41+
this._itemsMap.forEach((item) => {
42+
const option = document.createElement('option');
43+
44+
option.value = item.id;
45+
option.textContent = item.label;
46+
47+
if (this._value.includes(item.id)) {
48+
option.selected = true;
49+
}
50+
51+
this._sourceInputNode.appendChild(option);
52+
});
53+
54+
this.setValues(this.getSelectedValuesFromSource());
55+
}
56+
57+
protected setSourceValue(id: string, actionPerformed: DropdownMultiInputAction) {
58+
const optionNode = this._sourceInputNode.querySelector<HTMLOptionElement>(`option[value="${id}"]`);
59+
60+
if (!optionNode) {
61+
return;
62+
}
63+
64+
optionNode.selected = actionPerformed === DropdownMultiInputAction.Check;
65+
}
66+
67+
protected setSelectedItem(id: string, actionPerformed: DropdownMultiInputAction) {
68+
const listItemNode = this._itemsContainerNode.querySelector<HTMLLIElement>(`.ids-dropdown__item[data-id="${id}"]`);
69+
const checkboxNode = listItemNode?.querySelector<HTMLInputElement>('.ids-input--checkbox');
70+
71+
if (!checkboxNode) {
72+
return;
73+
}
74+
75+
checkboxNode.checked = actionPerformed === DropdownMultiInputAction.Check;
76+
}
77+
78+
protected setSelectionInfo(values: string[]) {
79+
const items = values.map((value) => this.getItemById(value)).filter((item): item is BaseDropdownItem => item !== undefined);
80+
81+
if (items.length) {
82+
// TODO: implement OverflowList when merged
83+
this._selectionInfoItemsNode.textContent = items.map(({ label }) => label).join(', ');
84+
this._selectionInfoItemsNode.removeAttribute('hidden');
85+
this._placeholderNode.setAttribute('hidden', '');
86+
} else {
87+
this._selectionInfoItemsNode.textContent = '';
88+
this._selectionInfoItemsNode.setAttribute('hidden', '');
89+
this._placeholderNode.removeAttribute('hidden');
90+
}
91+
}
92+
93+
public getItemContent(item: BaseDropdownItem, listItem: HTMLLIElement): NodeListOf<ChildNode> | string {
94+
const placeholders = {
95+
'{{ id }}': item.id,
96+
'{{ label }}': item.label,
97+
};
98+
99+
const itemContent = createNodesFromTemplate(listItem.innerHTML, placeholders);
100+
101+
return itemContent instanceof NodeList ? itemContent : item.label;
102+
}
103+
104+
public setItems(items: BaseDropdownItem[]) {
105+
super.setItems(items);
106+
107+
const tempValue = this._value;
108+
109+
this._value = [];
110+
111+
this.setValues(tempValue);
112+
}
113+
114+
public setValues(values: string[]) {
115+
values.forEach((value) => {
116+
this.setValue(value);
117+
});
118+
}
119+
120+
public setValue(value: string) {
121+
const isSelected = this.isSelected(value);
122+
const nextValue = isSelected ? this._value.filter((iteratedValue) => iteratedValue !== value) : [...this._value, value];
123+
const actionPerformed = isSelected ? DropdownMultiInputAction.Uncheck : DropdownMultiInputAction.Check;
124+
125+
this.setSourceValue(value, actionPerformed);
126+
this.setSelectedItem(value, actionPerformed);
127+
this.setSelectionInfo(nextValue);
128+
129+
this._value = nextValue;
130+
}
131+
132+
public onItemClick = (event: MouseEvent) => {
133+
if (event.currentTarget instanceof HTMLLIElement) {
134+
const { id } = event.currentTarget.dataset;
135+
136+
if (!id) {
137+
return;
138+
}
139+
140+
this.setValue(id);
141+
}
142+
};
143+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
export * from './dropdown_multi_input';
12
export * from './dropdown_single_input';

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { CheckboxInput, CheckboxesListField } from './components/checkbox';
2+
import { DropdownMultiInput, DropdownSingleInput } from './components/dropdown';
23
import { InputTextField, InputTextInput } from './components/input_text';
34
import { Accordion } from './components/accordion';
45
import { AltRadioInput } from './components/alt_radio/alt_radio_input';
5-
import { DropdownSingleInput } from './components/dropdown/dropdown_single_input';
66

77
const accordionContainers = document.querySelectorAll<HTMLDivElement>('.ids-accordion:not([data-ids-custom-init])');
88

@@ -36,9 +36,17 @@ checkboxesFieldContainers.forEach((checkboxesFieldContainer: HTMLDivElement) =>
3636
checkboxesFieldInstance.init();
3737
});
3838

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

41-
dropdownContainers.forEach((dropdownContainer: HTMLDivElement) => {
41+
dropdownMultiContainers.forEach((dropdownContainer: HTMLDivElement) => {
42+
const dropdownInstance = new DropdownMultiInput(dropdownContainer);
43+
44+
dropdownInstance.init();
45+
});
46+
47+
const dropdownSingleContainers = document.querySelectorAll<HTMLDivElement>('.ids-dropdown--single:not([data-ids-custom-init])');
48+
49+
dropdownSingleContainers.forEach((dropdownContainer: HTMLDivElement) => {
4250
const dropdownInstance = new DropdownSingleInput(dropdownContainer);
4351

4452
dropdownInstance.init();

src/bundle/Resources/public/ts/partials/base_dropdown/base_dropdown.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,17 @@ export abstract class BaseDropdown extends Base {
112112

113113
listItem.dataset.id = item.id;
114114
listItem.dataset.label = item.label;
115-
listItem.textContent = this.getItemContent(item);
115+
116+
const itemContent = this.getItemContent(item, listItem);
117+
118+
if (itemContent instanceof NodeList) {
119+
listItem.innerHTML = '';
120+
Array.from(itemContent).forEach((childNode) => {
121+
listItem.appendChild(childNode);
122+
});
123+
} else {
124+
listItem.textContent = itemContent;
125+
}
116126

117127
this._itemsNode.appendChild(listItem);
118128
});
@@ -132,15 +142,12 @@ export abstract class BaseDropdown extends Base {
132142
}
133143
}
134144

135-
protected abstract setSourceValue(id: string): void;
136-
137-
protected abstract setSelectedItem(id: string): void;
138-
139145
protected abstract setSource(): void;
140146

141147
/******* Items management ********/
142148

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
export const createNodesFromTemplate = (template: string, placeholders: Record<string, string>): NodeListOf<ChildNode> | null => {
2+
const container = document.createElement('div');
3+
let result = template;
4+
5+
Object.entries(placeholders).forEach(([placeholder, value]) => {
6+
result = result.replaceAll(placeholder, value);
7+
});
8+
9+
container.innerHTML = result;
10+
11+
if (container instanceof HTMLElement && container.childNodes.length > 0) {
12+
return container.childNodes;
13+
}
14+
15+
return null;
16+
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{% extends '@IbexaDesignSystemTwig/themes/standard/design_system/partials/base_dropdown.html.twig' %}
2+
3+
{% set class = html_classes('ids-dropdown--multi', attributes.render('class') ?? '') %}
4+
5+
{% block source %}
6+
<select multiple name="{{ name }}"{% if disabled %} disabled{% endif %}>
7+
{% for item in items %}
8+
<option value="{{ item.id }}"{% if item.id in value %} selected{% endif %}>{{ item.label }}</option>
9+
{% endfor %}
10+
</select>
11+
{% endblock source %}
12+
13+
{% block item_content %}
14+
<twig:ibexa:checkbox:input
15+
name="{{ name }}-checkbox"
16+
:checked="item.id in value"
17+
:value="item.id"
18+
data-ids-custom-init="true"
19+
/>
20+
{{ item.label }}
21+
{% endblock item_content %}
22+
23+
{% block selected_items %}
24+
{{ selected_items|map(item => item.label)|join(', ') }}
25+
{% endblock selected_items %}

src/bundle/Resources/views/themes/standard/design_system/partials/base_dropdown.html.twig

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,9 @@
6868
</div>
6969
{% block templates %}
7070
{% set item_content %}
71-
{{ block('item_content') is defined ? block('item_content') : '' }}
71+
{% with { item: item_template_props } %}
72+
{{ block('item_content') is defined ? block('item_content') : '' }}
73+
{% endwith %}
7274
{% endset %}
7375

7476
<template class="ids-dropdown__template" data-id="item">

src/lib/Twig/Components/AbstractDropdown.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ abstract class AbstractDropdown
3535
/** @var array<TDropdownItem> */
3636
public array $items = [];
3737

38+
/** @var array<string> */
39+
public array $itemTemplateProps = ['id', 'label'];
40+
3841
public string $placeholder;
3942

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

100+
/**
101+
* @return array<string, string>
102+
*/
103+
#[ExposeInTemplate('item_template_props')]
104+
public function getItemTemplateProps(): array
105+
{
106+
$itemPropsPatterns = array_map(
107+
fn (string $name): string => '{{ ' . $name . ' }}',
108+
$this->itemTemplateProps
109+
);
110+
111+
return array_combine($this->itemTemplateProps, $itemPropsPatterns);
112+
}
113+
97114
abstract protected function configurePropsResolver(OptionsResolver $resolver): void;
98115

99116
/**
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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\DropdownMulti;
10+
11+
use Ibexa\DesignSystemTwig\Twig\Components\AbstractDropdown;
12+
use Symfony\Component\OptionsResolver\OptionsResolver;
13+
use Symfony\UX\TwigComponent\Attribute\AsTwigComponent;
14+
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;
15+
use Symfony\UX\TwigComponent\Attribute\PostMount;
16+
17+
#[AsTwigComponent('ibexa:dropdown_multi:input')]
18+
final class Input extends AbstractDropdown
19+
{
20+
/** @var array<string> */
21+
public array $value = [];
22+
23+
#[ExposeInTemplate('selected_items')]
24+
public function getSelectedItems(): array
25+
{
26+
$items = $this->items;
27+
return array_map(
28+
static function (string $id) use ($items) {
29+
return array_find($items, static function (array $item) use ($id) {
30+
return $item['id'] === $id;
31+
});
32+
},
33+
$this->value
34+
);
35+
}
36+
37+
#[ExposeInTemplate('is_empty')]
38+
public function isEmpty(): bool
39+
{
40+
return count($this->value) === 0;
41+
}
42+
43+
protected function configurePropsResolver(OptionsResolver $resolver): void
44+
{
45+
$resolver
46+
->define('value')
47+
->allowedTypes('array')
48+
->default([]);
49+
}
50+
}

0 commit comments

Comments
 (0)