From 9fb9a4af5505bc8ef74e7cfb381bec47f00fcee9 Mon Sep 17 00:00:00 2001
From: IrkTheImp <41175833+IrkTheImp@users.noreply.github.com>
Date: Wed, 2 Jul 2025 17:02:20 -0500
Subject: [PATCH 1/3] fix dr command roll bug (#241)
* swap to use the DualityRoll not base roll
* update command to use new dice roll.
* reinstate DhpActor in action (which causes circular reference)
* fix additional dr options
---
daggerheart.mjs | 55 +++++----------
module/applications/chatMessage.mjs | 8 ++-
module/applications/roll.mjs | 106 ++++++++++++++++++----------
module/helpers/utils.mjs | 8 +--
templates/chat/duality-roll.hbs | 2 +-
5 files changed, 96 insertions(+), 83 deletions(-)
diff --git a/daggerheart.mjs b/daggerheart.mjs
index 2415c857..ac444c44 100644
--- a/daggerheart.mjs
+++ b/daggerheart.mjs
@@ -213,46 +213,27 @@ Hooks.on('chatMessage', (_, message) => {
})
: game.i18n.localize('DAGGERHEART.General.Duality');
- const hopeAndFearRoll = `1${rollCommand.hope ?? 'd12'}+1${rollCommand.fear ?? 'd12'}`;
- const advantageRoll = `${advantageState === true ? '+d6' : advantageState === false ? '-d6' : ''}`;
- const attributeRoll = `${trait?.value ? `${trait.value > 0 ? `+${trait.value}` : `${trait.value}`}` : ''}`;
- const roll = await Roll.create(`${hopeAndFearRoll}${advantageRoll}${attributeRoll}`).evaluate();
-
- setDiceSoNiceForDualityRoll(roll, advantageState);
-
- resolve({
- roll,
- trait: trait
- ? {
- value: trait.value,
- label: `${game.i18n.localize(abilities[traitValue].label)} ${trait.value >= 0 ? `+` : ``}${trait.value}`
- }
- : undefined,
- title
- });
- }).then(async ({ roll, trait, title }) => {
- const cls = getDocumentClass('ChatMessage');
- const systemData = new DHDualityRoll({
+ const config = {
title: title,
- origin: target?.id,
- roll: roll,
- modifiers: trait ? [trait] : [],
- hope: { dice: rollCommand.hope ?? 'd12', value: roll.dice[0].total },
- fear: { dice: rollCommand.fear ?? 'd12', value: roll.dice[1].total },
- advantage: advantageState !== null ? { dice: 'd6', value: roll.dice[2].total } : undefined,
- advantageState
- });
-
- const msgData = {
- type: 'dualityRoll',
- sound: CONFIG.sounds.dice,
- system: systemData,
- user: game.user.id,
- content: 'systems/daggerheart/templates/chat/duality-roll.hbs',
- rolls: [roll]
+ roll: {
+ trait: traitValue
+ },
+ data: {
+ traits: {
+ [traitValue]: trait
+ }
+ },
+ source: target,
+ hasSave: false,
+ dialog: { configure: false },
+ evaluate: true,
+ advantage: rollCommand.advantage == true,
+ disadvantage: rollCommand.disadvantage == true
};
- cls.create(msgData);
+ await CONFIG.Dice.daggerheart['DualityRoll'].build(config);
+
+ resolve();
});
}
diff --git a/module/applications/chatMessage.mjs b/module/applications/chatMessage.mjs
index 1328de57..ef76d18f 100644
--- a/module/applications/chatMessage.mjs
+++ b/module/applications/chatMessage.mjs
@@ -1,6 +1,10 @@
export default class DhpChatMessage extends foundry.documents.ChatMessage {
async renderHTML() {
- if(this.system.messageTemplate) this.content = await foundry.applications.handlebars.renderTemplate(this.system.messageTemplate, this.system);
+ if (this.system.messageTemplate)
+ this.content = await foundry.applications.handlebars.renderTemplate(
+ this.system.messageTemplate,
+ this.system
+ );
/* We can change to fully implementing the renderHTML function if needed, instead of augmenting it. */
const html = await super.renderHTML();
@@ -14,7 +18,7 @@ export default class DhpChatMessage extends foundry.documents.ChatMessage {
break;
case -1:
html.classList.add('fear');
- break;
+ break;
default:
html.classList.add('critical');
break;
diff --git a/module/applications/roll.mjs b/module/applications/roll.mjs
index 75d78938..321ead6f 100644
--- a/module/applications/roll.mjs
+++ b/module/applications/roll.mjs
@@ -1,6 +1,7 @@
import DHDamageRoll from '../data/chat-message/damageRoll.mjs';
import D20RollDialog from '../dialogs/d20RollDialog.mjs';
import DamageDialog from '../dialogs/damageDialog.mjs';
+import { setDiceSoNiceForDualityRoll } from '../helpers/utils.mjs';
/*
- Damage & other resources roll
@@ -38,7 +39,7 @@ export class DHRoll extends Roll {
if (config.dialog.configure !== false) {
// Open Roll Dialog
const DialogClass = config.dialog?.class ?? this.DefaultDialog;
- console.log(roll, config)
+ console.log(roll, config);
const configDialog = await DialogClass.configure(roll, config, message);
if (!configDialog) return;
}
@@ -96,7 +97,8 @@ export class DHRoll extends Roll {
}
static applyKeybindings(config) {
- config.dialog.configure ??= !(config.event.shiftKey || config.event.altKey || config.event.ctrlKey);
+ if (config.event)
+ config.dialog.configure ??= !(config.event.shiftKey || config.event.altKey || config.event.ctrlKey);
}
formatModifier(modifier) {
@@ -108,7 +110,7 @@ export class DHRoll extends Roll {
}
getFaces(faces) {
- return Number((faces.startsWith('d') ? faces.replace('d', '') : faces));
+ return Number(faces.startsWith('d') ? faces.replace('d', '') : faces);
}
constructFormula(config) {
@@ -131,7 +133,6 @@ export class DualityDie extends foundry.dice.terms.Die {
}
export class D20Roll extends DHRoll {
-
constructor(formula, data = {}, options = {}) {
super(formula, data, options);
this.constructFormula();
@@ -177,18 +178,27 @@ export class D20Roll extends DHRoll {
}
static applyKeybindings(config) {
- const keys = {
- normal: config.event.shiftKey || config.event.altKey || config.event.ctrlKey,
- advantage: config.event.altKey,
- disadvantage: config.event.ctrlKey
+ let keys = {
+ normal: true,
+ advantage: false,
+ disadvantage: false
};
+ if (config.event) {
+ keys = {
+ normal: config.event.shiftKey || config.event.altKey || config.event.ctrlKey,
+ advantage: config.event.altKey,
+ disadvantage: config.event.ctrlKey
+ };
+ }
+
// Should the roll configuration dialog be displayed?
config.dialog.configure ??= !Object.values(keys).some(k => k);
// Determine advantage mode
- const advantage = config.roll.advantage === this.ADV_MODE.ADVANTAGE || keys.advantage;
- const disadvantage = config.roll.advantage === this.ADV_MODE.DISADVANTAGE || keys.disadvantage;
+ const advantage = config.roll.advantage === this.ADV_MODE.ADVANTAGE || keys.advantage || config.advantage;
+ const disadvantage =
+ config.roll.advantage === this.ADV_MODE.DISADVANTAGE || keys.disadvantage || config.disadvantage;
if (advantage && !disadvantage) config.roll.advantage = this.ADV_MODE.ADVANTAGE;
else if (!advantage && disadvantage) config.roll.advantage = this.ADV_MODE.DISADVANTAGE;
else config.roll.advantage = this.ADV_MODE.NORMAL;
@@ -247,14 +257,24 @@ export class D20Roll extends DHRoll {
applyBaseBonus() {
this.options.roll.modifiers = [];
- if(!this.options.roll.bonus) return;
- this.options.roll.modifiers.push(
- {
- label: 'Bonus to Hit',
- value: this.options.roll.bonus
- // value: Roll.replaceFormulaData('@attackBonus', this.data)
- }
- );
+ if (!this.options.roll.bonus) return;
+ this.options.roll.modifiers.push({
+ label: 'Bonus to Hit',
+ value: this.options.roll.bonus
+ // value: Roll.replaceFormulaData('@attackBonus', this.data)
+ });
+ }
+
+ static async buildEvaluate(roll, config = {}, message = {}) {
+ if (config.evaluate !== false) await roll.evaluate();
+ const advantageState =
+ config.roll.advantage == this.ADV_MODE.ADVANTAGE
+ ? true
+ : config.roll.advantage == this.ADV_MODE.DISADVANTAGE
+ ? false
+ : null;
+ setDiceSoNiceForDualityRoll(roll, advantageState);
+ this.postEvaluate(roll, config);
}
static postEvaluate(roll, config = {}) {
@@ -264,21 +284,29 @@ export class D20Roll extends DHRoll {
const difficulty = config.roll.difficulty ?? target.difficulty ?? target.evasion;
target.hit = this.isCritical || roll.total >= difficulty;
});
- } else if (config.roll.difficulty) config.roll.success = roll.isCritical || roll.total >= config.roll.difficulty;
+ } else if (config.roll.difficulty)
+ config.roll.success = roll.isCritical || roll.total >= config.roll.difficulty;
config.roll.advantage = {
type: config.roll.advantage,
dice: roll.dAdvantage?.denomination,
value: roll.dAdvantage?.total
};
- config.roll.extra = roll.dice.filter(d => !roll.baseTerms.includes(d)).map(d => {
- return {
- dice: d.denomination,
- value: d.total
- }
- })
+ config.roll.extra = roll.dice
+ .filter(d => !roll.baseTerms.includes(d))
+ .map(d => {
+ return {
+ dice: d.denomination,
+ value: d.total
+ };
+ });
config.roll.modifierTotal = 0;
- for(let i = 0; i < roll.terms.length; i++) {
- if(roll.terms[i] instanceof foundry.dice.terms.NumericTerm && !!roll.terms[i-1] && roll.terms[i-1] instanceof foundry.dice.terms.OperatorTerm) config.roll.modifierTotal += Number(`${roll.terms[i-1].operator}${roll.terms[i].total}`);
+ for (let i = 0; i < roll.terms.length; i++) {
+ if (
+ roll.terms[i] instanceof foundry.dice.terms.NumericTerm &&
+ !!roll.terms[i - 1] &&
+ roll.terms[i - 1] instanceof foundry.dice.terms.OperatorTerm
+ )
+ config.roll.modifierTotal += Number(`${roll.terms[i - 1].operator}${roll.terms[i].total}`);
}
}
@@ -365,12 +393,13 @@ export class DualityRoll extends D20Roll {
return game.i18n.localize(label);
}
- updateFormula() {
-
- }
+ updateFormula() {}
createBaseDice() {
- if (this.dice[0] instanceof CONFIG.Dice.daggerheart.DualityDie && this.dice[1] instanceof CONFIG.Dice.daggerheart.DualityDie) {
+ if (
+ this.dice[0] instanceof CONFIG.Dice.daggerheart.DualityDie &&
+ this.dice[1] instanceof CONFIG.Dice.daggerheart.DualityDie
+ ) {
this.terms = [this.terms[0], this.terms[1], this.terms[2]];
return;
}
@@ -383,7 +412,8 @@ export class DualityRoll extends D20Roll {
const dieFaces = this.advantageFaces,
bardRallyFaces = this.hasBarRally,
advDie = new foundry.dice.terms.Die({ faces: dieFaces });
- if (this.hasAdvantage || this.hasDisadvantage || bardRallyFaces) this.terms.push(new foundry.dice.terms.OperatorTerm({ operator: this.hasDisadvantage ? '-' : '+' }));
+ if (this.hasAdvantage || this.hasDisadvantage || bardRallyFaces)
+ this.terms.push(new foundry.dice.terms.OperatorTerm({ operator: this.hasDisadvantage ? '-' : '+' }));
if (bardRallyFaces) {
const rallyDie = new foundry.dice.terms.Die({ faces: bardRallyFaces });
if (this.hasAdvantage) {
@@ -401,13 +431,11 @@ export class DualityRoll extends D20Roll {
applyBaseBonus() {
this.options.roll.modifiers = [];
- if(!this.options.roll.trait) return;
- this.options.roll.modifiers.push(
- {
- label: `DAGGERHEART.Abilities.${this.options.roll.trait}.name`,
- value: Roll.replaceFormulaData(`@traits.${this.options.roll.trait}.total`, this.data)
- }
- );
+ if (!this.options.roll.trait) return;
+ this.options.roll.modifiers.push({
+ label: `DAGGERHEART.Abilities.${this.options.roll.trait}.name`,
+ value: Roll.replaceFormulaData(`@traits.${this.options.roll.trait}.total`, this.data)
+ });
}
static postEvaluate(roll, config = {}) {
diff --git a/module/helpers/utils.mjs b/module/helpers/utils.mjs
index 990d0b35..62248af6 100644
--- a/module/helpers/utils.mjs
+++ b/module/helpers/utils.mjs
@@ -124,13 +124,13 @@ export const getCommandTarget = () => {
export const setDiceSoNiceForDualityRoll = (rollResult, advantageState) => {
const diceSoNicePresets = getDiceSoNicePresets();
- rollResult.dice[0].options.appearance = diceSoNicePresets.hope;
- rollResult.dice[1].options.appearance = diceSoNicePresets.fear;
+ rollResult.dice[0].options = { appearance: diceSoNicePresets.hope };
+ rollResult.dice[1].options = { appearance: diceSoNicePresets.fear }; //diceSoNicePresets.fear;
if (rollResult.dice[2]) {
if (advantageState === true) {
- rollResult.dice[2].options.appearance = diceSoNicePresets.advantage;
+ rollResult.dice[2].options = { appearance: diceSoNicePresets.advantage };
} else if (advantageState === false) {
- rollResult.dice[2].options.appearance = diceSoNicePresets.disadvantage;
+ rollResult.dice[2].options = { appearance: diceSoNicePresets.disadvantage };
}
}
};
diff --git a/templates/chat/duality-roll.hbs b/templates/chat/duality-roll.hbs
index 9a530649..ff1e9894 100644
--- a/templates/chat/duality-roll.hbs
+++ b/templates/chat/duality-roll.hbs
@@ -123,7 +123,7 @@
From 1b9bd45e9cd685f5b4b451dace341221b2468b84 Mon Sep 17 00:00:00 2001
From: joaquinpereyra98 <24190917+joaquinpereyra98@users.noreply.github.com>
Date: Thu, 3 Jul 2025 01:30:23 -0300
Subject: [PATCH 2/3] Feature/ 179 apply items filter in actors sheet (#249)
* FEAT: create FilterMenu class
FEAT: add FilterMenu to CharacterSheet
* FEAT: filter menu style
* FIX: file's names and import
* FEAT: add filters getters on FilterMenu class
* REFACTOR: prettier
* FIX: add again the Filter Menu implementation
---------
Co-authored-by: Joaquin Pereyra
---
module/applications/_module.mjs | 1 +
.../applications/sheets/actors/character.mjs | 157 ++++++++++--
module/applications/ux/_module.mjs | 1 +
module/applications/ux/filter-menu.mjs | 237 ++++++++++++++++++
styles/daggerheart.css | 42 ++++
styles/daggerheart.less | 2 +
styles/less/actors/character/loadout.less | 5 +
styles/less/global/filter-menu.less | 47 ++++
.../sheets/actors/character/inventory.hbs | 4 +-
templates/sheets/actors/character/loadout.hbs | 4 +-
10 files changed, 478 insertions(+), 22 deletions(-)
create mode 100644 module/applications/ux/_module.mjs
create mode 100644 module/applications/ux/filter-menu.mjs
create mode 100644 styles/less/global/filter-menu.less
diff --git a/module/applications/_module.mjs b/module/applications/_module.mjs
index 1a769052..1ee7f37a 100644
--- a/module/applications/_module.mjs
+++ b/module/applications/_module.mjs
@@ -18,3 +18,4 @@ export { default as DhContextMenu } from './contextMenu.mjs';
export { default as DhTooltipManager } from './tooltipManager.mjs';
export * as api from './sheets/api/_modules.mjs';
+export * as ux from "./ux/_module.mjs";
diff --git a/module/applications/sheets/actors/character.mjs b/module/applications/sheets/actors/character.mjs
index 8be31690..1a6fec84 100644
--- a/module/applications/sheets/actors/character.mjs
+++ b/module/applications/sheets/actors/character.mjs
@@ -6,6 +6,7 @@ import DaggerheartSheet from '.././daggerheart-sheet.mjs';
import { abilities } from '../../../config/actorConfig.mjs';
import DhCharacterlevelUp from '../../levelup/characterLevelup.mjs';
import DhCharacterCreation from '../../characterCreation.mjs';
+import FilterMenu from '../../ux/filter-menu.mjs';
const { ActorSheetV2 } = foundry.applications.sheets;
const { TextEditor } = foundry.applications.ux;
@@ -215,6 +216,7 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) {
await super._onFirstRender(context, options);
this._createContextMenues();
+ this._createFilterMenus();
}
/** @inheritDoc */
@@ -366,7 +368,7 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) {
}
/* -------------------------------------------- */
- /* Search Filter */
+ /* Filter Tracking */
/* -------------------------------------------- */
/**
@@ -376,12 +378,33 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) {
#search = {};
/**
- * Track which item IDs are currently displayed due to a search filter.
- * @type {{ inventory: Set, loadout: Set }}
+ * The currently active search filter.
+ * @type {FilterMenu}
+ */
+ #menu = {};
+
+ /**
+ * Tracks which item IDs are currently displayed, organized by filter type and section.
+ * @type {{
+ * inventory: {
+ * search: Set,
+ * menu: Set
+ * },
+ * loadout: {
+ * search: Set,
+ * menu: Set
+ * },
+ * }}
*/
#filteredItems = {
- inventory: new Set(),
- loadout: new Set()
+ inventory: {
+ search: new Set(),
+ menu: new Set()
+ },
+ loadout: {
+ search: new Set(),
+ menu: new Set()
+ }
};
/**
@@ -429,15 +452,14 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) {
* @protected
*/
_onSearchFilterInventory(event, query, rgx, html) {
- this.#filteredItems.inventory.clear();
+ this.#filteredItems.inventory.search.clear();
- for (const ul of html.querySelectorAll('.items-list')) {
- for (const li of ul.querySelectorAll('.inventory-item')) {
- const item = this.document.items.get(li.dataset.itemId);
- const match = !query || foundry.applications.ux.SearchFilter.testQuery(rgx, item.name);
- if (match) this.#filteredItems.inventory.add(item.id);
- li.hidden = !match;
- }
+ for (const li of html.querySelectorAll('.inventory-item')) {
+ const item = this.document.items.get(li.dataset.itemId);
+ const matchesSearch = !query || foundry.applications.ux.SearchFilter.testQuery(rgx, item.name);
+ if (matchesSearch) this.#filteredItems.inventory.search.add(item.id);
+ const { menu } = this.#filteredItems.inventory;
+ li.hidden = !(menu.has(item.id) && matchesSearch);
}
}
@@ -450,15 +472,14 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) {
* @protected
*/
_onSearchFilterCard(event, query, rgx, html) {
- this.#filteredItems.loadout.clear();
+ this.#filteredItems.loadout.search.clear();
- const elements = html.querySelectorAll('.items-list .inventory-item, .card-list .card-item');
-
- for (const li of elements) {
+ for (const li of html.querySelectorAll('.items-list .inventory-item, .card-list .card-item')) {
const item = this.document.items.get(li.dataset.itemId);
- const match = !query || foundry.applications.ux.SearchFilter.testQuery(rgx, item.name);
- if (match) this.#filteredItems.loadout.add(item.id);
- li.hidden = !match;
+ const matchesSearch = !query || foundry.applications.ux.SearchFilter.testQuery(rgx, item.name);
+ if (matchesSearch) this.#filteredItems.loadout.search.add(item.id);
+ const { menu } = this.#filteredItems.loadout;
+ li.hidden = !(menu.has(item.id) && matchesSearch);
}
}
@@ -474,6 +495,102 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) {
this.document.diceRoll(config);
}
+ /* -------------------------------------------- */
+ /* Filter Menus */
+ /* -------------------------------------------- */
+
+ _createFilterMenus() {
+ //Menus could be a application option if needed
+ const menus = [
+ {
+ key: 'inventory',
+ container: '[data-application-part="inventory"]',
+ content: '.items-section',
+ callback: this._onMenuFilterInventory.bind(this),
+ target: '.filter-button',
+ filters: FilterMenu.invetoryFilters
+ },
+ {
+ key: 'loadout',
+ container: '[data-application-part="loadout"]',
+ content: '.items-section',
+ callback: this._onMenuFilterLoadout.bind(this),
+ target: '.filter-button',
+ filters: FilterMenu.cardsFilters
+ }
+ ];
+
+ menus.forEach(m => {
+ const container = this.element.querySelector(m.container);
+ this.#menu[m.key] = new FilterMenu(container, m.target, m.filters, m.callback, {
+ contentSelector: m.content
+ });
+ });
+ }
+
+ /**
+ * Callback when filters change
+ * @param {PointerEvent} event
+ * @param {HTMLElement} html
+ * @param {import('../ux/filter-menu.mjs').FilterItem[]} filters
+ */
+ _onMenuFilterInventory(event, html, filters) {
+ this.#filteredItems.inventory.menu.clear();
+
+ for (const li of html.querySelectorAll('.inventory-item')) {
+ const item = this.document.items.get(li.dataset.itemId);
+
+ const matchesMenu =
+ filters.length === 0 || filters.some(f => foundry.applications.ux.SearchFilter.evaluateFilter(item, f));
+ if (matchesMenu) this.#filteredItems.inventory.menu.add(item.id);
+
+ const { search } = this.#filteredItems.inventory;
+ li.hidden = !(search.has(item.id) && matchesMenu);
+ }
+ }
+
+ /**
+ * Callback when filters change
+ * @param {PointerEvent} event
+ * @param {HTMLElement} html
+ * @param {import('../ux/filter-menu.mjs').FilterItem[]} filters
+ */
+ _onMenuFilterLoadout(event, html, filters) {
+ this.#filteredItems.loadout.menu.clear();
+
+ for (const li of html.querySelectorAll('.items-list .inventory-item, .card-list .card-item')) {
+ const item = this.document.items.get(li.dataset.itemId);
+
+ const matchesMenu =
+ filters.length === 0 || filters.some(f => foundry.applications.ux.SearchFilter.evaluateFilter(item, f));
+ if (matchesMenu) this.#filteredItems.loadout.menu.add(item.id);
+
+ const { search } = this.#filteredItems.loadout;
+ li.hidden = !(search.has(item.id) && matchesMenu);
+ }
+ }
+ /* -------------------------------------------- */
+
+ async mapFeatureType(data, configType) {
+ return await Promise.all(
+ data.map(async x => {
+ const abilities = x.system.abilities
+ ? await Promise.all(x.system.abilities.map(async x => await fromUuid(x.uuid)))
+ : [];
+
+ return {
+ ...x,
+ uuid: x.uuid,
+ system: {
+ ...x.system,
+ abilities: abilities,
+ type: game.i18n.localize(configType[x.system.type ?? x.type].label)
+ }
+ };
+ })
+ );
+ }
+
static async toggleMarks(_, button) {
const markValue = Number.parseInt(button.dataset.value);
const newValue = this.document.system.armor.system.marks.value >= markValue ? markValue - 1 : markValue;
diff --git a/module/applications/ux/_module.mjs b/module/applications/ux/_module.mjs
new file mode 100644
index 00000000..a16a4d6f
--- /dev/null
+++ b/module/applications/ux/_module.mjs
@@ -0,0 +1 @@
+export { default as FilterMenu } from './filter-menu.mjs';
diff --git a/module/applications/ux/filter-menu.mjs b/module/applications/ux/filter-menu.mjs
new file mode 100644
index 00000000..0973a358
--- /dev/null
+++ b/module/applications/ux/filter-menu.mjs
@@ -0,0 +1,237 @@
+/**
+ * @typedef {Object} FilterItem
+ * @property {string} group - The group name this filter belongs to (e.g., "Type").
+ * @property {string} name - The display name of the filter (e.g., "Weapons").
+ * @property {import("@client/applications/ux/search-filter.mjs").FieldFilter} filter - The filter condition.
+ */
+
+export default class FilterMenu extends foundry.applications.ux.ContextMenu {
+ /**
+ * Filter Menu
+ * @param {HTMLElement} container - Container element
+ * @param {string} selector - CSS selector for menu targets
+ * @param {Array} menuItems - Array of menu entries
+ * @param {Function} callback - Callback when filters change
+ * @param {Object} [options] - Additional options
+ */
+ constructor(container, selector, menuItems, callback, options = {}) {
+ // Set default options
+ const mergedOptions = {
+ eventName: 'click',
+ fixed: true,
+ ...options
+ };
+
+ super(container, selector, menuItems, mergedOptions);
+
+ // Initialize filter states
+ this.menuItems = menuItems.map(item => ({
+ ...item,
+ enabled: false
+ }));
+
+ this.callback = callback;
+ this.contentElement = container.querySelector(mergedOptions.contentSelector);
+
+ const syntheticEvent = {
+ type: 'pointerdown',
+ bubbles: true,
+ cancelable: true,
+ pointerType: 'mouse',
+ isPrimary: true,
+ button: 0
+ };
+
+ this.callback(syntheticEvent, this.contentElement, this.getActiveFilterData());
+ }
+
+ /** @inheritdoc */
+ async render(target, options = {}) {
+ await super.render(target, { ...options, animate: false });
+
+ // Create menu structure
+ const menu = document.createElement('menu');
+ menu.className = 'filter-menu';
+
+ // Group items by their group property
+ const groups = this.#groupItems(this.menuItems);
+
+ // Create sections for each group
+ for (const [groupName, items] of Object.entries(groups)) {
+ if (!items.length) continue;
+
+ const section = this.#createSection(groupName, items);
+ menu.appendChild(section);
+ }
+
+ // Update menu and set position
+ this.element.replaceChildren(menu);
+
+ menu.addEventListener('click', this.#handleClick.bind(this));
+
+ this._setPosition(this.element, target, options);
+
+ if (options.animate !== false) await this._animate(true);
+ return this._onRender(options);
+ }
+
+ /**
+ * Groups an array of items by their `group`.
+ * @param {Array