Merge branch 'refactor_compendium_pr' into 'dev'
Updating the compendium filter to make it more snappy See merge request teaml5r/l5r5e!61
This commit is contained in:
@@ -40,8 +40,8 @@
|
||||
},
|
||||
"Compendium": {
|
||||
"HideDisabledSources": {
|
||||
"Title": "[Compendium] Hide sources filter without reference",
|
||||
"Hint": "Hide empty source with no elements in source filter."
|
||||
"Title": "[Compendium] Hide unavailable sources",
|
||||
"Hint": "Hide sources that have no available content from the source filter dropdown."
|
||||
},
|
||||
"HideEmptySourcesFromPlayers": {
|
||||
"Title": "[Compendium] Hide elements with empty reference",
|
||||
@@ -136,6 +136,7 @@
|
||||
"player_filter_label": "Player filter",
|
||||
"player_filter_tooltip": "Apply player filter",
|
||||
"already_in_filter": "Already in filter",
|
||||
"no_results": "Not Found",
|
||||
"sources_categories": {
|
||||
"rules": "Rules",
|
||||
"adventures": "Adventures",
|
||||
@@ -810,7 +811,8 @@
|
||||
"filter": {
|
||||
"rank": "Rank",
|
||||
"rarity": "Rarity",
|
||||
"ring": "Ring"
|
||||
"ring": "Ring",
|
||||
"clear": "Clear Filter"
|
||||
}
|
||||
},
|
||||
"source_reference": {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { CompendiumDirectory } = foundry.applications.sidebar.tabs;
|
||||
|
||||
export class CompendiumDirectoryL5r5e extends CompendiumDirectory {
|
||||
|
||||
|
||||
497
system/scripts/compendium/l5r5e-item-compendium.js
Normal file
497
system/scripts/compendium/l5r5e-item-compendium.js
Normal file
@@ -0,0 +1,497 @@
|
||||
import { L5r5eHtmlMultiSelectElement } from "../misc/l5r5e-multiselect.js";
|
||||
|
||||
const { Compendium } = foundry.applications.sidebar.apps;
|
||||
|
||||
/**
|
||||
* Extended Compendium application for L5R5e.
|
||||
* Adds source/rank/ring/rarity filters to Item compendiums
|
||||
* @extends {Compendium}
|
||||
*/
|
||||
export class ItemCompendiumL5r5e extends Compendium {
|
||||
/** @override */
|
||||
static DEFAULT_OPTIONS = {
|
||||
actions: {
|
||||
applyPlayerView: ItemCompendiumL5r5e.#onApplyPlayerView,
|
||||
},
|
||||
window: {
|
||||
resizable: true
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Our own entry partial which mirrors Foundry's index-partial.hbs structure
|
||||
* and appends ring/rarity/rank badges using data from _prepareDirectoryContext.
|
||||
*
|
||||
* NOTE: We intentionally duplicate Foundry's <li> structure here rather than
|
||||
* trying to include their partial, because their partial renders a complete <li>
|
||||
* element which cannot be nested or extended from outside. If Foundry ever
|
||||
* changes their index-partial.hbs, this file will need updating to match.
|
||||
* @override
|
||||
*/
|
||||
static _entryPartial = "systems/l5r5e/templates/" + "compendium/l5r5e-index-partial.html";
|
||||
|
||||
/**
|
||||
* Sources present in this specific compendium, populated during _prepareContext.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
#sourcesInThisCompendium = new Set();
|
||||
|
||||
/**
|
||||
* Sources unavailable to players based on permission settings.
|
||||
* @type {Set<string>}
|
||||
*/
|
||||
#unavailableSourceForPlayersSet = new Set();
|
||||
|
||||
/**
|
||||
* Whether to hide entries with empty sources from players.
|
||||
* @type {boolean}
|
||||
*/
|
||||
#hideEmptySourcesFromPlayers = false;
|
||||
|
||||
/**
|
||||
* Which filter UI controls are worth showing.
|
||||
* Determined during _prepareContext by checking whether at least two
|
||||
* distinct values exist for each filterable property.
|
||||
* @type {{ rank: boolean, rarity: boolean, source: boolean, ring: boolean }|null}
|
||||
*/
|
||||
#filtersToShow = null;
|
||||
|
||||
/**
|
||||
* Cached active filter values, read from the DOM once at the start of
|
||||
* each filter pass in #reapplyFilters and held for _onMatchSearchEntry
|
||||
* to consume per-entry without re-querying the DOM.
|
||||
* @type {{ userFilter: string[], rankFilter: string[], ringFilter: string[], rarityFilter: string[] }}
|
||||
*/
|
||||
#activeFilters = {
|
||||
userFilter: [],
|
||||
rankFilter: [],
|
||||
ringFilter: [],
|
||||
rarityFilter: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* Insert the filter part between header and directory by composing with
|
||||
* the parent parts rather than replacing them, so future Foundry changes
|
||||
* to Compendium.PARTS are picked up automatically.
|
||||
* @override
|
||||
*/
|
||||
_configureRenderParts(options) {
|
||||
const parts = super._configureRenderParts(options);
|
||||
|
||||
const ordered = {};
|
||||
for (const [key, value] of Object.entries(parts)) {
|
||||
ordered[key] = value;
|
||||
if (key === "header") {
|
||||
ordered.filter = {
|
||||
template: `${CONFIG.l5r5e.paths.templates}compendium/filter-bar.html`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async _prepareContext(options) {
|
||||
const context = await super._prepareContext(options);
|
||||
this.#sourcesInThisCompendium = new Set();
|
||||
this.#resolvePermissions();
|
||||
this.#filtersToShow = this.#computeFilterVisibility();
|
||||
return context;
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async _preparePartContext(partId, context, options) {
|
||||
context = await super._preparePartContext(partId, context, options);
|
||||
|
||||
if (partId === "filter") {
|
||||
const ns = CONFIG.l5r5e.namespace;
|
||||
const allCompendiumReferencesSet = game.settings.get(ns, "all-compendium-references");
|
||||
const hideDisabledOptions = game.settings.get(ns, "compendium-hide-disabled-sources");
|
||||
|
||||
context.filtersToShow = this.#filtersToShow;
|
||||
context.ranks = [1, 2, 3, 4, 5];
|
||||
context.rarities = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
context.rings = ["fire", "water", "earth", "air", "void"];
|
||||
context.hideDisabledOptions = hideDisabledOptions;
|
||||
context.showPlayerView = game.user.isGM && this.#unavailableSourceForPlayersSet.size > 0;
|
||||
|
||||
// Source multiselect options — plain data for {{selectOptions}} in the template.
|
||||
context.sources = [...allCompendiumReferencesSet].map((reference) => ({
|
||||
value: reference,
|
||||
label: CONFIG.l5r5e.sourceReference[reference]?.label ?? reference,
|
||||
translate: true,
|
||||
group:
|
||||
CONFIG.l5r5e.sourceReference[reference]?.type.split(",")[0] ??
|
||||
"l5r5e.multiselect.sources_categories.others",
|
||||
disabled:
|
||||
!this.#sourcesInThisCompendium.has(reference) ||
|
||||
(!game.user.isGM && this.#unavailableSourceForPlayersSet.has(reference)),
|
||||
}));
|
||||
}
|
||||
|
||||
if (partId === "directory") {
|
||||
context.entryFilterData = Object.fromEntries(
|
||||
[...this.collection.index.values()].map((entry) => [
|
||||
entry._id,
|
||||
{
|
||||
rank: entry.system?.rank,
|
||||
ring: entry.system?.ring,
|
||||
rarity: entry.system?.rarity,
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
async _onRender(context, options) {
|
||||
await super._onRender(context, options);
|
||||
|
||||
if (options.parts.includes("filter")) {
|
||||
this.#bindButtonFilter(".rank-filter");
|
||||
this.#bindButtonFilter(".rarity-filter");
|
||||
this.#bindButtonFilter(".ring-filter");
|
||||
this.#bindSourceFilter();
|
||||
}
|
||||
|
||||
// Reapply filters whenever the filter controls or the entry list changes.
|
||||
if (options.parts.some((p) => p === "filter" || p === "directory")) {
|
||||
this.#reapplyFilters();
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_preSyncPartState(partId, newElement, priorElement, state) {
|
||||
super._preSyncPartState(partId, newElement, priorElement, state);
|
||||
|
||||
if (partId === "filter") {
|
||||
state.selectedRanks = [...priorElement.querySelectorAll(".rank-filter .selected")].map((element) => element.dataset.rank);
|
||||
state.selectedRarities = [...priorElement.querySelectorAll(".rarity-filter .selected")].map((element) => element.dataset.rarity);
|
||||
state.selectedRings = [...priorElement.querySelectorAll(".ring-filter .selected")].map((element) => element.dataset.ring);
|
||||
state.sourceValue = priorElement.querySelector("l5r5e-multi-select")?.value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore filter selections after the filter part has been re-rendered.
|
||||
* The [data-clear] button visibility is derived from whether any values
|
||||
* were restored — no extra state needed.
|
||||
* @override
|
||||
*/
|
||||
_syncPartState(partId, newElement, priorElement, state) {
|
||||
super._syncPartState(partId, newElement, priorElement, state);
|
||||
|
||||
if (partId === "filter") {
|
||||
for (const rank of state.selectedRanks ?? []) {
|
||||
newElement.querySelector(`.rank-filter [data-rank="${rank}"]`)?.classList.add("selected");
|
||||
}
|
||||
const rankClear = newElement.querySelector(".rank-filter [data-clear]");
|
||||
if (rankClear) {
|
||||
rankClear.style.display = state.selectedRanks?.length ? "" : "none";
|
||||
}
|
||||
|
||||
for (const rarity of state.selectedRarities ?? []) {
|
||||
newElement.querySelector(`.rarity-filter [data-rarity="${rarity}"]`)?.classList.add("selected");
|
||||
}
|
||||
const rarityClear = newElement.querySelector(".rarity-filter [data-clear]");
|
||||
if (rarityClear) {
|
||||
rarityClear.style.display = state.selectedRarities?.length ? "" : "none";
|
||||
}
|
||||
|
||||
for (const ring of state.selectedRings ?? []) {
|
||||
newElement.querySelector(`.ring-filter [data-ring="${ring}"]`)?.classList.add("selected");
|
||||
}
|
||||
const ringClear = newElement.querySelector(".ring-filter [data-clear]");
|
||||
if (ringClear) {
|
||||
ringClear.style.display = state.selectedRings?.length ? "" : "none";
|
||||
}
|
||||
|
||||
if (state.sourceValue) {
|
||||
const multiSelect = newElement.querySelector("l5r5e-multi-select");
|
||||
if (multiSelect) {
|
||||
multiSelect.value = state.sourceValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
*/
|
||||
_onMatchSearchEntry(query, entryIds, entry, options) {
|
||||
super._onMatchSearchEntry(query, entryIds, entry, options);
|
||||
if (entry.style.display === "none") {
|
||||
return;
|
||||
}
|
||||
this.#applyEntryFilter(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot active filter state then re-run the search filter (or walk entries directly as fallback).
|
||||
* @private
|
||||
*/
|
||||
#reapplyFilters() {
|
||||
this.#refreshActiveFilters();
|
||||
|
||||
const searchFilter = this._searchFilters?.[0];
|
||||
if (searchFilter) {
|
||||
searchFilter.filter(null, searchFilter.query);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback
|
||||
for (const entry of this.element.querySelectorAll(".directory-item")) {
|
||||
this.#applyEntryFilter(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read current filter selections from the DOM and cache them in #activeFilters.
|
||||
* @private
|
||||
*/
|
||||
#refreshActiveFilters() {
|
||||
const filterElement = this.element.querySelector("[data-application-part=\"filter\"]");
|
||||
const multiSelect = filterElement?.querySelector("l5r5e-multi-select");
|
||||
|
||||
const collectSelected = (containerSelector, dataKey) =>
|
||||
[...(filterElement?.querySelectorAll(`${containerSelector} .selected`) ?? [])]
|
||||
.map((element) => element.dataset[dataKey])
|
||||
.filter(Boolean);
|
||||
|
||||
this.#activeFilters = {
|
||||
userFilter: multiSelect?.value ?? [],
|
||||
rankFilter: collectSelected(".rank-filter", "rank"),
|
||||
ringFilter: collectSelected(".ring-filter", "ring"),
|
||||
rarityFilter: collectSelected(".rarity-filter", "rarity"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply all active filters to a single directory entry, showing or hiding it accordingly.
|
||||
* @param {HTMLElement} entry
|
||||
* @private
|
||||
*/
|
||||
#applyEntryFilter(entry) {
|
||||
const indexEntry = this.collection.index.get(entry.dataset.entryId);
|
||||
if (!indexEntry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const system = indexEntry.system;
|
||||
const lineSource = system?.source_reference?.source ?? null;
|
||||
const { userFilter, rankFilter, ringFilter, rarityFilter } = this.#activeFilters;
|
||||
let shouldShow = true;
|
||||
|
||||
const sourceUnavailable =
|
||||
(lineSource && this.#unavailableSourceForPlayersSet.has(lineSource)) ||
|
||||
(lineSource === "" && this.#hideEmptySourcesFromPlayers);
|
||||
|
||||
if (sourceUnavailable) {
|
||||
if (game.user.isGM) {
|
||||
entry.classList.add("not-for-players");
|
||||
entry.dataset.tooltip = game.i18n.localize("l5r5e.compendium.not_for_players");
|
||||
} else {
|
||||
shouldShow = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (rankFilter.length) {
|
||||
shouldShow &&= rankFilter.includes(String(system?.rank));
|
||||
}
|
||||
if (rarityFilter.length) {
|
||||
shouldShow &&= rarityFilter.includes(String(system?.rarity));
|
||||
}
|
||||
if (ringFilter.length) {
|
||||
shouldShow &&= ringFilter.includes(system?.ring);
|
||||
}
|
||||
if (userFilter.length) {
|
||||
shouldShow &&= userFilter.includes(lineSource);
|
||||
}
|
||||
|
||||
entry.style.display = shouldShow ? "" : "none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate the compendium index to:
|
||||
* 1. Populate #sourcesInThisCompendium for source filter options
|
||||
* 2. Determine which filter controls have enough distinct values to show
|
||||
* @returns {{ rank: boolean, rarity: boolean, source: boolean, ring: boolean }}
|
||||
* @private
|
||||
*/
|
||||
#computeFilterVisibility() {
|
||||
const filtersToShow = { rank: false, rarity: false, source: true, ring: false };
|
||||
const firstSeen = { rank: null, rarity: null, ring: null };
|
||||
|
||||
const markIfDistinct = (prop, value) => {
|
||||
if (filtersToShow[prop] || value === undefined || value === null) {
|
||||
return;
|
||||
}
|
||||
if (firstSeen[prop] === null) {
|
||||
firstSeen[prop] = value;
|
||||
} else if (firstSeen[prop] !== value) {
|
||||
filtersToShow[prop] = true;
|
||||
}
|
||||
};
|
||||
|
||||
for (const entry of this.collection.index.values()) {
|
||||
const sys = entry.system;
|
||||
if (!sys) {
|
||||
continue;
|
||||
}
|
||||
if (sys.rank !== undefined) {
|
||||
markIfDistinct("rank", sys.rank);
|
||||
}
|
||||
if (sys.ring !== undefined) {
|
||||
markIfDistinct("ring", sys.ring);
|
||||
}
|
||||
if (sys.rarity !== undefined) {
|
||||
markIfDistinct("rarity", sys.rarity);
|
||||
}
|
||||
if (sys.source_reference?.source !== undefined) {
|
||||
this.#sourcesInThisCompendium.add(sys.source_reference.source);
|
||||
}
|
||||
}
|
||||
|
||||
return filtersToShow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which sources are restricted from players and cache the result
|
||||
* in instance-level sets for use by #applyEntryFilter.
|
||||
* @private
|
||||
*/
|
||||
#resolvePermissions() {
|
||||
const ns = CONFIG.l5r5e.namespace;
|
||||
const officialSet = game.settings.get(ns, "compendium-official-content-for-players");
|
||||
const unofficialSet = game.settings.get(ns, "compendium-unofficial-content-for-players");
|
||||
const allRefsSet = game.settings.get(ns, "all-compendium-references");
|
||||
|
||||
this.#hideEmptySourcesFromPlayers = game.settings.get(ns, "compendium-hide-empty-sources-from-players");
|
||||
|
||||
this.#unavailableSourceForPlayersSet = new Set(
|
||||
[...allRefsSet].filter((ref) => {
|
||||
if (CONFIG.l5r5e.sourceReference[ref]) {
|
||||
return officialSet.size > 0 ? !officialSet.has(ref) : false;
|
||||
}
|
||||
return unofficialSet.size > 0 ? !unofficialSet.has(ref) : false;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind toggle-selection click handlers to all children of a button filter container.
|
||||
* A [data-clear] element at the end of the container acts as an inline reset:
|
||||
* - It is hidden (display:none in the template) when no values are selected.
|
||||
* - It becomes visible as soon as any value is selected.
|
||||
* - Clicking it deselects all values and hides itself again.
|
||||
* @param {string} containerSelector
|
||||
* @private
|
||||
*/
|
||||
#bindButtonFilter(containerSelector) {
|
||||
const container = this.element.querySelector(containerSelector);
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clearButton = container.querySelector("[data-clear]");
|
||||
|
||||
const updateClearButton = () => {
|
||||
if (!clearButton) {
|
||||
return;
|
||||
}
|
||||
const anySelected = [...container.children].some(
|
||||
(element) => element.dataset.clear === undefined && element.classList.contains("selected")
|
||||
);
|
||||
clearButton.style.display = anySelected ? "" : "none";
|
||||
};
|
||||
|
||||
for (const child of container.children) {
|
||||
child.addEventListener("click", (event) => {
|
||||
const target = event.currentTarget;
|
||||
|
||||
if (target.dataset.clear !== undefined) {
|
||||
// Clicked the clear button — deselect all value elements
|
||||
for (const element of container.children) {
|
||||
element.classList.remove("selected");
|
||||
}
|
||||
} else {
|
||||
// Clicked a value element — toggle it
|
||||
target.classList.toggle("selected");
|
||||
}
|
||||
|
||||
updateClearButton();
|
||||
this.#reapplyFilters();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire up the change listener on the already-rendered multiselect element.
|
||||
* The element and its options are fully declared in filter-bar.html via
|
||||
* {{selectOptions}} — no imperative construction needed here.
|
||||
* @private
|
||||
*/
|
||||
#bindSourceFilter() {
|
||||
const multiSelect = this.element.querySelector("l5r5e-multi-select[name=\"filter-sources\"]");
|
||||
if (!multiSelect) {
|
||||
return;
|
||||
}
|
||||
multiSelect.addEventListener("change", () => this.#reapplyFilters());
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the GM player-view button, selecting only the sources that are
|
||||
* both visible to players and present in this specific compendium.
|
||||
* @this {ItemCompendiumL5r5e}
|
||||
* @private
|
||||
*/
|
||||
static #onApplyPlayerView() {
|
||||
const ns = CONFIG.l5r5e.namespace;
|
||||
const allRefsSet = game.settings.get(ns, "all-compendium-references");
|
||||
|
||||
const availableForPlayers = [...allRefsSet]
|
||||
.filter((ref) => !this.#unavailableSourceForPlayersSet.has(ref))
|
||||
.filter((ref) => this.#sourcesInThisCompendium.has(ref));
|
||||
|
||||
const multiSelect = this.element.querySelector("l5r5e-multi-select[name=\"filter-sources\"]");
|
||||
if (!multiSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
multiSelect.value = availableForPlayers;
|
||||
this.#reapplyFilters();
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this compendium class and extend the index fields for all Item packs.
|
||||
*/
|
||||
static applyToPacks() {
|
||||
CONFIG.Item.compendiumIndexFields = [
|
||||
...(CONFIG.Item.compendiumIndexFields ?? []),
|
||||
"system.rank",
|
||||
"system.ring",
|
||||
"system.rarity",
|
||||
"system.source_reference.source",
|
||||
];
|
||||
|
||||
for (const pack of game.packs.filter((p) => p.metadata.type === "Item")) {
|
||||
pack.applicationClass = ItemCompendiumL5r5e;
|
||||
pack.getIndex(); // rebuild index with new fields — no need to await since this happens before anyone have a chance to act
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,110 @@
|
||||
import { L5r5eHtmlMultiSelectElement } from "../misc/l5r5e-multiselect.js";
|
||||
|
||||
/**
|
||||
* A subclass of [ArrayField]{@link ArrayField} which supports a set of contained elements.
|
||||
* Elements in this set are treated as fungible and may be represented in any order or discarded if invalid.
|
||||
* A Foundry `SetField` that renders as an {@link L5r5eHtmlMultiSelectElement} chip-input.
|
||||
*
|
||||
* Use this in a DataModel schema whenever a field stores an unordered collection of
|
||||
* string values drawn from a fixed option list. On form submission the element returns a
|
||||
* comma-separated string; `clean()` splits it back into an Array before Foundry processes
|
||||
* it, and `initialize()` wraps the result in a `Set` for use in the model.
|
||||
*
|
||||
* @example
|
||||
* // In a DataModel schema:
|
||||
* skills: new L5r5eSetField({
|
||||
* options: [
|
||||
* { value: "athletics", label: "Athletics" },
|
||||
* { value: "meditation", label: "Meditation", disabled: true, tooltip: "Requires rank 3" },
|
||||
* ]
|
||||
* })
|
||||
*
|
||||
* // Renders automatically via {{formGroup}} in a Handlebars template:
|
||||
* // {{formGroup fields.skills name="skills" value=data.skills localize=true}}
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {{ value: string, label: string, disabled?: boolean, tooltip?: string }[]} options.options
|
||||
* Flat list of selectable items. Passed directly to {@link L5r5eHtmlMultiSelectElement.create}.
|
||||
* @param {object[]} [options.groups]
|
||||
* Optional optgroup definitions, forwarded to the element factory unchanged.
|
||||
* @param {boolean} [options.hideDisabledOptions=false]
|
||||
* When true, disabled options are hidden from the dropdown instead of greyed out.
|
||||
*/
|
||||
export class L5r5eSetField extends foundry.data.fields.SetField {
|
||||
/**
|
||||
* Saved constructor options, used to reconstruct the multiselect input on form render.
|
||||
* @type {object}
|
||||
*/
|
||||
#savedOptions;
|
||||
|
||||
// We don't get the options we expect when we convert this to input,
|
||||
// So store them here
|
||||
#savedOptions;
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {object} context
|
||||
*/
|
||||
constructor(options = {}, context = {}) {
|
||||
super(
|
||||
new foundry.data.fields.StringField({
|
||||
choices: options.options?.map((option) => option.value) ?? [],
|
||||
}),
|
||||
options,
|
||||
context
|
||||
);
|
||||
|
||||
constructor(options={}, context={}) {
|
||||
super(new foundry.data.fields.StringField({
|
||||
choices: options.options.map((option) => option.value)
|
||||
}), options, context);
|
||||
|
||||
this.#savedOptions = options;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
initialize(value, model, options={}) {
|
||||
if ( !value ) return value;
|
||||
return new Set(super.initialize(value, model, options));
|
||||
this.#savedOptions = options;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
/**
|
||||
* @param {*} value
|
||||
* @param {object} model
|
||||
* @param {object} options
|
||||
* @return {Set}
|
||||
* @override
|
||||
*/
|
||||
initialize(value, model, options = {}) {
|
||||
if (!value || (Array.isArray(value) && value.length === 0)) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return new Set(super.initialize(value, model, options).filter(Boolean));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Set} value
|
||||
* @return {*[]|*}
|
||||
* @override
|
||||
*/
|
||||
toObject(value) {
|
||||
if ( !value ) return value;
|
||||
return Array.from(value).map(v => this.element.toObject(v));
|
||||
if (!value) {
|
||||
return value;
|
||||
}
|
||||
return Array.from(value).map((v) => this.element.toObject(v));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Form Field Integration */
|
||||
/* -------------------------------------------- */
|
||||
/**
|
||||
* @param {string|Array} value
|
||||
* @param {object} options
|
||||
* @return {Array}
|
||||
* @override
|
||||
*/
|
||||
clean(value, options) {
|
||||
// Settings forms submit comma-separated strings; split before normal cleaning.
|
||||
if (typeof value === "string") {
|
||||
value = value.length ? value.split(",").filter(Boolean) : [];
|
||||
}
|
||||
return super.clean(value, options);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
/**
|
||||
* @param {object} config
|
||||
* @return {L5r5eHtmlMultiSelectElement}
|
||||
* @override
|
||||
*/
|
||||
_toInput(config) {
|
||||
const e = this.element;
|
||||
return L5r5eHtmlMultiSelectElement.create({
|
||||
name: config.name,
|
||||
options: this.#savedOptions.options,
|
||||
groups: this.#savedOptions.groups,
|
||||
value: config.value,
|
||||
localize: config.localize
|
||||
});
|
||||
return L5r5eHtmlMultiSelectElement.create({
|
||||
name: config.name,
|
||||
options: this.#savedOptions.options,
|
||||
groups: this.#savedOptions.groups,
|
||||
value: config.value,
|
||||
localize: config.localize,
|
||||
hideDisabledOptions: this.#savedOptions.hideDisabledOptions,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { L5r5eHtmlMultiSelectElement } from "./misc/l5r5e-multiselect.js";
|
||||
import { ItemCompendiumL5r5e } from "./compendium/l5r5e-item-compendium.js"
|
||||
|
||||
export default class HooksL5r5e {
|
||||
/**
|
||||
@@ -26,6 +26,8 @@ export default class HooksL5r5e {
|
||||
) {
|
||||
game.babele.setSystemTranslationsDir("babele"); // Since Babele v2.0.7
|
||||
}
|
||||
|
||||
ItemCompendiumL5r5e.applyToPacks();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -245,236 +247,6 @@ export default class HooksL5r5e {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Compendium display (Add filters)
|
||||
*/
|
||||
static async renderCompendium(app, html, data) {
|
||||
html = $(html); // basic patch for v13
|
||||
|
||||
if (app.collection.documentName === "Item") {
|
||||
const content = await app.collection.getDocuments();
|
||||
const sourcesInThisCompendium = new Set([]);
|
||||
const filtersToShow = {
|
||||
rank: false,
|
||||
rarity: false,
|
||||
source: false,
|
||||
ring: false,
|
||||
};
|
||||
// Used to auto hide same values for a full compendium
|
||||
const previousValue = {
|
||||
rank: null,
|
||||
rarity: null,
|
||||
source: null,
|
||||
ring: null,
|
||||
};
|
||||
|
||||
// Cache
|
||||
const header = html.find(".directory-header");
|
||||
const entries = html.find(".directory-item");
|
||||
|
||||
// Add additional data to the entries to make it faster to lookup.
|
||||
// Add Ring/rank/rarity information
|
||||
for (const document of content) {
|
||||
const entry = entries.filter(`[data-entry-id="${document.id}"]`);
|
||||
|
||||
// Hide filter if only one value of this type is found in the compendium
|
||||
const autoDisplayFilter = (props, documentData = null) => {
|
||||
documentData ??= document.system[props];
|
||||
|
||||
if (filtersToShow[props] || previousValue[props] === documentData) {
|
||||
return;
|
||||
}
|
||||
filtersToShow[props] = previousValue[props] !== null && previousValue[props] !== documentData;
|
||||
previousValue[props] = documentData;
|
||||
};
|
||||
|
||||
if (document.system?.rank) {
|
||||
autoDisplayFilter('rank');
|
||||
entry.data("rank", document.system.rank);
|
||||
}
|
||||
|
||||
if (document.system?.source_reference.source) {
|
||||
autoDisplayFilter('source', document.system.source_reference.source);
|
||||
sourcesInThisCompendium.add(document.system.source_reference.source);
|
||||
entry.data("source", document.system.source_reference);
|
||||
}
|
||||
|
||||
if (document.system?.ring) {
|
||||
autoDisplayFilter('ring');
|
||||
entry.data("ring", document.system.ring);
|
||||
}
|
||||
|
||||
if (document.system?.rarity) {
|
||||
autoDisplayFilter('rarity');
|
||||
entry.data("rarity", document.system.rarity);
|
||||
}
|
||||
|
||||
// Add ring/rank/rarity information on the item in the compendium view
|
||||
if (document.system?.ring || document.system?.rarity || document.system?.rank) {
|
||||
const ringRarityRank = await foundry.applications.handlebars.renderTemplate(`${CONFIG.l5r5e.paths.templates}compendium/ring-rarity-rank.html`, document.system);
|
||||
entry.append(ringRarityRank);
|
||||
}
|
||||
}
|
||||
|
||||
// Setup filters
|
||||
const officialContentSet = game.settings.get(CONFIG.l5r5e.namespace, "compendium-official-content-for-players");
|
||||
const unofficialContentSet = game.settings.get(CONFIG.l5r5e.namespace, "compendium-unofficial-content-for-players");
|
||||
const allCompendiumReferencesSet = game.settings.get(CONFIG.l5r5e.namespace, "all-compendium-references")
|
||||
const hideEmptySourcesFromPlayers = game.settings.get(CONFIG.l5r5e.namespace, "compendium-hide-empty-sources-from-players");
|
||||
|
||||
const unavailableSourceForPlayersSet = new Set([...allCompendiumReferencesSet].filter((element) => {
|
||||
if (CONFIG.l5r5e.sourceReference[element]) {
|
||||
return officialContentSet.size > 0 ? !officialContentSet.has(element) : false;
|
||||
}
|
||||
return unofficialContentSet.size > 0 ? !unofficialContentSet.has(element) : false;
|
||||
}));
|
||||
|
||||
// Create filter function
|
||||
const applyCompendiumFilter = () => {
|
||||
const userFilter = header.find("l5r5e-multi-select").val();
|
||||
const rankFilter = header.find(".rank-filter .selected").data("rank");
|
||||
const ringFilter = header.find(".ring-filter .selected").data("ring");
|
||||
const rarityFilter = header.find(".rarity-filter .selected").data("rarity");
|
||||
|
||||
entries.each(function () {
|
||||
const lineSource = $(this).data("source")?.source;
|
||||
|
||||
// We might have stuff in the compendium view that does not have a source (folders etc.) Ignore those.
|
||||
if (lineSource === null || lineSource === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
let shouldShow = true;
|
||||
|
||||
// Handle unavailable sources
|
||||
if (unavailableSourceForPlayersSet.has(lineSource)) {
|
||||
if (game.user.isGM) {
|
||||
shouldShow &= true;
|
||||
$(this)
|
||||
.addClass("not-for-players")
|
||||
.attr("data-tooltip", game.i18n.localize("l5r5e.compendium.not_for_players"));
|
||||
} else {
|
||||
shouldShow &= false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle empty sources
|
||||
if (lineSource === "" && hideEmptySourcesFromPlayers) {
|
||||
if (game.user.isGM) {
|
||||
shouldShow &= true;
|
||||
$(this)
|
||||
.addClass("not-for-players")
|
||||
.attr("data-tooltip", game.i18n.localize("l5r5e.compendium.not_for_players"));
|
||||
} else {
|
||||
shouldShow &= false;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
if (rankFilter) {
|
||||
shouldShow &= $(this).data("rank") == rankFilter;
|
||||
}
|
||||
if (userFilter?.length) {
|
||||
shouldShow &= userFilter.includes(lineSource);
|
||||
}
|
||||
if (ringFilter) {
|
||||
shouldShow &= $(this).data("ring") == ringFilter;
|
||||
}
|
||||
if (rarityFilter >= 0) {
|
||||
shouldShow &= $(this).data("rarity") == rarityFilter;
|
||||
}
|
||||
|
||||
// Show or hide this entry based on the result
|
||||
shouldShow ? $(this).show() : $(this).hide();
|
||||
});
|
||||
};
|
||||
|
||||
// Filter setup
|
||||
const addFilter = async (filterType, templateFile, templateData) => {
|
||||
if (!filtersToShow[filterType]) {
|
||||
return;
|
||||
}
|
||||
const filterTemplate = await foundry.applications.handlebars.renderTemplate(
|
||||
`${CONFIG.l5r5e.paths.templates}compendium/${templateFile}.html`,
|
||||
templateData
|
||||
);
|
||||
header.append(filterTemplate);
|
||||
|
||||
header.find(`.${filterType}-filter`).children().each(function () {
|
||||
$(this).on("click", (event) => {
|
||||
const selected = $(event.target).hasClass("selected");
|
||||
header.find(`.${filterType}-filter`).children().removeClass("selected");
|
||||
$(event.target).toggleClass("selected", !selected);
|
||||
applyCompendiumFilter();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Add Rank, Rarity, Ring Filters
|
||||
await Promise.all([
|
||||
addFilter('rank' , 'rank-filter', { type: "rank", number: [1, 2, 3, 4, 5] }),
|
||||
addFilter('rarity', 'rank-filter', { type: "rarity", number: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] }),
|
||||
addFilter('ring' , 'ring-filter', {}),
|
||||
]);
|
||||
|
||||
if (filtersToShow.source) {
|
||||
// Build the source select
|
||||
const selectableSourcesArray = [...allCompendiumReferencesSet].map((reference) => ({
|
||||
value: reference,
|
||||
label: CONFIG.l5r5e.sourceReference[reference]?.label ?? reference,
|
||||
translate: true,
|
||||
group: CONFIG.l5r5e.sourceReference[reference]?.type.split(",")[0] ?? "l5r5e.multiselect.sources_categories.others",
|
||||
disabled: !sourcesInThisCompendium.has(reference) || (!game.user.isGM && unavailableSourceForPlayersSet.has(reference))
|
||||
}));
|
||||
const filterSourcesBox = L5r5eHtmlMultiSelectElement.create({
|
||||
name: "filter-sources",
|
||||
options: selectableSourcesArray,
|
||||
localize: true,
|
||||
});
|
||||
header.append(filterSourcesBox.outerHTML);
|
||||
$("l5r5e-multi-select").on("change", applyCompendiumFilter);
|
||||
|
||||
// If gm add an extra button to easily filter the content to see the same stuff as a player
|
||||
if (game.user.isGM && unavailableSourceForPlayersSet.size > 0) {
|
||||
const buttonHTML = `<button type="button" class="gm" data-tooltip="${game.i18n.localize('l5r5e.multiselect.player_filter_tooltip')}">`
|
||||
+ game.i18n.localize('l5r5e.multiselect.player_filter_label')
|
||||
+ '</button>'
|
||||
|
||||
const filterPlayerViewArray = [...allCompendiumReferencesSet]
|
||||
.filter((item) => !unavailableSourceForPlayersSet.has(item))
|
||||
.filter((item) => sourcesInThisCompendium.has(item));
|
||||
|
||||
$(buttonHTML).appendTo($(header).find("l5r5e-multi-select")).click(function() {
|
||||
header.find("l5r5e-multi-select")[0].value = filterPlayerViewArray;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: This delay is a workaround and should be addressed in another way.
|
||||
// This is ugly but if we hide the content too early then it won't be hidden for some reason.
|
||||
// Current guess is that the foundry search filter is doing something.
|
||||
// Adding a delay here so that we hide the content. This will fail on slow computers/network...
|
||||
setTimeout(() => {
|
||||
applyCompendiumFilter();
|
||||
}, 250);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static updateCompendium(pack, documents, options, userId) {
|
||||
documents.forEach((document) => {
|
||||
const inc_reference = document?.system?.source_reference?.source?.trim();
|
||||
if (!!inc_reference) {
|
||||
const references = game.settings.get(CONFIG.l5r5e.namespace, "all-compendium-references");
|
||||
if (!references.includes(inc_reference)) {
|
||||
references.push(inc_reference);
|
||||
game.settings.set(CONFIG.l5r5e.namespace, "all-compendium-references", references);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DiceSoNice - Add L5R DicePresets
|
||||
*/
|
||||
|
||||
@@ -47,8 +47,10 @@ import { GmMonitor } from "./gm/gm-monitor.js";
|
||||
import { Storage } from "./storage.js";
|
||||
// Misc
|
||||
import { L5r5eHtmlMultiSelectElement } from "./misc/l5r5e-multiselect.js";
|
||||
import { L5R5eHtmlComboBoxElement } from "./misc/l5r5e-combo-box.js";
|
||||
|
||||
window.customElements.define(L5r5eHtmlMultiSelectElement.tagName, L5r5eHtmlMultiSelectElement);
|
||||
window.customElements.define(L5R5eHtmlComboBoxElement.tagName, L5R5eHtmlComboBoxElement);
|
||||
|
||||
/* ------------------------------------ */
|
||||
/* Initialize system */
|
||||
@@ -286,6 +288,4 @@ Hooks.on("renderSidebarTab", (app, html, data) => HooksL5r5e.renderSidebarTab(ap
|
||||
Hooks.on("activateSettings", async (app)=> HooksL5r5e.activateSettings(app));
|
||||
Hooks.on("renderChatMessageHTML", (message, html, data) => HooksL5r5e.renderChatMessage(message, html, data));
|
||||
Hooks.on("renderCombatTracker", (app, html, data) => HooksL5r5e.renderCombatTracker(app, html, data));
|
||||
Hooks.on("renderCompendium", async (app, html, data) => HooksL5r5e.renderCompendium(app, html, data));
|
||||
Hooks.on("diceSoNiceRollStart", (messageId, context) => HooksL5r5e.diceSoNiceRollStart(messageId, context));
|
||||
Hooks.on("updateCompendium", (pack, documents, options, userId) => HooksL5r5e.updateCompendium(pack, documents, options, userId));
|
||||
|
||||
257
system/scripts/misc/l5r5e-combo-box.js
Normal file
257
system/scripts/misc/l5r5e-combo-box.js
Normal file
@@ -0,0 +1,257 @@
|
||||
import { DropdownMixin } from "./l5r5e-dropdown-mixin.js";
|
||||
|
||||
const { AbstractFormInputElement } = foundry.applications.elements;
|
||||
|
||||
/**
|
||||
* A custom `<l5r5e-combo-box>` combining a free-text input with a filterable option dropdown.
|
||||
*
|
||||
* Stores a **single string value** — either a predefined option's `value` attribute, or
|
||||
* whatever the user typed freely. Use this when a field holds exactly one value, whether
|
||||
* chosen from a list or entered manually (e.g. a weapon name, a title, a custom skill).
|
||||
* For storing multiple values from a list, use {@link L5r5eHtmlMultiSelectElement} instead.
|
||||
*
|
||||
* Picking a predefined option stores its `value` attribute while displaying its human-readable
|
||||
* label. Free-typing stores the typed string as both value and label. Fires `input` on every
|
||||
* keystroke and `change` only on commit (blur or Enter) to avoid triggering sheet re-renders
|
||||
* mid-typing. Picking from the dropdown fires both `input` and `change` immediately.
|
||||
*
|
||||
* Use `{{selectOptions}}` without `selected=` to render the available options, and set the
|
||||
* current value via the `value` attribute on the element directly.
|
||||
*
|
||||
* @example
|
||||
* ```hbs
|
||||
* {{!-- Pass current value via the element's `value` attribute, not selectOptions selected= --}}
|
||||
* <l5r5e-combo-box name="weapon" value="{{data.weapon}}">
|
||||
* {{selectOptions choices localize=true}}
|
||||
* </l5r5e-combo-box>
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* // Programmatic update — query the element by its name attribute, then set value directly.
|
||||
* // The visible input label updates automatically to match the selected option's label.
|
||||
* const el = document.querySelector("l5r5e-combo-box[name='weapon']");
|
||||
* el.value = "axe"; // input shows "Battleaxe", el.value returns "axe"
|
||||
*
|
||||
* // Free-text entry (no matching option) — value and label are both set to the typed string:
|
||||
* el.value = "naginata"; // input shows "naginata", el.value returns "naginata"
|
||||
*/
|
||||
export class L5R5eHtmlComboBoxElement extends DropdownMixin(
|
||||
AbstractFormInputElement,
|
||||
{ multiSelect: false, debounceMs: 150, clearOnClose: false }
|
||||
) {
|
||||
/**
|
||||
* The label currently shown in the text input. Differs from `_value` when a predefined
|
||||
* option is selected (value = option's value attribute, label = option's display text).
|
||||
* @type {string}
|
||||
*/
|
||||
_label = "";
|
||||
|
||||
/** @override */
|
||||
static tagName = "l5r5e-combo-box";
|
||||
|
||||
/** @override */
|
||||
static observedAttributes = ["disabled", "placeholder", "value"];
|
||||
|
||||
/**
|
||||
* Flat descriptor list built once in _initialize(), mirrors the light-DOM options.
|
||||
* @type {{ value: string, label: string, group: string|null }[]}
|
||||
*/
|
||||
#options = [];
|
||||
|
||||
/**
|
||||
* Value snapshot taken when the input receives focus.
|
||||
* Used by the blur handler to decide whether to fire a change event.
|
||||
* @type {string}
|
||||
*/
|
||||
#valueAtFocus = "";
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Accessors */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
get value() {
|
||||
return this._value ?? "";
|
||||
}
|
||||
|
||||
set value(value) {
|
||||
const match = this.#options.find(option => option.value === String(value ?? ""));
|
||||
if (match) {
|
||||
this._value = match.value;
|
||||
this._label = match.label;
|
||||
}
|
||||
else {
|
||||
this._value = String(value ?? "");
|
||||
this._label = this._value;
|
||||
}
|
||||
this._internals.setFormValue(this._value);
|
||||
if (this._dropdownInput) {
|
||||
this._dropdownInput.value = this._label;
|
||||
}
|
||||
this.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
* AbstractFormInputElement does not have an _initialize() hook, so we override
|
||||
* connectedCallback to snapshot options from the light DOM before _buildElements()
|
||||
* is called by super.connectedCallback().
|
||||
*
|
||||
* We keep this minimal: just build the flat #options list so _getDropdownOptions()
|
||||
* and the value setter can look options up by value.
|
||||
* @override
|
||||
*/
|
||||
connectedCallback() {
|
||||
this.#snapshotOptions();
|
||||
this.#resolveInitialValue();
|
||||
super.connectedCallback();
|
||||
}
|
||||
|
||||
#snapshotOptions() {
|
||||
const makeOption = (option, group = null) => ({
|
||||
value: option.value,
|
||||
label: option.innerText,
|
||||
group,
|
||||
});
|
||||
|
||||
this.#options = [...this.children].flatMap(child => {
|
||||
if (child instanceof HTMLOptGroupElement) {
|
||||
return [...child.querySelectorAll("option")]
|
||||
.filter(option => option.value)
|
||||
.map(option => makeOption(option, child.label));
|
||||
}
|
||||
if (child instanceof HTMLOptionElement && child.value) {
|
||||
return makeOption(child);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
#resolveInitialValue() {
|
||||
// Honour a `value` attribute if set, otherwise find a selected option.
|
||||
const attrValue = this.getAttribute("value");
|
||||
const initial = attrValue ?? this.#options.find(option => option.selected)?.value ?? "";
|
||||
const match = this.#options.find(option => option.value === initial);
|
||||
if (match) {
|
||||
this._value = match.value;
|
||||
this._label = match.label;
|
||||
} else {
|
||||
this._value = initial;
|
||||
this._label = initial;
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Element Lifecycle */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_buildElements() {
|
||||
const wrapper = this._buildDropdownElements({
|
||||
placeholder: this.getAttribute("placeholder") ?? "",
|
||||
});
|
||||
|
||||
this._dropdownInput.value = this._label || this._value || "";
|
||||
this._primaryInput = this._dropdownInput;
|
||||
|
||||
return [wrapper];
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_activateListeners() {
|
||||
const signal = this.abortSignal;
|
||||
this._activateDropdownListeners();
|
||||
|
||||
// Prevent the inner <input>'s own native change from reaching Foundry's form handler.
|
||||
// We dispatch our own change at commit time only (see below).
|
||||
this._dropdownInput.addEventListener("change", (e) => e.stopPropagation(), { signal });
|
||||
|
||||
// Free typing: update value and fire `input` immediately.
|
||||
// Do NOT fire `change` here — Foundry's form handler re-renders the sheet on
|
||||
// every `change` event, which would destroy the element mid-typing.
|
||||
// `change` is fired at commit time: on blur (if value changed) or Enter.
|
||||
this._dropdownInput.addEventListener("input", () => {
|
||||
const typed = this._dropdownInput.value;
|
||||
this._label = typed;
|
||||
this._value = typed;
|
||||
this._internals.setFormValue(typed);
|
||||
this.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
|
||||
// DropdownMixin's #onInput fires the debounced dropdown re-render.
|
||||
}, { signal });
|
||||
|
||||
// Commit on blur if the value has changed since the element was focused.
|
||||
this._dropdownInput.addEventListener("focus", () => {
|
||||
this.#valueAtFocus = this._value;
|
||||
}, { signal });
|
||||
|
||||
this._dropdownInput.addEventListener("blur", () => {
|
||||
if (this._value !== this.#valueAtFocus) {
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
|
||||
}
|
||||
}, { signal });
|
||||
|
||||
// Commit on Enter for free-text values (not picked from the dropdown).
|
||||
this._dropdownInput.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
this._dropdownInput.blur();
|
||||
}
|
||||
}, { signal });
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_toggleDisabled(disabled) {
|
||||
this._toggleDropdownDisabled(disabled);
|
||||
// Add/remove .disabled on the wrapper so CSS can dim the whole control.
|
||||
const wrapper = this.querySelector(".wrapper");
|
||||
if (wrapper) {
|
||||
wrapper.classList.toggle("disabled", disabled);
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
attributeChangedCallback(attrName, oldValue, newValue) {
|
||||
super.attributeChangedCallback(attrName, oldValue, newValue);
|
||||
if (attrName === "disabled") {
|
||||
this._toggleDropdownDisabled(newValue !== null);
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_refresh() {
|
||||
if (!this._dropdownInput)
|
||||
return;
|
||||
|
||||
this._dropdownInput.value = this._label ?? "";
|
||||
this._internals.setFormValue(this._value ?? "");
|
||||
this._dropdownRefresh();
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* DropdownMixin Contract */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_getDropdownOptions() {
|
||||
return this.#options;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_isOptionSelected(value) {
|
||||
return this._value === value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit the picked option as the current value and close the dropdown.
|
||||
* @override
|
||||
*/
|
||||
_onDropdownPick(option) {
|
||||
this._value = option.value;
|
||||
this._label = option.label;
|
||||
this._dropdownInput.value = option.label;
|
||||
this._internals.setFormValue(option.value);
|
||||
this._dropdownInput.blur();
|
||||
this.dispatchEvent(new Event("input", { bubbles: true, cancelable: true }));
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
|
||||
}
|
||||
}
|
||||
492
system/scripts/misc/l5r5e-dropdown-mixin.js
Normal file
492
system/scripts/misc/l5r5e-dropdown-mixin.js
Normal file
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* @typedef {Object} DropdownMixinConfig
|
||||
* @property {boolean} [multiSelect=false]
|
||||
* When true the dropdown toggles selection and stays open after a pick.
|
||||
* When false a pick commits the value and closes (combo-box mode).
|
||||
* @property {number} [debounceMs=150]
|
||||
* Trailing-debounce delay in milliseconds for dropdown re-renders while typing.
|
||||
*/
|
||||
|
||||
/**
|
||||
* DropdownMixin
|
||||
*
|
||||
* Adds a fully custom `<ul role="listbox">` dropdown to any AbstractFormInputElement
|
||||
* subclass. Handles UI only — building, opening, closing, rendering options, and keyboard
|
||||
* navigation. Has no opinion about how options are stored or how values are managed.
|
||||
*
|
||||
* ### Contract: three methods the host must implement
|
||||
*
|
||||
* - `_getDropdownOptions()`
|
||||
* Return `{ value, label, group, disabled, tooltip }[]` — the full unfiltered list.
|
||||
*
|
||||
* - `_isOptionSelected(value)` → boolean
|
||||
* Return whether a given value is currently selected.
|
||||
*
|
||||
* - `_onDropdownPick(option)` → void
|
||||
* Called when the user picks an option. The mixin handles post-pick behaviour
|
||||
* (close in single mode, re-render in multi mode) after this returns.
|
||||
*
|
||||
* ### Host responsibilities
|
||||
* - Call `this._buildDropdownElements({ placeholder })` inside `_buildElements()`
|
||||
* and include the returned wrapper in the returned array.
|
||||
* - Call `this._activateDropdownListeners()` inside `_activateListeners()`.
|
||||
* - Call `this._toggleDropdownDisabled(disabled)` inside `_toggleDisabled()`.
|
||||
* - Call `this._dropdownRefresh()` inside `_refresh()` to keep checkmarks in sync.
|
||||
*
|
||||
* @param {typeof AbstractFormInputElement} Base
|
||||
* @param {DropdownMixinConfig} [mixinConfig={}]
|
||||
* @return {typeof Base}
|
||||
*/
|
||||
export function DropdownMixin(Base, mixinConfig = {}) {
|
||||
const {
|
||||
multiSelect = false,
|
||||
debounceMs = 150,
|
||||
/**
|
||||
* When true, closing the dropdown clears the search input text.
|
||||
* Use true for multi-select (search is transient) and false for
|
||||
* combo-box (the input IS the value and must persist after close).
|
||||
*/
|
||||
clearOnClose = true,
|
||||
} = mixinConfig;
|
||||
|
||||
return class DropdownMixinElement extends Base {
|
||||
|
||||
/* --------------------------------------------------------- */
|
||||
/* Private Fields */
|
||||
/* --------------------------------------------------------- */
|
||||
|
||||
/** @type {HTMLInputElement} */
|
||||
#searchInput;
|
||||
|
||||
/** @type {HTMLUListElement} */
|
||||
#list;
|
||||
|
||||
/** @type {boolean} */
|
||||
#open = false;
|
||||
|
||||
/**
|
||||
* Snapshot of #open at the moment a focus event fires.
|
||||
* Lets us distinguish a fresh focus (should open) from a click
|
||||
* on an already-focused input (should toggle closed).
|
||||
* @type {boolean}
|
||||
*/
|
||||
#wasOpenOnFocus = false;
|
||||
|
||||
/** @type {number} */
|
||||
#activeIndex = -1;
|
||||
|
||||
/** @type {HTMLLIElement[]} — flat list of pickable <li>s, excludes group headers */
|
||||
#optionElements = [];
|
||||
|
||||
/** @type {Function} */
|
||||
#debouncedOpen;
|
||||
|
||||
/**
|
||||
* Per-instance key so multiple elements on the same page never share a timer.
|
||||
* @type {string}
|
||||
*/
|
||||
#debounceId = `l5r5e-dropdown-${foundry.utils.randomID()}`;
|
||||
|
||||
/**
|
||||
* The search input element. The host should assign this to `this._primaryInput`.
|
||||
* @return {HTMLInputElement}
|
||||
*/
|
||||
get _dropdownInput() {
|
||||
return this.#searchInput;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the search `<input>` + `<ul>` dropdown inside a positioned wrapper div.
|
||||
* Include the returned element in the array returned from `_buildElements()`.
|
||||
*
|
||||
* @param {object} [opts]
|
||||
* @param {string} [opts.placeholder=""]
|
||||
* @return {HTMLDivElement}
|
||||
*/
|
||||
_buildDropdownElements({ placeholder = "" } = {}) {
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.classList.add("wrapper");
|
||||
|
||||
this.#searchInput = document.createElement("input");
|
||||
this.#searchInput.type = "text";
|
||||
this.#searchInput.classList.add("input");
|
||||
this.#searchInput.setAttribute("autocomplete", "off");
|
||||
this.#searchInput.setAttribute("role", "combobox");
|
||||
this.#searchInput.setAttribute("aria-autocomplete", "list");
|
||||
this.#searchInput.setAttribute("aria-expanded", "false");
|
||||
this.#searchInput.setAttribute("aria-haspopup", "listbox");
|
||||
if (placeholder) {
|
||||
this.#searchInput.setAttribute("placeholder", placeholder);
|
||||
}
|
||||
|
||||
this.#list = document.createElement("ul");
|
||||
this.#list.classList.add("dropdown");
|
||||
this.#list.setAttribute("role", "listbox");
|
||||
if (multiSelect) {
|
||||
this.#list.setAttribute("aria-multiselectable", "true");
|
||||
}
|
||||
this.#list.hidden = true;
|
||||
|
||||
this.#syncOffset();
|
||||
wrapper.append(this.#searchInput, this.#list);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/** Attach dropdown event listeners. Call inside `_activateListeners()`. */
|
||||
_activateDropdownListeners() {
|
||||
const signal = this.abortSignal;
|
||||
|
||||
this.#debouncedOpen = game.l5r5e.HelpersL5r5e.debounce(
|
||||
this.#debounceId,
|
||||
(query) => this.#openDropdown(query),
|
||||
debounceMs,
|
||||
false // trailing — fires after the user pauses
|
||||
);
|
||||
|
||||
this.#searchInput.addEventListener("mousedown", this.#onMouseDown.bind(this), { signal });
|
||||
this.#searchInput.addEventListener("focus", this.#onFocus.bind(this), { signal });
|
||||
this.#searchInput.addEventListener("input", this.#onInput.bind(this), { signal });
|
||||
this.#searchInput.addEventListener("keydown", this.#onKeydown.bind(this), { signal });
|
||||
this.#searchInput.addEventListener("blur", this.#onBlur.bind(this), { signal });
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable the search input. Call inside `_toggleDisabled()`.
|
||||
* @param {boolean} disabled
|
||||
*/
|
||||
_toggleDropdownDisabled(disabled) {
|
||||
if (this.#searchInput) {
|
||||
this.#searchInput.disabled = disabled;
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- */
|
||||
/* Refresh */
|
||||
/* --------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Re-render the open dropdown in place so checkmarks and disabled states
|
||||
* stay in sync with the current value. Call inside `_refresh()`.
|
||||
*/
|
||||
_dropdownRefresh() {
|
||||
if (this.#open) {
|
||||
this.#renderOptions(this.#filter(this.#searchInput?.value ?? ""));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
* @return {{ value: string, label: string, group: string|null, disabled?: boolean, tooltip?: string }[]}
|
||||
*/
|
||||
_getDropdownOptions() {
|
||||
throw new Error(`${this.constructor.name} must implement _getDropdownOptions()`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
* @param {string} value
|
||||
* @return {boolean}
|
||||
*/
|
||||
_isOptionSelected(value) {
|
||||
throw new Error(`${this.constructor.name} must implement _isOptionSelected()`);
|
||||
}
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
* @param {{ value: string, label: string }} option
|
||||
*/
|
||||
_onDropdownPick(option) {
|
||||
throw new Error(`${this.constructor.name} must implement _onDropdownPick()`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive label filter over `_getDropdownOptions()`.
|
||||
* Returns all options when query is empty.
|
||||
* @param {string} query
|
||||
* @return {object[]}
|
||||
*/
|
||||
#filter(query) {
|
||||
const all = this._getDropdownOptions();
|
||||
if (!query) {
|
||||
return all;
|
||||
}
|
||||
const lower = query.toLowerCase();
|
||||
return all.filter(option => option.label.toLowerCase().includes(lower));
|
||||
}
|
||||
|
||||
#openDropdown(query = "") {
|
||||
this.#renderOptions(this.#filter(query));
|
||||
this.#list.hidden = false;
|
||||
this.#searchInput.setAttribute("aria-expanded", "true");
|
||||
this.#open = true;
|
||||
this.#activeIndex = -1;
|
||||
this._onDropdownOpened();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the dropdown opens. Override in host classes to snapshot
|
||||
* the current value so _onDropdownClosed can compare against it.
|
||||
* Default is a no-op.
|
||||
* @protected
|
||||
*/
|
||||
_onDropdownOpened() {}
|
||||
|
||||
#closeDropdown() {
|
||||
this.#list.hidden = true;
|
||||
this.#searchInput.setAttribute("aria-expanded", "false");
|
||||
this.#open = false;
|
||||
this.#wasOpenOnFocus = false;
|
||||
this.#activeIndex = -1;
|
||||
this.#optionElements = [];
|
||||
// In combo-box mode the input IS the value, so we leave it intact.
|
||||
// In multi-select mode the search text is transient and should reset.
|
||||
if (clearOnClose && this.#searchInput) {
|
||||
this.#searchInput.value = "";
|
||||
}
|
||||
// Notify the host that the dropdown has closed. Hosts can override this
|
||||
// to fire a single change event after a multi-pick session.
|
||||
this._onDropdownClosed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the dropdown closes. Override in host classes to fire a
|
||||
* consolidated change event after a multi-pick session.
|
||||
* Default is a no-op.
|
||||
* @protected
|
||||
*/
|
||||
_onDropdownClosed() {}
|
||||
|
||||
#renderOptions(options) {
|
||||
this.#list.innerHTML = "";
|
||||
this.#optionElements = [];
|
||||
|
||||
// hideDisabledOptions is a host-level attribute, read via the DOM.
|
||||
const visible = this.hasAttribute("hidedisabledoptions")
|
||||
? options.filter(o => !o.disabled)
|
||||
: options;
|
||||
|
||||
if (!visible.length) {
|
||||
const empty = document.createElement("li");
|
||||
empty.classList.add("no-results");
|
||||
empty.textContent = game.i18n.localize("l5r5e.multiselect.no_results") ?? "No matches";
|
||||
empty.setAttribute("aria-disabled", "true");
|
||||
this.#list.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bucket into groups, preserving insertion order.
|
||||
const groups = new Map();
|
||||
const ungrouped = [];
|
||||
for (const option of visible) {
|
||||
if (option.group) {
|
||||
if (!groups.has(option.group)) groups.set(option.group, []);
|
||||
groups.get(option.group).push(option);
|
||||
} else {
|
||||
ungrouped.push(option);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [label, options] of groups) {
|
||||
const header = document.createElement("li");
|
||||
header.classList.add("group");
|
||||
header.setAttribute("role", "presentation");
|
||||
header.textContent = label;
|
||||
this.#list.append(header);
|
||||
for (const option of options) {
|
||||
this.#list.append(this.#buildOptionEl(option));
|
||||
}
|
||||
}
|
||||
for (const option of ungrouped){
|
||||
this.#list.append(this.#buildOptionEl(option));
|
||||
}
|
||||
}
|
||||
|
||||
#buildOptionEl(option) {
|
||||
const selected = this._isOptionSelected(option.value);
|
||||
const disabled = !!option.disabled;
|
||||
|
||||
const li = document.createElement("li");
|
||||
li.classList.add("option");
|
||||
if (selected) {
|
||||
li.classList.add("selected");
|
||||
}
|
||||
if (disabled) {
|
||||
li.classList.add("disabled");
|
||||
}
|
||||
li.setAttribute("role", "option");
|
||||
li.setAttribute("aria-selected", String(selected));
|
||||
li.setAttribute("aria-disabled", String(disabled));
|
||||
li.dataset.value = option.value;
|
||||
|
||||
if (multiSelect) {
|
||||
const check = document.createElement("span");
|
||||
check.classList.add("checkmark");
|
||||
check.setAttribute("aria-hidden", "true");
|
||||
li.append(check);
|
||||
}
|
||||
|
||||
const labelEl = document.createElement("span");
|
||||
labelEl.classList.add("label");
|
||||
labelEl.textContent = option.label;
|
||||
li.append(labelEl);
|
||||
|
||||
if (selected && multiSelect) {
|
||||
li.title = game.i18n.localize("l5r5e.multiselect.already_in_filter");
|
||||
} else if (disabled && option.tooltip) {
|
||||
li.title = option.tooltip;
|
||||
}
|
||||
|
||||
if (!disabled) {
|
||||
li.addEventListener("mouseenter", () => {
|
||||
for (const element of this.#optionElements) {
|
||||
element.classList.remove("active");
|
||||
}
|
||||
this.#activeIndex = this.#optionElements.indexOf(li);
|
||||
li.classList.add("active");
|
||||
});
|
||||
li.addEventListener("mouseleave", () => {
|
||||
li.classList.remove("active");
|
||||
this.#activeIndex = -1;
|
||||
});
|
||||
li.addEventListener("mousedown", (event) => {
|
||||
event.preventDefault(); // keep focus on the search input
|
||||
this.#pick(option);
|
||||
});
|
||||
}
|
||||
|
||||
this.#optionElements.push(li);
|
||||
return li;
|
||||
}
|
||||
|
||||
#pick(option) {
|
||||
this._onDropdownPick(option);
|
||||
if (multiSelect) {
|
||||
// Stay open — re-render so checkmarks reflect the new state.
|
||||
this.#renderOptions(this.#filter(this.#searchInput.value));
|
||||
} else {
|
||||
this.#closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
#moveHighlight(direction) {
|
||||
if (!this.#optionElements.length) {
|
||||
return;
|
||||
}
|
||||
const prev = this.#optionElements[this.#activeIndex];
|
||||
if (prev) {
|
||||
prev.classList.remove("active");
|
||||
}
|
||||
|
||||
this.#activeIndex = Math.max(
|
||||
-1,
|
||||
Math.min(this.#optionElements.length - 1, this.#activeIndex + direction)
|
||||
);
|
||||
|
||||
const next = this.#optionElements[this.#activeIndex];
|
||||
if (next) {
|
||||
next.classList.add("active");
|
||||
next.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
}
|
||||
|
||||
#syncOffset() {
|
||||
if (!this.#searchInput || !this.#list) {
|
||||
return;
|
||||
}
|
||||
const offset = this.#searchInput.offsetLeft;
|
||||
this.#list.style.left = `${offset}px`;
|
||||
this.#list.style.right = `-${offset}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles click-to-toggle behaviour.
|
||||
*
|
||||
* Three cases:
|
||||
* 1. Dropdown is open → close it. preventDefault stops blur firing, which
|
||||
* would otherwise trigger a second close via #onBlur.
|
||||
* 2. Dropdown is closed AND input is already focused → open immediately.
|
||||
* In this case focus will NOT fire again (input never blurred after the
|
||||
* previous preventDefault), so we must open here rather than in #onFocus.
|
||||
* 3. Dropdown is closed AND input is not yet focused → do nothing; the
|
||||
* browser will fire focus naturally and #onFocus will open the dropdown.
|
||||
*/
|
||||
#onMouseDown(event) {
|
||||
this.#wasOpenOnFocus = this.#open;
|
||||
if (this.#open) {
|
||||
event.preventDefault();
|
||||
this.#closeDropdown();
|
||||
} else if (document.activeElement === this.#searchInput) {
|
||||
// Case 2: already focused, focus won't re-fire — open directly.
|
||||
this.#openDropdown(this.#searchInput.value);
|
||||
}
|
||||
}
|
||||
|
||||
#onFocus(event) {
|
||||
// Only open if mousedown didn't already handle it (cases 1 & 2 above).
|
||||
if (!this.#wasOpenOnFocus && document.activeElement === this.#searchInput) {
|
||||
this.#openDropdown(this.#searchInput.value);
|
||||
}
|
||||
}
|
||||
|
||||
#onInput(event) {
|
||||
this.#debouncedOpen(this.#searchInput.value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the dropdown when focus genuinely leaves the component.
|
||||
*
|
||||
* event.relatedTarget is the element that is RECEIVING focus. If it is
|
||||
* inside our host element (e.g. the clear button, a chip remove span that
|
||||
* managed to steal focus) we leave the dropdown open. Only when focus moves
|
||||
* completely outside do we close — and we do so synchronously, so that
|
||||
* _onDropdownClosed fires its change event BEFORE the browser hands control
|
||||
* to whatever the user clicked. This eliminates the 100 ms race where Foundry
|
||||
* could read stale FormData between the blur and a deferred close.
|
||||
*/
|
||||
#onBlur(event) {
|
||||
if (this.contains(event.relatedTarget)) {
|
||||
return;
|
||||
}
|
||||
this.#closeDropdown();
|
||||
}
|
||||
|
||||
#onKeydown(event) {
|
||||
if (!this.#open) {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
this.#openDropdown(this.#searchInput.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
switch (event.key) {
|
||||
case "ArrowDown": {
|
||||
event.preventDefault();
|
||||
this.#moveHighlight(1);
|
||||
} break;
|
||||
case "ArrowUp": {
|
||||
event.preventDefault();
|
||||
this.#moveHighlight(-1);
|
||||
}break;
|
||||
case "Enter": {
|
||||
event.preventDefault();
|
||||
if (this.#activeIndex >= 0) {
|
||||
const li = this.#optionElements[this.#activeIndex];
|
||||
const option = this._getDropdownOptions().find(option => option.value === li.dataset.value);
|
||||
if (option) {
|
||||
this.#pick(option);
|
||||
}
|
||||
} else {
|
||||
this.#closeDropdown();
|
||||
}
|
||||
} break;
|
||||
case "Escape": {
|
||||
event.preventDefault();
|
||||
this.#closeDropdown();
|
||||
} break;
|
||||
case "Tab": {
|
||||
this.#closeDropdown();
|
||||
} break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,301 +1,424 @@
|
||||
import { DropdownMixin } from "./l5r5e-dropdown-mixin.js";
|
||||
|
||||
const { AbstractMultiSelectElement } = foundry.applications.elements;
|
||||
|
||||
/**
|
||||
* Provide a multi-select workflow using a select element as the input mechanism.
|
||||
* It is a expanded copy of the HTMLMultiselect with support for disabling options
|
||||
* and a clear all button. Also have support for hover-over information using titlea
|
||||
* A custom `<l5r5e-multi-select>` form element providing Select2-style chip multi-selection.
|
||||
*
|
||||
* @example Multi-Select HTML Markup
|
||||
* ```html
|
||||
* <l5r5e-multi-select name="select-many-things">
|
||||
* <optgroup label="Basic Options">
|
||||
* <option value="foo">Foo</option>
|
||||
* <option value="bar">Bar</option>
|
||||
* <option value="baz">Baz</option>
|
||||
* </optgroup>
|
||||
* <optgroup label="Advanced Options">
|
||||
* <option value="fizz">Fizz</option>
|
||||
* <option value="buzz">Buzz</option>
|
||||
* </optgroup>
|
||||
* Stores **multiple string values** from a fixed option list, shown as removable chips inside
|
||||
* the input box. A live-search input filters the dropdown as the user types. Use this when a
|
||||
* field holds an unordered collection of values (e.g. a set of skills, tags, or abilities).
|
||||
* For storing a single value — predefined or free-text — use {@link L5R5eHtmlComboBoxElement} instead.
|
||||
*
|
||||
* The element's `value` getter returns a comma-separated string (e.g. `"fire,water"`),
|
||||
* which is what `FormData` will read on submission. `_getValue()` returns a plain Array,
|
||||
* which is what `FormDataExtended` will use.
|
||||
*
|
||||
* Pre-selection on render is handled via the `value` attribute on the element — NOT via
|
||||
* `{{selectOptions selected=...}}`, which cannot handle comma-separated strings. Use
|
||||
* `{{selectOptions}}` without `selected` purely to render the available options, and let
|
||||
* the `value` attribute drive pre-selection. Since `getAttribute()` always returns a string,
|
||||
* passing a `Set` or `Array` via Handlebars will not work correctly — always pass a
|
||||
* comma-separated string to `value=`.
|
||||
*
|
||||
* Prefer {@link L5r5eSetField} + `{{formGroup}}` when wiring this into a DataModel — the
|
||||
* field handles the full round-trip automatically.
|
||||
*
|
||||
* @example
|
||||
* ```hbs
|
||||
* {{!-- Use value= (comma-separated string) for pre-selection, not selectOptions selected= --}}
|
||||
* <l5r5e-multi-select name="elements" value="{{data.elements}}">
|
||||
* {{selectOptions choices localize=true}}
|
||||
* </l5r5e-multi-select>
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* // Static factory — use only when building outside of Foundry's field/template system:
|
||||
* const el = L5r5eHtmlMultiSelectElement.create({
|
||||
* name: "elements",
|
||||
* options: [{ value: "fire", label: "Fire" }, { value: "water", label: "Water" }],
|
||||
* value: "fire,water", // comma-separated pre-selection
|
||||
* });
|
||||
* form.appendChild(el);
|
||||
*
|
||||
* // Reading the value back:
|
||||
* el.value; // "fire,water" — comma-separated string, compatible with FormData
|
||||
* el._getValue(); // ["fire","water"] — array, compatible with FormDataExtended
|
||||
*/
|
||||
export class L5r5eHtmlMultiSelectElement extends AbstractMultiSelectElement {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#setup();
|
||||
}
|
||||
|
||||
export class L5r5eHtmlMultiSelectElement extends DropdownMixin(
|
||||
AbstractMultiSelectElement,
|
||||
{ multiSelect: true, debounceMs: 150 }
|
||||
) {
|
||||
/** @override */
|
||||
static tagName = "l5r5e-multi-select";
|
||||
|
||||
/**
|
||||
* A select element used to choose options.
|
||||
* @type {HTMLSelectElement}
|
||||
*/
|
||||
#select;
|
||||
/** @type {HTMLDivElement} — outer box containing chips, input, clear button */
|
||||
#selectionBox;
|
||||
|
||||
/** @type {HTMLDivElement} — chips are injected here */
|
||||
#chipList;
|
||||
|
||||
/** @type {HTMLSpanElement} — auto-sizing wrapper around the search input */
|
||||
#inputSizer;
|
||||
|
||||
/** @type {HTMLButtonElement} — trailing clear-all button */
|
||||
#clearButton;
|
||||
|
||||
/** @type {Set<string>} */
|
||||
#disabledValues = new Set();
|
||||
|
||||
/** @type {Map<string, string>} */
|
||||
#tooltips = new Map();
|
||||
|
||||
|
||||
/**
|
||||
* A display element which lists the chosen options.
|
||||
* @type {HTMLDivElement}
|
||||
* Returns a comma-separated string
|
||||
* FormData reads this via field.value.
|
||||
* @override
|
||||
*/
|
||||
#tags;
|
||||
get value() {
|
||||
return Array.from(this._value).join(",");
|
||||
}
|
||||
|
||||
/** @override */
|
||||
set value(val) {
|
||||
this._value.clear();
|
||||
const values = Array.isArray(val) ? val : String(val).split(",").filter(Boolean);
|
||||
for (const v of values) {
|
||||
this._value.add(v);
|
||||
}
|
||||
this._internals.setFormValue(this.value);
|
||||
this._refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* A button element which clear all the options.
|
||||
* @type {HTMLButtonElement}
|
||||
* Return an array so FormDataExtended.object[name] matches Foundry's own
|
||||
* HTMLMultiSelectElement — both field.value (string) and .object (array) are correct.
|
||||
* @override
|
||||
* @protected
|
||||
*/
|
||||
#clearAll;
|
||||
_getValue() {
|
||||
return Array.from(this._value);
|
||||
}
|
||||
|
||||
/**
|
||||
* A Set containing the values that should always be disabled.
|
||||
* @type {Set}
|
||||
* Accept either an array or comma-separated string when Foundry calls _setValue().
|
||||
* @override
|
||||
* @protected
|
||||
*/
|
||||
#disabledValues;
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
// We will call initialize twice (one in the parent constructor) then one in #setup
|
||||
// required since when we want to build the elements we should to an initialize first
|
||||
// and we cannot override _initialize since we don't have access to #disabledValues there
|
||||
#setup() {
|
||||
super._initialize();
|
||||
this.#disabledValues = new Set();
|
||||
for (const option of this.querySelectorAll("option")) {
|
||||
if (option.value === "") {
|
||||
option.label = game.i18n.localize("l5r5e.multiselect.empty_tag");
|
||||
this._choices[option.value] = game.i18n.localize("l5r5e.multiselect.empty_tag");
|
||||
}
|
||||
if (option.disabled) {
|
||||
this.#disabledValues.add(option.value);
|
||||
}
|
||||
_setValue(val) {
|
||||
const values = Array.isArray(val) ? val : String(val).split(",").filter(Boolean);
|
||||
if (values.some(v => v && !(v in this._choices))) {
|
||||
throw new Error("The values assigned to a multi-select element must all be valid options.");
|
||||
}
|
||||
this._value.clear();
|
||||
for (const v of values) {
|
||||
this._value.add(v);
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_initialize() {
|
||||
super._initialize(); // fills this._choices, this._value, this._options
|
||||
for (const option of this.querySelectorAll("option")) {
|
||||
if (option.disabled)
|
||||
this.#disabledValues.add(option.value);
|
||||
if (option.title)
|
||||
this.#tooltips.set(option.value, option.title);
|
||||
}
|
||||
if (this.hasAttribute("value")) {
|
||||
this._setValue(this.getAttribute("value"));
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Element Lifecycle */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_buildElements() {
|
||||
this.#setup();
|
||||
|
||||
// Create select element
|
||||
this.#select = this._primaryInput = document.createElement("select");
|
||||
this.#select.insertAdjacentHTML("afterbegin", `<option id="l5r5e-multiselect-placeholder" value="" disabled selected hidden>${game.i18n.localize("l5r5e.multiselect.placeholder")}</option>`);
|
||||
this.#select.append(...this._options);
|
||||
this.#select.disabled = !this.editable;
|
||||
|
||||
// Create a div element for display
|
||||
this.#tags = document.createElement("div");
|
||||
this.#tags.className = "tags input-element-tags";
|
||||
|
||||
// Create a clear all button
|
||||
this.#clearAll = document.createElement("button");
|
||||
this.#clearAll.textContent = "X";
|
||||
return [this.#select, this.#clearAll, this.#tags];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_refresh() {
|
||||
// Update the displayed tags
|
||||
const tags = Array.from(this._value).map(id => {
|
||||
return foundry.applications.elements.HTMLStringTagsElement.renderTag(id, this._choices[id], this.editable);
|
||||
// Ask mixin to build <input> + <ul>, then re-home them into our structure.
|
||||
const mixinWrapper = this._buildDropdownElements({
|
||||
placeholder: this.getAttribute("placeholder")
|
||||
?? game.i18n.localize("l5r5e.multiselect.placeholder"),
|
||||
});
|
||||
this.#tags.replaceChildren(...tags);
|
||||
const searchInput = mixinWrapper.querySelector("input.input");
|
||||
const dropdownList = mixinWrapper.querySelector("ul.dropdown");
|
||||
|
||||
// Figure out if we are overflowing the tag div.
|
||||
if($(this.#tags).css("max-height")) {
|
||||
const numericMaxHeight = parseInt($(this.#tags).css("max-height"), 10);
|
||||
if(numericMaxHeight) {
|
||||
if($(this.#tags).prop("scrollHeight") > numericMaxHeight) {
|
||||
this.#tags.classList.add("overflowing");
|
||||
}
|
||||
else {
|
||||
this.#tags.classList.remove("overflowing");
|
||||
}
|
||||
}
|
||||
}
|
||||
// Selection box
|
||||
this.#selectionBox = document.createElement("div");
|
||||
this.#selectionBox.classList.add("selection-box");
|
||||
|
||||
// Disable selected options
|
||||
const hideDisabled = game.settings.get(CONFIG.l5r5e.namespace, "compendium-hide-disabled-sources");
|
||||
for (const option of this.#select) {
|
||||
if (this._value.has(option.value)) {
|
||||
option.disabled = true;
|
||||
option.title = game.i18n.localize("l5r5e.multiselect.already_in_filter");
|
||||
continue;
|
||||
}
|
||||
if (this.#disabledValues.has(option.value)) {
|
||||
option.disabled = true;
|
||||
option.hidden = hideDisabled;
|
||||
continue;
|
||||
}
|
||||
option.disabled = false;
|
||||
option.removeAttribute("title");
|
||||
}
|
||||
// Chip list
|
||||
this.#chipList = document.createElement("div");
|
||||
this.#chipList.classList.add("chip-list");
|
||||
|
||||
// Auto-sizing sizer — CSS grid trick: ::after mirrors data-value, input shares the cell
|
||||
this.#inputSizer = document.createElement("span");
|
||||
this.#inputSizer.classList.add("input-sizer");
|
||||
this.#inputSizer.dataset.value = "";
|
||||
this.#inputSizer.append(searchInput);
|
||||
|
||||
// Clear-all button
|
||||
this.#clearButton = document.createElement("button");
|
||||
this.#clearButton.type = "button";
|
||||
this.#clearButton.classList.add("clear-btn");
|
||||
this.#clearButton.setAttribute("aria-label",
|
||||
game.i18n.localize("l5r5e.multiselect.clear_all") ?? "Clear all");
|
||||
this.#clearButton.textContent = "×";
|
||||
this.#clearButton.hidden = true;
|
||||
|
||||
this.#selectionBox.append(this.#chipList, this.#inputSizer, this.#clearButton);
|
||||
|
||||
// Container: selection box + dropdown must share the same positioned ancestor.
|
||||
const container = document.createElement("div");
|
||||
container.classList.add("multi-select-container");
|
||||
container.append(this.#selectionBox, dropdownList);
|
||||
|
||||
this._primaryInput = searchInput;
|
||||
return [container];
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/** @override */
|
||||
_activateListeners() {
|
||||
this.#select.addEventListener("change", this.#onChangeSelect.bind(this));
|
||||
this.#clearAll.addEventListener("click", this.#onClickClearAll.bind(this));
|
||||
this.#tags.addEventListener("click", this.#onClickTag.bind(this));
|
||||
this._activateDropdownListeners();
|
||||
const signal = this.abortSignal;
|
||||
|
||||
this.#tags.addEventListener("mouseleave", this.#onMouseLeave.bind(this));
|
||||
this.#selectionBox.addEventListener("mousedown", this.#onBoxMouseDown.bind(this), { signal });
|
||||
this.#chipList.addEventListener("click", this.#onChipClick.bind(this), { signal });
|
||||
this.#clearButton.addEventListener("click", this.#onClearAll.bind(this), { signal });
|
||||
// stop the clear button from opening the selection box when pressing it
|
||||
this.#clearButton.addEventListener("mousedown", (event) => {event.preventDefault(); event.stopPropagation();}, {signal});
|
||||
this._dropdownInput.addEventListener("input", () => this.#updateInputSizer(), { signal });
|
||||
}
|
||||
|
||||
#onMouseLeave(event) {
|
||||
// Figure out if we are overflowing the tag div.
|
||||
if($(this.#tags).css("max-height")) {
|
||||
const numericMaxHeight = parseInt($(this.#tags).css("max-height"), 10);
|
||||
|
||||
if($(this.#tags).prop("scrollHeight") > numericMaxHeight) {
|
||||
this.#tags.classList.add("overflowing");
|
||||
}
|
||||
else {
|
||||
this.#tags.classList.remove("overflowing");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle changes to the Select input, marking the selected option as a chosen value.
|
||||
* @param {Event} event The change event on the select element
|
||||
*/
|
||||
#onChangeSelect(event) {
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
const select = event.currentTarget;
|
||||
if (select.valueIndex === 0)
|
||||
return; // Ignore placeholder
|
||||
this.select(select.value);
|
||||
select.value = "";
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle click events on a tagged value, removing it from the chosen set.
|
||||
* @param {PointerEvent} event The originating click event on a chosen tag
|
||||
*/
|
||||
#onClickTag(event) {
|
||||
event.preventDefault();
|
||||
if (!event.target.classList.contains("remove"))
|
||||
return;
|
||||
if (!this.editable)
|
||||
return;
|
||||
const tag = event.target.closest(".tag");
|
||||
this.unselect(tag.dataset.key);
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Handle clickling the clear all button
|
||||
* @param {Event} event The originating click event on the clear all button
|
||||
*/
|
||||
#onClickClearAll(event) {
|
||||
event.preventDefault();
|
||||
var _this = this;
|
||||
$(this.#tags).children().each(function () {
|
||||
_this.unselect($(this).data("key"));
|
||||
})
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/** @override */
|
||||
_toggleDisabled(disabled) {
|
||||
this.#select.toggleAttribute("disabled", disabled);
|
||||
this._toggleDropdownDisabled(disabled);
|
||||
|
||||
if (this.#selectionBox) {
|
||||
this.#selectionBox.classList.toggle("disabled", disabled);
|
||||
}
|
||||
|
||||
if (this.#chipList) {
|
||||
this._refresh(); // re-render chips so × appears/disappears
|
||||
}
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_refresh() {
|
||||
const values = Array.from(this._value);
|
||||
this._internals.setFormValue(values.length ? values.join(",") : "");
|
||||
|
||||
this.#renderChips(values);
|
||||
|
||||
// Clear button: only visible when editable and something is selected.
|
||||
if (this.#clearButton) {
|
||||
this.#clearButton.hidden = (!this.editable || values.length === 0);
|
||||
}
|
||||
|
||||
this.#updateInputSizer();
|
||||
this._dropdownRefresh();
|
||||
}
|
||||
|
||||
/** @param {string[]} values */
|
||||
#renderChips(values) {
|
||||
if (!this.#chipList){
|
||||
return
|
||||
}
|
||||
this.#chipList.replaceChildren(...values.map(id => this.#buildChip(id)));
|
||||
}
|
||||
|
||||
/** @param {string} id */
|
||||
#buildChip(id) {
|
||||
const chip = document.createElement("span");
|
||||
chip.classList.add("chip");
|
||||
chip.dataset.key = id;
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.classList.add("chip-label");
|
||||
label.textContent = this._choices[id] ?? id;
|
||||
chip.append(label);
|
||||
|
||||
// Only add × when the element is editable (not disabled, not readonly).
|
||||
if (this.editable) {
|
||||
const remove = document.createElement("span");
|
||||
remove.classList.add("chip-remove");
|
||||
remove.setAttribute("aria-label", `Remove ${this._choices[id] ?? id}`);
|
||||
remove.setAttribute("aria-hidden", "true");
|
||||
remove.textContent = "×";
|
||||
chip.append(remove);
|
||||
}
|
||||
return chip;
|
||||
}
|
||||
|
||||
/** Mirror typed text into the sizer span so CSS sizes the input correctly. */
|
||||
#updateInputSizer() {
|
||||
if (!this.#inputSizer || !this._dropdownInput)
|
||||
return;
|
||||
|
||||
const input = this._dropdownInput;
|
||||
const text = input.value || input.placeholder || "";
|
||||
this.#inputSizer.dataset.value = text;
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_getDropdownOptions() {
|
||||
const makeOption = (option, group = null) => ({
|
||||
value: option.value,
|
||||
label: this._choices[option.value] ?? option.innerText,
|
||||
group,
|
||||
disabled: this.#disabledValues.has(option.value),
|
||||
tooltip: this.#tooltips.get(option.value) ?? "",
|
||||
});
|
||||
|
||||
return this._options.flatMap(child => {
|
||||
if (child instanceof HTMLOptGroupElement) {
|
||||
return [...child.querySelectorAll("option")]
|
||||
.filter(option => option.value)
|
||||
.map(option => makeOption(option, child.label));
|
||||
}
|
||||
if (child instanceof HTMLOptionElement && child.value) {
|
||||
return makeOption(child);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_isOptionSelected(value) {
|
||||
return this._value.has(value);
|
||||
}
|
||||
|
||||
/** @override */
|
||||
_onDropdownPick(option) {
|
||||
const inValue = this._value.has(option.value);
|
||||
const inChoices = option.value in this._choices;
|
||||
if(!(inValue || inChoices))
|
||||
return;
|
||||
|
||||
if (inValue) {
|
||||
this._value.delete(option.value);
|
||||
}
|
||||
else if(inChoices) {
|
||||
this._value.add(option.value);
|
||||
}
|
||||
|
||||
this._internals.setFormValue(this.value);
|
||||
this._refresh();
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
#onBoxMouseDown(event) {
|
||||
// Fully block interaction when not editable.
|
||||
if (!this.editable) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (event.target.classList.contains("chip-remove"))
|
||||
return;
|
||||
if (event.target === this._dropdownInput)
|
||||
return;
|
||||
|
||||
event.preventDefault();
|
||||
this._dropdownInput?.focus();
|
||||
}
|
||||
|
||||
#onChipClick(event) {
|
||||
if (!event.target.classList.contains("chip-remove") || !this.editable)
|
||||
return;
|
||||
|
||||
const chip = event.target.closest(".chip");
|
||||
if (!chip)
|
||||
return;
|
||||
|
||||
this._value.delete(chip.dataset.key);
|
||||
this._internals.setFormValue(this.value);
|
||||
this._refresh();
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
|
||||
this._dropdownInput?.focus();
|
||||
}
|
||||
|
||||
#onClearAll(event) {
|
||||
event.preventDefault();
|
||||
if (!this.editable)
|
||||
return;
|
||||
|
||||
this._value.clear();
|
||||
this._internals.setFormValue("");
|
||||
this._refresh();
|
||||
this.dispatchEvent(new Event("change", { bubbles: true, cancelable: true }));
|
||||
}
|
||||
|
||||
/* -------------------------------------------- */
|
||||
/* Static Factory */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Create a HTML_l5r5e_MultiSelectElement using provided configuration data.
|
||||
* @param {FormInputConfig<string[]> & Omit<SelectInputConfig, "blank">} config
|
||||
* @returns {L5r5eHtmlMultiSelectElement}
|
||||
*/
|
||||
static create(config) {
|
||||
// Foundry creates either a select with tag multi-select or multi-checkboxes. We want a l5r5e-multi-select
|
||||
// Copied the implementation from foundry.applications.fields.createMultiSelectInput with our required changes.
|
||||
const groups = prepareSelectOptionGroups(config);
|
||||
const element = document.createElement(L5r5eHtmlMultiSelectElement.tagName);
|
||||
element.name = config.name;
|
||||
foundry.applications.fields.setInputAttributes(element, config);
|
||||
if (config.hideDisabledOptions) {
|
||||
element.toggleAttribute("hidedisabledoptions", true);
|
||||
}
|
||||
|
||||
//Setup the HTML
|
||||
const select = document.createElement(L5r5eHtmlMultiSelectElement.tagName);
|
||||
select.name = config.name;
|
||||
foundry.applications.fields.setInputAttributes(select, config);
|
||||
for (const group_entry of groups) {
|
||||
let parent = select;
|
||||
if (group_entry.group) {
|
||||
parent = _appendOptgroupHtml(group_entry.group, select);
|
||||
for (const groupEntry of groups) {
|
||||
let parent = element;
|
||||
if (groupEntry.group) {
|
||||
parent = _appendOptgroup(groupEntry.group, element);
|
||||
}
|
||||
for (const option_entry of group_entry.options) {
|
||||
_appendOptionHtml(option_entry, parent);
|
||||
for (const groupOption of groupEntry.options){
|
||||
_appendOption(groupOption, parent);
|
||||
}
|
||||
}
|
||||
return select;
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
/** Stolen from foundry.applications.fields.prepareSelectOptionGroups: Needed to add support for tooltips
|
||||
*
|
||||
*/
|
||||
/* -------------------------------------------- */
|
||||
/* Module Helpers */
|
||||
/* -------------------------------------------- */
|
||||
|
||||
function prepareSelectOptionGroups(config) {
|
||||
const result = foundry.applications.fields.prepareSelectOptionGroups(config);
|
||||
|
||||
// Disable options based on input
|
||||
config.options.filter((option) => option?.disabled || option?.tooltip).forEach((SpecialOption) => {
|
||||
result.forEach((group) => {
|
||||
group.options.forEach((option) => {
|
||||
if (SpecialOption.value === option.value) {
|
||||
option.disabled = SpecialOption.disabled;
|
||||
option.tooltip = SpecialOption?.tooltip;
|
||||
config.options.filter(option => option?.disabled || option?.tooltip).forEach(special => {
|
||||
result.forEach(group => {
|
||||
group.options.forEach(groupOption => {
|
||||
if (groupOption.value === special.value) {
|
||||
groupOption.disabled = special.disabled;
|
||||
groupOption.tooltip = special.tooltip;
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Stolen from foundry.applications.fields
|
||||
* Create and append an optgroup element to a parent select.
|
||||
* @param {string} label
|
||||
* @param {HTMLSelectElement} parent
|
||||
* @returns {HTMLOptGroupElement}
|
||||
* @internal
|
||||
*/
|
||||
function _appendOptgroupHtml(label, parent) {
|
||||
const optgroup = document.createElement("optgroup");
|
||||
optgroup.label = label;
|
||||
parent.appendChild(optgroup);
|
||||
return optgroup;
|
||||
function _appendOptgroup(label, parent) {
|
||||
const element = document.createElement("optgroup");
|
||||
element.label = label;
|
||||
parent.appendChild(element);
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
/** Stolen from foundry.applications.fields
|
||||
* Create and append an option element to a parent select or optgroup.
|
||||
* @param {FormSelectOption} option
|
||||
* @param {HTMLSelectElement|HTMLOptGroupElement} parent
|
||||
* @internal
|
||||
*/
|
||||
function _appendOptionHtml(option, parent) {
|
||||
function _appendOption(option, parent) {
|
||||
const { value, label, selected, disabled, rule, tooltip } = option;
|
||||
if ((value !== undefined) && (label !== undefined)) {
|
||||
const option_html = document.createElement("option");
|
||||
option_html.value = value;
|
||||
option_html.innerText = label;
|
||||
if (value !== undefined && label !== undefined) {
|
||||
const element = document.createElement("option");
|
||||
element.value = value;
|
||||
element.innerText = label;
|
||||
if (selected) {
|
||||
option_html.toggleAttribute("selected", true);
|
||||
element.toggleAttribute("selected", true);
|
||||
}
|
||||
if (disabled) {
|
||||
option_html.toggleAttribute("disabled", true);
|
||||
element.toggleAttribute("disabled", true);
|
||||
}
|
||||
if (tooltip) {
|
||||
option_html.setAttribute("title", tooltip);
|
||||
element.setAttribute("title", tooltip);
|
||||
}
|
||||
parent.appendChild(option_html);
|
||||
parent.appendChild(element);
|
||||
}
|
||||
if (rule) {
|
||||
parent.insertAdjacentHTML("beforeend", "<hr>");
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
.application {
|
||||
color: var(--color-text-dark-primary);
|
||||
color: var(--color-text-primary);
|
||||
|
||||
.scrollable {
|
||||
--scroll-margin: 0;
|
||||
|
||||
@@ -1,5 +1,67 @@
|
||||
/* SCSS */
|
||||
|
||||
@mixin theme-dark {
|
||||
// Restricted entries
|
||||
--l5r5e-restricted-opacity: 0.6;
|
||||
--l5r5e-restricted-filter: grayscale(0.3);
|
||||
|
||||
// l5r5e-multi-select
|
||||
--l5r5e-dropdown-bg: var(--color-cool-4, #302831);
|
||||
--l5r5e-dropdown-color: var(--color-light-2, #efe6d8);
|
||||
--l5r5e-dropdown-shadow: 0 4px 12px rgba(0, 0, 0, 0.6);
|
||||
--l5r5e-dropdown-group-color: var(--color-light-5, #9f8475);
|
||||
--l5r5e-dropdown-group-bg: rgba(255, 255, 255, 0.05);
|
||||
--l5r5e-dropdown-option-color: var(--color-light-2, #efe6d8);
|
||||
--l5r5e-dropdown-no-results-color: var(--color-light-5, #9f8475);
|
||||
--l5r5e-chip-border-color: var(--color-light-5, #9f8475);
|
||||
--l5r5e-chip-bg: rgba(93, 20, 43, 0.12);
|
||||
--l5r5e-chip-color: var(--color-light-2, #efe6d8);
|
||||
|
||||
// selection ring
|
||||
--l5r5e-filter-selected-opacity: 0.6;
|
||||
--l5r5e-filter-selected-tint: invert(1) sepia(1) saturate(4) hue-rotate(5deg); // → gold
|
||||
}
|
||||
|
||||
@mixin theme-light {
|
||||
// Restricted entries
|
||||
--l5r5e-restricted-opacity: 0.35;
|
||||
--l5r5e-restricted-filter: grayscale(1) brightness(1.1);
|
||||
|
||||
// l5r5e-multi-select
|
||||
--l5r5e-dropdown-bg: #f4efe6;
|
||||
--l5r5e-dropdown-color: var(--color-dark-2, #222);
|
||||
--l5r5e-dropdown-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
--l5r5e-dropdown-group-color: var(--color-dark-4, #444);
|
||||
--l5r5e-dropdown-group-bg: rgba(0, 0, 0, 0.05);
|
||||
--l5r5e-dropdown-option-color: var(--color-dark-2, #222);
|
||||
--l5r5e-dropdown-no-results-color: var(--color-dark-4, #444);
|
||||
--l5r5e-chip-border-color: var(--color-light-5, #9f8475);
|
||||
--l5r5e-chip-bg: rgba(93, 20, 43, 0.08);
|
||||
--l5r5e-chip-color: var(--color-dark-2, #222);
|
||||
|
||||
// selection ring
|
||||
--l5r5e-filter-selected-opacity: 0.45;
|
||||
--l5r5e-filter-selected-tint: invert(0.15) sepia(1) saturate(6) hue-rotate(295deg) brightness(0.7); // → deep red
|
||||
}
|
||||
|
||||
body.theme-light {
|
||||
@include theme-light();
|
||||
}
|
||||
body.theme-dark {
|
||||
@include theme-dark();
|
||||
}
|
||||
|
||||
.application.theme-light,
|
||||
.themed.theme-light {
|
||||
@include theme-light();
|
||||
}
|
||||
.application.theme-dark,
|
||||
.themed.theme-dark {
|
||||
@include theme-dark();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
|
||||
button {
|
||||
font-size: 0.75rem;
|
||||
cursor: url("../assets/cursors/pointer.webp"), pointer;
|
||||
@@ -29,7 +91,6 @@ button {
|
||||
}
|
||||
}
|
||||
|
||||
// logo
|
||||
#logo {
|
||||
content: url("../assets/l5r-logo.webp");
|
||||
height: 80px;
|
||||
@@ -38,22 +99,8 @@ button {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
// navigation
|
||||
#navigation {
|
||||
left: 120px;
|
||||
//&:before {
|
||||
// content: "";
|
||||
// background: url("../assets/l5r-logo.webp") no-repeat 0 0;
|
||||
// background-size: cover;
|
||||
// height: 80px;
|
||||
// width: 88px;
|
||||
// opacity: 0.65;
|
||||
// position: absolute;
|
||||
// left: -12rem;
|
||||
// &:hover {
|
||||
// opacity: 0.75;
|
||||
// }
|
||||
//}
|
||||
#nav-toggle,
|
||||
#scene-list .scene.nav-item {
|
||||
cursor: default;
|
||||
@@ -115,7 +162,6 @@ button {
|
||||
}
|
||||
}
|
||||
|
||||
// controls
|
||||
#controls {
|
||||
top: 100px;
|
||||
ol {
|
||||
@@ -164,7 +210,6 @@ button {
|
||||
}
|
||||
}
|
||||
|
||||
// Playlist
|
||||
#playlists {
|
||||
.playlist {
|
||||
.playlist-header {
|
||||
@@ -177,7 +222,6 @@ button {
|
||||
}
|
||||
}
|
||||
|
||||
// Combat
|
||||
#combat,
|
||||
#combat-popout {
|
||||
.combat-tracker-header {
|
||||
@@ -246,50 +290,138 @@ button {
|
||||
}
|
||||
}
|
||||
|
||||
// Pause
|
||||
#pause {
|
||||
img {
|
||||
content: url("../assets/icons/pause.svg");
|
||||
}
|
||||
}
|
||||
|
||||
// Compendium
|
||||
.directory-item {
|
||||
.ring-filter {
|
||||
i.i_earth,
|
||||
i.i_fire,
|
||||
i.i_water,
|
||||
i.i_air,
|
||||
i.i_void {
|
||||
line-height: unset; // do not have it rised up
|
||||
.compendium-directory {
|
||||
|
||||
min-width: 350px; //Default size set as min
|
||||
|
||||
.directory-item {
|
||||
.ring-rarity-rank {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
padding-right: 1rem;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
|
||||
i[class^="i_"] { // i_water, i_air etc.
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ring-rarity-rank {
|
||||
flex: unset
|
||||
}
|
||||
}
|
||||
|
||||
.not-for-players {
|
||||
filter:brightness(0.5);
|
||||
}
|
||||
|
||||
.compendium-header {
|
||||
.ring-filter{
|
||||
.selected {
|
||||
filter: drop-shadow(0px 0px 5px yellow);
|
||||
}
|
||||
}
|
||||
.rank-filter {
|
||||
.selected {
|
||||
filter: drop-shadow(0px 0px 5px yellow) drop-shadow(0px 0px 5px yellow) drop-shadow(0px 0px 5px yellow);
|
||||
}
|
||||
.not-for-players {
|
||||
opacity: var(--l5r5e-restricted-opacity);
|
||||
filter: var(--l5r5e-restricted-filter);
|
||||
}
|
||||
|
||||
l5r5e-multi-select {
|
||||
.input-element-tags {
|
||||
max-height: 100px;
|
||||
[data-application-part="filter"] {
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.flexrow {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
margin: 0.35rem 0.5rem;
|
||||
padding-right: 1.5rem; // space for the clear filter x
|
||||
|
||||
&.source-filter {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
flex-shrink: 0;
|
||||
width: 3.5rem; // same column alignment
|
||||
font-size: 0.8rem;
|
||||
line-height: 1;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.number-filter {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
// ── Filter buttons (<a> and <i>)
|
||||
.number-filter a,
|
||||
.rank-filter a,
|
||||
.rarity-filter a,
|
||||
.ring-filter i {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.5rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
border-radius: 3px;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
// ── Selected state circle
|
||||
.rank-filter a.selected::before,
|
||||
.rarity-filter a.selected::before,
|
||||
.ring-filter i.selected::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: url("../assets/icons/circle.svg") center / contain no-repeat;
|
||||
opacity: var(--l5r5e-filter-selected-opacity);
|
||||
filter: var(--l5r5e-filter-selected-tint);
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
// Ring-specific adjustment for circle
|
||||
.ring-filter i.selected::after {
|
||||
inset: -4px;
|
||||
}
|
||||
|
||||
// ── Inline clear button
|
||||
a[data-clear] {
|
||||
position: absolute;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
right: 0.25rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
margin: 0;
|
||||
min-width: unset;
|
||||
opacity: 0.5;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
padding: 0.1rem 0.3rem;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.source-filter l5r5e-multi-select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
button.gm.applyPlayerFilter {
|
||||
margin-left: 0.5rem;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,49 +677,6 @@ button {
|
||||
}
|
||||
}
|
||||
|
||||
l5r5e-multi-select {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(auto, 1fr);
|
||||
select {
|
||||
justify-self: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
text-align-last: center;
|
||||
}
|
||||
.input-element-tags {
|
||||
grid-column-start: 1;
|
||||
grid-column-end: span 3;
|
||||
|
||||
max-height: fit-content; // use value in px to limit the initial size of the tags list, but expandable when hovering over
|
||||
overflow: hidden;
|
||||
|
||||
&.overflowing {
|
||||
// Apply mask-image with linear gradient fade-out effect
|
||||
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 80%, rgba(0, 0, 0, 0) 100%);
|
||||
|
||||
// Transition for smooth height change and mask removal
|
||||
transition: height 0.3s ease, mask-image 0.3s ease;
|
||||
}
|
||||
&.overflowing:hover {
|
||||
max-height: fit-content;
|
||||
mask-image: none;
|
||||
}
|
||||
}
|
||||
select {
|
||||
grid-row: 1;
|
||||
}
|
||||
button {
|
||||
grid-row: 1;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
l5r5e-multi-select:has( > button.gm) {
|
||||
.input-element-tags {
|
||||
grid-column-end: span 4;
|
||||
}
|
||||
}
|
||||
|
||||
form#settings-config {
|
||||
|
||||
div.form-group:has(l5r5e-multi-select) {
|
||||
@@ -607,31 +696,361 @@ form#settings-config {
|
||||
}
|
||||
}
|
||||
|
||||
l5r5e-combo-box {
|
||||
// Reset li rules
|
||||
li {
|
||||
flex: none !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.autocomplete-wrapper {
|
||||
input {
|
||||
margin: 0 !important;
|
||||
flex: none !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
display: block;
|
||||
flex: 1;
|
||||
min-width: 0; // prevents flex overflow
|
||||
width: 100%;
|
||||
|
||||
.wrapper {
|
||||
display: block;
|
||||
position: relative;
|
||||
.autocomplete-list {
|
||||
position: absolute;
|
||||
border: 1px solid #6e7e6b;
|
||||
border-bottom: none;
|
||||
border-top: none;
|
||||
z-index: 99;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 1px solid #6e7e6b;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
padding: 2px 4px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-bottom-color: #8b0000;
|
||||
}
|
||||
.autocomplete-list div {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #6e7e6b;
|
||||
text-align: left;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.autocomplete-list div:hover {
|
||||
background-color: #e9e9e9;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
// Reset any inherited text styles from parent label
|
||||
text-shadow: none;
|
||||
color: #333;
|
||||
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100; /* floats above everything */
|
||||
max-height: 200px; /* scrollable */
|
||||
overflow-y: auto;
|
||||
background: #f4efe6; /* matches parchment */
|
||||
border: 1px solid #6e7e6b;
|
||||
box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.3);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
text-align: center;
|
||||
font-size: var(--font-size-16);
|
||||
font-family: $font-primary;
|
||||
}
|
||||
|
||||
.option {
|
||||
display: block; // override list-item
|
||||
margin: 0; // reset any inherited li margins
|
||||
flex: none; // reset any inherited li flex
|
||||
padding: 4px 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
color: #333;
|
||||
text-transform: uppercase; /* matches the label style in your sheet */
|
||||
letter-spacing: 0.05em;
|
||||
border-bottom: 1px solid rgba(110, 126, 107, 0.3);
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
background: #8b0000;
|
||||
color: #f4efe6;
|
||||
}
|
||||
.autocomplete-active {
|
||||
background-color: DodgerBlue !important;
|
||||
color: #ffffff;
|
||||
}
|
||||
.option:last-child,
|
||||
.group:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.group {
|
||||
padding: 4px 8px 2px;
|
||||
font-size: 0.7em;
|
||||
font-weight: bold;
|
||||
color: #6e7e6b;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
background: rgba(110, 126, 107, 0.15);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 6px 8px;
|
||||
color: #999;
|
||||
font-style: italic;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
}
|
||||
|
||||
l5r5e-multi-select,
|
||||
l5r5e-combo-box {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
l5r5e-combo-box {
|
||||
.wrapper {
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-color: var(--color-border, #6e7e6b);
|
||||
border-radius: 4px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--color-warm-3, #5d142b);
|
||||
box-shadow: 0 0 0 2px rgba(93, 20, 43, 0.2);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
input.input {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font-size: inherit;
|
||||
line-height: 1.4;
|
||||
min-width: 4ch;
|
||||
}
|
||||
}
|
||||
|
||||
l5r5e-multi-select {
|
||||
.multi-select-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.selection-box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.3125rem 0.5rem;
|
||||
min-height: 2.25rem;
|
||||
|
||||
border: 1px solid var(--color-border, #6e7e6b);
|
||||
border-radius: 4px;
|
||||
cursor: text;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: var(--color-warm-3, #5d142b);
|
||||
box-shadow: 0 0 0 2px rgba(93, 20, 43, 0.2);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
|
||||
.input-sizer {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chip-list {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.1875rem;
|
||||
padding: 0.0625rem 0.3125rem;
|
||||
|
||||
border: 1px solid var(--l5r5e-chip-border-color);
|
||||
border-radius: 3px;
|
||||
background: var(--l5r5e-chip-bg);
|
||||
color: var(--l5r5e-chip-color);
|
||||
|
||||
font-size: 0.8em;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.chip-remove {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 0.0625rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-light-5, #9f8475);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-warm-2, #c9593f);
|
||||
}
|
||||
}
|
||||
|
||||
/* Auto-sizing input */
|
||||
.input-sizer {
|
||||
display: inline-grid;
|
||||
flex: 1 1 auto;
|
||||
min-width: 4ch; // never collapse
|
||||
max-width: 100%;
|
||||
|
||||
&::after {
|
||||
content: attr(data-value) " ";
|
||||
grid-area: 1 / 1;
|
||||
visibility: hidden;
|
||||
white-space: pre;
|
||||
font: inherit;
|
||||
line-height: 1.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
input.input {
|
||||
grid-area: 1 / 1;
|
||||
width: 100%;
|
||||
min-width: 0; // allow flexbox to shrink
|
||||
padding: 0.125rem 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
font: inherit;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto; // pushes it to the end
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
|
||||
padding: 0 0.125rem;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 1.1em;
|
||||
cursor: pointer;
|
||||
color: var(--color-light-5, #9f8475);
|
||||
|
||||
&:hover {
|
||||
color: var(--color-warm-2, #c9593f);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
l5r5e-multi-select,
|
||||
l5r5e-combo-box {
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.25rem);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0.25rem 0;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
border-radius: 4px;
|
||||
background: var(--l5r5e-dropdown-bg);
|
||||
border-color: var(--color-border, #6e7e6b);
|
||||
box-shadow: var(--l5r5e-dropdown-shadow);
|
||||
color: var(--l5r5e-dropdown-color);
|
||||
|
||||
li.group {
|
||||
padding: 0.375rem 0.75rem 0.125rem;
|
||||
font-size: 0.7em;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
color: var(--l5r5e-dropdown-group-color);
|
||||
background: var(--l5r5e-dropdown-group-bg);
|
||||
}
|
||||
|
||||
li.option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875em;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
color: var(--l5r5e-dropdown-option-color);
|
||||
|
||||
&:hover,
|
||||
&.active {
|
||||
background: var(--color-warm-3, #5d142b);
|
||||
color: var(--color-light-1, #f7f3e8);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
background: rgba(93, 20, 43, 0.25);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.checkmark {
|
||||
flex-shrink: 0;
|
||||
width: 0.875rem;
|
||||
text-align: center;
|
||||
font-size: 0.85em;
|
||||
color: var(--color-warm-1, #ee9b3a);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
li.no-results {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875em;
|
||||
font-style: italic;
|
||||
user-select: none;
|
||||
color: var(--l5r5e-dropdown-no-results-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
52
system/templates/compendium/filter-bar.html
Normal file
52
system/templates/compendium/filter-bar.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<div class="l5r5e filter-bar">
|
||||
|
||||
{{#if filtersToShow.rank}}
|
||||
<div class="flexrow l5r5e rank-filter number-filter">
|
||||
<label>{{localize 'l5r5e.compendium.filter.rank'}}:</label>
|
||||
{{#each ranks}}
|
||||
<a data-rank="{{this}}"
|
||||
data-tooltip="{{localize 'l5r5e.compendium.filter.rank'}} {{this}}">{{this}}</a>
|
||||
{{/each}}
|
||||
<a data-clear style="display:none" data-tooltip="{{localize 'l5r5e.compendium.filter.clear'}}">×</a>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if filtersToShow.rarity}}
|
||||
<div class="flexrow l5r5e rarity-filter number-filter">
|
||||
<label>{{localize 'l5r5e.compendium.filter.rarity'}}:</label>
|
||||
{{#each rarities}}
|
||||
<a data-rarity="{{this}}"
|
||||
data-tooltip="{{localize 'l5r5e.compendium.filter.rarity'}} {{this}}">{{this}}</a>
|
||||
{{/each}}
|
||||
<a data-clear style="display:none" data-tooltip="{{localize 'l5r5e.compendium.filter.clear'}}">×</a>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if filtersToShow.ring}}
|
||||
<div class="flexrow l5r5e ring-filter">
|
||||
<label>{{localize 'l5r5e.rings.label'}}:</label>
|
||||
{{#each rings}}
|
||||
<i data-ring="{{this}}"
|
||||
data-tooltip="{{localize (concat 'l5r5e.rings.' this)}}"
|
||||
class="i_{{this}}"></i>
|
||||
{{/each}}
|
||||
<a data-clear style="display:none" data-tooltip="{{localize 'l5r5e.compendium.filter.clear'}}">×</a>
|
||||
</div>
|
||||
{{/if}}
|
||||
|
||||
{{#if filtersToShow.source}}
|
||||
<div class="flexrow l5r5e source-filter">
|
||||
<l5r5e-multi-select name="filter-sources" {{#if hideDisabledOptions}}hidedisabledoptions{{/if}}>
|
||||
{{selectOptions sources localize=true}}
|
||||
</l5r5e-multi-select>
|
||||
</div>
|
||||
{{#if showPlayerView}}
|
||||
<button type="button" class="gm applyPlayerFilter"
|
||||
data-action="applyPlayerView"
|
||||
data-tooltip="{{localize 'l5r5e.multiselect.player_filter_tooltip'}}">
|
||||
{{localize 'l5r5e.multiselect.player_filter_label'}}
|
||||
</button>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
30
system/templates/compendium/l5r5e-index-partial.html
Normal file
30
system/templates/compendium/l5r5e-index-partial.html
Normal file
@@ -0,0 +1,30 @@
|
||||
{{!--
|
||||
L5R5e entry partial — mirrors Foundry's index-partial.hbs structure and adds
|
||||
ring/rarity/rank badges from entryFilterData injected by _prepareDirectoryContext.
|
||||
|
||||
NOTE: Foundry's index-partial.hbs renders a complete <li> element so it cannot
|
||||
be composed via {{> partial}} without nesting <li> inside <li>. We therefore
|
||||
duplicate its simple structure here and own it ourselves. If Foundry changes
|
||||
their index-partial.hbs, this file should be checked for parity.
|
||||
|
||||
Foundry original: templates/sidebar/apps/compendium/index-partial.hbs
|
||||
--}}
|
||||
<li class="directory-item entry document {{ @root.documentCls }} flexrow" data-entry-id="{{ _id }}">
|
||||
{{#if thumb}}
|
||||
<img class="thumbnail" src="{{ thumb }}" alt="{{ name }}" loading="lazy">
|
||||
{{else if img}}
|
||||
<img class="thumbnail" src="{{ img }}" alt="{{ name }}" loading="lazy">
|
||||
{{else}}
|
||||
<i class="{{ @root.sidebarIcon }}" inert></i>
|
||||
{{/if}}
|
||||
<a class="entry-name ellipsis" data-action="activateEntry">{{ name }}</a>
|
||||
{{#with (lookup @root.entryFilterData _id)}}
|
||||
{{#if (or ring rarity rank)}}
|
||||
<div class="l5r5e ring-rarity-rank" data-action="activateEntry">
|
||||
{{#if ring}} <i class="i_{{ring}}"></i>{{/if}}
|
||||
{{#if rarity}}<a>{{localize "l5r5e.sheets.rarity"}} {{rarity}}</a>{{/if}}
|
||||
{{#if rank}}<a>{{localize "l5r5e.sheets.rank"}} {{rank}}</a>{{/if}}
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/with}}
|
||||
</li>
|
||||
@@ -1,6 +0,0 @@
|
||||
<div class="flexrow l5r5e {{type}}-filter number-filter">
|
||||
<label>{{localize (localize 'l5r5e.compendium.filter.{type}' type=type)}}:</label>
|
||||
{{#each number}}
|
||||
<a data-{{../type}}="{{.}}" data-tooltip="{{localize (localize 'l5r5e.compendium.filter.{type}' type=../type)}} {{.}}">{{.}}</a>
|
||||
{{/each}}
|
||||
</div>
|
||||
@@ -1,8 +0,0 @@
|
||||
<div class="flexrow l5r5e ring-filter number-filter">
|
||||
<label>{{localize 'l5r5e.rings.label'}}:</label>
|
||||
<i data-ring="fire" data-ringId="1" data-tooltip="{{localize 'l5r5e.rings.fire'}}" class="i_fire"></i>
|
||||
<i data-ring="water" data-ringId="2" data-tooltip="{{localize 'l5r5e.rings.water'}}" class="i_water"></i>
|
||||
<i data-ring="earth" data-ringId="3" data-tooltip="{{localize 'l5r5e.rings.earth'}}" class="i_earth"></i>
|
||||
<i data-ring="air" data-ringId="4" data-tooltip="{{localize 'l5r5e.rings.air'}}" class="i_air"></i>
|
||||
<i data-ring="void" data-ringId="5" data-tooltip="{{localize 'l5r5e.rings.void'}}" class="i_void"></i>
|
||||
</div>
|
||||
@@ -1,6 +0,0 @@
|
||||
<div class="l5r5e ring-rarity-rank">
|
||||
<i {{#if ring}} class="i_{{ring}}" {{/if}}>
|
||||
{{#if rarity}} {{localize "l5r5e.sheets.rarity"}} {{rarity}} {{/if}}
|
||||
{{#if rank}} {{localize "l5r5e.sheets.rank"}} {{rank}} {{/if}}
|
||||
</i>
|
||||
</div>
|
||||
Reference in New Issue
Block a user