Merge pull request #175 from Oliver-Akins/feature/incrementer-component
Custom Web Components
This commit is contained in:
commit
118dcfb71c
16 changed files with 543 additions and 63 deletions
125
module/components/icon.mjs
Normal file
125
module/components/icon.mjs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { StyledShadowElement } from "./mixins/Styles.mjs";
|
||||
|
||||
/**
|
||||
Attributes:
|
||||
@property {string} name - The name of the icon, takes precedence over the path
|
||||
@property {string} path - The path of the icon file
|
||||
*/
|
||||
export class DotDungeonIcon extends StyledShadowElement(HTMLElement) {
|
||||
static elementName = `dd-icon`;
|
||||
static formAssociated = false;
|
||||
|
||||
/* Stuff for the mixin to use */
|
||||
static _stylePath = `v3/components/icon.css`;
|
||||
|
||||
|
||||
static _cache = new Map();
|
||||
#container;
|
||||
/** @type {null | string} */
|
||||
_name;
|
||||
/** @type {null | string} */
|
||||
_path;
|
||||
|
||||
/* Stored IDs for all of the hooks that are in this component */
|
||||
#svgHmr;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
// this._shadow = this.attachShadow({ mode: `open`, delegatesFocus: true });
|
||||
|
||||
this.#container = document.createElement(`div`);
|
||||
this._shadow.appendChild(this.#container);
|
||||
};
|
||||
|
||||
_mounted = false;
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
if (this._mounted) return;
|
||||
|
||||
this._name = this.getAttribute(`name`);
|
||||
this._path = this.getAttribute(`path`);
|
||||
|
||||
/*
|
||||
This converts all of the double-dash prefixed properties on the element to
|
||||
CSS variables so that they don't all need to be provided by doing style=""
|
||||
*/
|
||||
for (const attrVar of this.attributes) {
|
||||
if (attrVar.name?.startsWith(`var:`)) {
|
||||
const prop = attrVar.name.replace(`var:`, ``);
|
||||
this.style.setProperty(`--` + prop, attrVar.value);
|
||||
};
|
||||
};
|
||||
|
||||
/*
|
||||
Try to retrieve the icon if it isn't present, try the path then default to
|
||||
the slot content, as then we can have a default per-icon usage
|
||||
*/
|
||||
let content;
|
||||
if (this._name) {
|
||||
content = await this.#getIcon(`./systems/dotdungeon/assets/${this._name}.svg`);
|
||||
};
|
||||
|
||||
if (this._path && !content) {
|
||||
content = await this.#getIcon(this._path);
|
||||
};
|
||||
|
||||
if (content) {
|
||||
this.#container.appendChild(content.cloneNode(true));
|
||||
};
|
||||
|
||||
/*
|
||||
This is so that when we get an HMR event from Foundry we can appropriately
|
||||
handle it using our logic to update the component and the icon cache.
|
||||
*/
|
||||
if (game.settings.get(`dotdungeon`, `devMode`)) {
|
||||
this.#svgHmr = Hooks.on(`dd-hmr:svg`, (iconName, data) => {
|
||||
if (this._name === iconName || this._path?.endsWith(data.path)) {
|
||||
const svg = this.#parseSVG(data.content);
|
||||
this.constructor._cache.set(iconName, svg);
|
||||
this.#container.replaceChildren(svg.cloneNode(true));
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
this._mounted = true;
|
||||
};
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (!this._mounted) return;
|
||||
|
||||
Hooks.off(`dd-hmr:svg`, this.#svgHmr);
|
||||
|
||||
this._mounted = false;
|
||||
};
|
||||
|
||||
async #getIcon(path) {
|
||||
// Cache hit!
|
||||
if (this.constructor._cache.has(path)) {
|
||||
console.debug(`.dungeon | Icon ${path} cache hit`);
|
||||
return this.constructor._cache.get(path);
|
||||
};
|
||||
|
||||
const r = await fetch(path);
|
||||
switch (r.status) {
|
||||
case 200:
|
||||
case 201:
|
||||
break;
|
||||
default:
|
||||
console.error(`.dungeon | Failed to fetch icon: ${path}`);
|
||||
return;
|
||||
};
|
||||
|
||||
console.debug(`.dungeon | Adding icon ${path} to the cache`);
|
||||
const svg = this.#parseSVG(await r.text());
|
||||
this.constructor._cache.set(path, svg);
|
||||
return svg;
|
||||
};
|
||||
|
||||
/** Takes an SVG string and returns it as a DOM node */
|
||||
#parseSVG(content) {
|
||||
const temp = document.createElement(`div`);
|
||||
temp.innerHTML = content;
|
||||
return temp.querySelector(`svg`);
|
||||
};
|
||||
};
|
||||
152
module/components/incrementer.mjs
Normal file
152
module/components/incrementer.mjs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { DotDungeonIcon } from "./icon.mjs";
|
||||
import { StyledShadowElement } from "./mixins/Styles.mjs";
|
||||
|
||||
/**
|
||||
Attributes:
|
||||
@property {string} name - The path to the value to update
|
||||
@property {number} value - The actual value of the input
|
||||
@property {number} min - The minimum value of the input
|
||||
@property {number} max - The maximum value of the input
|
||||
@property {number?} smallStep - The step size used for the buttons and arrow keys
|
||||
@property {number?} largeStep - The step size used for the buttons + Ctrl and page up / down
|
||||
|
||||
Styling:
|
||||
- `--height`: Controls the height of the element + the width of the buttons (default: 1.25rem)
|
||||
- `--width`: Controls the width of the number input (default 50px)
|
||||
*/
|
||||
export class DotDungeonIncrementer extends StyledShadowElement(HTMLElement) {
|
||||
static elementName = `dd-incrementer`;
|
||||
static formAssociated = true;
|
||||
|
||||
static _stylePath = `v3/components/incrementer.css`;
|
||||
|
||||
_internals;
|
||||
#input;
|
||||
|
||||
_min;
|
||||
_max;
|
||||
_smallStep;
|
||||
_largeStep;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Form internals
|
||||
this._internals = this.attachInternals();
|
||||
this._internals.role = `spinbutton`;
|
||||
};
|
||||
|
||||
get form() {
|
||||
return this._internals.form;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this.getAttribute(`name`);
|
||||
}
|
||||
set name(value) {
|
||||
this.setAttribute(`name`, value);
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.getAttribute(`value`);
|
||||
};
|
||||
set value(value) {
|
||||
this.setAttribute(`value`, value);
|
||||
};
|
||||
|
||||
get type() {
|
||||
return `number`;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.replaceChildren();
|
||||
|
||||
// Attribute parsing / registration
|
||||
const value = this.getAttribute(`value`);
|
||||
this._min = parseInt(this.getAttribute(`min`) ?? 0);
|
||||
this._max = parseInt(this.getAttribute(`max`) ?? 0);
|
||||
this._smallStep = parseInt(this.getAttribute(`smallStep`) ?? 1);
|
||||
this._largeStep = parseInt(this.getAttribute(`largeStep`) ?? 5);
|
||||
|
||||
this._internals.ariaValueMin = this._min;
|
||||
this._internals.ariaValueMax = this._max;
|
||||
|
||||
const container = document.createElement(`div`);
|
||||
|
||||
// The input that the user can see / modify
|
||||
const input = document.createElement(`input`);
|
||||
this.#input = input;
|
||||
input.type = `number`;
|
||||
input.ariaHidden = true;
|
||||
input.min = this.getAttribute(`min`);
|
||||
input.max = this.getAttribute(`max`);
|
||||
input.addEventListener(`change`, this.#updateValue.bind(this));
|
||||
input.value = value;
|
||||
|
||||
// plus button
|
||||
const increment = document.createElement("dd-icon");
|
||||
increment.setAttribute(`name`, `create`);
|
||||
increment.setAttribute(`var:size`, `0.75rem`);
|
||||
increment.setAttribute(`var:fill`, `currentColor`);
|
||||
increment.ariaHidden = true;
|
||||
increment.classList.value = `increment`;
|
||||
increment.addEventListener(`mousedown`, this.#increment.bind(this));
|
||||
|
||||
// minus button
|
||||
const decrement = document.createElement(DotDungeonIcon.elementName);
|
||||
decrement.setAttribute(`name`, `minus`);
|
||||
decrement.setAttribute(`var:size`, `0.75rem`);
|
||||
decrement.setAttribute(`var:fill`, `currentColor`);
|
||||
decrement.ariaHidden = true;
|
||||
decrement.classList.value = `decrement`;
|
||||
decrement.addEventListener(`mousedown`, this.#decrement.bind(this));
|
||||
|
||||
// Construct the DOM
|
||||
container.appendChild(decrement);
|
||||
container.appendChild(input);
|
||||
container.appendChild(increment);
|
||||
this._shadow.appendChild(container);
|
||||
|
||||
/*
|
||||
This converts all of the namespace prefixed properties on the element to
|
||||
CSS variables so that they don't all need to be provided by doing style=""
|
||||
*/
|
||||
for (const attrVar of this.attributes) {
|
||||
if (attrVar.name?.startsWith(`var:`)) {
|
||||
const prop = attrVar.name.replace(`var:`, ``);
|
||||
this.style.setProperty(`--` + prop, attrVar.value);
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
#updateValue() {
|
||||
let value = parseInt(this.#input.value);
|
||||
if (this.getAttribute(`min`)) value = Math.max(this._min, value);
|
||||
if (this.getAttribute(`max`)) value = Math.min(this._max, value);
|
||||
this.#input.value = value;
|
||||
this.value = value;
|
||||
this.dispatchEvent(new Event(`change`, { bubbles: true }));
|
||||
|
||||
// NOTE: This may be really annoying, in that case, remove it later
|
||||
this.blur();
|
||||
};
|
||||
|
||||
/** @param {Event} $e */
|
||||
#increment($e) {
|
||||
$e.preventDefault();
|
||||
let value = parseInt(this.#input.value);
|
||||
value += $e.ctrlKey ? this._largeStep : this._smallStep;
|
||||
this.#input.value = value;
|
||||
this.#updateValue();
|
||||
};
|
||||
|
||||
/** @param {Event} $e */
|
||||
#decrement($e) {
|
||||
$e.preventDefault();
|
||||
let value = parseInt(this.#input.value);
|
||||
value -= $e.ctrlKey ? this._largeStep : this._smallStep;
|
||||
this.#input.value = value;
|
||||
this.#updateValue();
|
||||
};
|
||||
};
|
||||
23
module/components/index.mjs
Normal file
23
module/components/index.mjs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { DotDungeonIncrementer } from "./incrementer.mjs";
|
||||
import { DotDungeonIcon } from "./icon.mjs";
|
||||
|
||||
const components = [
|
||||
DotDungeonIcon,
|
||||
DotDungeonIncrementer,
|
||||
];
|
||||
|
||||
export function registerCustomComponents() {
|
||||
(CONFIG.CACHE ??= {}).componentListeners ??= [];
|
||||
for (const component of components) {
|
||||
if (!window.customElements.get(component.elementName)) {
|
||||
console.debug(`.dungeon | Registering component "${component.elementName}"`);
|
||||
window.customElements.define(
|
||||
component.elementName,
|
||||
component
|
||||
);
|
||||
if (component.formAssociated) {
|
||||
CONFIG.CACHE.componentListeners.push(component.elementName);
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
80
module/components/mixins/Styles.mjs
Normal file
80
module/components/mixins/Styles.mjs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* @param {HTMLElement} Base
|
||||
*/
|
||||
export function StyledShadowElement(Base) {
|
||||
return class extends Base {
|
||||
/**
|
||||
* The path to the CSS that is loaded
|
||||
* @type {string}
|
||||
*/
|
||||
static _stylePath;
|
||||
|
||||
/**
|
||||
* The stringified CSS to use
|
||||
* @type {string}
|
||||
*/
|
||||
static _styles;
|
||||
|
||||
/**
|
||||
* The HTML element of the stylesheet
|
||||
* @type {HTMLStyleElement}
|
||||
*/
|
||||
_style;
|
||||
|
||||
/** @type {ShadowRoot} */
|
||||
_shadow;
|
||||
|
||||
/**
|
||||
* The hook ID for this element's CSS hot reload
|
||||
* @type {number}
|
||||
*/
|
||||
#cssHmr;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this._shadow = this.attachShadow({ mode: `open` });
|
||||
this._style = document.createElement(`style`);
|
||||
this._shadow.appendChild(this._style);
|
||||
};
|
||||
|
||||
#mounted = false;
|
||||
connectedCallback() {
|
||||
if (this.#mounted) return;
|
||||
|
||||
this._getStyles();
|
||||
|
||||
if (game.settings.get(`dotdungeon`, `devMode`)) {
|
||||
this.#cssHmr = Hooks.on(`dd-hmr:css`, (data) => {
|
||||
if (data.path.endsWith(this.constructor._stylePath)) {
|
||||
this._style.innerHTML = data.content;
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
this.#mounted = true;
|
||||
};
|
||||
|
||||
disconnectedCallback() {
|
||||
if (!this.#mounted) return;
|
||||
if (this.#cssHmr != null) {
|
||||
Hooks.off(`dd-hmr:css`, this.#cssHmr);
|
||||
this.#cssHmr = null;
|
||||
};
|
||||
this.#mounted = false;
|
||||
};
|
||||
|
||||
_getStyles() {
|
||||
if (this.constructor._styles) {
|
||||
this._style.innerHTML = this.constructor._styles;
|
||||
} else {
|
||||
fetch(`./systems/dotdungeon/.styles/${this.constructor._stylePath}`)
|
||||
.then(r => r.text())
|
||||
.then(t => {
|
||||
this.constructor._styles = t;
|
||||
this._style.innerHTML = t;
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -32,6 +32,7 @@ import * as hbs from "./handlebars.mjs";
|
|||
import "./hooks/hotReload.mjs";
|
||||
|
||||
// Misc Imports
|
||||
import { registerCustomComponents } from "./components/index.mjs";
|
||||
import loadSettings from "./settings/index.mjs";
|
||||
import { devInit } from "./hooks/devInit.mjs";
|
||||
import DOTDUNGEON from "./config.mjs";
|
||||
|
|
@ -108,8 +109,9 @@ Hooks.once(`init`, async () => {
|
|||
|
||||
hbs.registerHandlebarsHelpers();
|
||||
hbs.preloadHandlebarsTemplates();
|
||||
registerCustomComponents();
|
||||
|
||||
CONFIG.CACHE = {};
|
||||
CONFIG.CACHE ??= {};
|
||||
CONFIG.CACHE.icons = await hbs.preloadIcons();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import * as hbs from "../handlebars.mjs";
|
|||
const loaders = {
|
||||
svg(data) {
|
||||
const iconName = data.path.split(`/`).slice(-1)[0].slice(0, -4);
|
||||
console.log(`.dungeon | hot-reloading icon: ${iconName}`);
|
||||
CONFIG.CACHE.icons[iconName] = data.content;
|
||||
console.debug(`.dungeon | hot-reloading icon: ${iconName}`);
|
||||
Hooks.call(`dd-hmr:svg`, iconName, data);
|
||||
},
|
||||
hbs(data) {
|
||||
if (!hbs.partials.some(p => data.path.endsWith(p))) {
|
||||
|
|
@ -35,6 +35,10 @@ const loaders = {
|
|||
},
|
||||
js() {window.location.reload()},
|
||||
mjs() {window.location.reload()},
|
||||
css(data) {
|
||||
console.debug(`.dungeon | Hot-reloading CSS: ${data.path}`);
|
||||
Hooks.call(`dd-hmr:css`, data);
|
||||
},
|
||||
};
|
||||
|
||||
Hooks.on(`hotReload`, async (data) => {
|
||||
|
|
|
|||
|
|
@ -167,9 +167,4 @@ export class PlayerSheetv2 extends GenericActorSheet {
|
|||
max: this.actor.system.inventory_slots,
|
||||
};
|
||||
};
|
||||
|
||||
_updateObject(...args) {
|
||||
console.log(args)
|
||||
super._updateObject(...args);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,6 +52,17 @@ export class GenericActorSheet extends ActorSheet {
|
|||
if (!this.isEditable) return;
|
||||
console.debug(`.dungeon | Generic sheet adding listeners`);
|
||||
|
||||
/*
|
||||
Custom element event listeners because Foundry doesn't listen to them by
|
||||
default.
|
||||
*/
|
||||
html.find(
|
||||
CONFIG.CACHE.componentListeners.join(`,`)
|
||||
).on(`change`, this._onChangeInput.bind(this));
|
||||
|
||||
/*
|
||||
Utility event listeners that apply
|
||||
*/
|
||||
html.find(`[data-collapse-id]`).on(`click`, this._handleSummaryToggle.bind(this));
|
||||
html.find(`[data-roll-formula]`).on(`click`, this._handleRoll.bind(this));
|
||||
html.find(`[data-embedded-update-on="change"]`)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue