@mkmonkeycat/dom-utils 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Parses class names from various input formats into a flat array of class names.
3
+ * @param classNames - Class names as strings, arrays of strings, or undefined.
4
+ * @returns An array of individual class names.
5
+ */
6
+ export const parseClass = (...classNames: (string | string[] | undefined)[]): string[] => {
7
+ return classNames.flatMap((className) => {
8
+ if (typeof className === 'string') {
9
+ return className.trim().split(/\s+/).filter(Boolean);
10
+ }
11
+ return className || [];
12
+ });
13
+ };
14
+
15
+ /**
16
+ * Creates an HTML element with specified attributes and children.
17
+ * @param tag - The tag name of the HTML element to create.
18
+ * @param attributes - An object containing attributes, styles, event handlers, and dataset entries to set on the element.
19
+ * @param children - Child nodes or strings to append to the created element.
20
+ * @returns The created HTML element.
21
+ */
22
+ export const createElement = <K extends keyof HTMLElementTagNameMap>(
23
+ tag: K,
24
+ attributes?: ElementAttributes<HTMLElementTagNameMap[K]>,
25
+ ...children: (Node | string)[]
26
+ ): HTMLElementTagNameMap[K] => {
27
+ const element = document.createElement(tag);
28
+
29
+ if (!attributes) {
30
+ addChildren(element, children);
31
+ return element;
32
+ }
33
+
34
+ Object.entries(attributes).forEach(([key, value]) => {
35
+ if (value == null) return;
36
+
37
+ if (key === 'className' || key === 'class') {
38
+ const classes = Array.isArray(value) ? parseClass(...value) : parseClass(value as string);
39
+ element.className = classes.join(' ');
40
+ return;
41
+ }
42
+
43
+ if (key === 'style') {
44
+ if (typeof value === 'string') {
45
+ element.style.cssText = value;
46
+ } else if (typeof value === 'object') {
47
+ Object.assign(element.style, value);
48
+ }
49
+ return;
50
+ }
51
+
52
+ if (key === 'dataset' && typeof value === 'object') {
53
+ Object.assign(element.dataset, value as Record<string, string>);
54
+ return;
55
+ }
56
+
57
+ if (key.startsWith('on')) {
58
+ const eventName = key.slice(2).toLowerCase();
59
+ if (typeof value === 'function') {
60
+ element.addEventListener(eventName, value as EventListener);
61
+ } else if (Array.isArray(value)) {
62
+ value.forEach((handler) => {
63
+ if (typeof handler === 'function') {
64
+ element.addEventListener(eventName, handler);
65
+ }
66
+ });
67
+ }
68
+ return;
69
+ }
70
+
71
+ if (key in element) {
72
+ (element as Record<string, unknown>)[key] = value;
73
+ } else {
74
+ element.setAttribute(key, String(value));
75
+ }
76
+ });
77
+
78
+ addChildren(element, children);
79
+ return element;
80
+ };
81
+
82
+ /**
83
+ * Creates a DocumentFragment for batch DOM operations.
84
+ * @param children - Child nodes or strings to add to the fragment.
85
+ * @returns A DocumentFragment containing the children.
86
+ */
87
+ export const createFragment = (...children: (Node | string)[]): DocumentFragment => {
88
+ const frag = document.createDocumentFragment();
89
+ children.forEach((child) => {
90
+ if (typeof child === 'string') {
91
+ frag.appendChild(document.createTextNode(child));
92
+ } else {
93
+ frag.appendChild(child);
94
+ }
95
+ });
96
+ return frag;
97
+ };
98
+
99
+ const addChildren = (element: HTMLElement, children: (Node | string)[]) => {
100
+ children.forEach((child) => {
101
+ if (typeof child === 'string') {
102
+ element.appendChild(document.createTextNode(child));
103
+ } else {
104
+ element.appendChild(child);
105
+ }
106
+ });
107
+ };
108
+
109
+ export type CSSProperties = Partial<CSSStyleDeclaration> | string;
110
+
111
+ export type EventHandlers = {
112
+ [K in keyof HTMLElementEventMap as `on${Capitalize<K>}`]?: (
113
+ event: HTMLElementEventMap[K],
114
+ ) => void;
115
+ };
116
+
117
+ export type ElementAttributes<T extends HTMLElement = HTMLElement> = {
118
+ className?: string | string[];
119
+ class?: string | string[];
120
+ style?: CSSProperties;
121
+ dataset?: Record<string, string>;
122
+ } & EventHandlers &
123
+ Partial<Omit<T, 'style' | 'children' | 'className' | 'classList' | 'dataset'>> & {
124
+ [key: string]: unknown;
125
+ };
126
+
127
+ /**
128
+ * Gets the width and height of an element (including padding and border).
129
+ * @param element - The element to measure.
130
+ * @returns An object with width and height properties.
131
+ */
132
+ export const getSize = (element: HTMLElement): { width: number; height: number } => {
133
+ return {
134
+ width: element.offsetWidth,
135
+ height: element.offsetHeight,
136
+ };
137
+ };
138
+
139
+ /**
140
+ * Gets the offset position of an element relative to the document.
141
+ * @param element - The element to measure.
142
+ * @returns An object with top and left properties.
143
+ */
144
+ export const getOffset = (element: HTMLElement): { top: number; left: number } => {
145
+ return {
146
+ top: element.offsetTop,
147
+ left: element.offsetLeft,
148
+ };
149
+ };
150
+
151
+ /**
152
+ * Gets a computed style value for an element.
153
+ * @param element - The element to query.
154
+ * @param property - The CSS property name.
155
+ * @returns The computed style value.
156
+ */
157
+ export const getComputedStyle = (element: HTMLElement, property: string): string => {
158
+ return window.getComputedStyle(element).getPropertyValue(property).trim();
159
+ };
160
+
161
+ /**
162
+ * Scrolls the element to a specific position (or to view if no position specified).
163
+ * @param element - The element to scroll.
164
+ * @param options - Scroll behavior options.
165
+ */
166
+ export const scrollTo = (
167
+ element: HTMLElement,
168
+ options?: ScrollToOptions | { top?: number; left?: number },
169
+ ): void => {
170
+ if (!element) return;
171
+ if (element === document.documentElement || element === document.body) {
172
+ window.scrollTo(options as ScrollToOptions);
173
+ } else {
174
+ element.scrollTo?.(options as ScrollToOptions);
175
+ }
176
+ };
177
+
178
+ /**
179
+ * Scrolls the element into the viewport.
180
+ * @param element - The element to scroll into view.
181
+ * @param options - Scroll behavior options.
182
+ */
183
+ export const scrollIntoView = (
184
+ element: HTMLElement,
185
+ options?: ScrollIntoViewOptions | boolean,
186
+ ): void => {
187
+ element.scrollIntoView?.(options);
188
+ };
189
+
190
+ /**
191
+ * Gets the inner size of an element (client width/height: includes padding, excludes border and scrollbar).
192
+ * @param element - The element to measure.
193
+ * @returns An object with width and height properties.
194
+ */
195
+ export const getInnerSize = (element: HTMLElement): { width: number; height: number } => {
196
+ return {
197
+ width: element.clientWidth,
198
+ height: element.clientHeight,
199
+ };
200
+ };
201
+
202
+ /**
203
+ * Gets the outer size of an element (offset size). Optionally includes margins.
204
+ * @param element - The element to measure.
205
+ * @param includeMargin - Whether to include margins in the calculation.
206
+ * @returns An object with width and height properties.
207
+ */
208
+ export const getOuterSize = (
209
+ element: HTMLElement,
210
+ includeMargin = false,
211
+ ): { width: number; height: number } => {
212
+ let width = element.offsetWidth;
213
+ let height = element.offsetHeight;
214
+
215
+ if (includeMargin) {
216
+ const styles = window.getComputedStyle(element);
217
+ const ml = parseFloat(styles.marginLeft || '0');
218
+ const mr = parseFloat(styles.marginRight || '0');
219
+ const mt = parseFloat(styles.marginTop || '0');
220
+ const mb = parseFloat(styles.marginBottom || '0');
221
+ width += ml + mr;
222
+ height += mt + mb;
223
+ }
224
+
225
+ return { width, height };
226
+ };
227
+
228
+ /**
229
+ * Gets the bounding client rect of an element.
230
+ * @param element - The element to measure.
231
+ * @returns The DOMRect representing the element's bounding box.
232
+ */
233
+ export const getRect = (element: HTMLElement): DOMRect => {
234
+ return element.getBoundingClientRect();
235
+ };
236
+
237
+ /**
238
+ * Creates an SVG element from an SVG string.
239
+ * @param svgString - The SVG markup as a string.
240
+ * @returns The parsed SVGElement.
241
+ * @throws Error if the SVG string is invalid or contains multiple root elements.
242
+ */
243
+ export const createSVGFromString = (svgString: string): SVGElement => {
244
+ const parser = new DOMParser();
245
+ const doc = parser.parseFromString(svgString, 'image/svg+xml');
246
+
247
+ if (doc.documentElement.nodeName === 'parsererror') {
248
+ throw new Error('Invalid SVG string: Failed to parse SVG');
249
+ }
250
+
251
+ return doc.documentElement as unknown as SVGElement;
252
+ };
@@ -0,0 +1,245 @@
1
+ import type { SingleOrArray } from '../utils/types';
2
+ import { markAsEventFunc } from './tag-internal';
3
+
4
+ /**
5
+ * Options for onClickOutside function.
6
+ */
7
+ export interface OnClickOutsideOptions {
8
+ /** Event type to listen for. Default: 'mousedown' */
9
+ event?: 'mousedown' | 'mouseup' | 'click';
10
+ /** Whether to capture the event. Default: true */
11
+ capture?: boolean;
12
+ /** Elements to exclude from the outside check */
13
+ exclude?: (HTMLElement | null)[];
14
+ }
15
+
16
+ /**
17
+ * Attaches a listener that triggers when clicking outside the specified element.
18
+ * @param element - The element to watch.
19
+ * @param callback - The callback to invoke when clicking outside.
20
+ * @param options - Configuration options.
21
+ * @returns A function to remove the listener.
22
+ */
23
+ export const onClickOutside = (
24
+ element: HTMLElement,
25
+ callback: (event: MouseEvent) => void,
26
+ options: OnClickOutsideOptions = {},
27
+ ): (() => void) => {
28
+ const { event = 'mousedown', capture = true, exclude = [] } = options;
29
+
30
+ const handleClick = markAsEventFunc((e: MouseEvent) => {
31
+ const target = e.target as Node;
32
+
33
+ // Check if click is on the element or its descendants
34
+ if (element.contains(target)) return;
35
+
36
+ // Check if click is on excluded elements
37
+ if (exclude.some((el) => el?.contains(target))) return;
38
+
39
+ callback(e);
40
+ });
41
+
42
+ document.addEventListener(event, handleClick, capture);
43
+
44
+ return () => document.removeEventListener(event, handleClick, capture);
45
+ };
46
+
47
+ /**
48
+ * Options for onKeyPress function.
49
+ */
50
+ export interface OnKeyPressOptions {
51
+ /** Whether to prevent default behavior. Default: false */
52
+ preventDefault?: boolean;
53
+ /** Whether to stop propagation. Default: false */
54
+ stopPropagation?: boolean;
55
+ /** Event type. Default: 'keydown' */
56
+ event?: 'keydown' | 'keyup' | 'keypress';
57
+ /** Target element. Default: document */
58
+ target?: HTMLElement | Document;
59
+ }
60
+
61
+ /**
62
+ * Attaches a listener for specific key presses.
63
+ * @param key - The key or keys to listen for (e.g., 'Escape', 'Enter', ['a', 'b']).
64
+ * @param callback - The callback to invoke when the key is pressed.
65
+ * @param options - Configuration options.
66
+ * @returns A function to remove the listener.
67
+ */
68
+ export const onKeyPress = (
69
+ key: string | string[],
70
+ callback: (event: KeyboardEvent) => void,
71
+ options: OnKeyPressOptions = {},
72
+ ): (() => void) => {
73
+ const {
74
+ preventDefault = false,
75
+ stopPropagation = false,
76
+ event = 'keydown',
77
+ target = document,
78
+ } = options;
79
+
80
+ const keys = Array.isArray(key) ? key : [key];
81
+
82
+ const handleKeyPress = markAsEventFunc((e: KeyboardEvent) => {
83
+ if (keys.includes(e.key)) {
84
+ if (preventDefault) e.preventDefault();
85
+ if (stopPropagation) e.stopPropagation();
86
+ callback(e);
87
+ }
88
+ });
89
+
90
+ target.addEventListener(event, handleKeyPress as EventListener);
91
+
92
+ return () => target.removeEventListener(event, handleKeyPress as EventListener);
93
+ };
94
+
95
+ /**
96
+ * Options for onEscape function.
97
+ */
98
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
99
+ export interface OnEscapeOptions extends Omit<OnKeyPressOptions, 'event'> {}
100
+
101
+ /**
102
+ * Attaches a listener for the Escape key.
103
+ * @param callback - The callback to invoke when Escape is pressed.
104
+ * @param options - Configuration options.
105
+ * @returns A function to remove the listener.
106
+ */
107
+ export const onEscape = (
108
+ callback: (event: KeyboardEvent) => void,
109
+ options: OnEscapeOptions = {},
110
+ ): (() => void) => {
111
+ return onKeyPress('Escape', callback, options);
112
+ };
113
+
114
+ /**
115
+ * Options for onEnter function.
116
+ */
117
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
118
+ export interface OnEnterOptions extends Omit<OnKeyPressOptions, 'event'> {}
119
+
120
+ /**
121
+ * Attaches a listener for the Enter key.
122
+ * @param callback - The callback to invoke when Enter is pressed.
123
+ * @param options - Configuration options.
124
+ * @returns A function to remove the listener.
125
+ */
126
+ export const onEnter = (
127
+ callback: (event: KeyboardEvent) => void,
128
+ options: OnEnterOptions = {},
129
+ ): (() => void) => {
130
+ return onKeyPress('Enter', callback, options);
131
+ };
132
+
133
+ /**
134
+ * Options for onLongPress function.
135
+ */
136
+ export interface OnLongPressOptions {
137
+ /** Duration in milliseconds to trigger long press. Default: 500 */
138
+ duration?: number;
139
+ /** Threshold in pixels for movement tolerance. Default: 10 */
140
+ threshold?: number;
141
+ }
142
+
143
+ /**
144
+ * Attaches a listener for long press events.
145
+ * @param element - The element to watch.
146
+ * @param callback - The callback to invoke on long press.
147
+ * @param options - Configuration options.
148
+ * @returns A function to remove the listeners.
149
+ */
150
+ export const onLongPress = (
151
+ element: HTMLElement,
152
+ callback: (event: MouseEvent | TouchEvent) => void,
153
+ options: OnLongPressOptions = {},
154
+ ): (() => void) => {
155
+ const { duration = 500, threshold = 10 } = options;
156
+
157
+ let timeoutId: ReturnType<typeof setTimeout> | null = null;
158
+ let startX = 0;
159
+ let startY = 0;
160
+
161
+ const getPoint = (e: MouseEvent | TouchEvent) => ('touches' in e ? e.touches[0] : e);
162
+
163
+ const handleStart = markAsEventFunc((e: MouseEvent | TouchEvent) => {
164
+ const point = getPoint(e);
165
+ startX = point.clientX;
166
+ startY = point.clientY;
167
+
168
+ timeoutId = setTimeout(() => {
169
+ callback(e);
170
+ }, duration);
171
+ });
172
+
173
+ const handleMove = markAsEventFunc((e: MouseEvent | TouchEvent) => {
174
+ if (!timeoutId) return;
175
+
176
+ const point = getPoint(e);
177
+ const deltaX = Math.abs(point.clientX - startX);
178
+ const deltaY = Math.abs(point.clientY - startY);
179
+
180
+ if (deltaX > threshold || deltaY > threshold) {
181
+ handleEnd();
182
+ }
183
+ });
184
+
185
+ const handleEnd = markAsEventFunc(() => {
186
+ if (timeoutId) {
187
+ clearTimeout(timeoutId);
188
+ timeoutId = null;
189
+ }
190
+ });
191
+
192
+ const removeListeners = addEventsListener(element, {
193
+ mousedown: handleStart as EventListener,
194
+ mousemove: handleMove as EventListener,
195
+ mouseup: handleEnd,
196
+ mouseleave: handleEnd,
197
+ touchstart: handleStart as EventListener,
198
+ touchmove: handleMove as EventListener,
199
+ touchend: handleEnd,
200
+ touchcancel: handleEnd,
201
+ });
202
+
203
+ return () => {
204
+ removeListeners();
205
+ if (timeoutId) clearTimeout(timeoutId);
206
+ };
207
+ };
208
+
209
+ /**
210
+ * Attaches multiple event listeners to an element.
211
+ * @param element - The element to attach listeners to.
212
+ * @param events - An object mapping event types to handlers.
213
+ */
214
+ export const addEventsListener = <E extends HTMLElement>(
215
+ element: E,
216
+ events: Partial<{
217
+ [K in Parameters<E['addEventListener']>[0]]: SingleOrArray<
218
+ Parameters<E['addEventListener']>[1]
219
+ >;
220
+ }>,
221
+ options?: AddEventListenerOptions,
222
+ ): (() => void) => {
223
+ const handlers: [eventName: string, eventHandlers: EventListener][] = [];
224
+
225
+ Object.entries(events).forEach(([eventKey, handler]) => {
226
+ if (!handler) return;
227
+ const event = eventKey;
228
+
229
+ if (Array.isArray(handler)) {
230
+ handler.forEach((h: EventListener) => {
231
+ element.addEventListener(event, h, options);
232
+ handlers.push([event, h]);
233
+ });
234
+ } else {
235
+ element.addEventListener(event, handler as EventListener, options);
236
+ handlers.push([event, handler as EventListener]);
237
+ }
238
+ });
239
+
240
+ return () => {
241
+ handlers.forEach(([event, handler]) => {
242
+ element.removeEventListener(event, handler, options);
243
+ });
244
+ };
245
+ };
@@ -0,0 +1,5 @@
1
+ export * from './element';
2
+ export * from './events';
3
+ export * from './observer';
4
+ export * from './style';
5
+ export * from './win';
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Options for waitForElement function.
3
+ */
4
+ export interface WaitForElementOptions {
5
+ /** Maximum time to wait in milliseconds. Default: 5000 */
6
+ timeout?: number;
7
+ /** Root element to observe. Default: document.body */
8
+ root?: HTMLElement;
9
+ /** Check interval in milliseconds. Default: 100 */
10
+ interval?: number;
11
+ }
12
+
13
+ /**
14
+ * Waits for an element matching the selector to appear in the DOM.
15
+ * @param selector - CSS selector to match.
16
+ * @param options - Configuration options.
17
+ * @returns A promise that resolves with the element when found.
18
+ */
19
+ export const waitForElement = <T extends HTMLElement = HTMLElement>(
20
+ selector: string,
21
+ options: WaitForElementOptions = {},
22
+ ): Promise<T> => {
23
+ const { timeout = 5000, root = document.body, interval = 100 } = options;
24
+
25
+ return new Promise((resolve, reject) => {
26
+ // Check if element already exists
27
+ const existingElement = root.querySelector<T>(selector);
28
+ if (existingElement) {
29
+ resolve(existingElement);
30
+ return;
31
+ }
32
+
33
+ // Use MutationObserver for efficient detection
34
+ const observer = new MutationObserver(() => {
35
+ const element = root.querySelector<T>(selector);
36
+ if (element) {
37
+ cleanup();
38
+ resolve(element);
39
+ }
40
+ });
41
+
42
+ observer.observe(root, {
43
+ childList: true,
44
+ subtree: true,
45
+ });
46
+
47
+ // Fallback interval check
48
+ const intervalId = setInterval(() => {
49
+ const element = root.querySelector<T>(selector);
50
+ if (element) {
51
+ cleanup();
52
+ resolve(element);
53
+ }
54
+ }, interval);
55
+
56
+ // Timeout handler
57
+ const timeoutId = setTimeout(() => {
58
+ cleanup();
59
+ reject(new Error(`Timeout: Element "${selector}" not found within ${timeout}ms`));
60
+ }, timeout);
61
+
62
+ const cleanup = () => {
63
+ observer.disconnect();
64
+ clearTimeout(timeoutId);
65
+ clearInterval(intervalId);
66
+ };
67
+ });
68
+ };
69
+
70
+ /**
71
+ * Options for watchElementRemoval function.
72
+ */
73
+ export interface WatchElementRemovalOptions {
74
+ /** Callback to invoke when the element is removed */
75
+ onRemove?: () => void;
76
+ /** Root element to observe. Default: document.body */
77
+ root?: HTMLElement;
78
+ }
79
+
80
+ /**
81
+ * Watches for an element to be removed from the DOM.
82
+ * @param element - The element to watch.
83
+ * @param options - Configuration options.
84
+ * @returns A function to stop watching.
85
+ */
86
+ export const watchElementRemoval = (
87
+ element: HTMLElement,
88
+ options: WatchElementRemovalOptions = {},
89
+ ): (() => void) => {
90
+ const { onRemove, root = document.body } = options;
91
+
92
+ const observer = new MutationObserver(() => {
93
+ if (!root.contains(element)) {
94
+ onRemove?.();
95
+ observer.disconnect();
96
+ }
97
+ });
98
+
99
+ observer.observe(root, {
100
+ childList: true,
101
+ subtree: true,
102
+ });
103
+
104
+ return () => observer.disconnect();
105
+ };
106
+
107
+ /**
108
+ * Returns a promise that resolves when the element is removed from the DOM.
109
+ * @param element - The element to watch.
110
+ * @param options - Configuration options.
111
+ * @returns A promise that resolves when the element is removed.
112
+ */
113
+ export const waitForElementRemoval = (
114
+ element: HTMLElement,
115
+ options: { root?: HTMLElement; timeout?: number } = {},
116
+ ): Promise<void> => {
117
+ const { root = document.body, timeout } = options;
118
+
119
+ return new Promise((resolve, reject) => {
120
+ // Check if element is already removed
121
+ if (!root.contains(element)) {
122
+ resolve();
123
+ return;
124
+ }
125
+
126
+ let timeoutId: ReturnType<typeof setTimeout> | undefined;
127
+
128
+ const observer = new MutationObserver(() => {
129
+ if (!root.contains(element)) {
130
+ cleanup();
131
+ resolve();
132
+ }
133
+ });
134
+
135
+ observer.observe(root, {
136
+ childList: true,
137
+ subtree: true,
138
+ });
139
+
140
+ if (timeout) {
141
+ timeoutId = setTimeout(() => {
142
+ cleanup();
143
+ reject(new Error(`Timeout: Element not removed within ${timeout}ms`));
144
+ }, timeout);
145
+ }
146
+
147
+ const cleanup = () => {
148
+ observer.disconnect();
149
+ if (timeoutId) clearTimeout(timeoutId);
150
+ };
151
+ });
152
+ };
153
+
154
+ /**
155
+ * Options for watchElementChanges function.
156
+ */
157
+ export interface WatchElementChangesOptions {
158
+ /** Watch for attribute changes. Default: true */
159
+ attributes?: boolean;
160
+ /** Watch for child node changes. Default: true */
161
+ childList?: boolean;
162
+ /** Watch for text content changes. Default: false */
163
+ characterData?: boolean;
164
+ /** Watch subtree. Default: false */
165
+ subtree?: boolean;
166
+ /** Callback for mutations */
167
+ onMutation?: (mutations: MutationRecord[]) => void;
168
+ }
169
+
170
+ /**
171
+ * Watches for changes to an element and its descendants.
172
+ * @param element - The element to watch.
173
+ * @param options - Configuration options.
174
+ * @returns A function to stop watching.
175
+ */
176
+ export const watchElementChanges = (
177
+ element: HTMLElement,
178
+ options: WatchElementChangesOptions = {},
179
+ ): (() => void) => {
180
+ const {
181
+ attributes = true,
182
+ childList = true,
183
+ characterData = false,
184
+ subtree = false,
185
+ onMutation,
186
+ } = options;
187
+
188
+ const observer = new MutationObserver((mutations) => onMutation?.(mutations));
189
+ observer.observe(element, {
190
+ attributes,
191
+ childList,
192
+ characterData,
193
+ subtree,
194
+ });
195
+
196
+ return () => observer.disconnect();
197
+ };