Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow InputStyleCloneElement to be constructed directly #6

Merged
merged 3 commits into from
Sep 24, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 61 additions & 49 deletions src/input-style-clone-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<InputElement>;
#container: HTMLDivElement;
#inputRef?: WeakRef<InputElement>;
#container?: HTMLDivElement;

/**
* Get the clone for an input, reusing an existing one if available. This avoids creating unecessary clones, which
Expand All @@ -49,18 +49,26 @@ 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
* 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)`.
*/
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
Expand All @@ -83,55 +91,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);
Expand All @@ -140,9 +148,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<T>(fn: (input: InputElement) => T | void) {
const input = this.#inputRef.deref();
const input = this.#input;
if (!input) return this.remove();
return fn(input);
}
Expand Down