From 4e7c46bb3d502a680c52c3d56cb336dc4a209ad0 Mon Sep 17 00:00:00 2001 From: Joaquin Pereyra Date: Wed, 2 Jul 2025 16:36:53 -0300 Subject: [PATCH] FEAT: create FilterMenu class FEAT: add FilterMenu to CharacterSheet --- module/applications/_module.mjs | 1 + module/applications/sheets/character.mjs | 161 +++++++++++++++--- module/applications/ux/_module.mjs | 1 + module/applications/ux/property-filter.mjs | 161 ++++++++++++++++++ .../sheets/actors/character/inventory.hbs | 4 +- templates/sheets/actors/character/loadout.hbs | 4 +- 6 files changed, 307 insertions(+), 25 deletions(-) create mode 100644 module/applications/ux/_module.mjs create mode 100644 module/applications/ux/property-filter.mjs diff --git a/module/applications/_module.mjs b/module/applications/_module.mjs index c9f5ddc6..4e6f0667 100644 --- a/module/applications/_module.mjs +++ b/module/applications/_module.mjs @@ -16,3 +16,4 @@ export { default as DhActiveEffectConfig } from './sheets/activeEffectConfig.mjs export { default as DhContextMenu } from './contextMenu.mjs'; export * as api from './sheets/api/_modules.mjs'; +export * as ux from "./ux/_module.mjs"; diff --git a/module/applications/sheets/character.mjs b/module/applications/sheets/character.mjs index ad294aff..b2b04320 100644 --- a/module/applications/sheets/character.mjs +++ b/module/applications/sheets/character.mjs @@ -6,6 +6,7 @@ import DaggerheartSheet from './daggerheart-sheet.mjs'; import { abilities } from '../../config/actorConfig.mjs'; import DhlevelUp from '../levelup.mjs'; import DhCharacterCreation from '../characterCreation.mjs'; +import FilterMenu from '../ux/property-filter.mjs'; const { ActorSheetV2 } = foundry.applications.sheets; const { TextEditor } = foundry.applications.ux; @@ -215,6 +216,8 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) { await super._onFirstRender(context, options); this._createContextMenues(); + this._createFilterMenus(); + } /** @inheritDoc */ @@ -440,7 +443,7 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) { } /* -------------------------------------------- */ - /* Search Filter */ + /* Filter Tracking */ /* -------------------------------------------- */ /** @@ -450,12 +453,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(), + } }; /** @@ -503,15 +527,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); } } @@ -524,18 +547,111 @@ 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); } } + /* -------------------------------------------- */ + /* 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: [{ + group: "Type", + name: "Weapons", + filter: { + field: "type", + operator: foundry.applications.ux.SearchFilter.OPERATORS.EQUALS, + value: "weapon", + } + }, { + group: "Type", + name: "Armor", + filter: { + field: "type", + operator: foundry.applications.ux.SearchFilter.OPERATORS.EQUALS, + value: "armor", + } + }], + }, + { + key: 'loadout', + container: '[data-application-part="loadout"]', + content: '.items-section', + callback: this._onMenuFilterLoadout.bind(this), + target: '.filter-button', + filters: [], + } + ]; + + 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/property-filter.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/property-filter.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) { @@ -815,9 +931,8 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) { const cls = getDocumentClass('ChatMessage'); const systemData = { name: game.i18n.localize('DAGGERHEART.General.Experience.Single'), - description: `${experience.description} ${ - experience.total < 0 ? experience.total : `+${experience.total}` - }` + description: `${experience.description} ${experience.total < 0 ? experience.total : `+${experience.total}` + }` }; const msg = new cls({ type: 'abilityUse', diff --git a/module/applications/ux/_module.mjs b/module/applications/ux/_module.mjs new file mode 100644 index 00000000..82dd819a --- /dev/null +++ b/module/applications/ux/_module.mjs @@ -0,0 +1 @@ +export { default as PropertyFilter} from "./property-filter.mjs"; \ No newline at end of file diff --git a/module/applications/ux/property-filter.mjs b/module/applications/ux/property-filter.mjs new file mode 100644 index 00000000..b5ce5bfe --- /dev/null +++ b/module/applications/ux/property-filter.mjs @@ -0,0 +1,161 @@ +/** + * @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("div"); + 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} items - The array of items to group. Each item is expected to have an optional `group` property. + * @returns {Object>} An object where keys are group names and values are arrays of items belonging to each group. + */ + #groupItems(items) { + return items.reduce((groups, item) => { + const group = item.group ?? "_none"; + groups[group] = groups[group] || []; + groups[group].push(item); + return groups; + }, {}); + } + + /** + * Creates a DOM section element for a group of items with corresponding filter buttons. + * @param {string} groupName - The name of the group, used as the section label. + * @param {Array} items - The items to create buttons for. Each item should have: + * @returns {HTMLDivElement} The section DOM element containing the label and buttons. + */ + #createSection(groupName, items) { + const section = document.createElement("div"); + section.className = "filter-section"; + + const header = document.createElement("label"); + header.textContent = groupName; + section.appendChild(header); + + const buttons = document.createElement("div"); + buttons.className = "filter-buttons"; + + items.forEach(item => { + const button = document.createElement("button"); + button.className = `filter-button ${item.enabled ? "active" : ""}`; + button.textContent = item.name; + item.element = button; + buttons.appendChild(button); + }); + + section.appendChild(buttons); + return section; + } + + + /** + * Get filter data from active filters + * @returns {Array} Array of filter configurations + */ + getActiveFilterData() { + return this.menuItems.filter(item => item.enabled).map(item => item.filter); + } + + /** + * Handles click events on filter buttons. + * Toggles the active state of the clicked button and updates the corresponding item's `enabled` state. + * Then triggers the provided callback with the event, the content element, and the current active filter data. + * @param {PointerEvent} event - The click event triggered by interacting with a filter button. + * @returns {void} + */ + #handleClick(event) { + event.preventDefault(); + event.stopPropagation(); + + const button = event.target.closest(".filter-button"); + if (!button) return; + + const clickedItem = this.menuItems.find(item => item.element === button); + if (!clickedItem) return; + + const isActive = button.classList.toggle("active"); + clickedItem.enabled = isActive; + + const filters = this.getActiveFilterData(); + + if (filters.length > 0) { + this.target.classList.add("fa-beat", "active"); + } else { + this.target.classList.remove("fa-beat", "active"); + } + + this.callback(event, this.contentElement, filters); + + } +} \ No newline at end of file diff --git a/templates/sheets/actors/character/inventory.hbs b/templates/sheets/actors/character/inventory.hbs index 3f4b98be..be8bb251 100644 --- a/templates/sheets/actors/character/inventory.hbs +++ b/templates/sheets/actors/character/inventory.hbs @@ -10,7 +10,9 @@ - + + +
diff --git a/templates/sheets/actors/character/loadout.hbs b/templates/sheets/actors/character/loadout.hbs index de63323c..c9c9d4b2 100644 --- a/templates/sheets/actors/character/loadout.hbs +++ b/templates/sheets/actors/character/loadout.hbs @@ -10,7 +10,9 @@
- + + +