Skip to content

Commit 7e30cce

Browse files
authored
feat(utils): add new utilities (#226)
1 parent fb36f7b commit 7e30cce

File tree

9 files changed

+262
-0
lines changed

9 files changed

+262
-0
lines changed

src/utils/SystemError.ts

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
class SystemError extends Error {
2+
constructor(message: string, scope: string) {
3+
const completeMessage = `[TAPSI][${scope}]: ${message}`;
4+
5+
super(completeMessage);
6+
7+
this.name = "SystemError";
8+
}
9+
}
10+
11+
export default SystemError;
+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import type { ReactiveController, ReactiveControllerHost } from "lit";
2+
import type { DirectiveResult } from "lit/async-directive";
3+
import { live, type LiveDirective } from "lit/directives/live.js";
4+
import kebabToCamel from "../kebab-to-camel";
5+
import SystemError from "../SystemError";
6+
7+
class ControlledPropController<
8+
T,
9+
H extends ReactiveControllerHost = ReactiveControllerHost,
10+
> implements ReactiveController
11+
{
12+
private _host: ReactiveControllerHost;
13+
14+
private _isControlled: boolean = false;
15+
private _propKey: PropertyKey;
16+
private _controlBehaviorPropKey: PropertyKey;
17+
18+
constructor(host: H, propKey: keyof H, controlBehaviorPropKey?: string) {
19+
host.addController(this);
20+
21+
this._host = host;
22+
this._propKey = propKey;
23+
24+
if (typeof controlBehaviorPropKey === "undefined") {
25+
let key: PropertyKey;
26+
27+
if (typeof propKey === "symbol") {
28+
key = Symbol(kebabToCamel(`controlled-${propKey.description}`));
29+
} else {
30+
key = kebabToCamel(`controlled-${String(propKey)}`);
31+
}
32+
33+
this._controlBehaviorPropKey = key;
34+
} else this._controlBehaviorPropKey = controlBehaviorPropKey;
35+
}
36+
37+
private _getPropDescriptor(propKey: PropertyKey): PropertyDescriptor {
38+
const proto = Object.getPrototypeOf(this._host) as object;
39+
const prop = Object.getOwnPropertyDescriptor(proto, propKey);
40+
41+
if (!prop) {
42+
throw new SystemError(
43+
[
44+
`The required member \`${String(propKey)}\` is not present in the prototype.`,
45+
"Please ensure it is included for correct functionality.",
46+
].join("\n"),
47+
`${proto.constructor.name}`,
48+
);
49+
}
50+
51+
return prop;
52+
}
53+
54+
private _getControlledProp(): PropertyDescriptor {
55+
return this._getPropDescriptor(this._propKey);
56+
}
57+
58+
private _getBehaviorProp(): PropertyDescriptor {
59+
return this._getPropDescriptor(this._controlBehaviorPropKey);
60+
}
61+
62+
private _setProp(newValue: T): void {
63+
const prop = this._getControlledProp();
64+
65+
prop.set?.call(this._host, newValue);
66+
}
67+
68+
public get isControlled(): boolean {
69+
return this._isControlled;
70+
}
71+
72+
public get value(): T {
73+
return this._getControlledProp().get?.call(this._host) as T;
74+
}
75+
76+
public get liveInputBinding(): DirectiveResult<typeof LiveDirective> {
77+
return live(this.value);
78+
}
79+
80+
public set value(newValue: T) {
81+
if (this._isControlled) {
82+
this._host.requestUpdate();
83+
84+
return;
85+
}
86+
87+
this._setProp(newValue);
88+
}
89+
90+
hostConnected(): void {
91+
const behaviorProp = this._getBehaviorProp();
92+
93+
this._isControlled = Boolean(behaviorProp.get?.call(this._host));
94+
}
95+
96+
hostDisconnected(): void {}
97+
}
98+
99+
export default ControlledPropController;

src/utils/controllers/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as ControlledPropController } from "./controlled-prop";

src/utils/dom/get.ts

