Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<script data-relocate="true">
{jsphrase name='wcf.global.filter.button.visibility'}
{jsphrase name='wcf.global.filter.button.clear'}
{jsphrase name='wcf.global.filter.error.noMatches'}
{jsphrase name='wcf.global.filter.placeholder'}
{jsphrase name='wcf.global.filter.visibility.activeOnly'}
{jsphrase name='wcf.global.filter.visibility.highlightActive'}
{jsphrase name='wcf.global.filter.visibility.showAll'}

require(['WoltLabSuite/Core/Component/ItemList/Categorized'], ({ CategorizedItemList }) => {
new CategorizedItemList('{unsafe:$field->getPrefixedId()|encodeJS}_list');
});
</script>

<div class="itemListFilter" id="{$field->getPrefixedId()}_list">
<div class="inputAddon">
<input type="text" class="long" placeholder="{lang}wcf.global.filter.placeholder{/lang}">
<button type="button" class="button clearButton inputSuffix disabled jsTooltip" title="{lang}wcf.global.filter.button.clear{/lang}">{icon name="xmark" solid=true}</button>
</div>
<ul class="scrollableCheckboxList">
{foreach from=$field->getNestedOptions() item=__fieldNestedOption}
<li
{if $__fieldNestedOption[depth] > 0} style="padding-left: {$__fieldNestedOption[depth]*20}px"{/if}
{if !$__fieldNestedOption[isSelectable]} class="scrollableCheckboxList__category"{/if}
>
{if !$__fieldNestedOption[isSelectable]}
<span class="scrollableCheckboxList__category__label">{unsafe:$__fieldNestedOption[label]}</span>
{else}
<label>
<input {*
*}type="radio" {*
*}name="{$field->getPrefixedId()}" {*
*}value="{$__fieldNestedOption[value]}"{*
*}{if !$field->getFieldClasses()|empty} class="{implode from=$field->getFieldClasses() item='class' glue=' '}{$class}{/implode}"{/if}{*
*}{if $field->getValue() == $__fieldNestedOption[value] && $__fieldNestedOption[isSelectable]} checked{/if}{*
*}{if $field->isImmutable()} disabled{/if}{*
*}> <span>{unsafe:$__fieldNestedOption[label]}</span>
</label>
{/if}
</li>
{/foreach}
</ul>
</div>
140 changes: 140 additions & 0 deletions ts/WoltLabSuite/Core/Component/ItemList/Categorized.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Provides a filter input for a categorized item list.
*
* @author Olaf Braun
* @copyright 2001-2025 WoltLab GmbH
* @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
* @sice 6.3
*/

import { innerError } from "WoltLabSuite/Core/Dom/Util";
import { getPhrase } from "WoltLabSuite/Core/Language";
import { escapeRegExp } from "WoltLabSuite/Core/StringUtil";

type Item = {
element: HTMLLIElement;
span: HTMLSpanElement;
text: string;
};

type Category = {
items: Item[];
element: HTMLLIElement;
};

export class CategorizedItemList {
readonly #container: HTMLElement;
readonly #elementList: HTMLUListElement;
readonly #input: HTMLInputElement;
#value: string = "";
readonly #clearButton: HTMLButtonElement;
#categories: Category[] = [];
readonly #fragment: DocumentFragment;

constructor(elementId: string) {
this.#fragment = document.createDocumentFragment();

const container = document.getElementById(elementId);
if (!container) {
throw new Error(`Element with ID ${elementId} not found.`);
}

this.#container = container;
this.#elementList = this.#container.querySelector<HTMLUListElement>(".scrollableCheckboxList")!;

this.#input = this.#container.querySelector(".inputAddon > input") as HTMLInputElement;
this.#input.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
}
});
this.#input.addEventListener("keyup", () => this.#keyup());

this.#clearButton = this.#container.querySelector<HTMLButtonElement>(".inputAddon > .clearButton")!;
this.#clearButton.addEventListener("click", (event) => {
event.preventDefault();

this.#input.value = "";
this.#keyup();
});

this.#buildItemMap();
}

