diff --git a/com.woltlab.wcf/acpSearchProvider.xml b/com.woltlab.wcf/acpSearchProvider.xml
deleted file mode 100644
index f561e00eeb0..00000000000
--- a/com.woltlab.wcf/acpSearchProvider.xml
+++ /dev/null
@@ -1,41 +0,0 @@
-
-
-
-
- wcf\system\search\acp\MenuItemACPSearchResultProvider
- 1
-
-
- wcf\system\search\acp\OptionACPSearchResultProvider
- 2
-
-
- wcf\system\search\acp\UserACPSearchResultProvider
- 3
-
-
- wcf\system\search\acp\UserGroupOptionACPSearchResultProvider
- 4
-
-
- wcf\system\search\acp\PackageACPSearchResultProvider
- 5
-
-
- wcf\system\search\acp\PageACPSearchResultProvider
- 6
-
-
- wcf\system\search\acp\BoxACPSearchResultProvider
- 7
-
-
- wcf\system\search\acp\ArticleACPSearchResultProvider
- 8
-
-
- wcf\system\search\acp\TrophyACPSearchResultProvider
- 9
-
-
-
diff --git a/com.woltlab.wcf/package.xml b/com.woltlab.wcf/package.xml
index 9519e7a6a71..7c9a83c0f28 100644
--- a/com.woltlab.wcf/package.xml
+++ b/com.woltlab.wcf/package.xml
@@ -29,7 +29,6 @@
-
defaultStyle.tar
diff --git a/ts/WoltLabSuite/Core/Acp/Bootstrap.ts b/ts/WoltLabSuite/Core/Acp/Bootstrap.ts
index 9eaa9540a66..b60025f89c1 100644
--- a/ts/WoltLabSuite/Core/Acp/Bootstrap.ts
+++ b/ts/WoltLabSuite/Core/Acp/Bootstrap.ts
@@ -10,6 +10,7 @@ import * as Core from "../Core";
import { BoostrapOptions, setup as bootstrapSetup } from "../Bootstrap";
import * as UiPageMenu from "./Ui/Page/Menu";
import AcpUiPageMenuMainBackend from "./Ui/Page/Menu/Main/Backend";
+import { setup as setupAcpSearch } from "./Ui/Search";
interface AcpBootstrapOptions {
bootstrap: BoostrapOptions;
@@ -33,4 +34,5 @@ export function setup(options: AcpBootstrapOptions): void {
bootstrapSetup(options.bootstrap);
UiPageMenu.init();
+ setupAcpSearch();
}
diff --git a/ts/WoltLabSuite/Core/Acp/Ui/Search.ts b/ts/WoltLabSuite/Core/Acp/Ui/Search.ts
new file mode 100644
index 00000000000..6dc2b6b0c2c
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Acp/Ui/Search.ts
@@ -0,0 +1,337 @@
+/**
+ * Provides the search dropdown for the ACP.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ * @woltlabExcludeBundle tiny
+ */
+
+import { getPhrase } from "WoltLabSuite/Core/Language";
+import { searchAcp, type AcpSearchResultGroup } from "WoltLabSuite/Core/Api/Acp/Search";
+import UiDropdownSimple from "WoltLabSuite/Core/Ui/Dropdown/Simple";
+
+const DELAY = 250;
+const TRIGGER_LENGTH = 3;
+
+let providerName = "";
+let lastQuery = "";
+let timer: number | undefined;
+let requestToken = 0;
+let itemIndex = -1;
+let navigationItems: HTMLAnchorElement[] = [];
+
+function getSearchContainer(): HTMLElement | null {
+ return document.getElementById("pageHeaderSearch");
+}
+
+function getSearchInput(): HTMLInputElement | null {
+ return document.getElementById("pageHeaderSearchInput") as HTMLInputElement | null;
+}
+
+function getOrCreateList(input: HTMLInputElement): HTMLUListElement {
+ let list = input.parentElement!.querySelector(".acpSearchDropdown");
+ if (list === null) {
+ list = document.createElement("ul");
+ list.className = "dropdownMenu acpSearchDropdown";
+ list.dataset.dropdownIgnorePageScroll = "true";
+ input.insertAdjacentElement("afterend", list);
+ }
+
+ return list;
+}
+
+function showList(list: HTMLUListElement): void {
+ const container = document.querySelector("#pageHeaderSearch .pageHeaderSearchInputContainer");
+ if (container !== null) {
+ const { bottom } = container.getBoundingClientRect();
+ list.style.setProperty("top", `${Math.trunc(bottom)}px`, "important");
+ }
+
+ list.classList.add("dropdownOpen");
+}
+
+function clearList(list: HTMLUListElement): void {
+ list.innerHTML = "";
+ list.classList.remove("dropdownOpen");
+ navigationItems = [];
+ itemIndex = -1;
+}
+
+function renderEmpty(list: HTMLUListElement): void {
+ clearList(list);
+
+ const empty = document.createElement("li");
+ empty.className = "dropdownText";
+ empty.textContent = getPhrase("wcf.acp.search.noResults");
+ list.append(empty);
+
+ showList(list);
+}
+
+function renderResults(list: HTMLUListElement, groups: AcpSearchResultGroup[]): void {
+ clearList(list);
+
+ if (groups.length === 0) {
+ renderEmpty(list);
+ return;
+ }
+
+ groups.forEach((group, index) => {
+ if (index > 0) {
+ const divider = document.createElement("li");
+ divider.className = "dropdownDivider";
+ list.append(divider);
+ }
+
+ const caption = document.createElement("li");
+ caption.className = "dropdownText";
+ caption.textContent = group.title;
+ list.append(caption);
+
+ for (const item of group.items) {
+ const li = document.createElement("li");
+ const link = document.createElement("a");
+ link.href = item.link;
+
+ const title = document.createElement("span");
+ title.textContent = item.title;
+ link.append(title);
+
+ if (item.subtitle) {
+ const subtitle = document.createElement("small");
+ subtitle.textContent = item.subtitle;
+ link.append(subtitle);
+ }
+
+ li.append(link);
+ list.append(li);
+ navigationItems.push(link);
+ }
+ });
+
+ truncateSubtitles(list);
+ showList(list);
+}
+
+function truncateSubtitles(list: HTMLUListElement): void {
+ list.querySelectorAll("small").forEach((element) => {
+ while (element.scrollWidth > element.clientWidth) {
+ element.innerText = `… ${element.innerText.substring(3)}`;
+ }
+ });
+}
+
+function highlightItem(): void {
+ navigationItems.forEach((link, index) => {
+ link.parentElement!.classList.toggle("dropdownNavigationItem", index === itemIndex);
+ });
+}
+
+function selectNext(): void {
+ if (navigationItems.length === 0) {
+ return;
+ }
+
+ itemIndex = (itemIndex + 1) % navigationItems.length;
+ highlightItem();
+}
+
+function selectPrevious(): void {
+ if (navigationItems.length === 0) {
+ return;
+ }
+
+ itemIndex = itemIndex <= 0 ? navigationItems.length - 1 : itemIndex - 1;
+ highlightItem();
+}
+
+function activateSelection(): boolean {
+ if (itemIndex < 0 || itemIndex >= navigationItems.length) {
+ return false;
+ }
+
+ window.location.href = navigationItems[itemIndex].href;
+ return true;
+}
+
+async function performSearch(query: string): Promise {
+ const input = getSearchInput();
+ if (input === null) {
+ return;
+ }
+
+ const token = ++requestToken;
+ const list = getOrCreateList(input);
+
+ const response = await searchAcp(query, providerName);
+ if (token !== requestToken) {
+ return;
+ }
+ if (!response.ok) {
+ return;
+ }
+
+ renderResults(list, response.value.results);
+}
+
+function scheduleSearch(query: string): void {
+ if (timer !== undefined) {
+ window.clearTimeout(timer);
+ }
+
+ timer = window.setTimeout(() => {
+ timer = undefined;
+ void performSearch(query);
+ }, DELAY);
+}
+
+function onInput(event: Event): void {
+ const input = event.currentTarget as HTMLInputElement;
+ const query = input.value.trim();
+
+ if (query.length < TRIGGER_LENGTH) {
+ requestToken++;
+ if (timer !== undefined) {
+ window.clearTimeout(timer);
+ timer = undefined;
+ }
+ lastQuery = "";
+
+ const list = input.parentElement!.querySelector(".acpSearchDropdown");
+ if (list !== null) {
+ clearList(list);
+ }
+
+ return;
+ }
+
+ if (query === lastQuery) {
+ return;
+ }
+
+ lastQuery = query;
+ scheduleSearch(query);
+}
+
+function onKeyDown(event: KeyboardEvent): void {
+ switch (event.key) {
+ case "ArrowDown":
+ event.preventDefault();
+ selectNext();
+ return;
+ case "ArrowUp":
+ event.preventDefault();
+ selectPrevious();
+ return;
+ case "Enter":
+ if (activateSelection()) {
+ event.preventDefault();
+ }
+ return;
+ case "Escape": {
+ const input = event.currentTarget as HTMLInputElement;
+ if (input.value.trim() === "") {
+ event.preventDefault();
+ input.blur();
+ }
+ return;
+ }
+ }
+}
+
+function onBlur(event: FocusEvent): void {
+ const input = event.currentTarget as HTMLInputElement;
+ // Defer to allow click on result items to register.
+ window.setTimeout(() => {
+ const list = input.parentElement?.querySelector(".acpSearchDropdown");
+ if (list !== null && list !== undefined) {
+ clearList(list);
+ }
+ }, 250);
+}
+
+function initProviderSelection(): void {
+ // `UiDropdownSimple` reparents the dropdown menu to a global container
+ // during its lazy initialization, so the menu items are no longer
+ // descendants of `#pageHeaderSearchType` at this point. Look it up
+ // through the dropdown registry instead.
+ const menu = UiDropdownSimple.getDropdownMenu("pageHeaderSearchType");
+ if (menu === undefined) {
+ return;
+ }
+
+ menu.addEventListener("click", (event) => {
+ const target = event.target as HTMLElement | null;
+ const link = target?.closest("a[data-provider-name]") ?? null;
+ if (link === null) {
+ return;
+ }
+
+ event.preventDefault();
+
+ const label = document.querySelector(
+ ".pageHeaderSearchType > .button > .pageHeaderSearchTypeLabel",
+ );
+ if (label !== null) {
+ label.textContent = link.textContent;
+ }
+
+ const oldProvider = providerName;
+ const newProvider = link.dataset.providerName === "everywhere" ? "" : link.dataset.providerName!;
+ providerName = newProvider;
+
+ if (oldProvider !== newProvider) {
+ const input = getSearchInput();
+ if (input !== null) {
+ const query = input.value.trim();
+ if (query.length >= TRIGGER_LENGTH) {
+ lastQuery = query;
+ void performSearch(query);
+ }
+ }
+ }
+ });
+}
+
+function initShortcuts(input: HTMLInputElement): void {
+ document.addEventListener(
+ "keydown",
+ (event) => {
+ if (event.key !== "s") {
+ return;
+ }
+ if (event.defaultPrevented || document.activeElement !== document.body) {
+ return;
+ }
+
+ event.preventDefault();
+ input.focus();
+ },
+ { passive: false },
+ );
+}
+
+export function setup(): void {
+ const container = getSearchContainer();
+ if (container === null) {
+ return;
+ }
+
+ const input = getSearchInput();
+ if (input === null) {
+ return;
+ }
+
+ const form = container.querySelector("form");
+ form?.addEventListener("submit", (event) => event.preventDefault());
+
+ input.autocomplete = "off";
+ input.addEventListener("input", onInput);
+ input.addEventListener("keydown", onKeyDown);
+ input.addEventListener("blur", onBlur);
+
+ initProviderSelection();
+ initShortcuts(input);
+}
diff --git a/ts/WoltLabSuite/Core/Api/Acp/Search.ts b/ts/WoltLabSuite/Core/Api/Acp/Search.ts
new file mode 100644
index 00000000000..a9038e17e9d
--- /dev/null
+++ b/ts/WoltLabSuite/Core/Api/Acp/Search.ts
@@ -0,0 +1,44 @@
+/**
+ * Fetches ACP search results for a given query.
+ *
+ * @author Marcel Werk
+ * @copyright 2001-2026 WoltLab GmbH
+ * @license GNU Lesser General Public License
+ * @since 6.3
+ * @woltlabExcludeBundle tiny
+ */
+
+import { prepareRequest } from "WoltLabSuite/Core/Ajax/Backend";
+import { ApiResult, apiResultFromError, apiResultFromValue } from "WoltLabSuite/Core/Api/Result";
+
+export type AcpSearchResultItem = {
+ link: string;
+ title: string;
+ subtitle?: string;
+};
+
+export type AcpSearchResultGroup = {
+ title: string;
+ items: AcpSearchResultItem[];
+};
+
+type Response = {
+ results: AcpSearchResultGroup[];
+};
+
+export async function searchAcp(query: string, provider = ""): Promise> {
+ const url = new URL(`${window.WSC_RPC_API_URL}core/acp/search`);
+ url.searchParams.set("query", query);
+ if (provider) {
+ url.searchParams.set("provider", provider);
+ }
+
+ let response: Response;
+ try {
+ response = (await prepareRequest(url.toString()).get().fetchAsJson()) as Response;
+ } catch (e) {
+ return apiResultFromError(e);
+ }
+
+ return apiResultFromValue(response);
+}
diff --git a/wcfsetup/install/files/acp/js/WCF.ACP.js b/wcfsetup/install/files/acp/js/WCF.ACP.js
index 65fc3e22fe5..af81cf86d28 100644
--- a/wcfsetup/install/files/acp/js/WCF.ACP.js
+++ b/wcfsetup/install/files/acp/js/WCF.ACP.js
@@ -807,166 +807,6 @@ WCF.ACP.Worker = Class.extend({
}
});
-/**
- * Provides the search dropdown for ACP
- *
- * @see WCF.Search.Base
- */
-WCF.ACP.Search = WCF.Search.Base.extend({
- _delay: 250,
-
- /**
- * name of the selected search provider
- * @var string
- */
- _providerName: '',
-
- /**
- * @see WCF.Search.Base.init()
- */
- init: function() {
- this._className = 'wcf\\data\\acp\\search\\provider\\ACPSearchProviderAction';
- this._super('#pageHeaderSearch input[name=q]');
-
- // disable form submitting
- $('#pageHeaderSearch > form').on('submit', function(event) {
- event.preventDefault();
- });
-
- var $dropdown = WCF.Dropdown.getDropdownMenu('pageHeaderSearchType');
- $dropdown.find('a[data-provider-name]').on('click', $.proxy(function(event) {
- event.preventDefault();
- var $button = $(event.target);
- $('.pageHeaderSearchType > .button > .pageHeaderSearchTypeLabel').text($button.text());
-
- var $oldProviderName = this._providerName;
- this._providerName = ($button.data('providerName') != 'everywhere' ? $button.data('providerName') : '');
-
- if ($oldProviderName != this._providerName) {
- var $searchString = $.trim(this._searchInput.val());
- if ($searchString) {
- var $parameters = {
- data: {
- excludedSearchValues: this._excludedSearchValues,
- searchString: $searchString
- }
- };
- this._queryServer($parameters);
- }
- }
- }, this));
-
- const searchInput = document.querySelector("#pageHeaderSearch input[name=q]");
- document.addEventListener("keydown", (event) => {
- if (event.key !== "s") {
- return;
- }
-
- if (!event.defaultPrevented && document.activeElement === document.body) {
- searchInput.focus();
-
- event.preventDefault();
- }
- }, {
- passive: false,
- });
-
- searchInput.addEventListener("keydown", (event) => {
- if (event.key !== "Escape") {
- return;
- }
-
- if (!event.defaultPrevented && searchInput.value.trim() === "") {
- event.preventDefault();
-
- searchInput.blur();
- }
- }, {
- passive: false,
- });
- },
-
- /**
- * @see WCF.Search.Base._createListItem()
- */
- _createListItem: function(resultList) {
- // add a divider between result lists
- if (this._list.children('li').length > 0) {
- $('').appendTo(this._list);
- }
-
- // add caption
- $('' + resultList.title + '').appendTo(this._list);
-
- // add menu items
- for (var $i in resultList.items) {
- var $item = resultList.items[$i];
-
- $('' + WCF.String.escapeHTML($item.title) + '' + ($item.subtitle ? '' + WCF.String.escapeHTML($item.subtitle) + '' : '') + '').appendTo(this._list);
-
- this._itemCount++;
- }
- },
-
- /**
- * @see WCF.Search.Base._openDropdown()
- */
- _openDropdown: function() {
- this._list.find('small').each(function(index, element) {
- while (element.scrollWidth > element.clientWidth) {
- element.innerText = '\u2026 ' + element.innerText.substr(3);
- }
- });
- },
-
- /**
- * @see WCF.Search.Base._handleEmptyResult()
- */
- _handleEmptyResult: function() {
- $('' + WCF.Language.get('wcf.acp.search.noResults') + '').appendTo(this._list);
-
- return true;
- },
-
- /**
- * @see WCF.Search.Base._highlightSelectedElement()
- */
- _highlightSelectedElement: function() {
- this._list.find('li').removeClass('dropdownNavigationItem');
- this._list.find('li:not(.dropdownDivider):not(.dropdownText)').eq(this._itemIndex).addClass('dropdownNavigationItem');
- },
-
- /**
- * @see WCF.Search.Base._selectElement()
- */
- _selectElement: function(event) {
- if (this._itemIndex === -1) {
- return false;
- }
-
- window.location = this._list.find('li.dropdownNavigationItem > a').attr('href');
- },
-
- _success: function(data) {
- this._super(data);
-
- const container = document.getElementById("pageHeaderSearch").querySelector(".pageHeaderSearchInputContainer");
- const { bottom } = container.getBoundingClientRect();
- this._list[0].style.setProperty("top", `${Math.trunc(bottom)}px`, "important");
- this._list[0].classList.add("acpSearchDropdown");
- this._list[0].dataset.dropdownIgnorePageScroll = "true";
- },
-
- /**
- * @see WCF.Search.Base._getParameters()
- */
- _getParameters: function(parameters) {
- parameters.data.providerName = this._providerName;
-
- return parameters;
- }
-});
-
/**
* Namespace for stat-related classes.
*/
diff --git a/wcfsetup/install/files/acp/templates/header.tpl b/wcfsetup/install/files/acp/templates/header.tpl
index 250ba7c5eb5..7ce3667ab11 100644
--- a/wcfsetup/install/files/acp/templates/header.tpl
+++ b/wcfsetup/install/files/acp/templates/header.tpl
@@ -135,11 +135,7 @@