Skip to content

Commit

Permalink
feat(utils): add new utilities (#226)
Browse files Browse the repository at this point in the history
  • Loading branch information
mimshins authored Oct 16, 2024
1 parent fb36f7b commit 7e30cce
Show file tree
Hide file tree
Showing 9 changed files with 262 additions and 0 deletions.
11 changes: 11 additions & 0 deletions src/utils/SystemError.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
class SystemError extends Error {
constructor(message: string, scope: string) {
const completeMessage = `[TAPSI][${scope}]: ${message}`;

super(completeMessage);

this.name = "SystemError";
}
}

export default SystemError;
99 changes: 99 additions & 0 deletions src/utils/controllers/controlled-prop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import type { ReactiveController, ReactiveControllerHost } from "lit";
import type { DirectiveResult } from "lit/async-directive";
import { live, type LiveDirective } from "lit/directives/live.js";
import kebabToCamel from "../kebab-to-camel";
import SystemError from "../SystemError";

class ControlledPropController<
T,
H extends ReactiveControllerHost = ReactiveControllerHost,
> implements ReactiveController
{
private _host: ReactiveControllerHost;

private _isControlled: boolean = false;
private _propKey: PropertyKey;
private _controlBehaviorPropKey: PropertyKey;

constructor(host: H, propKey: keyof H, controlBehaviorPropKey?: string) {
host.addController(this);

this._host = host;
this._propKey = propKey;

if (typeof controlBehaviorPropKey === "undefined") {
let key: PropertyKey;

if (typeof propKey === "symbol") {
key = Symbol(kebabToCamel(`controlled-${propKey.description}`));
} else {
key = kebabToCamel(`controlled-${String(propKey)}`);
}

this._controlBehaviorPropKey = key;
} else this._controlBehaviorPropKey = controlBehaviorPropKey;
}

private _getPropDescriptor(propKey: PropertyKey): PropertyDescriptor {
const proto = Object.getPrototypeOf(this._host) as object;
const prop = Object.getOwnPropertyDescriptor(proto, propKey);

if (!prop) {
throw new SystemError(
[
`The required member \`${String(propKey)}\` is not present in the prototype.`,
"Please ensure it is included for correct functionality.",
].join("\n"),
`${proto.constructor.name}`,
);
}

return prop;
}

private _getControlledProp(): PropertyDescriptor {
return this._getPropDescriptor(this._propKey);
}

private _getBehaviorProp(): PropertyDescriptor {
return this._getPropDescriptor(this._controlBehaviorPropKey);
}

private _setProp(newValue: T): void {
const prop = this._getControlledProp();

prop.set?.call(this._host, newValue);
}

public get isControlled(): boolean {
return this._isControlled;
}

public get value(): T {
return this._getControlledProp().get?.call(this._host) as T;
}

public get liveInputBinding(): DirectiveResult<typeof LiveDirective> {
return live(this.value);
}

public set value(newValue: T) {
if (this._isControlled) {
this._host.requestUpdate();

return;
}

this._setProp(newValue);
}

hostConnected(): void {
const behaviorProp = this._getBehaviorProp();

this._isControlled = Boolean(behaviorProp.get?.call(this._host));
}

hostDisconnected(): void {}
}

export default ControlledPropController;
1 change: 1 addition & 0 deletions src/utils/controllers/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as ControlledPropController } from "./controlled-prop";
69 changes: 69 additions & 0 deletions src/utils/dom/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { isHTMLElement, isShadowRoot, isWindow } from "./is";

export const getWindow = (node: Node | Window): Window => {
if (!node) return window;

if (!isWindow(node)) {
const ownerDocument = node.ownerDocument;

return ownerDocument ? ownerDocument.defaultView || window : window;
}

return node;
};

export const getDocumentElement = (node: Node | Window): HTMLElement =>
(
(node instanceof Node ? node.ownerDocument : node.document) ??
window.document
).documentElement;

export const getNodeName = (node: Node | Window): string =>
isWindow(node) ? "" : node ? (node.nodeName || "").toLowerCase() : "";

