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 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() { - $('').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 @@