diff --git a/com.woltlab.wcf/templates/shared_labelFormField.tpl b/com.woltlab.wcf/templates/shared_labelFormField.tpl index 0563b03755f..1002db36420 100644 --- a/com.woltlab.wcf/templates/shared_labelFormField.tpl +++ b/com.woltlab.wcf/templates/shared_labelFormField.tpl @@ -1 +1 @@ -{unsafe:$field->getLabelGroup()->renderPicker($field->getPrefixedId(), $field->getValue(), $field->isRequired(), $field->isInvertible(), $field->getPrefixedId())} +{unsafe:$field->getLabelGroup()->renderPicker($field->getPrefixedId(), $field->getValue(), $field->isRequired(), $field->isInvertible(), $field->getPrefixedId(), $field->getMaxSelection())} diff --git a/ts/WoltLabSuite/Core/Bootstrap.ts b/ts/WoltLabSuite/Core/Bootstrap.ts index 6c38389fcb7..2029717400c 100644 --- a/ts/WoltLabSuite/Core/Bootstrap.ts +++ b/ts/WoltLabSuite/Core/Bootstrap.ts @@ -175,6 +175,10 @@ export function setup(options: BoostrapOptions): void { void import("./Ui/Pagination/JumpToPage").then(({ setup }) => setup()); }); + whenFirstSeen("woltlab-core-label-picker", () => { + void import("./Component/LabelPicker").then(({ setup }) => setup()); + }); + whenFirstSeen("woltlab-core-google-maps", () => { void import("./Component/GoogleMaps/woltlab-core-google-maps"); }); diff --git a/ts/WoltLabSuite/Core/Component/LabelPicker.ts b/ts/WoltLabSuite/Core/Component/LabelPicker.ts new file mode 100644 index 00000000000..293dbfb4b4e --- /dev/null +++ b/ts/WoltLabSuite/Core/Component/LabelPicker.ts @@ -0,0 +1,83 @@ +/** + * Positions the popover of `` relative to its + * toggle button while it is open. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ + +import { wheneverFirstSeen } from "../Helper/Selector"; +import * as UiAlignment from "../Ui/Alignment"; + +function setupPicker(picker: WoltlabCoreLabelPickerElement): void { + let popover: HTMLElement | undefined = undefined; + + const updatePosition = (event?: Event) => { + if (popover === undefined) { + return; + } + + // Removing the picker from the DOM hides the popover without any events. + if (!popover.matches(":popover-open")) { + stopTracking(); + return; + } + + // Scrolling the list of labels does not affect the position. + if (event?.type === "scroll" && popover.contains(event.target as Node)) { + return; + } + + const button = picker.querySelector(":scope > .labelPicker__toggle")!; + + // The popover is at least as wide as the toggle button. + popover.style.setProperty("min-width", `${button.offsetWidth}px`); + + UiAlignment.set(popover, button, { + verticalOffset: 4, + }); + }; + + const stopTracking = () => { + popover = undefined; + + window.removeEventListener("resize", updatePosition); + window.removeEventListener("scroll", updatePosition, { capture: true }); + }; + + // The popover is created by the web component, which might not have been + // upgraded yet. `beforetoggle` does not bubble, but can be captured. + picker.addEventListener( + "beforetoggle", + (event) => { + const target = event.target as HTMLElement; + if (!target.classList.contains("labelPicker__popover")) { + return; + } + + if (event.newState === "open") { + popover = target; + + // The popover is shown after this event, position it before the next paint. + window.requestAnimationFrame(() => updatePosition()); + + window.addEventListener("resize", updatePosition, { passive: true }); + window.addEventListener("scroll", updatePosition, { capture: true, passive: true }); + } else { + stopTracking(); + } + }, + { capture: true }, + ); + + // The size of the toggle button depends on the selected labels. + picker.addEventListener("change", () => updatePosition()); +} + +export function setup(): void { + wheneverFirstSeen("woltlab-core-label-picker", (picker: WoltlabCoreLabelPickerElement) => { + setupPicker(picker); + }); +} diff --git a/ts/WoltLabSuite/Core/Form/Builder/Field/Label.ts b/ts/WoltLabSuite/Core/Form/Builder/Field/Label.ts new file mode 100644 index 00000000000..3fbd6f17596 --- /dev/null +++ b/ts/WoltLabSuite/Core/Form/Builder/Field/Label.ts @@ -0,0 +1,23 @@ +/** + * Data handler for a label form field in an Ajax form. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ + +import Field from "./Field"; +import { FormBuilderData } from "../Data"; + +class Label extends Field { + protected _getData(): FormBuilderData { + const labelPicker = this._field as WoltlabCoreLabelPickerElement; + + return { + [this._fieldId]: labelPicker.multiple ? labelPicker.values : labelPicker.value, + }; + } +} + +export = Label; diff --git a/ts/WoltLabSuite/WebComponent/woltlab-core-label-picker.ts b/ts/WoltLabSuite/WebComponent/woltlab-core-label-picker.ts index 86d6e1684f6..07c9ee5e46b 100644 --- a/ts/WoltLabSuite/WebComponent/woltlab-core-label-picker.ts +++ b/ts/WoltLabSuite/WebComponent/woltlab-core-label-picker.ts @@ -1,9 +1,17 @@ /** - * `` provides a dropdown to select a single label + * `` provides a popover to select a single label * out of a label group. The element is form-associated and submits the id of * the selected label, `0` for no selection or `-1` for objects without any * label of this group (requires `invertible`). * + * Setting `max` to a value greater than 1 allows the selection of up to `max` + * labels. The value is then a comma-separated list of label ids and each + * selected label is submitted as `name[]`. + * + * The labels are presented as a listbox inside a native popover. The popover + * is positioned by `WoltLabSuite/Core/Component/LabelPicker`. When selecting + * multiple labels, a footer names the limit and offers to reset the selection. + * * Usage: `` * * @author Alexander Ebert, Marcel Werk @@ -13,14 +21,22 @@ */ { + let pickerCount = 0; + class WoltlabCoreLabelPickerElement extends HTMLElement { static formAssociated = true; readonly #internals: ElementInternals; + #badges: HTMLElement | undefined = undefined; #button: HTMLButtonElement | undefined = undefined; - #defaultValue = 0; - #items = new Map(); + #defaultValue = ""; #labels = new Map(); + #listbox: HTMLElement | undefined = undefined; + #options = new Map(); + #popover: HTMLElement | undefined = undefined; + #resetButton: HTMLButtonElement | undefined = undefined; + #typeahead = ""; + #typeaheadTimer: number | undefined = undefined; constructor() { super(); @@ -29,8 +45,7 @@ } connectedCallback() { - // The dropdown menu is moved into a global container, building the - // element again when it is moved within the DOM would orphan it. + // Moving the element within the DOM must not build it again. if (this.#button !== undefined) { return; } @@ -40,17 +55,59 @@ this.removeAttribute("labels"); } - this.#defaultValue = this.value; + if (this.multiple && this.invertible) { + throw new Error("A label picker cannot be invertible if more than one label can be selected."); + } - this.innerHTML = ""; - this.classList.add("dropdown"); + this.#defaultValue = this.getAttribute("value") || ""; + + const id = `labelPicker${++pickerCount}`; + + this.#popover = document.createElement("div"); + this.#popover.id = id; + this.#popover.popover = "auto"; + this.#popover.classList.add("labelPicker__popover"); + this.#popover.addEventListener("toggle", (event) => { + if (event.newState === "open") { + this.#open(); + } else { + this.#closed(); + } + }); + this.#popover.addEventListener("focusout", (event) => { + // Moving the focus out of the picker, e.g. by tabbing, closes the popover. + if (event.relatedTarget instanceof Node && !this.contains(event.relatedTarget) && this.#isOpen()) { + this.#popover!.hidePopover(); + } + }); this.#button = document.createElement("button"); this.#button.type = "button"; - this.#button.classList.add("dropdownToggle"); + this.#button.classList.add("labelPicker__toggle"); this.#button.disabled = this.matches(":disabled"); + this.#button.popoverTargetElement = this.#popover; + this.#button.setAttribute("aria-haspopup", "listbox"); + + this.#badges = document.createElement("span"); + this.#badges.classList.add("labelPicker__badges"); + + const chevron = document.createElement("fa-icon"); + chevron.classList.add("labelPicker__chevron"); + chevron.setIcon("chevron-down"); - this.append(this.#button, this.#buildMenu()); + this.#button.append(this.#badges, chevron); + this.#button.addEventListener("keydown", (event) => { + if ((event.key === "ArrowDown" || event.key === "ArrowUp") && !this.#isOpen()) { + event.preventDefault(); + + this.#popover!.showPopover({ source: this.#button }); + } + }); + + this.#popover.append(...this.#buildContent(id)); + + this.innerHTML = ""; + this.append(this.#button, this.#popover); // Clicks on an associated `