79724b5bc9
Add a reusable multiselect dropdown component inspired by the issue
sidebar label picker, but decoupled from issue-specific logic.
Components:
- templates/shared/combolist.tmpl — generic template accepting Items,
Name, Title, SelectedValues parameters
- web_src/js/features/combo-multiselect.ts — lightweight JS init that
handles check/uncheck, search, and hidden input updates
- web_src/css/modules/combo-multiselect.css — check-mark visibility
and selected-items list styling
Usage in any template:
{{template "shared/combolist" dict
"Name" "channels"
"Title" "Update Channels"
"Items" .AvailableChannels
"SelectedValues" .SelectedChannelIDs
}}
Items must have .Value and .Label fields.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
94 lines
3.3 KiB
TypeScript
94 lines
3.3 KiB
TypeScript
// Copyright 2026 Moko Consulting. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
|
import {addDelegatedEventListener, queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
|
|
|
|
/**
|
|
* Generic multiselect combo list component.
|
|
* Works with the "shared/combolist" template to provide a reusable
|
|
* dropdown with search, checkable items, and a selected-items display list.
|
|
*
|
|
* Usage: add class="combo-multiselect" to the container element.
|
|
* The component is self-contained — no backend calls, just updates the hidden input.
|
|
*/
|
|
class ComboMultiselect {
|
|
container: HTMLElement;
|
|
elDropdown: HTMLElement;
|
|
elList: HTMLElement;
|
|
elComboValue: HTMLInputElement;
|
|
|
|
constructor(container: HTMLElement) {
|
|
this.container = container;
|
|
this.elDropdown = container.querySelector<HTMLElement>(':scope > .ui.dropdown')!;
|
|
this.elList = container.querySelector<HTMLElement>(':scope > .combo-multiselect-list')!;
|
|
this.elComboValue = container.querySelector<HTMLInputElement>(':scope > .combo-value')!;
|
|
}
|
|
|
|
collectCheckedValues(): string[] {
|
|
return Array.from(
|
|
this.elDropdown.querySelectorAll('.menu > .item.checked'),
|
|
(el) => el.getAttribute('data-value')!,
|
|
);
|
|
}
|
|
|
|
updateUiList() {
|
|
const checkedValues = this.collectCheckedValues();
|
|
const elEmptyTip = this.elList.querySelector(':scope > .item.empty-list')!;
|
|
queryElemChildren(this.elList, '.item:not(.empty-list)', (el) => el.remove());
|
|
|
|
for (const value of checkedValues) {
|
|
const el = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
|
|
if (!el) continue;
|
|
const labelText = el.querySelector('.item-label')?.textContent || value;
|
|
const listItem = document.createElement('span');
|
|
listItem.classList.add('item');
|
|
listItem.textContent = labelText;
|
|
this.elList.append(listItem);
|
|
}
|
|
|
|
toggleElem(elEmptyTip, checkedValues.length === 0);
|
|
this.elComboValue.value = checkedValues.join(',');
|
|
this.elComboValue.dispatchEvent(new Event('change', {bubbles: true}));
|
|
}
|
|
|
|
onItemClick(elItem: HTMLElement, e: Event) {
|
|
e.preventDefault();
|
|
|
|
if (elItem.matches('.clear-selection')) {
|
|
queryElems(this.elDropdown, '.menu > .item', (el) => el.classList.remove('checked'));
|
|
this.updateUiList();
|
|
return;
|
|
}
|
|
|
|
elItem.classList.toggle('checked');
|
|
this.updateUiList();
|
|
}
|
|
|
|
init() {
|
|
// Restore checked state from initial value
|
|
const initialValues = this.elComboValue.value ? this.elComboValue.value.split(',') : [];
|
|
for (const value of initialValues) {
|
|
const elItem = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
|
|
elItem?.classList.add('checked');
|
|
}
|
|
this.updateUiList();
|
|
|
|
addDelegatedEventListener(this.elDropdown, 'click', '.item', (el, e) => this.onItemClick(el, e));
|
|
|
|
fomanticQuery(this.elDropdown).dropdown('setting', {
|
|
action: 'nothing',
|
|
fullTextSearch: 'exact',
|
|
hideDividers: 'empty',
|
|
});
|
|
}
|
|
}
|
|
|
|
export function initComboMultiselect() {
|
|
queryElems(document, '.combo-multiselect', (el) => {
|
|
if (el.hasAttribute('data-combo-inited')) return;
|
|
el.setAttribute('data-combo-inited', 'true');
|
|
new ComboMultiselect(el).init();
|
|
});
|
|
}
|