+69
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { isHTMLElement, isShadowRoot, isWindow } from "./is";
2+
3+
export const getWindow = (node: Node | Window): Window => {
4+
if (!node) return window;
5+
6+
if (!isWindow(node)) {
7+
const ownerDocument = node.ownerDocument;
8+
9+
return ownerDocument ? ownerDocument.defaultView || window : window;
10+
}
11+
12+
return node;
13+
};
14+
15+
export const getDocumentElement = (node: Node | Window): HTMLElement =>
16+
(
17+
(node instanceof Node ? node.ownerDocument : node.document) ??
18+
window.document
19+
).documentElement;
20+
21+
export const getNodeName = (node: Node | Window): string =>
22+
isWindow(node) ? "" : node ? (node.nodeName || "").toLowerCase() : "";
23+
24+
export const getParentNode = (node: Node): Node => {
25+
if (getNodeName(node) === "html") return node;
26+
27+
return (
28+
// Step into the shadow DOM of the parent of a slotted node
29+
(<HTMLElement>node).assignedSlot ||
30+
// DOM Element detected
31+
node.parentNode ||
32+
// ShadowRoot detected
33+
(isShadowRoot(node) ? node.host : null) ||
34+
// Fallback
35+
getDocumentElement(node)
36+
);
37+
};
38+
39+
export const getBoundingClientRect = (
40+
element: Element,
41+
includeScale = false,
42+
) => {
43+
const clientRect = element.getBoundingClientRect();
44+
45+
let scaleX = 1;
46+
let scaleY = 1;
47+
48+
if (includeScale && isHTMLElement(element)) {
49+
scaleX =
50+
element.offsetWidth > 0
51+
? Math.round(clientRect.width) / element.offsetWidth || 1
52+
: 1;
53+
scaleY =
54+
element.offsetHeight > 0
55+
? Math.round(clientRect.height) / element.offsetHeight || 1
56+
: 1;
57+
}
58+
59+
return {
60+
width: clientRect.width / scaleX,
61+
height: clientRect.height / scaleY,
62+
top: clientRect.top / scaleY,
63+
right: clientRect.right / scaleX,
64+
bottom: clientRect.bottom / scaleY,
65+
left: clientRect.left / scaleX,
66+
x: clientRect.left / scaleX,
67+
y: clientRect.top / scaleY,
68+
};
69+
};

src/utils/dom/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./get";
2+
export * from "./is";

src/utils/dom/is.ts

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export const isWindow = <T extends { toString?: () => string }>(
2+
input: unknown,
3+
): input is Window =>
4+
!input ? false : (input as T).toString?.() === "[object Window]";
5+
6+
export const isElement = (input: unknown): input is Element =>
7+
input instanceof Element;
8+
9+
export const isHTMLElement = (input: unknown): input is HTMLElement =>
10+
input instanceof HTMLElement;
11+
12+
export const isHTMLInputElement = (input: unknown): input is HTMLInputElement =>
13+
input instanceof HTMLInputElement;
14+
15+
export const isNode = (input: unknown): input is Node => input instanceof Node;
16+
17+
export const isShadowRoot = (node: Node): node is ShadowRoot =>
18+
node instanceof ShadowRoot || node instanceof ShadowRoot;
19+
20+
export const contains = (parent: Element, child: Element): boolean => {
21+
if (parent.contains(child)) return true;
22+
23+
const rootNode = child.getRootNode?.();
24+
25+
// Fallback to custom implementation with Shadow DOM support
26+
if (rootNode && isShadowRoot(rootNode)) {
27+
let next: Node = child;
28+
29+
do {
30+
if (next && parent === next) return true;
31+
32+
next = next.parentNode || (next as unknown as ShadowRoot).host;
33+
} while (next);
34+
}
35+
36+
return false;
37+
};

src/utils/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1+
export * from "./controllers";
2+
export * from "./dom";
13
export * from "./events";
24
export * from "./mixins";
35

46
export { default as debounce } from "./debounce";
7+
export { default as kebabToCamel } from "./kebab-to-camel";
58
export { default as logger } from "./logger";
9+
export { default as SystemError } from "./SystemError";

src/utils/kebab-to-camel.ts

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
const kebabToCamel = (kebabcase: string) =>
2+
kebabcase.replace(/-./g, x => x.toUpperCase()?.[1] ?? "");
3+
4+
export default kebabToCamel;

src/utils/math.ts

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Returns value wrapped to the inclusive range of `min` and `max`.
3+
*/
4+
export const wrap = (number: number, min: number, max: number): number =>
5+
min + ((((number - min) % (max - min)) + (max - min)) % (max - min));
6+
7+
/**
8+
* Returns value clamped to the inclusive range of `min` and `max`.
9+
*/
10+
export const clamp = (number: number, min: number, max: number): number =>
11+
Math.max(Math.min(number, max), min);
12+
13+
/**
14+
* Linear interpolate on the scale given by `a` to `b`, using `t` as the point on that scale.
15+
*/
16+
export const lerp = (a: number, b: number, t: number) => a + t * (b - a);
17+
18+
/**
19+
* Inverse Linar Interpolation, get the fraction between `a` and `b` on which `v` resides.
20+
*/
21+
export const inLerp = (a: number, b: number, v: number) => (v - a) / (b - a);
22+
23+
/**
24+
* Remap values from one linear scale to another.
25+
*
26+
* `oMin` and `oMax` are the scale on which the original value resides,
27+
* `rMin` and `rMax` are the scale to which it should be mapped.
28+
*/
29+
export const remap = (
30+
v: number,
31+
oMin: number,
32+
oMax: number,
33+
rMin: number,
34+
rMax: number,
35+
) => lerp(rMin, rMax, inLerp(oMin, oMax, v));

0 commit comments

Comments
 (0)