diff --git a/module/applications/components/_module.mjs b/module/applications/components/_module.mjs index af3673137e..df141867c3 100644 --- a/module/applications/components/_module.mjs +++ b/module/applications/components/_module.mjs @@ -5,6 +5,7 @@ import InventoryElement from "./inventory.mjs"; import ItemListControlsElement from "./item-list-controls.mjs"; import ProficiencyCycleElement from "./proficiency-cycle.mjs"; import SlideToggleElement from "./slide-toggle.mjs"; +import AdoptedStyleSheetMixin from "./adopted-stylesheet-mixin.mjs"; window.customElements.define("dnd5e-effects", EffectsElement); window.customElements.define("dnd5e-icon", IconElement); @@ -15,6 +16,6 @@ window.customElements.define("proficiency-cycle", ProficiencyCycleElement); window.customElements.define("slide-toggle", SlideToggleElement); export { - EffectsElement, IconElement, InventoryElement, ItemListControlsElement, FiligreeBoxElement, ProficiencyCycleElement, - SlideToggleElement + AdoptedStyleSheetMixin, EffectsElement, IconElement, InventoryElement, ItemListControlsElement, FiligreeBoxElement, + ProficiencyCycleElement, SlideToggleElement }; diff --git a/module/applications/components/adopted-stylesheet-mixin.mjs b/module/applications/components/adopted-stylesheet-mixin.mjs new file mode 100644 index 0000000000..0cd7ebbc9f --- /dev/null +++ b/module/applications/components/adopted-stylesheet-mixin.mjs @@ -0,0 +1,54 @@ +/** + * Adds functionality to a custom HTML element for caching its stylesheet and adopting it into its Shadow DOM, rather + * than having each stylesheet duplicated per element. + * @param {typeof HTMLElement} Base The base class being mixed. + * @returns {typeof AdoptedStyleSheetElement} + */ +export default function AdoptedStyleSheetMixin(Base) { + return class AdoptedStyleSheetElement extends Base { + /** + * A map of cached stylesheets per Document root. + * @type {WeakMap, CSSStyleSheet>} + * @protected + */ + static _stylesheets = new WeakMap(); + + /** + * The CSS content for this element. + * @type {string} + */ + static CSS = ""; + + /* -------------------------------------------- */ + + /** @inheritDoc */ + adoptedCallback() { + this._adoptStyleSheet(this._getStyleSheet()); + } + + /* -------------------------------------------- */ + + /** + * Retrieves the cached stylesheet, or generates a new one. + * @protected + */ + _getStyleSheet() { + let sheet = this.constructor._stylesheets.get(this.ownerDocument); + if ( !sheet ) { + sheet = new this.ownerDocument.defaultView.CSSStyleSheet(); + sheet.replaceSync(this.constructor.CSS); + this.constructor._stylesheets.set(this.ownerDocument, sheet); + } + return sheet; + } + + /* -------------------------------------------- */ + + /** + * Adopt the stylesheet into the Shadow DOM. + * @param {CSSStyleSheet} sheet The sheet to adopt. + * @abstract + */ + _adoptStyleSheet(sheet) {} + } +} diff --git a/module/applications/components/filigree-box.mjs b/module/applications/components/filigree-box.mjs index 6c746d6461..8b011d3874 100644 --- a/module/applications/components/filigree-box.mjs +++ b/module/applications/components/filigree-box.mjs @@ -1,11 +1,13 @@ +import AdoptedStyleSheetMixin from "./adopted-stylesheet-mixin.mjs"; + /** * Custom element that adds a filigree border that can be colored. */ -export default class FiligreeBoxElement extends HTMLElement { +export default class FiligreeBoxElement extends AdoptedStyleSheetMixin(HTMLElement) { constructor() { super(); this.#shadowRoot = this.attachShadow({ mode: "closed" }); - this.#buildStyle(); + this._adoptStyleSheet(this._getStyleSheet()); const backdrop = document.createElement("div"); backdrop.classList.add("backdrop"); this.#shadowRoot.appendChild(backdrop); @@ -21,82 +23,55 @@ export default class FiligreeBoxElement extends HTMLElement { this.#shadowRoot.appendChild(slot); } - /** - * Shadow root that contains the box shapes. - * @type {ShadowRoot} - */ - #shadowRoot; - - /* -------------------------------------------- */ + /** @inheritDoc */ + static CSS = ` + :host { + position: relative; + isolation: isolate; + min-height: 56px; + filter: var(--filigree-drop-shadow, drop-shadow(0 0 12px var(--dnd5e-shadow-15))); + } + .backdrop { + --chamfer: 12px; + position: absolute; + inset: 0; + background: var(--filigree-background-color, var(--dnd5e-color-card)); + z-index: -2; + clip-path: polygon( + var(--chamfer) 0, + calc(100% - var(--chamfer)) 0, + 100% var(--chamfer), + 100% calc(100% - var(--chamfer)), + calc(100% - var(--chamfer)) 100%, + var(--chamfer) 100%, + 0 calc(100% - var(--chamfer)), + 0 var(--chamfer) + ); + } + .filigree { + position: absolute; + fill: var(--filigree-border-color, var(--dnd5e-color-gold)); + z-index: -1; - /** - * The stylesheet to attach to the element's shadow root. - * @type {CSSStyleSheet} - * @protected - */ - static _stylesheet; + &.top, &.bottom { height: 30px; } + &.top { top: 0; } + &.bottom { bottom: 0; scale: 1 -1; } - /* -------------------------------------------- */ + &.left, &.right { width: 25px; } + &.left { left: 0; } + &.right { right: 0; scale: -1 1; } - /** - * Build the shadow DOM's styles. - */ - #buildStyle() { - if ( !this.constructor._stylesheet ) { - this.constructor._stylesheet = new CSSStyleSheet(); - this.constructor._stylesheet.replaceSync(` - :host { - position: relative; - isolation: isolate; - min-height: 56px; - filter: var(--filigree-drop-shadow, drop-shadow(0 0 12px var(--dnd5e-shadow-15))); - } - .backdrop { - --chamfer: 12px; - position: absolute; - inset: 0; - background: var(--filigree-background-color, var(--dnd5e-color-card)); - z-index: -2; - clip-path: polygon( - var(--chamfer) 0, - calc(100% - var(--chamfer)) 0, - 100% var(--chamfer), - 100% calc(100% - var(--chamfer)), - calc(100% - var(--chamfer)) 100%, - var(--chamfer) 100%, - 0 calc(100% - var(--chamfer)), - 0 var(--chamfer) - ); - } - .filigree { - position: absolute; - fill: var(--filigree-border-color, var(--dnd5e-color-gold)); - z-index: -1; - - &.top, &.bottom { height: 30px; } - &.top { top: 0; } - &.bottom { bottom: 0; scale: 1 -1; } - - &.left, &.right { width: 25px; } - &.left { left: 0; } - &.right { right: 0; scale: -1 1; } - - &.bottom.right { scale: -1 -1; } - } - .filigree.block { - inline-size: calc(100% - 50px); - inset-inline: 25px; - } - .filigree.inline { - block-size: calc(100% - 60px); - inset-block: 30px; - } - `); + &.bottom.right { scale: -1 -1; } } - this.#shadowRoot.adoptedStyleSheets = [this.constructor._stylesheet]; - } - - /* -------------------------------------------- */ + .filigree.block { + inline-size: calc(100% - 50px); + inset-inline: 25px; + } + .filigree.inline { + block-size: calc(100% - 60px); + inset-block: 30px; + } + `; /** * Path definitions for the various box corners and edges. @@ -108,6 +83,21 @@ export default class FiligreeBoxElement extends HTMLElement { inline: "M 0 10 L 0 0 L 2.99 0 L 2.989 10 L 0 10 Z M 6.9 10 L 6.9 0 L 8.6 0 L 8.6 10 L 6.9 10 Z" }); + /** + * Shadow root that contains the box shapes. + * @type {ShadowRoot} + */ + #shadowRoot; + + /* -------------------------------------------- */ + + /** @inheritDoc */ + _adoptStyleSheet(sheet) { + this.#shadowRoot.adoptedStyleSheets = [sheet]; + } + + /* -------------------------------------------- */ + /** * Build an SVG element. * @param {string} path SVG path to use. diff --git a/module/applications/components/icon.mjs b/module/applications/components/icon.mjs index 260f85ecc0..0ad89e019e 100644 --- a/module/applications/components/icon.mjs +++ b/module/applications/components/icon.mjs @@ -1,7 +1,9 @@ +import AdoptedStyleSheetMixin from "./adopted-stylesheet-mixin.mjs"; + /** * Custom element for displaying SVG icons that are cached and can be styled. */ -export default class IconElement extends HTMLElement { +export default class IconElement extends AdoptedStyleSheetMixin(HTMLElement) { constructor() { super(); this.#internals = this.attachInternals(); @@ -9,6 +11,24 @@ export default class IconElement extends HTMLElement { this.#shadowRoot = this.attachShadow({ mode: "closed" }); } + /** @inheritDoc */ + static CSS = ` + :host { + display: contents; + } + svg { + fill: var(--icon-fill, #000); + width: var(--icon-width, var(--icon-size, 1em)); + height: var(--icon-height, var(--icon-size, 1em)); + } + `; + + /** + * Cached SVG files by SRC. + * @type {Map>} + */ + static #svgCache = new Map(); + /** * The custom element's form and accessibility internals. * @type {ElementInternals} @@ -37,40 +57,16 @@ export default class IconElement extends HTMLElement { /* -------------------------------------------- */ - /** - * Stylesheet that is shared among all icons. - * @type {CSSStyleSheet} - */ - static #stylesheet; - - /* -------------------------------------------- */ - - /** - * Cached SVG files by SRC. - * @type {Map>} - */ - static #svgCache = new Map(); + /** @inheritDoc */ + _adoptStyleSheet(sheet) { + this.#shadowRoot.adoptedStyleSheets = [sheet]; + } /* -------------------------------------------- */ /** @inheritDoc */ connectedCallback() { - // Create icon styles - if ( !this.constructor.#stylesheet ) { - this.constructor.#stylesheet = new CSSStyleSheet(); - this.constructor.#stylesheet.replaceSync(` - :host { - display: contents; - } - svg { - fill: var(--icon-fill, #000); - width: var(--icon-width, var(--icon-size, 1em)); - height: var(--icon-height, var(--icon-size, 1em)); - } - `); - } - this.#shadowRoot.adoptedStyleSheets = [this.constructor.#stylesheet]; - + this._adoptStyleSheet(this._getStyleSheet()); const insertElement = element => { if ( !element ) return; const clone = element.cloneNode(true); diff --git a/module/applications/components/proficiency-cycle.mjs b/module/applications/components/proficiency-cycle.mjs index 0699cfc7b4..e4489c3953 100644 --- a/module/applications/components/proficiency-cycle.mjs +++ b/module/applications/components/proficiency-cycle.mjs @@ -1,8 +1,10 @@ +import AdoptedStyleSheetMixin from "./adopted-stylesheet-mixin.mjs"; + /** * A custom HTML element that displays proficiency status and allows cycling through values. * @fires change */ -export default class ProficiencyCycleElement extends HTMLElement { +export default class ProficiencyCycleElement extends AdoptedStyleSheetMixin(HTMLElement) { /** @inheritDoc */ constructor() { super(); @@ -10,10 +12,69 @@ export default class ProficiencyCycleElement extends HTMLElement { this.#internals = this.attachInternals(); this.#internals.role = "spinbutton"; this.#shadowRoot = this.attachShadow({ mode: "open" }); - this.#buildCSS(); + this._adoptStyleSheet(this._getStyleSheet()); this.#value = Number(this.getAttribute("value") ?? 0); } + /** @inheritDoc */ + static CSS = ` + :host { display: inline-block; } + div { --_fill: var(--proficiency-cycle-enabled-color, var(--dnd5e-color-blue)); } + div:has(:disabled, :focus-visible) { --_fill: var(--proficiency-cycle-disabled-color, var(--dnd5e-color-gold)); } + div:not(:has(:disabled)) { cursor: pointer; } + + div { + position: relative; + overflow: clip; + width: 100%; + aspect-ratio: 1; + + &::before { + content: ""; + position: absolute; + display: block; + inset: 3px; + border: 1px solid var(--_fill); + border-radius: 100%; + } + + &:has([value="1"])::before { background: var(--_fill); } + + &:has([value="0.5"], [value="2"])::after { + content: ""; + position: absolute; + background: var(--_fill); + } + + &:has([value="0.5"])::after { + inset: 4px; + width: 4px; + aspect-ratio: 1 / 2; + border-radius: 100% 0 0 100%; + } + + &:has([value="2"]) { + &::before { + inset: 1px; + border-width: 2px; + } + + &::after { + inset: 5px; + border-radius: 100%; + } + } + } + + input { + position: absolute; + inset-block-start: -100px; + width: 1px; + height: 1px; + opacity: 0; + } + `; + /** * Controller for removing listeners automatically. * @type {AbortController} @@ -32,13 +93,6 @@ export default class ProficiencyCycleElement extends HTMLElement { */ #shadowRoot; - /** - * The stylesheet to attach to the element's shadow root. - * @type {CSSStyleSheet} - * @protected - */ - static _stylesheet; - /* -------------------------------------------- */ /** @override */ @@ -122,7 +176,6 @@ export default class ProficiencyCycleElement extends HTMLElement { /** @override */ connectedCallback() { - this.replaceChildren(); this.#buildHTML(); this.#refreshValue(); @@ -135,71 +188,9 @@ export default class ProficiencyCycleElement extends HTMLElement { /* -------------------------------------------- */ - /** - * Build the CSS internals. - */ - #buildCSS() { - if ( !this.constructor._stylesheet ) { - this.constructor._stylesheet = new CSSStyleSheet(); - this.constructor._stylesheet.replaceSync(` - :host { display: inline-block; } - div { --_fill: var(--proficiency-cycle-enabled-color, var(--dnd5e-color-blue)); } - div:has(:disabled, :focus-visible) { --_fill: var(--proficiency-cycle-disabled-color, var(--dnd5e-color-gold)); } - div:not(:has(:disabled)) { cursor: pointer; } - - div { - position: relative; - overflow: clip; - width: 100%; - aspect-ratio: 1; - - &::before { - content: ""; - position: absolute; - display: block; - inset: 3px; - border: 1px solid var(--_fill); - border-radius: 100%; - } - - &:has([value="1"])::before { background: var(--_fill); } - - &:has([value="0.5"], [value="2"])::after { - content: ""; - position: absolute; - background: var(--_fill); - } - - &:has([value="0.5"])::after { - inset: 4px; - width: 4px; - aspect-ratio: 1 / 2; - border-radius: 100% 0 0 100%; - } - - &:has([value="2"]) { - &::before { - inset: 1px; - border-width: 2px; - } - - &::after { - inset: 5px; - border-radius: 100%; - } - } - } - - input { - position: absolute; - inset-block-start: -100px; - width: 1px; - height: 1px; - opacity: 0; - } - `); - } - this.#shadowRoot.adoptedStyleSheets = [this.constructor._stylesheet]; + /** @inheritDoc */ + _adoptStyleSheet(sheet) { + this.#shadowRoot.adoptedStyleSheets = [sheet]; } /* -------------------------------------------- */ @@ -209,7 +200,7 @@ export default class ProficiencyCycleElement extends HTMLElement { */ #buildHTML() { const div = document.createElement("div"); - this.#shadowRoot.appendChild(div); + this.#shadowRoot.replaceChildren(div); const input = document.createElement("input"); input.setAttribute("type", "number");