-
Notifications
You must be signed in to change notification settings - Fork 0
IBX-7908: Multivalue dropdown #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b875048
be70422
0b72b65
568277a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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[]; | ||
|
|
||
| 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'; |
| 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 |
|---|---|---|
| @@ -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')] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what this fancy thing do? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| public function isEmpty(): bool | ||
| { | ||
| return count($this->value) === 0; | ||
| } | ||
|
|
||
| protected function configurePropsResolver(OptionsResolver $resolver): void | ||
| { | ||
| $resolver | ||
| ->define('value') | ||
| ->allowedTypes('array') | ||
| ->default([]); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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