Merge pull request #175 from Oliver-Akins/feature/incrementer-component

Custom Web Components
This commit is contained in:
Oliver 2024-04-13 14:45:43 -04:00 committed by GitHub
commit 118dcfb71c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 543 additions and 63 deletions

27
.vscode/components.html-data.json vendored Normal file
View file

@ -0,0 +1,27 @@
{
"version": 1.1,
"tags": [
{
"name": "dd-incrementer",
"description": "A number input that allows more flexible increase/decrease buttons",
"attributes": [
{ "name": "value", "description": "The initial value to put in the input" },
{ "name": "name", "description": "The form name to use when this input is used to submit data" },
{ "name": "min", "description": "The minimum value that this input can contain" },
{ "name": "max", "description": "The maximum value that this input can contain" },
{ "name": "smallStep", "description": "The value that the input is changed by when clicking a delta button or using the up/down arrow key" },
{ "name": "largeStep", "description": "The value that the input is changed by when clicking a delta button with control held or using the page up/ page down arrow key" }
]
},
{
"name": "dd-icon",
"description": "Loads an icon asynchronously, caching the result for future uses",
"attributes": [
{ "name": "name", "description": "The name of the icon, this is relative to the assets folder of the dotdungeon system" },
{ "name": "path", "description": "The full path of the icon, this will only be used if `name` isn't provided or fails to fetch." }
]
}
],
"globalAttributes": [],
"valueSets": []
}

View file

@ -12,5 +12,8 @@
"node_modules": true,
"packs": true,
".gitattributes": true,
}
}
},
"html.customData": [
"./.vscode/components.html-data.json"
]
}

125
module/components/icon.mjs Normal file
View 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`);
};
};

View 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();
};
};

View 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);
}
};
}
};

View 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;
});
}
};
};
};

View file

@ -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();
});

View file

@ -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) => {

View file

@ -167,9 +167,4 @@ export class PlayerSheetv2 extends GenericActorSheet {
max: this.actor.system.inventory_slots,
};
};
_updateObject(...args) {
console.log(args)
super._updateObject(...args);
};
}
}

View file

@ -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"]`)

View file

@ -68,7 +68,7 @@
.bytes-panel {
display: grid;
grid-template-columns: 1fr min-content 50px min-content;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: center;

View file

@ -0,0 +1,7 @@
// Disclaimer: This CSS is used by a custom web component and is scoped to JUST
// the corresponding web component. This should only be imported by web component
// style files.
:host {
display: inline-block;
}

View file

@ -0,0 +1,23 @@
/*
Disclaimer: This CSS is used by a custom web component and is scoped to JUST
the corresponding web component. Importing this into other files is forbidden
*/
$default-size: 1rem;
@use "./common.scss";
div {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
svg {
width: var(--size, $default-size);
height: var(--size, $default-size);
fill: var(--fill);
stroke: var(--stroke);
}

View file

@ -0,0 +1,63 @@
/*
Disclaimer: This CSS is used by a custom web component and is scoped to JUST
the corresponding web component. Importing this into other files is forbidden
*/
$default-border-radius: 4px;
$default-height: 1.5rem;
@use "../mixins/material";
@use "./common.scss";
div {
display: grid;
grid-template-columns: var(--height, $default-height) var(--width, 50px) var(--height, $default-height);
grid-template-rows: var(--height, 1fr);
border-radius: var(--border-radius, $default-border-radius);
@include material.elevate(2);
&:hover {
@include material.elevate(4);
}
&:focus-within {
@include material.elevate(6);
}
}
span, input {
border: none;
outline: none;
background: none;
color: inherit;
}
input {
font-family: var(--font-family, inherit);
text-align: center;
font-size: var(--font-size, inherit);
padding: 2px 4px;
&::-webkit-inner-spin-button, &::-webkit-outer-spin-button {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
margin: 0
}
}
.increment, .decrement {
aspect-ratio: 1 / 1;
padding: 0;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.increment {
border-radius: 0 var(--border-radius, $default-border-radius) var(--border-radius, 4px) 0;
}
.decrement {
border-radius: var(--border-radius, $default-border-radius) 0 0 var(--border-radius, $default-border-radius);
}

View file

@ -11,35 +11,17 @@
</div>
<div class="e-1dp panel bytes-panel">
<label
for="{{meta.idp}}-player-inventory-supplies-input"
for="{{meta.idp}}-player-inventory-supplies"
>
Supplies
</label>
<button
type="button"
class="icon"
data-decrement="system.supplies"
aria-label="Decrease supply count by one"
>
<div aria-hidden="true" class="icon icon--14">
{{{ icons.minus }}}
</div>
</button>
<input
type="number"
id="{{meta.idp}}-player-inventory-supplies-input"
<dd-incrementer
var:height="1.5rem"
name="system.supplies"
value="{{system.supplies}}"
>
<button
type="button"
class="icon"
data-increment="system.supplies"
aria-label="Increase supply count by one"
>
<div aria-hidden="true" class="icon icon--14">
{{{ icons.create }}}
</div>
</button>
id="{{meta.idp}}-player-inventory-supplies"
min="0"
></dd-incrementer>
</div>
<div class="e-1dp panel bytes-panel">
<label
@ -47,31 +29,13 @@
>
Bytes
</label>
<button
type="button"
class="equal-padding"
data-decrement="system.bytes"
aria-label="Decrease byte count by one"
>
<div aria-hidden="true" class="icon icon--14">
{{{ icons.minus }}}
</div>
</button>
<input
type="number"
id="{{meta.idp}}-player-inventory-bytes-input"
<dd-incrementer
var:height="1.5rem"
name="system.bytes"
value="{{system.bytes}}"
>
<button
type="button"
class="equal-padding"
data-increment="system.bytes"
aria-label="Increase byte count by one"
>
<div aria-hidden="true" class="icon icon--14">
{{{ icons.create }}}
</div>
</button>
id="{{meta.idp}}-player-inventory-bytes"
min="0"
></dd-incrementer>
</div>
<div class="e-1dp panel filter-panel">
<h2>Show</h2>

View file

@ -73,13 +73,14 @@
<label for="{{meta.idp}}-quantity">
Quantity
</label>
<input
<dd-incrementer value="{{system.supplies}}"></dd-incrementer>
{{!-- <input
type="number"
min="0"
name="system.quantity"
value="{{system.quantity}}"
id="{{meta.idp}}-quantity"
>
> --}}
{{else}}
Quantity
{{system.quantity}}