export const getParentNode = (node: Node): Node => {
if (getNodeName(node) === "html") return node;

return (
// Step into the shadow DOM of the parent of a slotted node
(<HTMLElement>node).assignedSlot ||
// DOM Element detected
node.parentNode ||
// ShadowRoot detected
(isShadowRoot(node) ? node.host : null) ||
// Fallback
getDocumentElement(node)
);
};

export const getBoundingClientRect = (
element: Element,
includeScale = false,
) => {
const clientRect = element.getBoundingClientRect();

let scaleX = 1;
let scaleY = 1;

if (includeScale && isHTMLElement(element)) {
scaleX =
element.offsetWidth > 0
? Math.round(clientRect.width) / element.offsetWidth || 1
: 1;
scaleY =
element.offsetHeight > 0
? Math.round(clientRect.height) / element.offsetHeight || 1
: 1;
}

return {
width: clientRect.width / scaleX,
height: clientRect.height / scaleY,
top: clientRect.top / scaleY,
right: clientRect.right / scaleX,
bottom: clientRect.bottom / scaleY,
left: clientRect.left / scaleX,
x: clientRect.left / scaleX,
y: clientRect.top / scaleY,
};
};
2 changes: 2 additions & 0 deletions src/utils/dom/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./get";
export * from "./is";
37 changes: 37 additions & 0 deletions src/utils/dom/is.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export const isWindow = <T extends { toString?: () => string }>(
input: unknown,
): input is Window =>
!input ? false : (input as T).toString?.() === "[object Window]";

export const isElement = (input: unknown): input is Element =>
input instanceof Element;

export const isHTMLElement = (input: unknown): input is HTMLElement =>
input instanceof HTMLElement;

export const isHTMLInputElement = (input: unknown): input is HTMLInputElement =>
input instanceof HTMLInputElement;

export const isNode = (input: unknown): input is Node => input instanceof Node;

export const isShadowRoot = (node: Node): node is ShadowRoot =>
node instanceof ShadowRoot || node instanceof ShadowRoot;

export const contains = (parent: Element, child: Element): boolean => {
if (parent.contains(child)) return true;

const rootNode = child.getRootNode?.();

// Fallback to custom implementation with Shadow DOM support
if (rootNode && isShadowRoot(rootNode)) {
let next: Node = child;

do {
if (next && parent === next) return true;

next = next.parentNode || (next as unknown as ShadowRoot).host;
} while (next);
}

return false;
};
4 changes: 4 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
export * from "./controllers";
export * from "./dom";
export * from "./events";
export * from "./mixins";

export { default as debounce } from "./debounce";
export { default as kebabToCamel } from "./kebab-to-camel";
export { default as logger } from "./logger";
export { default as SystemError } from "./SystemError";
4 changes: 4 additions & 0 deletions src/utils/kebab-to-camel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const kebabToCamel = (kebabcase: string) =>
kebabcase.replace(/-./g, x => x.toUpperCase()?.[1] ?? "");

export default kebabToCamel;
35 changes: 35 additions & 0 deletions src/utils/math.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Returns value wrapped to the inclusive range of `min` and `max`.
*/
export const wrap = (number: number, min: number, max: number): number =>
min + ((((number - min) % (max - min)) + (max - min)) % (max - min));

/**
* Returns value clamped to the inclusive range of `min` and `max`.
*/
export const clamp = (number: number, min: number, max: number): number =>
Math.max(Math.min(number, max), min);

/**
* Linear interpolate on the scale given by `a` to `b`, using `t` as the point on that scale.
*/
export const lerp = (a: number, b: number, t: number) => a + t * (b - a);

/**
* Inverse Linar Interpolation, get the fraction between `a` and `b` on which `v` resides.
*/
export const inLerp = (a: number, b: number, v: number) => (v - a) / (b - a);

/**
* Remap values from one linear scale to another.
*
* `oMin` and `oMax` are the scale on which the original value resides,
* `rMin` and `rMax` are the scale to which it should be mapped.
*/
export const remap = (
v: number,
oMin: number,
oMax: number,
rMin: number,
rMax: number,
) => lerp(rMin, rMax, inLerp(oMin, oMax, v));

0 comments on commit 7e30cce

Please sign in to comment.