();
+ $.fn.searchableSelect = function (options?: SearchableSelectOptions) {
+ if (this.length === 0) return this;
+ const settings: SearchableSelectOptions = $.extend({
+ target: this.parent()[0],
+ classList: [],
+ placeholder: 'Select an option',
+ element: this[0] as HTMLSelectElement
+ }, options);
+ this.each(function () {
+ const element = this as HTMLSelectElement;
+ if (element.tagName.toLowerCase() === 'select') {
+ const select = new SearchableSelect(settings);
+ selectMap.set(element, select);
+ $(element).data('searchableSelect', 'true');
+ } else {
+ console.warn('Element is not a select:', element);
+ }
+ });
+ return this;
+ };
+ $.fn.getSearchableSelect = function () {
+ const element = this[0] as HTMLSelectElement;
+ if (element && selectMap.get(element)) {
+ return selectMap.get(element) as SearchableSelect;
+ } else {
+ console.warn('No SearchableSelect instance found for this element:', element);
+ return null;
+ }
+ };
+})(jQuery);
diff --git a/src/frontend/components/form-group/searchable-select/_searchable-select.scss b/src/frontend/components/form-group/searchable-select/_searchable-select.scss
new file mode 100644
index 000000000..7049dc3a3
--- /dev/null
+++ b/src/frontend/components/form-group/searchable-select/_searchable-select.scss
@@ -0,0 +1,41 @@
+.btn-searchable-select.dropdown-toggle {
+ @include button-variant($gray-100, $gray-100, $secondary);
+ margin-top: .75rem;
+ margin-bottom: 1rem;
+ min-width: 100%;
+ max-width: 100%;
+ text-align: center;
+ white-space: nowrap;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+
+ & span {
+ flex-grow: 1;
+ }
+}
+
+.btn-searchable-select {
+ .dropdown-menu.show {
+ border-color: $gray-200;
+ }
+}
+
+.rule-container {
+ display: flex;
+ align-items: center;
+}
+
+.searchable-select-options {
+ max-height: 300px;
+ overflow-y: auto;
+
+ .searchable-select-search {
+ @extend .sticky-top;
+ @extend .p-3;
+ @extend .bg-light;
+ @extend .border-bottom;
+ border-color: $gray-200;
+ box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
+ }
+}
diff --git a/src/frontend/components/form-group/searchable-select/lib/SearchableSelect.test.ts b/src/frontend/components/form-group/searchable-select/lib/SearchableSelect.test.ts
new file mode 100644
index 000000000..b3f3179f2
--- /dev/null
+++ b/src/frontend/components/form-group/searchable-select/lib/SearchableSelect.test.ts
@@ -0,0 +1,57 @@
+/* eslint-disable */
+import { describe, it, expect, beforeEach, afterEach } from "@jest/globals";
+import { SearchableSelect } from "./SearchableSelect";
+
+describe("SearchableSelect", () => {
+ beforeEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ it("should get the correct last created dropdown ID", () => {
+ // Make this a semi-random order to ensure the logic works correctly
+ document.body.innerHTML = `
+
+
+
+
+ `;
+ const lastId = SearchableSelect.LastCreatedDropdownId;
+ expect(lastId).toBe("dropdown-5");
+ });
+
+ it("should return dropdown-1 when no dropdowns exist", () => {
+ const lastId = SearchableSelect.LastCreatedDropdownId;
+ expect(lastId).toBe("dropdown-1");
+ });
+
+ it("should return the correct dropdown ID when no dropdowns match the pattern", () => {
+ document.body.innerHTML = `
+
+
+ `;
+ const lastId = SearchableSelect.LastCreatedDropdownId;
+ expect(lastId).toBe("dropdown-1");
+ });
+
+ it("should get the version of Bootstrap", () => {
+ const version = SearchableSelect.BootstrapVersion;
+ expect(version).toBeGreaterThan(0);
+ });
+
+ it("should be able to create a dropdown", () => {
+ const selectElement = document.createElement('select');
+ selectElement.innerHTML = `
+
+
+
+ `;
+ document.body.appendChild(selectElement);
+
+ const searchableSelect = new SearchableSelect({ element: selectElement });
+ expect(searchableSelect).toBeInstanceOf(SearchableSelect);
+ })
+});
diff --git a/src/frontend/components/form-group/searchable-select/lib/SearchableSelect.ts b/src/frontend/components/form-group/searchable-select/lib/SearchableSelect.ts
new file mode 100644
index 000000000..1de3246b0
--- /dev/null
+++ b/src/frontend/components/form-group/searchable-select/lib/SearchableSelect.ts
@@ -0,0 +1,169 @@
+import { Tooltip } from 'bootstrap';
+import { SearchableSelectOptions } from './options';
+
+/**
+ * SearchableSelect class provides a searchable dropdown interface for a standard HTML select element.
+ */
+export class SearchableSelect {
+ dropdown: HTMLDivElement = null;
+ button: HTMLElement = null;
+ target: HTMLElement;
+ element: HTMLSelectElement;
+ classList: string[];
+ placeholder: string;
+
+ /**
+ * Create a SearchableSelect instance.
+ * @param target The target HTMLElement where the dropdown will be appended.
+ * @param element The HTMLSelectElement that will be transformed into a searchable dropdown.
+ */
+ constructor({ element, target, classList, placeholder }: SearchableSelectOptions) {
+ this.element = element;
+ this.target = target || element.parentElement || document.body;
+ this.classList = classList || [];
+ this.placeholder = placeholder || 'Select an option';
+ this.init();
+ }
+
+ /**
+ * Initializes the SearchableSelect by creating the dropdown and hiding the original select element.
+ * It also sets up the event listeners for the search input and option selection.
+ * @private
+ */
+ private init() {
+ const options = Array.from(this.element.options);
+ this.createDropdown(options);
+ this.element.style.display = 'none'; // Hide the original select element
+ }
+
+ /**
+ * Creates the dropdown element and appends it to the target.
+ * This method generates a unique ID for the dropdown and creates the button and options list.
+ * @param options An array of HTMLOptionElement objects to populate the dropdown.
+ * @private
+ */
+ private createDropdown(options: HTMLOptionElement[]) {
+ const id = SearchableSelect.LastCreatedDropdownId;
+ this.dropdown = document.createElement('div');
+ this.dropdown.className = 'dropdown btn-searchable-select';
+ this.dropdown.id = id;
+ this.createButton(this.dropdown);
+ this.createOptions(options, this.dropdown);
+ this.target.appendChild(this.dropdown);
+ this.refresh();
+ }
+
+ /**
+ * Creates the options list for the dropdown.
+ * It includes a search input field at the top for filtering options.
+ * @param options An array of HTMLOptionElement objects to populate the dropdown.
+ * @param dropdown The HTMLDivElement that represents the dropdown container.
+ * @private
+ */
+ private createOptions(options: HTMLOptionElement[], dropdown: HTMLDivElement) {
+ const ul = document.createElement('ul');
+ ul.className = 'dropdown-menu searchable-select-options pt-0';
+ const searchLi = document.createElement('li');
+ searchLi.classList.add('searchable-select-search');
+ const searchInput = document.createElement('input');
+ searchInput.type = 'text';
+ searchInput.className = 'form-control';
+ searchInput.placeholder = 'Search...';
+ searchInput.addEventListener('input', () => {
+ this.createOptionsList(options, searchInput.value, ul);
+ });
+ searchLi.appendChild(searchInput);
+ ul.appendChild(searchLi);
+ this.createOptionsList(options, '', ul);
+ dropdown.appendChild(ul);
+ }
+
+ /**
+ * Creates the list of options based on the current search input.
+ * @param options Array of HTMLOptionElement objects to filter and display in the dropdown.
+ * @param searchInput The current value of the search input field.
+ * @param ul The HTMLUListElement where the filtered options will be appended.
+ */
+ private createOptionsList(options: HTMLOptionElement[], searchInput: string, ul: HTMLUListElement) {
+ ul.querySelectorAll('.searchable-select-option').forEach(option => option.remove());
+ options.filter(o => o.text.toLowerCase().includes(searchInput.toLowerCase())).forEach(option => {
+ const li = document.createElement('li');
+ const a = document.createElement('a');
+ a.classList.add('dropdown-item', 'searchable-select-option');
+ a.href = '#';
+ a.textContent = option.text;
+ a.role = 'option';
+ a.addEventListener('click', (e) => {
+ e.preventDefault();
+ this.element.value = option.value;
+ this.refresh();
+ this.element.dispatchEvent(new Event('change', { bubbles: true }));
+ });
+ li.appendChild(a);
+ ul.appendChild(li);
+ });
+ }
+
+ /**
+ * Creates the button that toggles the dropdown.
+ * @param dropdown The HTMLDivElement that represents the dropdown container.
+ */
+ private createButton(dropdown: HTMLDivElement) {
+ const button = document.createElement('button');
+ button.classList.add('btn', 'dropdown-toggle', 'btn-searchable-select', ...this.classList);
+ button.type = 'button';
+ button.setAttribute(SearchableSelect.BootstrapVersion >= 5 ? 'data-bs-toggle' : 'data-toggle', 'dropdown');
+ button.setAttribute('aria-expanded', 'false');
+ const span = document.createElement('span');
+ span.textContent = 'Select an option';
+ button.appendChild(span);
+ this.button = span;
+ dropdown.appendChild(button);
+ }
+
+ /**
+ * Creates a unique ID for the dropdown based on existing dropdowns in the document.
+ * @returns A unique ID for the dropdown, incrementing from the last created dropdown ID.
+ */
+ static get LastCreatedDropdownId(): string {
+ const dropdowns = document.querySelectorAll('.dropdown');
+ if (dropdowns.length === 0) {
+ return 'dropdown-1';
+ }
+ const filteredDropdowns = Array.from(dropdowns).filter((dropdown) => {
+ return dropdown.id.startsWith('dropdown-');
+ });
+ if (filteredDropdowns.length === 0) {
+ return 'dropdown-1';
+ }
+ const lastID = filteredDropdowns.map((dropdown) => {
+ const match = dropdown.id.match(/dropdown-(\d+)/);
+ const r = match ? parseInt(match[1], 10) : 0;
+ return r;
+ }).reduce((max, id) => Math.max(max, id), 0);
+ return `dropdown-${lastID + 1}`;
+ }
+
+ /**
+ * Gets the major version of Bootstrap being used.
+ * @returns The major version of Bootstrap being used, based on the Tooltip.VERSION.
+ */
+ static get BootstrapVersion(): number {
+ return parseInt(Tooltip.VERSION.split('.')[0]);
+ }
+
+ /**
+ * Refreshes the dropdown button text and triggers a change event on the original select element.
+ */
+ refresh() {
+ this.button.textContent = this.element.options[this.element.selectedIndex]?.text || 'Select an option';
+ $(this.dropdown).find('.dropdown-item')
+ .each((index, item) => {
+ if (item.textContent === this.button.textContent) {
+ item.classList.add('active');
+ } else {
+ item.classList.remove('active');
+ }
+ });
+ }
+}
diff --git a/src/frontend/components/form-group/searchable-select/lib/options.ts b/src/frontend/components/form-group/searchable-select/lib/options.ts
new file mode 100644
index 000000000..7c0519217
--- /dev/null
+++ b/src/frontend/components/form-group/searchable-select/lib/options.ts
@@ -0,0 +1,6 @@
+export type SearchableSelectOptions = {
+ element: HTMLSelectElement;
+ target?: HTMLElement;
+ classList?: string[];
+ placeholder?: string;
+};
diff --git a/src/frontend/components/form-group/select-widget/lib/component.js b/src/frontend/components/form-group/select-widget/lib/component.js
index 6cb5da7c2..551e4891b 100644
--- a/src/frontend/components/form-group/select-widget/lib/component.js
+++ b/src/frontend/components/form-group/select-widget/lib/component.js
@@ -1,4 +1,3 @@
-// We import Bootstrap because there is an error that throws if we don't (this.collapse is not a function).
/* eslint-disable @typescript-eslint/no-this-alias */
import { Component } from 'component';
import { fromJson } from 'util/common';
@@ -270,7 +269,6 @@ class SelectWidgetComponent extends Component {
/**
* Checks if the widget should be closed based on focus changes.
- * @param {JQuery.TriggeredEvent} e The event triggered when the widget might need to be closed.
*/
possibleCloseWidget(e) {
const newlyFocussedElement = e.relatedTarget || document.activeElement;
@@ -462,7 +460,7 @@ class SelectWidgetComponent extends Component {
// Add space at beginning to keep format consistent with that in template
const detailsButton =
' ' +
- '