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] 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} 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('fieldset'); + section.className = 'filter-section'; + + const header = document.createElement('legend'); + 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); + } + + /** + * Generate and return a sorted array of inventory filters. + * @returns {Array} An array of filter objects, sorted by name within each group. + */ + static get invetoryFilters() { + const { OPERATORS } = foundry.applications.ux.SearchFilter; + + const typesFilters = Object.entries(CONFIG.Item.dataModels) + .filter(([, { metadata }]) => metadata.isInventoryItem) + .map(([type, { metadata }]) => ({ + group: game.i18n.localize('Type'), + name: game.i18n.localize(metadata.label), + filter: { + field: 'type', + operator: OPERATORS.EQUALS, + value: type + } + })); + + const burdenFilter = Object.values(CONFIG.daggerheart.GENERAL.burden).map(({ value, label }) => ({ + group: game.i18n.localize('DAGGERHEART.Sheets.Weapon.Burden'), + name: game.i18n.localize(label), + filter: { + field: 'system.burden', + operator: OPERATORS.EQUALS, + value: value + } + })); + + const damageTypeFilter = Object.values(CONFIG.daggerheart.GENERAL.damageTypes).map(({ id, abbreviation }) => ({ + group: 'Damage Type', //TODO localize + name: game.i18n.localize(abbreviation), + filter: { + field: 'system.damage.type', + operator: OPERATORS.EQUALS, + value: id + } + })); + + return [ + ...game.i18n.sortObjects(typesFilters, 'name'), + ...game.i18n.sortObjects(burdenFilter, 'name'), + ...game.i18n.sortObjects(damageTypeFilter, 'name') + ]; + } + + /** + * Generate and return a sorted array of inventory filters. + * @returns {Array} An array of filter objects, sorted by name within each group. + */ + static get cardsFilters() { + const { OPERATORS } = foundry.applications.ux.SearchFilter; + + const typesFilters = Object.values(CONFIG.daggerheart.DOMAIN.cardTypes).map(({ id, label }) => ({ + group: game.i18n.localize('Type'), + name: game.i18n.localize(label), + filter: { + field: 'system.type', + operator: OPERATORS.EQUALS, + value: id + } + })); + + const domainFilter = Object.values(CONFIG.daggerheart.DOMAIN.domains).map(({ id, label }) => ({ + group: game.i18n.localize('DAGGERHEART.Sheets.DomainCard.Domain'), + name: game.i18n.localize(label), + filter: { + field: 'system.domain', + operator: OPERATORS.EQUALS, + value: id + } + })); + + const sort = arr => game.i18n.sortObjects(arr, 'name'); + + return [...sort(typesFilters), ...sort(domainFilter)]; + } +} diff --git a/styles/daggerheart.css b/styles/daggerheart.css index 4d8546b1..763b7e7d 100755 --- a/styles/daggerheart.css +++ b/styles/daggerheart.css @@ -4206,6 +4206,10 @@ div.daggerheart.views.multiclass { .application.sheet.daggerheart.actor.dh-style.character .tab.loadout .search-section .search-bar input:placeholder { color: light-dark(#18162e50, #efe6d850); } +.application.sheet.daggerheart.actor.dh-style.character .tab.loadout .search-section .search-bar input::-webkit-search-cancel-button { + -webkit-appearance: none; + display: none; +} .application.sheet.daggerheart.actor.dh-style.character .tab.loadout .search-section .search-bar .icon { align-content: center; height: 32px; @@ -6054,6 +6058,44 @@ div.daggerheart.views.multiclass { .application prose-mirror .editor-content h3 { font-size: 24px; } +.filter-menu { + width: auto; +} +.filter-menu fieldset.filter-section { + align-items: center; + margin: 5px; + border-radius: 6px; + border-color: light-dark(#18162e, #f3c267); + padding: 5px; +} +.filter-menu fieldset.filter-section legend { + font-family: 'Montserrat', sans-serif; + font-weight: bold; + color: light-dark(#18162e, #f3c267); + font-size: var(--font-size-12); +} +.filter-menu fieldset.filter-section .filter-buttons { + display: flex; + flex-wrap: wrap; + justify-content: space-evenly; + gap: 5px; +} +.filter-menu fieldset.filter-section .filter-buttons button { + background: light-dark(rgba(0, 0, 0, 0.3), #18162e); + color: light-dark(#18162e, #f3c267); + outline: none; + box-shadow: none; + border: 1px solid light-dark(#18162e, #18162e); + padding: 0 0.2rem; + font-size: var(--font-size-12); +} +.filter-menu fieldset.filter-section .filter-buttons button:hover { + background: light-dark(transparent, #f3c267); + color: light-dark(#18162e, #18162e); +} +.filter-menu fieldset.filter-section .filter-buttons button.active { + animation: glow 0.75s infinite alternate; +} .daggerheart { /* Flex */ /****/ diff --git a/styles/daggerheart.less b/styles/daggerheart.less index 362856ce..8903dcf0 100755 --- a/styles/daggerheart.less +++ b/styles/daggerheart.less @@ -64,6 +64,8 @@ @import './less/global/inventory-item.less'; @import './less/global/inventory-fieldset-items.less'; @import './less/global/prose-mirror.less'; +@import "./less/global/filter-menu.less"; + @import '../node_modules/@yaireo/tagify/dist/tagify.css'; .daggerheart { diff --git a/styles/less/actors/character/loadout.less b/styles/less/actors/character/loadout.less index bf1474a2..72393597 100644 --- a/styles/less/actors/character/loadout.less +++ b/styles/less/actors/character/loadout.less @@ -30,6 +30,11 @@ &:placeholder { color: light-dark(@dark-blue-50, @beige-50); } + + &::-webkit-search-cancel-button { + -webkit-appearance: none; + display: none; + } } .icon { diff --git a/styles/less/global/filter-menu.less b/styles/less/global/filter-menu.less new file mode 100644 index 00000000..09962afb --- /dev/null +++ b/styles/less/global/filter-menu.less @@ -0,0 +1,47 @@ +@import '../utils/colors.less'; +@import '../utils/fonts.less'; + +.filter-menu { + width: auto; + + fieldset.filter-section { + align-items: center; + margin: 5px; + border-radius: 6px; + border-color: light-dark(@dark-blue, @golden); + padding: 5px; + + legend { + font-family: @font-body; + font-weight: bold; + color: light-dark(@dark-blue, @golden); + font-size: var(--font-size-12); + } + + .filter-buttons { + display: flex; + flex-wrap: wrap; + justify-content: space-evenly; + gap: 5px; + + button { + background: light-dark(@light-black, @dark-blue); + color: light-dark(@dark-blue, @golden); + outline: none; + box-shadow: none; + border: 1px solid light-dark(@dark-blue, @dark-blue); + padding: 0 0.2rem; + font-size: var(--font-size-12); + + &:hover { + background: light-dark(transparent, @golden); + color: light-dark(@dark-blue, @dark-blue); + } + + &.active { + animation: glow 0.75s infinite alternate; + } + } + } + } +} 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 @@
- + + +