From 76fc958c6ee4431e3965bdb44f23119262857c9d Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Tue, 24 Sep 2024 19:51:46 +0000 Subject: [PATCH 1/3] Allow `InputStyleCloneElement` to be constructed directly --- src/input-style-clone-element.ts | 106 ++++++++++++++++--------------- 1 file changed, 55 insertions(+), 51 deletions(-) diff --git a/src/input-style-clone-element.ts b/src/input-style-clone-element.ts index 5a0ff6a..3e25413 100644 --- a/src/input-style-clone-element.ts +++ b/src/input-style-clone-element.ts @@ -36,8 +36,8 @@ export class InputStyleCloneElement extends CustomHTMLElement { // observers (if never detached). Because of this, we want to avoid preventing the existence of this class from also // preventing the garbage collection of the associated input. This also allows us to automatically detach if the // input gets collected. - #inputRef: WeakRef; - #container: HTMLDivElement; + #inputRef?: WeakRef; + #container?: HTMLDivElement; /** * Get the clone for an input, reusing an existing one if available. This avoids creating unecessary clones, which @@ -49,18 +49,18 @@ export class InputStyleCloneElement extends CustomHTMLElement { * @param input The target input to clone. */ static for(input: InputElement) { - const clone = CloneRegistry.get(input) ?? new InputStyleCloneElement(input); - CloneRegistry.set(input, clone); + let clone = CloneRegistry.get(input); + + if (!clone) { + clone = new InputStyleCloneElement(); + clone.connect(input); + CloneRegistry.set(input, clone); + } + return clone; } - /** - * Avoid constructing directly: Use `InputStyleCloneElement.for` instead. - * @private - */ - constructor(input: InputElement) { - super(); - + connect(input: InputElement) { this.#inputRef = new WeakRef(input); // We want position:absolute so it doesn't take space in the layout, but that doesn't work with display:table-cell @@ -83,55 +83,55 @@ export class InputStyleCloneElement extends CustomHTMLElement { /** @private */ connectedCallback() { - const input = this.#inputRef.deref(); - if (!input) return this.remove(); - - this.style.pointerEvents = "none"; - this.style.userSelect = "none"; - this.style.overflow = "hidden"; - this.style.display = "block"; - - // Important not to use display:none which would not render the content at all - this.style.visibility = "hidden"; - - if (input instanceof HTMLTextAreaElement) { - this.style.whiteSpace = "pre-wrap"; - this.style.wordWrap = "break-word"; - } else { - this.style.whiteSpace = "nowrap"; - // text in single-line inputs is vertically centered - this.style.display = "table-cell"; - this.style.verticalAlign = "middle"; - } - - this.setAttribute("aria-hidden", "true"); - - this.#updateStyles(); - this.#updateText(); - - this.#styleObserver.observe(input, { - attributeFilter: [ - "style", - "dir", // users can right-click in some browsers to change the text direction dynamically - ], + this.#usingInput((input) => { + this.style.pointerEvents = "none"; + this.style.userSelect = "none"; + this.style.overflow = "hidden"; + this.style.display = "block"; + + // Important not to use display:none which would not render the content at all + this.style.visibility = "hidden"; + + if (input instanceof HTMLTextAreaElement) { + this.style.whiteSpace = "pre-wrap"; + this.style.wordWrap = "break-word"; + } else { + this.style.whiteSpace = "nowrap"; + // text in single-line inputs is vertically centered + this.style.display = "table-cell"; + this.style.verticalAlign = "middle"; + } + + this.setAttribute("aria-hidden", "true"); + + this.#updateStyles(); + this.#updateText(); + + this.#styleObserver.observe(input, { + attributeFilter: [ + "style", + "dir", // users can right-click in some browsers to change the text direction dynamically + ], + }); + this.#resizeObserver.observe(input); + + document.addEventListener("scroll", this.#onDocumentScrollOrResize, { capture: true }); + window.addEventListener("resize", this.#onDocumentScrollOrResize, { capture: true }); + // capture so this happens first, so other things can respond to `input` events after this data updates + input.addEventListener("input", this.#onInput, { capture: true }); }); - this.#resizeObserver.observe(input); - - document.addEventListener("scroll", this.#onDocumentScrollOrResize, { capture: true }); - window.addEventListener("resize", this.#onDocumentScrollOrResize, { capture: true }); - // capture so this happens first, so other things can respond to `input` events after this data updates - input.addEventListener("input", this.#onInput, { capture: true }); } /** @private */ disconnectedCallback() { - this.#container.remove(); + this.#container?.remove(); this.#styleObserver.disconnect(); this.#resizeObserver.disconnect(); document.removeEventListener("scroll", this.#onDocumentScrollOrResize, { capture: true }); window.removeEventListener("resize", this.#onDocumentScrollOrResize, { capture: true }); - const input = this.#inputRef.deref(); + // Can't use `usingInput` here since that could infinitely recurse + const input = this.#input; if (input) { input.removeEventListener("input", this.#onInput, { capture: true }); CloneRegistry.delete(input); @@ -140,9 +140,13 @@ export class InputStyleCloneElement extends CustomHTMLElement { // --- private --- + get #input() { + return this.#inputRef?.deref(); + } + /** Perform `fn` using the `input` if it is still available. If not, clean up the clone instead. */ #usingInput(fn: (input: InputElement) => T | void) { - const input = this.#inputRef.deref(); + const input = this.#input; if (!input) return this.remove(); return fn(input); } From 8823707e361e2a1157c234ccf6c6363eafdb9545 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Tue, 24 Sep 2024 19:58:13 +0000 Subject: [PATCH 2/3] Add documentation --- src/input-style-clone-element.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/input-style-clone-element.ts b/src/input-style-clone-element.ts index 3e25413..5d2c04b 100644 --- a/src/input-style-clone-element.ts +++ b/src/input-style-clone-element.ts @@ -60,6 +60,14 @@ export class InputStyleCloneElement extends CustomHTMLElement { return clone; } + /** + * Connect this instance to a target input element and insert this instance into the DOM in the correct location. + * + * NOTE: calling the static `for` method is nearly always preferable as it will reuse an existing clone if available. + * However, if reusing clones is problematic (ie, if the clone needs to be mutated), a clone can be constructed + * directly with `new InputStyleCloneElement()` and then bound to an input and inserted into the DOM with + * `clone.connect(target)`. + */ connect(input: InputElement) { this.#inputRef = new WeakRef(input); From 6433721eaf7869c47fbcf4df414d692b2b6c3566 Mon Sep 17 00:00:00 2001 From: Ian Sanders Date: Tue, 24 Sep 2024 19:59:54 +0000 Subject: [PATCH 3/3] Format --- src/input-style-clone-element.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/input-style-clone-element.ts b/src/input-style-clone-element.ts index 5d2c04b..8c62b46 100644 --- a/src/input-style-clone-element.ts +++ b/src/input-style-clone-element.ts @@ -62,7 +62,7 @@ export class InputStyleCloneElement extends CustomHTMLElement { /** * Connect this instance to a target input element and insert this instance into the DOM in the correct location. - * + * * NOTE: calling the static `for` method is nearly always preferable as it will reuse an existing clone if available. * However, if reusing clones is problematic (ie, if the clone needs to be mutated), a clone can be constructed * directly with `new InputStyleCloneElement()` and then bound to an input and inserted into the DOM with