Skip to content

Commit be973d3

Browse files
committed
Group the condition types and make it possible to open/close the groups in the input field.
1 parent 9e8a1d5 commit be973d3

26 files changed

+565
-12
lines changed
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<script data-relocate="true">
2+
{jsphrase name='wcf.global.filter.button.visibility'}
3+
{jsphrase name='wcf.global.filter.button.clear'}
4+
{jsphrase name='wcf.global.filter.error.noMatches'}
5+
{jsphrase name='wcf.global.filter.placeholder'}
6+
{jsphrase name='wcf.global.filter.visibility.activeOnly'}
7+
{jsphrase name='wcf.global.filter.visibility.highlightActive'}
8+
{jsphrase name='wcf.global.filter.visibility.showAll'}
9+
10+
require(['WoltLabSuite/Core/Component/ItemList/Categorized'], ({ CategorizedItemList }) => {
11+
new CategorizedItemList('{unsafe:$field->getPrefixedId()|encodeJS}_list');
12+
});
13+
</script>
14+
15+
<div class="itemListFilter" id="{$field->getPrefixedId()}_list">
16+
<div class="inputAddon">
17+
<input type="text" class="long" placeholder="{lang}wcf.global.filter.placeholder{/lang}" />
18+
<button type="button" class="button clearButton inputSuffix disabled jsTooltip" title="{lang}wcf.global.filter.button.clear{/lang}">{icon name="xmark" solid=true}</button>
19+
</div>
20+
<ul class="scrollableCheckboxList">
21+
{foreach from=$field->getNestedOptions() item=__fieldNestedOption}
22+
<li{if $__fieldNestedOption[depth] > 0} style="padding-left: {$__fieldNestedOption[depth]*20}px"{/if}{if !$__fieldNestedOption[isSelectable]} data-open="true"{/if}>
23+
{if $__fieldNestedOption[isSelectable]}
24+
<label>
25+
<input {*
26+
*}type="radio" {*
27+
*}name="{$field->getPrefixedId()}" {*
28+
*}value="{$__fieldNestedOption[value]}"{*
29+
*}{if !$field->getFieldClasses()|empty} class="{implode from=$field->getFieldClasses() item='class' glue=' '}{$class}{/implode}"{/if}{*
30+
*}{if $field->getValue() == $__fieldNestedOption[value] && $__fieldNestedOption[isSelectable]} checked{/if}{*
31+
*}{if $field->isImmutable()} disabled{/if}{*
32+
*}> <span>{unsafe:$__fieldNestedOption[label]}</span>
33+
</label>
34+
{else}
35+
<button type="button" class="pointer">{icon name="chevron-down"} {unsafe:$__fieldNestedOption[label]}</button>
36+
{/if}
37+
</li>
38+
{/foreach}
39+
</ul>
40+
</div>
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/**
2+
* Provides a filter input for a categorized item list.
3+
*
4+
* @author Olaf Braun
5+
* @copyright 2001-2025 WoltLab GmbH
6+
* @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
7+
* @sice 6.3
8+
*/
9+
10+
import { innerError, show, hide, isHidden } from "WoltLabSuite/Core/Dom/Util";
11+
import { getPhrase } from "WoltLabSuite/Core/Language";
12+
import { escapeRegExp } from "WoltLabSuite/Core/StringUtil";
13+
14+
interface Item {
15+
element: HTMLLIElement;
16+
span: HTMLSpanElement;
17+
text: string;
18+
}
19+
20+
interface Category {
21+
items: Item[];
22+
element: HTMLLIElement;
23+
}
24+
25+
export class CategorizedItemList {
26+
#container: HTMLElement;
27+
#elementList: HTMLUListElement;
28+
#input: HTMLInputElement;
29+
#value: string = "";
30+
#clearButton: HTMLButtonElement;
31+
#categories: Category[] = [];
32+
#fragment: DocumentFragment;
33+
34+
constructor(elementId: string) {
35+
this.#fragment = document.createDocumentFragment();
36+
37+
const container = document.getElementById(elementId);
38+
if (!container) {
39+
throw new Error(`Element with ID ${elementId} not found.`);
40+
}
41+
42+
this.#container = container;
43+
this.#elementList = this.#container.querySelector<HTMLUListElement>(".scrollableCheckboxList")!;
44+
45+
this.#input = this.#container.querySelector(".inputAddon > input") as HTMLInputElement;
46+
this.#input.addEventListener("keydown", (event) => {
47+
if (event.key === "Enter") {
48+
event.preventDefault();
49+
}
50+
});
51+
this.#input.addEventListener("keyup", () => this.#keyup());
52+
53+
this.#clearButton = this.#container.querySelector<HTMLButtonElement>(".inputAddon > .clearButton")!;
54+
this.#clearButton.addEventListener("click", (event) => {
55+
event.preventDefault();
56+
57+
this.#input.value = "";
58+
this.#keyup();
59+
});
60+
61+
this.#buildItemMap();
62+
}
63+
64+
#buildItemMap(): void {
65+
let category: Category | null = null;
66+
for (const li of this.#elementList.querySelectorAll<HTMLLIElement>(":scope > li")) {
67+
const input = li.querySelector('input[type="radio"]');
68+
if (input) {
69+
if (!category) {
70+
throw new Error("Input found without a preceding category.");
71+
}
72+
73+
category.items.push({
74+
element: li,
75+
span: li.querySelector<HTMLSpanElement>("span")!,
76+
text: li.innerText.trim(),
77+
});
78+
} else {
79+
const items: Item[] = [];
80+
category = {
81+
items: items,
82+
element: li,
83+
};
84+
this.#categories.push(category);
85+
86+
li.addEventListener("click", (event) => {
87+
this.#categoryClick(event, li, items);
88+
});
89+
}
90+
}
91+
}
92+
93+
#categoryClick(event: MouseEvent, li: HTMLLIElement, items: Item[]): void {
94+
event.preventDefault();
95+
96+
const isOpen = !this.#categoryIsOpen(li);
97+
li.dataset.open = isOpen ? "true" : "false";
98+
99+
li.querySelector<FaIcon>("fa-icon")!.setIcon(isOpen ? "chevron-down" : "chevron-right");
100+
101+
this.#showItems({
102+
items: items,
103+
element: li,
104+
});
105+
}
106+
107+
#categoryIsOpen(category: HTMLLIElement): boolean {
108+
return category.dataset.open === "true";
109+
}
110+
111+
#keyup(): void {
112+
const value = this.#input.value.trim();
113+
if (this.#value === value) {
114+
return;
115+
}
116+
117+
this.#value = value;
118+
119+
if (this.#value) {
120+
this.#clearButton.classList.remove("disabled");
121+
} else {
122+
this.#clearButton.classList.add("disabled");
123+
}
124+
125+
// move list into fragment before editing items, increases performance
126+
// by avoiding the browser to perform repaint/layout over and over again
127+
this.#fragment.appendChild(this.#elementList);
128+
129+
this.#categories.forEach((category) => {
130+
this.#showItems(category);
131+
});
132+
133+
const hasVisibleItems = Array.from(
134+
this.#elementList.querySelectorAll<HTMLLIElement>(".scrollableCheckboxList > li"),
135+
).some((li) => {
136+
return !isHidden(li);
137+
});
138+
139+
this.#container.insertAdjacentElement("beforeend", this.#elementList);
140+
141+
innerError(this.#container, hasVisibleItems ? false : getPhrase("wcf.global.filter.error.noMatches"));
142+
}
143+
144+
#showItems(category: Category): void {
145+
const categoryIsOpen = this.#categoryIsOpen(category.element);
146+
const regexp = new RegExp("(" + escapeRegExp(this.#value) + ")", "i");
147+
148+
if (this.#value === "") {
149+
show(category.element);
150+
151+
category.items.forEach((item) => {
152+
item.span.innerHTML = item.text; // Reset highlighting
153+
154+
if (categoryIsOpen) {
155+
show(item.element);
156+
} else {
157+
hide(item.element);
158+
}
159+
});
160+
} else {
161+
if (category.items.some((item) => regexp.test(item.text))) {
162+
show(category.element);
163+
164+
category.items.forEach((item) => {
165+
if (categoryIsOpen && regexp.test(item.text)) {
166+
item.span.innerHTML = item.text.replace(regexp, "<u>$1</u>");
167+
168+
show(item.element);
169+
} else {
170+
hide(item.element);
171+
}
172+
});
173+
} else {
174+
hide(category.element);
175+
176+
category.items.forEach((item) => {
177+
hide(item.element);
178+
});
179+
}
180+
}
181+
}
182+
}

wcfsetup/install/files/js/WoltLabSuite/Core/Component/ItemList/Categorized.js

Lines changed: 146 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)