From 39230b916350bb8890139ee67cf0f7b58ee8b704 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Mon, 9 Nov 2026 14:46:53 +0100 Subject: [PATCH 1/4] Allow selecting multiple labels per label group --- .../templates/shared_labelFormField.tpl | 2 +- .../Core/Form/Builder/Field/Label.ts | 23 +++ ts/WoltLabSuite/Core/Ui/Dropdown/Simple.ts | 5 +- .../WebComponent/woltlab-core-label-picker.ts | 171 +++++++++++++++--- ts/global.d.ts | 4 + .../update_com.woltlab.wcf_6.3_step1.php | 5 +- .../Core/Form/Builder/Field/Label.js | 21 +++ .../WoltLabSuite/Core/Ui/Dropdown/Simple.js | 5 +- .../lib/acp/form/ArticleAddForm.class.php | 17 +- .../lib/acp/form/LabelGroupAddForm.class.php | 13 ++ .../lib/data/label/group/LabelGroup.class.php | 1 + .../label/group/LabelGroupBuilder.class.php | 14 ++ .../label/group/ViewableLabelGroup.class.php | 20 +- .../field/label/LabelFormField.class.php | 128 ++++++++++++- .../lib/system/label/LabelHandler.class.php | 55 +++++- .../AbstractLabelObjectHandler.class.php | 48 ++++- .../ArticleLabelObjectHandler.class.php | 2 +- .../object/ILabelObjectHandler.class.php | 6 +- .../IMultipleLabelsObjectHandler.class.php | 19 ++ wcfsetup/install/files/style/ui/label.scss | 6 + wcfsetup/install/lang/de.xml | 4 + wcfsetup/install/lang/en.xml | 4 + wcfsetup/setup/db/install_com.woltlab.wcf.php | 3 + 23 files changed, 518 insertions(+), 58 deletions(-) create mode 100644 ts/WoltLabSuite/Core/Form/Builder/Field/Label.ts create mode 100644 wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Label.js create mode 100644 wcfsetup/install/files/lib/system/label/object/IMultipleLabelsObjectHandler.class.php 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/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/Core/Ui/Dropdown/Simple.ts b/ts/WoltLabSuite/Core/Ui/Dropdown/Simple.ts index 813a0766533..b306011ad8e 100644 --- a/ts/WoltLabSuite/Core/Ui/Dropdown/Simple.ts +++ b/ts/WoltLabSuite/Core/Ui/Dropdown/Simple.ts @@ -274,7 +274,10 @@ function toggle( const activeListItem = nonEmptyListItems.find((listItem) => listItem.classList.contains("active")); firstListItem = activeListItem || nonEmptyListItems[0] || null; nonEmptyListItems.forEach((listItem) => { - listItem.setAttribute("role", "menuitem"); + // Preserve more specific roles, such as `menuitemcheckbox`. + if (!listItem.hasAttribute("role")) { + listItem.setAttribute("role", "menuitem"); + } listItem.tabIndex = -1; }); } diff --git a/ts/WoltLabSuite/WebComponent/woltlab-core-label-picker.ts b/ts/WoltLabSuite/WebComponent/woltlab-core-label-picker.ts index 86d6e1684f6..6472e553351 100644 --- a/ts/WoltLabSuite/WebComponent/woltlab-core-label-picker.ts +++ b/ts/WoltLabSuite/WebComponent/woltlab-core-label-picker.ts @@ -4,6 +4,10 @@ * 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[]`. + * * Usage: `` * * @author Alexander Ebert, Marcel Werk @@ -18,7 +22,7 @@ readonly #internals: ElementInternals; #button: HTMLButtonElement | undefined = undefined; - #defaultValue = 0; + #defaultValue = ""; #items = new Map(); #labels = new Map(); @@ -40,7 +44,11 @@ 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.#defaultValue = this.getAttribute("value") || ""; this.innerHTML = ""; this.classList.add("dropdown"); @@ -69,7 +77,7 @@ } formResetCallback(): void { - this.setAttribute("value", this.#defaultValue.toString()); + this.setAttribute("value", this.#defaultValue); this.#render(); } @@ -79,9 +87,9 @@ } // The restored state might reference a label that no longer exists. - const value = parseInt(state); - if (this.#isValidValue(value)) { - this.setAttribute("value", value.toString()); + const values = this.#parseValues(state); + if (values.every((value) => this.#isValidValue(value))) { + this.setAttribute("value", values.join(",")); this.#render(); } } @@ -94,6 +102,13 @@ return value === 0 || this.#labels.has(value); } + #parseValues(value: string): number[] { + return value + .split(",") + .map((labelId) => parseInt(labelId)) + .filter((labelId) => !Number.isNaN(labelId) && labelId !== 0); + } + #buildMenu(): HTMLElement { const labelList = document.createElement("ul"); labelList.classList.add("scrollableDropdownMenu"); @@ -105,7 +120,8 @@ dropdownMenu.classList.add("dropdownMenu"); dropdownMenu.append(labelList); - if (this.invertible || !this.required) { + // Labels are deselected individually when selecting multiple labels. + if (!this.multiple && (this.invertible || !this.required)) { const divider = document.createElement("li"); divider.classList.add("dropdownDivider"); @@ -130,23 +146,66 @@ const button = document.createElement("button"); button.type = "button"; button.innerHTML = html; - button.addEventListener("click", () => { - if (this.value === labelId) { - return; - } - - this.value = labelId; - this.dispatchEvent(new Event("input", { bubbles: true })); - this.dispatchEvent(new Event("change", { bubbles: true })); - }); const listItem = document.createElement("li"); listItem.append(button); this.#items.set(labelId, listItem); + if (!this.multiple) { + button.addEventListener("click", () => { + if (this.value === labelId) { + return; + } + + this.value = labelId; + this.#dispatchChange(); + }); + + return listItem; + } + + listItem.setAttribute("role", "menuitemcheckbox"); + button.addEventListener("click", (event) => { + // Keep the menu open to allow the selection of further labels. + event.stopPropagation(); + + this.#toggleLabel(labelId); + }); + listItem.addEventListener("keydown", (event) => { + if (event.key === "Enter" || event.key === " ") { + // Prevent the dropdown from closing and moving the focus to the toggle. + event.preventDefault(); + event.stopPropagation(); + + this.#toggleLabel(labelId); + } + }); + return listItem; } + #toggleLabel(labelId: number): void { + const values = this.values; + const index = values.indexOf(labelId); + if (index === -1) { + if (values.length >= this.max) { + return; + } + + values.push(labelId); + } else { + values.splice(index, 1); + } + + this.values = values; + this.#dispatchChange(); + } + + #dispatchChange(): void { + this.dispatchEvent(new Event("input", { bubbles: true })); + this.dispatchEvent(new Event("change", { bubbles: true })); + } + #getBadge(phrase: string): string { const badge = document.createElement("span"); badge.classList.add("badge", "label"); @@ -156,6 +215,14 @@ } #render(): void { + if (this.multiple) { + this.#renderMultiple(); + } else { + this.#renderSingle(); + } + } + + #renderSingle(): void { const value = this.value; if (this.#button !== undefined) { @@ -171,11 +238,38 @@ } this.#internals.setFormValue(value.toString()); - this.#updateValidity(value); + this.#updateValidity(value === 0); + } + + #renderMultiple(): void { + const values = this.values; + + if (this.#button !== undefined) { + const html = values.map((labelId) => this.#labels.get(labelId)!).join(" "); + this.#button.innerHTML = html || this.#getBadge("wcf.label.none"); + } + + const limitReached = values.length >= this.max; + for (const [labelId, listItem] of this.#items) { + const checked = values.includes(labelId); + listItem.classList.toggle("active", checked); + listItem.setAttribute("aria-checked", checked.toString()); + + const disabled = !checked && limitReached; + listItem.classList.toggle("disabled", disabled); + listItem.setAttribute("aria-disabled", disabled.toString()); + } + + const formData = new FormData(); + for (const labelId of values) { + formData.append(`${this.name}[]`, labelId.toString()); + } + this.#internals.setFormValue(formData, values.join(",")); + this.#updateValidity(values.length === 0); } - #updateValidity(value: number): void { - if (this.required && value === 0) { + #updateValidity(isEmpty: boolean): void { + if (this.required && isEmpty) { this.#internals.setValidity( { valueMissing: true }, window.WoltLabLanguage.getPhrase("wcf.global.form.error.empty"), @@ -187,9 +281,7 @@ } get value(): number { - const value = parseInt(this.getAttribute("value") || "0"); - - return Number.isNaN(value) ? 0 : value; + return this.#parseValues(this.getAttribute("value") || "")[0] ?? 0; } set value(value: number) { @@ -201,6 +293,41 @@ this.#render(); } + get values(): number[] { + const values = this.#parseValues(this.getAttribute("value") || ""); + + return this.multiple ? values : values.slice(0, 1); + } + + set values(values: number[]) { + if (values.length > this.max) { + throw new Error(`Cannot select more than ${this.max} labels.`); + } + + if (new Set(values).size !== values.length) { + throw new Error("The list of labels contains duplicates."); + } + + for (const value of values) { + if (value === 0 || !this.#isValidValue(value)) { + throw new Error(`There is no label with the id ${value}.`); + } + } + + this.setAttribute("value", values.join(",")); + this.#render(); + } + + get max(): number { + const max = parseInt(this.getAttribute("max") || "1"); + + return Number.isNaN(max) || max < 1 ? 1 : max; + } + + get multiple(): boolean { + return this.max > 1; + } + get name(): string { return this.getAttribute("name") || ""; } diff --git a/ts/global.d.ts b/ts/global.d.ts index 7d96937e96f..fa8a4db4ff3 100644 --- a/ts/global.d.ts +++ b/ts/global.d.ts @@ -123,6 +123,10 @@ declare global { interface WoltlabCoreLabelPickerElement extends HTMLElement { get value(): number; set value(value: number); + get values(): number[]; + set values(values: number[]); + get max(): number; + get multiple(): boolean; get name(): string; get form(): HTMLFormElement | null; get disabled(): boolean; diff --git a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php index 33f9302a997..e42b3d485a2 100644 --- a/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php +++ b/wcfsetup/install/files/acp/database/update_com.woltlab.wcf_6.3_step1.php @@ -56,7 +56,10 @@ ]), PartialDatabaseTable::create('wcf1_label_group') ->columns([ - DefaultFalseBooleanDatabaseTableColumn::create('sortAlphabetically') + DefaultFalseBooleanDatabaseTableColumn::create('sortAlphabetically'), + SmallintDatabaseTableColumn::create('maxSelection') + ->notNull() + ->defaultValue(1), ]), PartialDatabaseTable::create('wcf1_trophy') ->columns([ diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Label.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Label.js new file mode 100644 index 00000000000..7df63c3a114 --- /dev/null +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Form/Builder/Field/Label.js @@ -0,0 +1,21 @@ +/** + * 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 + */ +define(["require", "exports", "tslib", "./Field"], function (require, exports, tslib_1, Field_1) { + "use strict"; + Field_1 = tslib_1.__importDefault(Field_1); + class Label extends Field_1.default { + _getData() { + const labelPicker = this._field; + return { + [this._fieldId]: labelPicker.multiple ? labelPicker.values : labelPicker.value, + }; + } + } + return Label; +}); diff --git a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Dropdown/Simple.js b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Dropdown/Simple.js index e6837a63b4b..b67f4ad37e0 100644 --- a/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Dropdown/Simple.js +++ b/wcfsetup/install/files/js/WoltLabSuite/Core/Ui/Dropdown/Simple.js @@ -237,7 +237,10 @@ define(["require", "exports", "tslib", "../../CallbackList", "../../Core", "../. const activeListItem = nonEmptyListItems.find((listItem) => listItem.classList.contains("active")); firstListItem = activeListItem || nonEmptyListItems[0] || null; nonEmptyListItems.forEach((listItem) => { - listItem.setAttribute("role", "menuitem"); + // Preserve more specific roles, such as `menuitemcheckbox`. + if (!listItem.hasAttribute("role")) { + listItem.setAttribute("role", "menuitem"); + } listItem.tabIndex = -1; }); } diff --git a/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php b/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php index 01e1be6fd7f..bc2dbe61c9c 100644 --- a/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/ArticleAddForm.class.php @@ -397,26 +397,31 @@ protected function createLabelFormFields(): array || $labelGroup->getPermission($optionID) ) ->required($labelGroup->forceSelection !== 0) + ->maxSelection(ArticleLabelObjectHandler::getInstance()->getMaxSelection($labelGroup)) ->addDependency( ValueFormFieldDependency::create('categoryID') ->fieldId('categoryID') ->values($categoryIDs) ) ->labelGroup($labelGroup) - ->saveValueCallback(static function (ArticleBuilder $builder, IFormField $field) { - // `-1` and `0` are special values that are irrelevant for saving. - $labelID = (int)$field->getSaveValue(); - if ($labelID > 0) { + ->saveValueCallback(static function (ArticleBuilder $builder, LabelFormField $field) { + foreach ($field->getSelectedLabelIDs() as $labelID) { $builder->setLabelID($labelID); } }) ->loadValueCallback(static function (Article $object, LabelFormField $field) use ($groupID) { + $labelIDs = []; foreach ($object->getLabels() as $label) { if ($label->groupID === $groupID) { - $field->value($label->labelID); - break; + $labelIDs[] = $label->labelID; } } + + if ($field->getMaxSelection() > 1) { + $field->value($labelIDs); + } elseif ($labelIDs !== []) { + $field->value($labelIDs[0]); + } }); } diff --git a/wcfsetup/install/files/lib/acp/form/LabelGroupAddForm.class.php b/wcfsetup/install/files/lib/acp/form/LabelGroupAddForm.class.php index 55eeabf7dca..18d76727b86 100644 --- a/wcfsetup/install/files/lib/acp/form/LabelGroupAddForm.class.php +++ b/wcfsetup/install/files/lib/acp/form/LabelGroupAddForm.class.php @@ -160,6 +160,19 @@ protected function createForm(): void ->loadValueCallback(static function (LabelGroup $object, IFormField $field): void { $field->value($object->forceSelection); }), + IntegerFormField::create('maxSelection') + ->label('wcf.acp.label.group.maxSelection') + ->description('wcf.acp.label.group.maxSelection.description') + ->minimum(1) + ->maximum(100) + ->value(1) + ->required() + ->saveValueCallback(static function (LabelGroupBuilder $builder, IntegerFormField $field): void { + $builder->setMaxSelection($field->getSaveValue()); + }) + ->loadValueCallback(static function (LabelGroup $object, IFormField $field): void { + $field->value($object->maxSelection); + }), BooleanFormField::create('sortAlphabetically') ->label('wcf.acp.label.group.sortAlphabetically') ->saveValueCallback(static function (LabelGroupBuilder $builder, BooleanFormField $field): void { diff --git a/wcfsetup/install/files/lib/data/label/group/LabelGroup.class.php b/wcfsetup/install/files/lib/data/label/group/LabelGroup.class.php index 95cf78ee9c8..0ec1a938461 100644 --- a/wcfsetup/install/files/lib/data/label/group/LabelGroup.class.php +++ b/wcfsetup/install/files/lib/data/label/group/LabelGroup.class.php @@ -21,6 +21,7 @@ * @property-read string $groupDescription description of the label group (only shown in ACP) * @property-read 0|1 $forceSelection is `1` if a label in the label group has to be selected when creating an object for which the label group is available, otherwise `0` * @property-read 0|1 $sortAlphabetically is `1` if labels in the label group are sorted alphabetically by their translated name, otherwise `0` + * @property-read int $maxSelection maximum number of labels of this group that can be assigned to a single object, only respected by label object types that support multiple labels per group * @property-read int $showOrder position of the label group in relation to the other label groups * * @extends CollectionDatabaseObject diff --git a/wcfsetup/install/files/lib/data/label/group/LabelGroupBuilder.class.php b/wcfsetup/install/files/lib/data/label/group/LabelGroupBuilder.class.php index 5d8b2b2e129..8108849d495 100644 --- a/wcfsetup/install/files/lib/data/label/group/LabelGroupBuilder.class.php +++ b/wcfsetup/install/files/lib/data/label/group/LabelGroupBuilder.class.php @@ -62,6 +62,20 @@ public function setSortAlphabetically(bool $sortAlphabetically): static return $this; } + /** + * @since 6.3 + */ + public function setMaxSelection(int $maxSelection): static + { + if ($maxSelection < 1) { + throw new \InvalidArgumentException("The maximum selection must be at least 1."); + } + + $this->properties['maxSelection'] = $maxSelection; + + return $this; + } + public function setShowOrder(int $showOrder): static { $this->properties['showOrder'] = $showOrder; diff --git a/wcfsetup/install/files/lib/data/label/group/ViewableLabelGroup.class.php b/wcfsetup/install/files/lib/data/label/group/ViewableLabelGroup.class.php index 909727be995..b9a54fedc14 100644 --- a/wcfsetup/install/files/lib/data/label/group/ViewableLabelGroup.class.php +++ b/wcfsetup/install/files/lib/data/label/group/ViewableLabelGroup.class.php @@ -266,23 +266,36 @@ public function sortLabelsAlphabetically(): void * * The selected value is `0` or `null` for no selection, `-1` for objects * without any label of this group (requires `$invertible`) or a label id. + * If `$maxSelection` is greater than 1, the value is a list of label ids. * Invalid values are treated as no selection. * + * @param int|list|null $value * @since 6.3 */ public function renderPicker( string $name, - ?int $value = null, + int|array|null $value = null, bool $required = false, bool $invertible = false, string $id = '', + int $maxSelection = 1, ): string { + if ($invertible && $maxSelection > 1) { + throw new \InvalidArgumentException("A label picker cannot be invertible if more than one label can be selected."); + } + $labels = []; foreach ($this->labels as $label) { $labels[] = [$label->labelID, $label->render()]; } - if ($value === null || ($value === -1 ? !$invertible : !$this->isValid($value))) { + if ($maxSelection > 1) { + $labelIDs = \array_filter( + \array_unique((array)$value), + fn(int $labelID) => $this->isValid($labelID) + ); + $value = \implode(',', \array_slice($labelIDs, 0, $maxSelection)); + } elseif (\is_array($value) || $value === null || ($value === -1 ? !$invertible : !$this->isValid($value))) { $value = 0; } @@ -291,6 +304,9 @@ public function renderPicker( 'labels' => JSON::encode($labels), 'value' => $value, ]; + if ($maxSelection > 1) { + $attributes['max'] = $maxSelection; + } if ($id !== '') { $attributes['id'] = $id; } diff --git a/wcfsetup/install/files/lib/system/form/builder/field/label/LabelFormField.class.php b/wcfsetup/install/files/lib/system/form/builder/field/label/LabelFormField.class.php index aebdd910302..65a2e058130 100644 --- a/wcfsetup/install/files/lib/system/form/builder/field/label/LabelFormField.class.php +++ b/wcfsetup/install/files/lib/system/form/builder/field/label/LabelFormField.class.php @@ -28,7 +28,7 @@ final class LabelFormField extends AbstractFormField implements IObjectTypeFormN /** * @inheritDoc */ - protected $javaScriptDataHandlerModule = 'WoltLabSuite/Core/Form/Builder/Field/Value'; + protected $javaScriptDataHandlerModule = 'WoltLabSuite/Core/Form/Builder/Field/Label'; /** * `true` if the value `-1` can be selected for objects without any label of the label group @@ -46,6 +46,13 @@ final class LabelFormField extends AbstractFormField implements IObjectTypeFormN */ protected $templateName = 'shared_labelFormField'; + /** + * maximum number of labels that can be selected, the value is a list of label ids if more + * than one label can be selected + * @since 6.3 + */ + protected int $maxSelection = 1; + /** * loaded labels grouped by label object type and object id to avoid loading the same labels * over and over again for the same object and different label groups @@ -89,6 +96,10 @@ public function hasSaveValue(): bool */ public function invertible(bool $invertible = true): static { + if ($invertible && $this->maxSelection > 1) { + throw new \BadMethodCallException("Field '{$this->getId()}' cannot be invertible if more than one label can be selected."); + } + $this->invertible = $invertible; return $this; @@ -105,6 +116,53 @@ public function isInvertible(): bool return $this->invertible; } + /** + * Sets the maximum number of labels that can be selected and returns this form field. + * + * If more than one label can be selected, the value of this field is a list of label ids. + * Per default, only a single label can be selected. + * + * @since 6.3 + */ + public function maxSelection(int $maxSelection): static + { + if ($maxSelection < 1) { + throw new \InvalidArgumentException("The maximum selection of field '{$this->getId()}' must be at least 1."); + } + if ($maxSelection > 1 && $this->isInvertible()) { + throw new \BadMethodCallException("Field '{$this->getId()}' cannot select more than one label because it is invertible."); + } + + $this->maxSelection = $maxSelection; + + return $this; + } + + /** + * Returns the maximum number of labels that can be selected. + * + * @since 6.3 + */ + public function getMaxSelection(): int + { + return $this->maxSelection; + } + + /** + * Returns the ids of the selected labels, ignoring the special values `0` and `-1`. + * + * @return list + * @since 6.3 + */ + public function getSelectedLabelIDs(): array + { + if (\is_array($this->getValue())) { + return $this->getValue(); + } + + return $this->getValue() > 0 ? [$this->getValue()] : []; + } + /** * Sets the label group whose labels can be selected via this form field and returns this * form field. @@ -143,12 +201,19 @@ public function updatedObject(array $data, IStorableObject $object, bool $loadVa } $labelIDs = $this->getLabelGroup()->getLabelIDs(); + $selectedLabelIDs = []; /** @var Label $label */ foreach (static::$loadedLabels[$objectTypeID][$objectID] as $label) { if (\in_array($label->labelID, $labelIDs, true)) { - $this->value($label->labelID); + $selectedLabelIDs[] = $label->labelID; } } + + if ($this->maxSelection > 1) { + $this->value($selectedLabelIDs); + } elseif ($selectedLabelIDs !== []) { + $this->value(\end($selectedLabelIDs)); + } } return $this; @@ -162,11 +227,20 @@ public function populate(): static $this->getDocument()->getDataHandler()->addProcessor(new CustomFormDataProcessor( 'label', function (IFormDocument $document, array $parameters) { + if (!$this->checkDependencies()) { + return $parameters; + } + + if ($this->maxSelection > 1) { + if ($this->getSelectedLabelIDs() !== []) { + $parameters[$this->getObjectProperty()][$this->getLabelGroup()->groupID] = $this->getSelectedLabelIDs(); + } + + return $parameters; + } + // `0` is irrelevant for saving, `-1` is only relevant for invertible fields. - if ( - $this->checkDependencies() - && ($this->getValue() > 0 || ($this->getValue() === -1 && $this->isInvertible())) - ) { + if ($this->getValue() > 0 || ($this->getValue() === -1 && $this->isInvertible())) { if (!isset($parameters[$this->getObjectProperty()])) { $parameters[$this->getObjectProperty()] = []; } @@ -185,7 +259,17 @@ function (IFormDocument $document, array $parameters) { public function readValue(): static { if ($this->getDocument()->hasRequestData($this->getPrefixedId())) { - $this->value = \intval($this->getDocument()->getRequestData($this->getPrefixedId())); + $value = $this->getDocument()->getRequestData($this->getPrefixedId()); + + if ($this->maxSelection > 1) { + $labelIDs = \array_map(static fn($labelID) => (int)$labelID, (array)$value); + $this->value = \array_values(\array_unique(\array_filter( + $labelIDs, + static fn(int $labelID) => $labelID > 0 + ))); + } elseif (!\is_array($value)) { + $this->value = (int)$value; + } } return $this; @@ -194,6 +278,35 @@ public function readValue(): static #[\Override] public function validate(): void { + if ($this->maxSelection > 1) { + $labelIDs = $this->getSelectedLabelIDs(); + if ($labelIDs === []) { + if ($this->isRequired()) { + $this->addValidationError(new FormFieldValidationError('empty')); + } + + return; + } + + if (\array_diff($labelIDs, $this->getLabelGroup()->getLabelIDs()) !== []) { + $this->addValidationError(new FormFieldValidationError( + 'invalidValue', + 'wcf.global.form.error.noValidSelection' + )); + } elseif (\count($labelIDs) > $this->maxSelection) { + $this->addValidationError(new FormFieldValidationError( + 'maximumMultiples', + 'wcf.form.field.label.error.maximumMultiples', + [ + 'count' => \count($labelIDs), + 'maximumCount' => $this->maxSelection, + ] + )); + } + + return; + } + if ($this->value > 0) { if (!\in_array($this->value, $this->getLabelGroup()->getLabelIDs())) { $this->addValidationError(new FormFieldValidationError( @@ -226,6 +339,7 @@ public static function createFields(string $objectType, array $labelGroups, stri ->objectProperty($objectProperty) ->objectType($objectType) ->required((bool)$labelGroup->forceSelection) + ->maxSelection(LabelHandler::getInstance()->getMaxSelection($labelGroup, $objectType)) ->labelGroup($labelGroup); } diff --git a/wcfsetup/install/files/lib/system/label/LabelHandler.class.php b/wcfsetup/install/files/lib/system/label/LabelHandler.class.php index 5cb2fa21ef1..a8bc97dd18f 100644 --- a/wcfsetup/install/files/lib/system/label/LabelHandler.class.php +++ b/wcfsetup/install/files/lib/system/label/LabelHandler.class.php @@ -11,6 +11,7 @@ use wcf\system\cache\builder\LabelCacheBuilder; use wcf\system\database\util\PreparedStatementConditionBuilder; use wcf\system\exception\SystemException; +use wcf\system\label\object\IMultipleLabelsObjectHandler; use wcf\system\SingletonFactory; use wcf\system\WCF; @@ -90,6 +91,41 @@ public function getObjectType(string $objectType) return null; } + /** + * Returns the maximum number of labels of the given label group that can + * be assigned to a single object of the given label object type. + * + * @since 6.3 + */ + public function getMaxSelection(ViewableLabelGroup $labelGroup, string $objectType): int + { + $className = $this->getObjectType($objectType)?->className; + if ($className === null || !\is_subclass_of($className, IMultipleLabelsObjectHandler::class)) { + return 1; + } + + return \max(1, $labelGroup->maxSelection); + } + + /** + * Flattens a `groupID => labelID|labelID[]` map into a list of label ids. + * + * @param array> $labelIDs + * @return list + * @since 6.3 + */ + public static function flattenLabelIDs(array $labelIDs): array + { + $flattened = []; + foreach ($labelIDs as $labelID) { + foreach ((array)$labelID as $value) { + $flattened[] = $value; + } + } + + return \array_values(\array_unique($flattened)); + } + /** * Returns an array with view permissions for the labels with the given id. * @@ -311,18 +347,23 @@ public function getAssignedLabels(int $objectTypeID, array $objectIDs, bool $val } } - // order label ids by label group + // order label ids by label group and by their position within the group $labelGroups = &$this->labelGroups; + $labelPositions = []; + $getLabelPosition = function (Label $label) use (&$labelPositions): int { + if (!isset($labelPositions[$label->groupID])) { + $labelPositions[$label->groupID] = \array_flip($this->getLabelGroup($label->groupID)->getLabelIDs()); + } + + return $labelPositions[$label->groupID][$label->labelID]; + }; foreach ($data as &$labels) { - \uasort($labels, static function ($a, $b) use ($labelGroups) { + \uasort($labels, static function (Label $a, Label $b) use ($labelGroups, $getLabelPosition) { $groupA = $labelGroups['groups'][$a->groupID]; $groupB = $labelGroups['groups'][$b->groupID]; - if ($groupA->showOrder === $groupB->showOrder) { - return ($groupA->groupID > $groupB->groupID) ? 1 : -1; - } - - return ($groupA->showOrder > $groupB->showOrder) ? 1 : -1; + return [$groupA->showOrder, $groupA->groupID, $getLabelPosition($a)] + <=> [$groupB->showOrder, $groupB->groupID, $getLabelPosition($b)]; }); } unset($labels); diff --git a/wcfsetup/install/files/lib/system/label/object/AbstractLabelObjectHandler.class.php b/wcfsetup/install/files/lib/system/label/object/AbstractLabelObjectHandler.class.php index c1e8ae12750..bd645ea309d 100644 --- a/wcfsetup/install/files/lib/system/label/object/AbstractLabelObjectHandler.class.php +++ b/wcfsetup/install/files/lib/system/label/object/AbstractLabelObjectHandler.class.php @@ -85,23 +85,44 @@ public function validateLabelIDs(array $labelIDs, string $optionName = '', bool $validationErrors = []; $satisfiedGroups = []; foreach ($labelIDs as $groupID => $labelID) { - // only one label per group is allowed - // @phpstan-ignore function.impossibleType + // label group id is unknown + if (!isset($this->labelGroups[$groupID])) { + $validationErrors[0] = 'invalid'; + continue; + } + if (\is_array($labelID)) { + // only handlers that support multiple labels per group accept a list + if (!($this instanceof IMultipleLabelsObjectHandler)) { + $validationErrors[$groupID] = 'invalid'; + continue; + } + + $groupLabelIDs = $labelID; + } else { + $groupLabelIDs = [$labelID]; + } + + if ($groupLabelIDs === []) { + continue; + } + + if (\count($groupLabelIDs) !== \count(\array_unique($groupLabelIDs))) { $validationErrors[$groupID] = 'invalid'; continue; } - // label group id is unknown - if (!isset($this->labelGroups[$groupID])) { - $validationErrors[0] = 'invalid'; + if (\count($groupLabelIDs) > $this->getMaxSelection($this->labelGroups[$groupID])) { + $validationErrors[$groupID] = 'tooMany'; continue; } // label id is invalid for this group - if (!$this->labelGroups[$groupID]->isValid($labelID)) { - $validationErrors[$groupID] = 'invalid'; - continue; + foreach ($groupLabelIDs as $groupLabelID) { + if (!$this->labelGroups[$groupID]->isValid($groupLabelID)) { + $validationErrors[$groupID] = 'invalid'; + continue 2; + } } // check permission @@ -141,6 +162,17 @@ public function validateLabelIDs(array $labelIDs, string $optionName = '', bool } } + /** + * Returns the maximum number of labels of the given label group that can + * be assigned to a single object. + * + * @since 6.3 + */ + public function getMaxSelection(ViewableLabelGroup $labelGroup): int + { + return LabelHandler::getInstance()->getMaxSelection($labelGroup, $this->objectType); + } + #[\Override] public function setLabels(array $labelIDs, int $objectID, bool $validatePermissions = true) { diff --git a/wcfsetup/install/files/lib/system/label/object/ArticleLabelObjectHandler.class.php b/wcfsetup/install/files/lib/system/label/object/ArticleLabelObjectHandler.class.php index 39f1f460376..d5aae9fea8d 100644 --- a/wcfsetup/install/files/lib/system/label/object/ArticleLabelObjectHandler.class.php +++ b/wcfsetup/install/files/lib/system/label/object/ArticleLabelObjectHandler.class.php @@ -12,7 +12,7 @@ * @copyright 2001-2019 WoltLab GmbH * @license GNU Lesser General Public License */ -class ArticleLabelObjectHandler extends AbstractLabelObjectHandler +class ArticleLabelObjectHandler extends AbstractLabelObjectHandler implements IMultipleLabelsObjectHandler { /** * @inheritDoc diff --git a/wcfsetup/install/files/lib/system/label/object/ILabelObjectHandler.class.php b/wcfsetup/install/files/lib/system/label/object/ILabelObjectHandler.class.php index 61dad1fba8d..91dd097d6c9 100644 --- a/wcfsetup/install/files/lib/system/label/object/ILabelObjectHandler.class.php +++ b/wcfsetup/install/files/lib/system/label/object/ILabelObjectHandler.class.php @@ -33,7 +33,11 @@ public function getLabelGroups(array $parameters = []); /** * Returns true, if all given label ids are valid and accessible. * - * @param int[] $labelIDs + * Expects a `groupID => labelID` map, implementations of + * `IMultipleLabelsObjectHandler` additionally accept a list of label ids + * per group. + * + * @param array> $labelIDs * @return mixed */ public function validateLabelIDs(array $labelIDs, string $optionName = '', bool $legacyReturnValue = true); diff --git a/wcfsetup/install/files/lib/system/label/object/IMultipleLabelsObjectHandler.class.php b/wcfsetup/install/files/lib/system/label/object/IMultipleLabelsObjectHandler.class.php new file mode 100644 index 00000000000..83aaeb785c4 --- /dev/null +++ b/wcfsetup/install/files/lib/system/label/object/IMultipleLabelsObjectHandler.class.php @@ -0,0 +1,19 @@ + labelID[]` for groups that allow + * multiple labels. + * + * @author Marcel Werk + * @copyright 2001-2026 WoltLab GmbH + * @license GNU Lesser General Public License + * @since 6.3 + */ +interface IMultipleLabelsObjectHandler extends ILabelObjectHandler {} diff --git a/wcfsetup/install/files/style/ui/label.scss b/wcfsetup/install/files/style/ui/label.scss index 1e02e835208..7e8a82968a3 100644 --- a/wcfsetup/install/files/style/ui/label.scss +++ b/wcfsetup/install/files/style/ui/label.scss @@ -51,6 +51,12 @@ cursor: pointer; } +woltlab-core-label-picker[max] > .dropdownToggle { + display: inline-flex; + flex-wrap: wrap; + gap: 5px; +} + .labelSelection { column-gap: 10px; display: grid; diff --git a/wcfsetup/install/lang/de.xml b/wcfsetup/install/lang/de.xml index b8cc82e9057..ebc10a1871f 100644 --- a/wcfsetup/install/lang/de.xml +++ b/wcfsetup/install/lang/de.xml @@ -1023,6 +1023,8 @@ Sie erreichen das Fehlerprotokoll unter: {link controller='ExceptionLogView' isE + + @@ -4057,6 +4059,7 @@ Dateianhänge: + @@ -4139,6 +4142,7 @@ Dateianhänge: + diff --git a/wcfsetup/install/lang/en.xml b/wcfsetup/install/lang/en.xml index f4ab343ca5a..32f9901882f 100644 --- a/wcfsetup/install/lang/en.xml +++ b/wcfsetup/install/lang/en.xml @@ -1001,6 +1001,8 @@ You can access the error log at: {link controller='ExceptionLogView' isEmail=tru + + @@ -4003,6 +4005,7 @@ Attachments: + @@ -4085,6 +4088,7 @@ Attachments: + diff --git a/wcfsetup/setup/db/install_com.woltlab.wcf.php b/wcfsetup/setup/db/install_com.woltlab.wcf.php index 26d631049a1..1f0b402ab4b 100644 --- a/wcfsetup/setup/db/install_com.woltlab.wcf.php +++ b/wcfsetup/setup/db/install_com.woltlab.wcf.php @@ -1708,6 +1708,9 @@ ->defaultValue(''), DefaultFalseBooleanDatabaseTableColumn::create('forceSelection'), DefaultFalseBooleanDatabaseTableColumn::create('sortAlphabetically'), + SmallintDatabaseTableColumn::create('maxSelection') + ->notNull() + ->defaultValue(1), NotNullInt10DatabaseTableColumn::create('showOrder') ->defaultValue(0), ]) From 240888c9e0e53a4746e00c4a1e009c9d43709ab1 Mon Sep 17 00:00:00 2001 From: Marcel Werk Date: Mon, 9 Nov 2026 15:35:22 +0100 Subject: [PATCH 2/4] Use the native `popover` mechanic instead of utilizing the existing dropdown implementation --- ts/WoltLabSuite/Core/Ui/Dropdown/Simple.ts | 5 +- .../WebComponent/woltlab-core-label-picker.ts | 308 ++++++++++++++---- .../WoltLabSuite/Core/Ui/Dropdown/Simple.js | 5 +- .../files/js/WoltLabSuite/WebComponent.min.js | 37 ++- ...PreloadPhrasesCollectingListener.class.php | 1 + wcfsetup/install/files/style/ui/darkMode.scss | 3 +- wcfsetup/install/files/style/ui/label.scss | 84 ++++- wcfsetup/install/lang/de.xml | 1 + wcfsetup/install/lang/en.xml | 1 + 9 files changed, 349 insertions(+), 96 deletions(-) diff --git a/ts/WoltLabSuite/Core/Ui/Dropdown/Simple.ts b/ts/WoltLabSuite/Core/Ui/Dropdown/Simple.ts index b306011ad8e..813a0766533 100644 --- a/ts/WoltLabSuite/Core/Ui/Dropdown/Simple.ts +++ b/ts/WoltLabSuite/Core/Ui/Dropdown/Simple.ts @@ -274,10 +274,7 @@ function toggle( const activeListItem = nonEmptyListItems.find((listItem) => listItem.classList.contains("active")); firstListItem = activeListItem || nonEmptyListItems[0] || null; nonEmptyListItems.forEach((listItem) => { - // Preserve more specific roles, such as `menuitemcheckbox`. - if (!listItem.hasAttribute("role")) { - listItem.setAttribute("role", "menuitem"); - } + listItem.setAttribute("role", "menuitem"); listItem.tabIndex = -1; }); } diff --git a/ts/WoltLabSuite/WebComponent/woltlab-core-label-picker.ts b/ts/WoltLabSuite/WebComponent/woltlab-core-label-picker.ts index 6472e553351..a434408e1bf 100644 --- a/ts/WoltLabSuite/WebComponent/woltlab-core-label-picker.ts +++ b/ts/WoltLabSuite/WebComponent/woltlab-core-label-picker.ts @@ -1,5 +1,5 @@ /** - * `` 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`). @@ -8,6 +8,9 @@ * 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 that is + * anchored to the toggle button. + * * Usage: `` * * @author Alexander Ebert, Marcel Werk @@ -17,14 +20,22 @@ */ { + const supportsAnchorPositioning = CSS.supports("position-area", "block-end"); + let pickerCount = 0; + class WoltlabCoreLabelPickerElement extends HTMLElement { static formAssociated = true; readonly #internals: ElementInternals; #button: HTMLButtonElement | undefined = undefined; #defaultValue = ""; - #items = new Map(); #labels = new Map(); + #listbox: HTMLElement | undefined = undefined; + #options = new Map(); + #popover: HTMLElement | undefined = undefined; + readonly #updatePosition = () => this.#setPosition(); + #typeahead = ""; + #typeaheadTimer: number | undefined = undefined; constructor() { super(); @@ -33,8 +44,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; } @@ -50,15 +60,40 @@ this.#defaultValue = this.getAttribute("value") || ""; - this.innerHTML = ""; - this.classList.add("dropdown"); + 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.style.setProperty("position-anchor", `--${id}`); + this.#popover.addEventListener("toggle", (event) => { + if (event.newState === "open") { + this.#open(); + } else { + this.#closed(); + } + }); 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.#button.style.setProperty("anchor-name", `--${id}`); + this.#button.addEventListener("keydown", (event) => { + if ((event.key === "ArrowDown" || event.key === "ArrowUp") && !this.#isOpen()) { + event.preventDefault(); - this.append(this.#button, this.#buildMenu()); + this.#popover!.showPopover({ source: this.#button }); + } + }); + + this.#popover.append(...this.#buildContent(id)); + + this.innerHTML = ""; + this.append(this.#button, this.#popover); // Clicks on an associated `