feat(ui): add generic combo-multiselect component

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>
This commit is contained in:
Jonathan Miller
2026-05-31 11:41:08 -05:00
parent 88e210bffb
commit 79724b5bc9
5 changed files with 169 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
{{/*
Generic multiselect combo list component.
Provides a dropdown with search, checkable items, and a selected-items display list.
Parameters:
Name - form input name (required)
Title - display label (required)
Items - slice of items, each must have .Value and .Label fields
SelectedValues - comma-separated string of selected values
Placeholder - search input placeholder (optional, defaults to "Filter...")
EmptyText - text when nothing is selected (optional)
Disabled - whether the control is disabled (optional)
Icon - gear icon shown next to title (optional, defaults to "octicon-gear")
*/}}
<div class="combo-multiselect" data-field-name="{{.Name}}">
<input class="combo-value" name="{{.Name}}" type="hidden" value="{{.SelectedValues}}">
<div class="ui dropdown full-width {{if .Disabled}}disabled{{end}}">
<a class="fixed-text muted">
<strong>{{.Title}}</strong> {{if not .Disabled}}{{svg (or .Icon "octicon-gear")}}{{end}}
</a>
<div class="menu">
{{if .Items}}
<div class="ui icon search input">
<i class="icon">{{svg "octicon-search" 16}}</i>
<input type="text" placeholder="{{or .Placeholder (ctx.Locale.Tr "search.filter")}}">
</div>
<div class="scrolling menu">
<a class="item clear-selection" href="#">{{ctx.Locale.Tr "repo.issues.new.clear_labels"}}</a>
<div class="divider"></div>
{{range .Items}}
<a class="item" data-value="{{.Value}}">
<span class="item-check-mark">{{svg "octicon-check" 16}}</span>
<span class="item-label">{{.Label}}</span>
</a>
{{end}}
</div>
{{else}}
<div class="item disabled">{{or .EmptyText (ctx.Locale.Tr "repo.issues.new.no_items")}}</div>
{{end}}
</div>
</div>
<div class="ui list combo-multiselect-list">
<span class="item empty-list">{{or .EmptyText "None"}}</span>
</div>
</div>
+1
View File
@@ -34,6 +34,7 @@
@import "./modules/codeeditor.css";
@import "./modules/chroma.css";
@import "./modules/charescape.css";
@import "./modules/combo-multiselect.css";
@import "./shared/flex-list.css";
@import "./shared/milestone.css";
+27
View File
@@ -0,0 +1,27 @@
/* Styles for the generic combo-multiselect component (shared/combolist.tmpl) */
.combo-multiselect > .ui.dropdown .item:not(.checked) .item-check-mark {
visibility: hidden;
}
.combo-multiselect > .ui.dropdown .item .item-check-mark {
margin-right: 0.5em;
}
.combo-multiselect > .combo-multiselect-list {
margin-top: 0.25em;
}
.combo-multiselect > .combo-multiselect-list > .item {
display: inline-block;
padding: 2px 6px;
margin: 2px;
border-radius: var(--border-radius);
background: var(--color-label-bg);
font-size: 0.9em;
}
.combo-multiselect > .combo-multiselect-list > .item.empty-list {
background: none;
color: var(--color-text-light-2);
}
+93
View File
@@ -0,0 +1,93 @@
// 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();
});
}
+2
View File
@@ -22,6 +22,7 @@ import {initUserExternalLogins, initUserCheckAppUrl} from './features/user-auth.
import {initRepoPullRequestReview, initRepoIssueFilterItemLabel} from './features/repo-issue.ts';
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
import {initRepoTopicBar} from './features/repo-home.ts';
import {initComboMultiselect} from './features/combo-multiselect.ts';
import {initAdminCommon} from './features/admin/common.ts';
import {initRepoCodeView} from './features/repo-code.ts';
import {initSshKeyFormParser} from './features/sshkey-helper.ts';
@@ -105,6 +106,7 @@ const initPerformanceTracer = callInitFunctions([
initTableSort,
initRepoFileSearch,
initCopyContent,
initComboMultiselect,
initAdminCommon,
initAdminUserListSearchForm,