From 7e30cce442b6a936218b268107a3279dcf5ed7c5 Mon Sep 17 00:00:00 2001 From: Mostafa Shamsitabar <50550858+mimshins@users.noreply.github.com> Date: Wed, 16 Oct 2024 11:17:49 +0330 Subject: [PATCH] feat(utils): add new utilities (#226) --- src/utils/SystemError.ts | 11 +++ src/utils/controllers/controlled-prop.ts | 99 ++++++++++++++++++++++++ src/utils/controllers/index.ts | 1 + src/utils/dom/get.ts | 69 +++++++++++++++++ src/utils/dom/index.ts | 2 + src/utils/dom/is.ts | 37 +++++++++ src/utils/index.ts | 4 + src/utils/kebab-to-camel.ts | 4 + src/utils/math.ts | 35 +++++++++ 9 files changed, 262 insertions(+) create mode 100644 src/utils/SystemError.ts create mode 100644 src/utils/controllers/controlled-prop.ts create mode 100644 src/utils/controllers/index.ts create mode 100644 src/utils/dom/get.ts create mode 100644 src/utils/dom/index.ts create mode 100644 src/utils/dom/is.ts create mode 100644 src/utils/kebab-to-camel.ts create mode 100644 src/utils/math.ts diff --git a/src/utils/SystemError.ts b/src/utils/SystemError.ts new file mode 100644 index 00000000..df204c62 --- /dev/null +++ b/src/utils/SystemError.ts @@ -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; diff --git a/src/utils/controllers/controlled-prop.ts b/src/utils/controllers/controlled-prop.ts new file mode 100644 index 00000000..b4a55a3a --- /dev/null +++ b/src/utils/controllers/controlled-prop.ts @@ -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 { + 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; diff --git a/src/utils/controllers/index.ts b/src/utils/controllers/index.ts new file mode 100644 index 00000000..2b7d62c9 --- /dev/null +++ b/src/utils/controllers/index.ts @@ -0,0 +1 @@ +export { default as ControlledPropController } from "./controlled-prop"; diff --git a/src/utils/dom/get.ts b/src/utils/dom/get.ts new file mode 100644 index 00000000..ed496ee9 --- /dev/null +++ b/src/utils/dom/get.ts @@ -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 + (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, + }; +}; diff --git a/src/utils/dom/index.ts b/src/utils/dom/index.ts new file mode 100644 index 00000000..fc1c7d92 --- /dev/null +++ b/src/utils/dom/index.ts @@ -0,0 +1,2 @@ +export * from "./get"; +export * from "./is"; diff --git a/src/utils/dom/is.ts b/src/utils/dom/is.ts new file mode 100644 index 00000000..f3a0dd8a --- /dev/null +++ b/src/utils/dom/is.ts @@ -0,0 +1,37 @@ +export const isWindow = 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; +}; diff --git a/src/utils/index.ts b/src/utils/index.ts index 04898bf8..4cfd0d76 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -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"; diff --git a/src/utils/kebab-to-camel.ts b/src/utils/kebab-to-camel.ts new file mode 100644 index 00000000..502831ba --- /dev/null +++ b/src/utils/kebab-to-camel.ts @@ -0,0 +1,4 @@ +const kebabToCamel = (kebabcase: string) => + kebabcase.replace(/-./g, x => x.toUpperCase()?.[1] ?? ""); + +export default kebabToCamel; diff --git a/src/utils/math.ts b/src/utils/math.ts new file mode 100644 index 00000000..c0bbf60b --- /dev/null +++ b/src/utils/math.ts @@ -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));