#buildItemMap(): void {
let category: Category | null = null;
for (const li of this.#elementList.querySelectorAll<HTMLLIElement>(":scope > li")) {
const input = li.querySelector('input[type="radio"]');
if (input) {
if (!category) {
throw new Error("Input found without a preceding category.");
}

category.items.push({
element: li,
span: li.querySelector("span")!,
text: li.textContent!.trim(),
});
} else {
const items: Item[] = [];
category = {
items: items,
element: li,
};
this.#categories.push(category);
}
}
}

#keyup(): void {
const value = this.#input.value.trim();
if (this.#value === value) {
return;
}

this.#value = value;

if (this.#value) {
this.#clearButton.classList.remove("disabled");
} else {
this.#clearButton.classList.add("disabled");
}

// move list into fragment before editing items, increases performance
// by avoiding the browser to perform repaint/layout over and over again
this.#fragment.appendChild(this.#elementList);

this.#categories.forEach((category) => {
this.#filterItems(category);
});

const hasVisibleItem = this.#elementList.querySelector(".scrollableCheckboxList > li:not([hidden])") !== null;

this.#container.insertAdjacentElement("beforeend", this.#elementList);

innerError(this.#container, hasVisibleItem ? false : getPhrase("wcf.global.filter.error.noMatches"));
}

#filterItems(category: Category): void {
const regexp = new RegExp("(" + escapeRegExp(this.#value) + ")", "i");

let hasMatchingItem = false;
for (const item of category.items) {
if (this.#value === "") {
item.span.innerHTML = item.text; // Reset highlighting

hasMatchingItem = true;
item.element.hidden = false;
} else if (regexp.test(item.text)) {
item.span.innerHTML = item.text.replace(regexp, "<u>$1</u>");

item.element.hidden = false;
hasMatchingItem = true;
} else {
item.element.hidden = true;
}
}

category.element.hidden = !hasMatchingItem;
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

81 changes: 70 additions & 11 deletions wcfsetup/install/files/lib/action/ConditionAddAction.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,27 +82,86 @@ private function getForm(AbstractConditionProvider $provider): Psr15DialogForm
self::class,
WCF::getLanguage()->get('wcf.condition.add')
);
$options = \array_map(
static fn (IConditionType $conditionType) => WCF::getLanguage()->get($conditionType->getLabel()),
$provider->getConditionTypes()
);
$collator = new \Collator(WCF::getLanguage()->getLocale());
\uasort(
$options,
static fn (string $a, string $b) => $collator->compare($a, $b)
);

$form->appendChild(
SingleSelectionFormField::create('conditionType')
$this->getConditionTypeFormField()
->id('conditionType')
->label('wcf.condition.condition')
->filterable()
->required()
->options($options)
->options($this->getOptions($provider), true, false)
);

$form->markRequiredFields(false);
$form->build();

return $form;
}

/**
* @param AbstractConditionProvider<IConditionType<mixed>> $provider
*
* @return array{}
*/
private function getOptions(AbstractConditionProvider $provider): array
{
$conditionTypes = $provider->getConditionTypes();

$grouped = [];
foreach ($conditionTypes as $key => $conditionType) {
$category = $conditionType->getCategory();
$label = $conditionType->getLabel();

if (!isset($grouped[$category])) {
$grouped[$category] = [
'items' => [],
'label' => WCF::getLanguage()->get('wcf.condition.category.' . $category),
];
}

$grouped[$category]['items'][$key] = WCF::getLanguage()->get($label);
}

$collator = new \Collator(WCF::getLanguage()->getLocale());

foreach ($grouped as &$category) {
\uasort($category['items'], static function ($labelA, $labelB) use ($collator) {
return $collator->compare($labelA, $labelB);
});
}
unset($category);

\uasort($grouped, static function ($catA, $catB) use ($collator) {
return $collator->compare($catA['label'], $catB['label']);
});

$options = [];

foreach ($grouped as $categoryKey => $category) {
$options[] = [
'depth' => 0,
'isSelectable' => false,
'label' => $category['label'],
'value' => $categoryKey,
];

foreach ($category['items'] as $key => $label) {
$options[] = [
'depth' => 1,
'isSelectable' => true,
'label' => $label,
'value' => $key,
];
}
}

return $options;
}

private function getConditionTypeFormField(): SingleSelectionFormField
{
return new class extends SingleSelectionFormField {
protected $templateName = 'shared_categorizedSingleSelectionFormField';
};
}
}
Loading