mirror of
https://github.com/Foundryborne/daggerheart.git
synced 2026-03-07 14:36:13 +01:00
FEAT: create FilterMenu class
FEAT: add FilterMenu to CharacterSheet
This commit is contained in:
parent
19cc10a3c9
commit
4e7c46bb3d
6 changed files with 307 additions and 25 deletions
|
|
@ -16,3 +16,4 @@ export { default as DhActiveEffectConfig } from './sheets/activeEffectConfig.mjs
|
||||||
export { default as DhContextMenu } from './contextMenu.mjs';
|
export { default as DhContextMenu } from './contextMenu.mjs';
|
||||||
|
|
||||||
export * as api from './sheets/api/_modules.mjs';
|
export * as api from './sheets/api/_modules.mjs';
|
||||||
|
export * as ux from "./ux/_module.mjs";
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import DaggerheartSheet from './daggerheart-sheet.mjs';
|
||||||
import { abilities } from '../../config/actorConfig.mjs';
|
import { abilities } from '../../config/actorConfig.mjs';
|
||||||
import DhlevelUp from '../levelup.mjs';
|
import DhlevelUp from '../levelup.mjs';
|
||||||
import DhCharacterCreation from '../characterCreation.mjs';
|
import DhCharacterCreation from '../characterCreation.mjs';
|
||||||
|
import FilterMenu from '../ux/property-filter.mjs';
|
||||||
|
|
||||||
const { ActorSheetV2 } = foundry.applications.sheets;
|
const { ActorSheetV2 } = foundry.applications.sheets;
|
||||||
const { TextEditor } = foundry.applications.ux;
|
const { TextEditor } = foundry.applications.ux;
|
||||||
|
|
@ -215,6 +216,8 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) {
|
||||||
await super._onFirstRender(context, options);
|
await super._onFirstRender(context, options);
|
||||||
|
|
||||||
this._createContextMenues();
|
this._createContextMenues();
|
||||||
|
this._createFilterMenus();
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @inheritDoc */
|
/** @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 = {};
|
#search = {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Track which item IDs are currently displayed due to a search filter.
|
* The currently active search filter.
|
||||||
* @type {{ inventory: Set<string>, loadout: Set<string> }}
|
* @type {FilterMenu}
|
||||||
|
*/
|
||||||
|
#menu = {};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks which item IDs are currently displayed, organized by filter type and section.
|
||||||
|
* @type {{
|
||||||
|
* inventory: {
|
||||||
|
* search: Set<string>,
|
||||||
|
* menu: Set<string>
|
||||||
|
* },
|
||||||
|
* loadout: {
|
||||||
|
* search: Set<string>,
|
||||||
|
* menu: Set<string>
|
||||||
|
* },
|
||||||
|
* }}
|
||||||
*/
|
*/
|
||||||
#filteredItems = {
|
#filteredItems = {
|
||||||
inventory: new Set(),
|
inventory: {
|
||||||
loadout: new Set()
|
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
|
* @protected
|
||||||
*/
|
*/
|
||||||
_onSearchFilterInventory(event, query, rgx, html) {
|
_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 html.querySelectorAll('.inventory-item')) {
|
||||||
for (const li of ul.querySelectorAll('.inventory-item')) {
|
const item = this.document.items.get(li.dataset.itemId);
|
||||||
const item = this.document.items.get(li.dataset.itemId);
|
const matchesSearch = !query || foundry.applications.ux.SearchFilter.testQuery(rgx, item.name);
|
||||||
const match = !query || foundry.applications.ux.SearchFilter.testQuery(rgx, item.name);
|
if (matchesSearch) this.#filteredItems.inventory.search.add(item.id);
|
||||||
if (match) this.#filteredItems.inventory.add(item.id);
|
const { menu } = this.#filteredItems.inventory;
|
||||||
li.hidden = !match;
|
li.hidden = !(menu.has(item.id) && matchesSearch);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -524,18 +547,111 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) {
|
||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
_onSearchFilterCard(event, query, rgx, html) {
|
_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 html.querySelectorAll('.items-list .inventory-item, .card-list .card-item')) {
|
||||||
|
|
||||||
for (const li of elements) {
|
|
||||||
const item = this.document.items.get(li.dataset.itemId);
|
const item = this.document.items.get(li.dataset.itemId);
|
||||||
const match = !query || foundry.applications.ux.SearchFilter.testQuery(rgx, item.name);
|
const matchesSearch = !query || foundry.applications.ux.SearchFilter.testQuery(rgx, item.name);
|
||||||
if (match) this.#filteredItems.loadout.add(item.id);
|
if (matchesSearch) this.#filteredItems.loadout.search.add(item.id);
|
||||||
li.hidden = !match;
|
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) {
|
async mapFeatureType(data, configType) {
|
||||||
|
|
@ -815,9 +931,8 @@ export default class CharacterSheet extends DaggerheartSheet(ActorSheetV2) {
|
||||||
const cls = getDocumentClass('ChatMessage');
|
const cls = getDocumentClass('ChatMessage');
|
||||||
const systemData = {
|
const systemData = {
|
||||||
name: game.i18n.localize('DAGGERHEART.General.Experience.Single'),
|
name: game.i18n.localize('DAGGERHEART.General.Experience.Single'),
|
||||||
description: `${experience.description} ${
|
description: `${experience.description} ${experience.total < 0 ? experience.total : `+${experience.total}`
|
||||||
experience.total < 0 ? experience.total : `+${experience.total}`
|
}`
|
||||||
}`
|
|
||||||
};
|
};
|
||||||
const msg = new cls({
|
const msg = new cls({
|
||||||
type: 'abilityUse',
|
type: 'abilityUse',
|
||||||
|
|
|
||||||
1
module/applications/ux/_module.mjs
Normal file
1
module/applications/ux/_module.mjs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default as PropertyFilter} from "./property-filter.mjs";
|
||||||
161
module/applications/ux/property-filter.mjs
Normal file
161
module/applications/ux/property-filter.mjs
Normal file
|
|
@ -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<Object>} items - The array of items to group. Each item is expected to have an optional `group` property.
|
||||||
|
* @returns {Object<string, Array<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<Object>} 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);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -10,7 +10,9 @@
|
||||||
</div>
|
</div>
|
||||||
<input type="search" name="search" class="search-inventory" placeholder="Search...">
|
<input type="search" name="search" class="search-inventory" placeholder="Search...">
|
||||||
</div>
|
</div>
|
||||||
<a><i class="fa-solid fa-filter"></i></a>
|
<a class="filter-button">
|
||||||
|
<i class="fa-solid fa-filter"></i>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="items-section">
|
<div class="items-section">
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,9 @@
|
||||||
</div>
|
</div>
|
||||||
<input type="search" name="search" class="search-loadout" placeholder="Search...">
|
<input type="search" name="search" class="search-loadout" placeholder="Search...">
|
||||||
</div>
|
</div>
|
||||||
<a><i class="fa-solid fa-filter"></i></a>
|
<a class="filter-button">
|
||||||
|
<i class="fa-solid fa-filter"></i>
|
||||||
|
</a>
|
||||||
<button class="btn-toggle-view" data-action="toggleLoadoutView" data-value="{{this.abilities.loadout.listView}}">
|
<button class="btn-toggle-view" data-action="toggleLoadoutView" data-value="{{this.abilities.loadout.listView}}">
|
||||||
<span class="{{#if this.abilities.loadout.listView}}list-active{{/if}} list-icon">
|
<span class="{{#if this.abilities.loadout.listView}}list-active{{/if}} list-icon">
|
||||||
<i class="fa-solid fa-bars"></i>
|
<i class="fa-solid fa-bars"></i>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue