dom-relocator 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.
package/README.md ADDED
@@ -0,0 +1,151 @@
1
+ # DOMRelocator
2
+
3
+ A small, dependency-free TypeScript library that moves existing DOM elements between containers when media queries match and restores them to their exact original position when they no longer match.
4
+
5
+ Created by **Dmytro Frolov**.
6
+
7
+ ## Why DOMRelocator?
8
+
9
+ Responsive interfaces sometimes need the same interactive element in a different part of the document—not merely styled differently. Duplicating markup can introduce duplicate IDs, stale state, and accessibility problems. DOMRelocator moves the original node, preserving its state and event listeners.
10
+
11
+ ## Features
12
+
13
+ - Uses `matchMedia`; no resize polling
14
+ - Restores the exact original DOM position
15
+ - Supports `first`, `last`, and zero-based numeric positions
16
+ - Optional dynamic DOM observation
17
+ - Manual `refresh()` and complete `destroy()` lifecycle
18
+ - Scoped roots and typed callbacks
19
+ - Zero runtime dependencies
20
+
21
+ ## Installation
22
+
23
+ Using pnpm:
24
+
25
+ ```bash
26
+ pnpm add dom-relocator
27
+ ```
28
+
29
+ Using npm:
30
+
31
+ ```bash
32
+ npm install dom-relocator
33
+ ```
34
+
35
+ ## Quick start
36
+
37
+ ```html
38
+ <div id="mobile-actions"></div>
39
+
40
+ <div data-relocate-to="#mobile-actions" data-relocate-query="(max-width: 48rem)" data-relocate-position="last">
41
+ <button type="button">Save changes</button>
42
+ </div>
43
+ ```
44
+
45
+ ```ts
46
+ import DOMRelocator from "dom-relocator";
47
+
48
+ const relocator = new DOMRelocator();
49
+
50
+ // Restore every element and remove all listeners.
51
+ relocator.destroy();
52
+ ```
53
+
54
+ Instances discover elements immediately when constructed.
55
+
56
+ ## Data attributes
57
+
58
+ | Attribute | Required | Default | Description |
59
+ | ------------------------ | -------- | -------------------- | ----------------------------------------------------------- |
60
+ | `data-relocate-to` | Yes | — | CSS selector for the destination inside the configured root |
61
+ | `data-relocate-query` | No | `(max-width: 767px)` | Any valid media query |
62
+ | `data-relocate-position` | No | `last` | `first`, `last`, or a zero-based integer |
63
+
64
+ ### Position examples
65
+
66
+ ```html
67
+ <!-- Insert before all existing destination children. -->
68
+ <div data-relocate-to="#target" data-relocate-position="first"></div>
69
+
70
+ <!-- Append after all existing destination children. -->
71
+ <div data-relocate-to="#target" data-relocate-position="last"></div>
72
+
73
+ <!-- Insert at zero-based index 1. -->
74
+ <div data-relocate-to="#target" data-relocate-position="1"></div>
75
+ ```
76
+
77
+ Numeric positions greater than the number of destination children append the element.
78
+
79
+ ## Options
80
+
81
+ ```ts
82
+ const relocator = new DOMRelocator({
83
+ root: document,
84
+ observe: false,
85
+ onChange: change => {
86
+ console.log(change.action, change.element, change.target);
87
+ },
88
+ onError: error => {
89
+ console.error(error.message, error.element);
90
+ },
91
+ });
92
+ ```
93
+
94
+ | Option | Default | Description |
95
+ | ---------- | -------------- | ---------------------------------------------------- |
96
+ | `root` | `document` | Scope used to find managed elements and destinations |
97
+ | `observe` | `false` | Refresh automatically after relevant DOM mutations |
98
+ | `onChange` | — | Called after an element moves or restores |
99
+ | `onError` | `console.warn` | Receives configuration and runtime errors |
100
+
101
+ ## API
102
+
103
+ ### `size`
104
+
105
+ The number of elements currently managed by the instance.
106
+
107
+ ```ts
108
+ console.log(relocator.size);
109
+ ```
110
+
111
+ ### `refresh()`
112
+
113
+ Discovers added elements, removes stale records, and applies changed data attributes. Call it after dynamic DOM updates when `observe` is disabled.
114
+
115
+ ```ts
116
+ relocator.refresh();
117
+ ```
118
+
119
+ ### `destroy()`
120
+
121
+ Restores all managed elements and removes media-query and mutation listeners. A destroyed instance cannot be restarted; create a new instance if needed.
122
+
123
+ ```ts
124
+ relocator.destroy();
125
+ ```
126
+
127
+ ## Dynamic DOM
128
+
129
+ Observation is opt-in so the default runtime cost stays minimal:
130
+
131
+ ```ts
132
+ const relocator = new DOMRelocator({ observe: true });
133
+ ```
134
+
135
+ For controlled applications, keep observation disabled and call `refresh()` after rendering new markup.
136
+
137
+ ## Development
138
+
139
+ ```bash
140
+ pnpm install
141
+ pnpm dev # Open the interactive demo
142
+ pnpm typecheck # Check TypeScript
143
+ pnpm test # Run the test suite
144
+ pnpm build # Build ESM, CommonJS, and declarations
145
+ ```
146
+
147
+ The interactive demo includes a draggable viewport resizer and examples for different media queries and insertion positions.
148
+
149
+ ## Browser support
150
+
151
+ DOMRelocator targets modern browsers with `matchMedia`, `Map`, and standard DOM event APIs.
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Moves elements to responsive destinations and restores their exact original
3
+ * position when the media query stops matching.
4
+ *
5
+ * @example
6
+ * <div
7
+ * data-relocate-to="#mobile-actions"
8
+ * data-relocate-query="(max-width: 48rem)"
9
+ * data-relocate-position="last"
10
+ * >...</div>
11
+ *
12
+ * const relocator = new DOMRelocator();
13
+ * relocator.destroy();
14
+ */
15
+ export type DOMRelocatorPosition = "first" | "last" | number;
16
+ export interface DOMRelocatorChange {
17
+ action: "move" | "restore";
18
+ element: HTMLElement;
19
+ target: Element | null;
20
+ }
21
+ export interface DOMRelocatorOptions {
22
+ /** Scope used to find managed elements and destinations. */
23
+ root?: ParentNode;
24
+ /** Automatically refresh after DOM changes. Disabled by default. */
25
+ observe?: boolean;
26
+ onChange?: (change: DOMRelocatorChange) => void;
27
+ onError?: (error: DOMRelocatorError) => void;
28
+ }
29
+ export declare class DOMRelocatorError extends Error {
30
+ readonly element: HTMLElement;
31
+ constructor(message: string, element: HTMLElement);
32
+ }
33
+ export default class DOMRelocator {
34
+ readonly root: ParentNode;
35
+ private readonly observe;
36
+ private readonly onChange?;
37
+ private readonly onError?;
38
+ private readonly records;
39
+ private observer;
40
+ private refreshPending;
41
+ private destroyed;
42
+ constructor(options?: DOMRelocatorOptions);
43
+ /** Number of elements currently managed by this instance. */
44
+ get size(): number;
45
+ /** Finds new elements and applies changed configurations. */
46
+ refresh(): void;
47
+ /** Restores all elements and removes every listener. */
48
+ destroy(): void;
49
+ private findElements;
50
+ private readConfig;
51
+ private parsePosition;
52
+ private register;
53
+ private unregister;
54
+ private apply;
55
+ private insert;
56
+ private restore;
57
+ private reportOnce;
58
+ private fail;
59
+ private queueRefresh;
60
+ }
61
+ //# sourceMappingURL=DOMRelocator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DOMRelocator.d.ts","sourceRoot":"","sources":["../src/DOMRelocator.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,MAAM,MAAM,oBAAoB,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,CAAC;AAE7D,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC;IAC3B,OAAO,EAAE,WAAW,CAAC;IACrB,MAAM,EAAE,OAAO,GAAG,IAAI,CAAC;CACxB;AAED,MAAM,WAAW,mBAAmB;IAClC,4DAA4D;IAC5D,IAAI,CAAC,EAAE,UAAU,CAAC;IAClB,oEAAoE;IACpE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,kBAAkB,KAAK,IAAI,CAAC;IAChD,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;CAC9C;AAED,qBAAa,iBAAkB,SAAQ,KAAK;IAC1C,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC;IAE9B,YAAY,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,EAIhD;CACF;AA8BD,MAAM,CAAC,OAAO,OAAO,YAAY;IAC/B,QAAQ,CAAC,IAAI,EAAE,UAAU,CAAC;IAE1B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAU;IAClC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAkC;IAC5D,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAiC;IAC1D,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0C;IAClE,OAAO,CAAC,QAAQ,CAAiC;IACjD,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,SAAS,CAAS;IAE1B,YAAY,OAAO,GAAE,mBAAwB,EAqB5C;IAED,6DAA6D;IAC7D,IAAI,IAAI,IAAI,MAAM,CAEjB;IAED,6DAA6D;IAC7D,OAAO,IAAI,IAAI,CA0Bd;IAED,wDAAwD;IACxD,OAAO,IAAI,IAAI,CAUd;IAED,OAAO,CAAC,YAAY;IAUpB,OAAO,CAAC,UAAU;IA0BlB,OAAO,CAAC,aAAa;IAKrB,OAAO,CAAC,QAAQ;IA4BhB,OAAO,CAAC,UAAU;IAOlB,OAAO,CAAC,KAAK;IAoCb,OAAO,CAAC,MAAM;IAQd,OAAO,CAAC,OAAO;IAkBf,OAAO,CAAC,UAAU;IAMlB,OAAO,CAAC,IAAI;IAOZ,OAAO,CAAC,YAAY;CASrB"}
package/dist/index.cjs ADDED
@@ -0,0 +1,244 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ DOMRelocatorError: () => DOMRelocatorError,
24
+ default: () => DOMRelocator
25
+ });
26
+ module.exports = __toCommonJS(index_exports);
27
+
28
+ // src/DOMRelocator.ts
29
+ var DOMRelocatorError = class extends Error {
30
+ element;
31
+ constructor(message, element) {
32
+ super(message);
33
+ this.name = "DOMRelocatorError";
34
+ this.element = element;
35
+ }
36
+ };
37
+ var ATTR = {
38
+ target: "data-relocate-to",
39
+ query: "data-relocate-query",
40
+ position: "data-relocate-position"
41
+ };
42
+ var SELECTOR = `[${ATTR.target}]`;
43
+ var DEFAULT_QUERY = "(max-width: 767px)";
44
+ var DEFAULT_POSITION = "last";
45
+ var DOMRelocator = class {
46
+ root;
47
+ observe;
48
+ onChange;
49
+ onError;
50
+ records = /* @__PURE__ */ new Map();
51
+ observer = null;
52
+ refreshPending = false;
53
+ destroyed = false;
54
+ constructor(options = {}) {
55
+ if (!options.root && typeof document === "undefined") {
56
+ throw new Error("DOMRelocator requires a browser DOM or an explicit root.");
57
+ }
58
+ this.root = options.root ?? document;
59
+ this.observe = options.observe ?? false;
60
+ this.onChange = options.onChange;
61
+ this.onError = options.onError;
62
+ this.refresh();
63
+ if (this.observe && typeof MutationObserver !== "undefined") {
64
+ this.observer = new MutationObserver(() => this.queueRefresh());
65
+ this.observer.observe(this.root, {
66
+ attributes: true,
67
+ attributeFilter: Object.values(ATTR),
68
+ childList: true,
69
+ subtree: true
70
+ });
71
+ }
72
+ }
73
+ /** Number of elements currently managed by this instance. */
74
+ get size() {
75
+ return this.records.size;
76
+ }
77
+ /** Finds new elements and applies changed configurations. */
78
+ refresh() {
79
+ if (this.destroyed) return;
80
+ const elements = new Set(this.findElements());
81
+ for (const [element, record] of this.records) {
82
+ if (!elements.has(element)) this.unregister(record, element.isConnected);
83
+ }
84
+ for (const element of elements) {
85
+ const config = this.readConfig(element);
86
+ const current = this.records.get(element);
87
+ if (!config) {
88
+ if (current) this.unregister(current, true);
89
+ continue;
90
+ }
91
+ if (current?.signature === config.signature) {
92
+ this.apply(current);
93
+ continue;
94
+ }
95
+ if (current) this.unregister(current, true);
96
+ this.register(element, config);
97
+ }
98
+ }
99
+ /** Restores all elements and removes every listener. */
100
+ destroy() {
101
+ if (this.destroyed) return;
102
+ this.destroyed = true;
103
+ this.observer?.disconnect();
104
+ this.observer = null;
105
+ for (const record of [...this.records.values()]) {
106
+ this.unregister(record, true);
107
+ }
108
+ }
109
+ findElements() {
110
+ const elements = Array.from(this.root.querySelectorAll(SELECTOR));
111
+ if ("matches" in this.root && typeof this.root.matches === "function" && this.root.matches(SELECTOR)) {
112
+ elements.unshift(this.root);
113
+ }
114
+ return elements;
115
+ }
116
+ readConfig(element) {
117
+ const target = element.getAttribute(ATTR.target)?.trim();
118
+ if (!target) return this.fail(`${ATTR.target} must contain a CSS selector.`, element);
119
+ const query = element.getAttribute(ATTR.query)?.trim() || DEFAULT_QUERY;
120
+ const rawPosition = element.getAttribute(ATTR.position)?.trim() || DEFAULT_POSITION;
121
+ const position = this.parsePosition(rawPosition);
122
+ if (position === null) {
123
+ return this.fail(`${ATTR.position} must be "first", "last", or a non-negative integer.`, element);
124
+ }
125
+ try {
126
+ window.matchMedia(query);
127
+ } catch {
128
+ return this.fail(`Invalid media query: "${query}".`, element);
129
+ }
130
+ return {
131
+ target,
132
+ query,
133
+ position,
134
+ signature: `${target}\0${query}\0${position}`
135
+ };
136
+ }
137
+ parsePosition(value) {
138
+ if (value === "first" || value === "last") return value;
139
+ return /^\d+$/.test(value) ? Number(value) : null;
140
+ }
141
+ register(element, config) {
142
+ const parent = element.parentNode;
143
+ if (!parent) {
144
+ this.fail("A managed element must have a parent node.", element);
145
+ return;
146
+ }
147
+ const marker = element.ownerDocument.createComment("dom-relocator");
148
+ parent.insertBefore(marker, element);
149
+ const mediaQuery = window.matchMedia(config.query);
150
+ const record = {
151
+ ...config,
152
+ element,
153
+ marker,
154
+ originalParent: parent,
155
+ originalNextSibling: element.nextSibling,
156
+ mediaQuery,
157
+ listener: () => this.apply(record),
158
+ moved: false,
159
+ lastError: null
160
+ };
161
+ this.records.set(element, record);
162
+ mediaQuery.addEventListener("change", record.listener);
163
+ this.apply(record);
164
+ }
165
+ unregister(record, restore) {
166
+ record.mediaQuery.removeEventListener("change", record.listener);
167
+ this.records.delete(record.element);
168
+ if (restore) this.restore(record);
169
+ record.marker.remove();
170
+ }
171
+ apply(record) {
172
+ if (!record.mediaQuery.matches) {
173
+ if (record.moved) this.restore(record);
174
+ return;
175
+ }
176
+ let target;
177
+ try {
178
+ target = this.root.querySelector(record.target);
179
+ } catch {
180
+ this.reportOnce(record, `Invalid target selector: "${record.target}".`);
181
+ return;
182
+ }
183
+ if (!target) {
184
+ this.reportOnce(record, `Target not found: "${record.target}".`);
185
+ return;
186
+ }
187
+ if (target === record.element || record.element.contains(target)) {
188
+ this.reportOnce(record, "The destination cannot be the element or one of its descendants.");
189
+ return;
190
+ }
191
+ record.lastError = null;
192
+ if (!record.moved || record.element.parentElement !== target) {
193
+ this.insert(record.element, target, record.position);
194
+ }
195
+ if (!record.moved) {
196
+ record.moved = true;
197
+ this.onChange?.({ action: "move", element: record.element, target });
198
+ }
199
+ }
200
+ insert(element, target, position) {
201
+ if (position === "first") return target.prepend(element);
202
+ if (position === "last") return target.append(element);
203
+ const children = Array.from(target.children).filter((child) => child !== element);
204
+ target.insertBefore(element, children[position] ?? null);
205
+ }
206
+ restore(record) {
207
+ const { element, marker, originalParent, originalNextSibling } = record;
208
+ if (marker.parentNode) {
209
+ marker.parentNode.insertBefore(element, marker.nextSibling);
210
+ } else if (originalParent.isConnected) {
211
+ const reference = originalNextSibling?.parentNode === originalParent ? originalNextSibling : null;
212
+ originalParent.insertBefore(element, reference);
213
+ } else {
214
+ return;
215
+ }
216
+ if (record.moved) {
217
+ record.moved = false;
218
+ this.onChange?.({ action: "restore", element, target: null });
219
+ }
220
+ }
221
+ reportOnce(record, message) {
222
+ if (record.lastError === message) return;
223
+ record.lastError = message;
224
+ this.fail(message, record.element);
225
+ }
226
+ fail(message, element) {
227
+ const error = new DOMRelocatorError(message, element);
228
+ if (this.onError) this.onError(error);
229
+ else console.warn(error);
230
+ return null;
231
+ }
232
+ queueRefresh() {
233
+ if (this.refreshPending || this.destroyed) return;
234
+ this.refreshPending = true;
235
+ queueMicrotask(() => {
236
+ this.refreshPending = false;
237
+ this.refresh();
238
+ });
239
+ }
240
+ };
241
+ // Annotate the CommonJS export names for ESM import in node:
242
+ 0 && (module.exports = {
243
+ DOMRelocatorError
244
+ });
@@ -0,0 +1,3 @@
1
+ export { default, DOMRelocatorError } from "./DOMRelocator";
2
+ export type { DOMRelocatorChange, DOMRelocatorOptions, DOMRelocatorPosition } from "./DOMRelocator";
3
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAC5D,YAAY,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,217 @@
1
+ // src/DOMRelocator.ts
2
+ var DOMRelocatorError = class extends Error {
3
+ element;
4
+ constructor(message, element) {
5
+ super(message);
6
+ this.name = "DOMRelocatorError";
7
+ this.element = element;
8
+ }
9
+ };
10
+ var ATTR = {
11
+ target: "data-relocate-to",
12
+ query: "data-relocate-query",
13
+ position: "data-relocate-position"
14
+ };
15
+ var SELECTOR = `[${ATTR.target}]`;
16
+ var DEFAULT_QUERY = "(max-width: 767px)";
17
+ var DEFAULT_POSITION = "last";
18
+ var DOMRelocator = class {
19
+ root;
20
+ observe;
21
+ onChange;
22
+ onError;
23
+ records = /* @__PURE__ */ new Map();
24
+ observer = null;
25
+ refreshPending = false;
26
+ destroyed = false;
27
+ constructor(options = {}) {
28
+ if (!options.root && typeof document === "undefined") {
29
+ throw new Error("DOMRelocator requires a browser DOM or an explicit root.");
30
+ }
31
+ this.root = options.root ?? document;
32
+ this.observe = options.observe ?? false;
33
+ this.onChange = options.onChange;
34
+ this.onError = options.onError;
35
+ this.refresh();
36
+ if (this.observe && typeof MutationObserver !== "undefined") {
37
+ this.observer = new MutationObserver(() => this.queueRefresh());
38
+ this.observer.observe(this.root, {
39
+ attributes: true,
40
+ attributeFilter: Object.values(ATTR),
41
+ childList: true,
42
+ subtree: true
43
+ });
44
+ }
45
+ }
46
+ /** Number of elements currently managed by this instance. */
47
+ get size() {
48
+ return this.records.size;
49
+ }
50
+ /** Finds new elements and applies changed configurations. */
51
+ refresh() {
52
+ if (this.destroyed) return;
53
+ const elements = new Set(this.findElements());
54
+ for (const [element, record] of this.records) {
55
+ if (!elements.has(element)) this.unregister(record, element.isConnected);
56
+ }
57
+ for (const element of elements) {
58
+ const config = this.readConfig(element);
59
+ const current = this.records.get(element);
60
+ if (!config) {
61
+ if (current) this.unregister(current, true);
62
+ continue;
63
+ }
64
+ if (current?.signature === config.signature) {
65
+ this.apply(current);
66
+ continue;
67
+ }
68
+ if (current) this.unregister(current, true);
69
+ this.register(element, config);
70
+ }
71
+ }
72
+ /** Restores all elements and removes every listener. */
73
+ destroy() {
74
+ if (this.destroyed) return;
75
+ this.destroyed = true;
76
+ this.observer?.disconnect();
77
+ this.observer = null;
78
+ for (const record of [...this.records.values()]) {
79
+ this.unregister(record, true);
80
+ }
81
+ }
82
+ findElements() {
83
+ const elements = Array.from(this.root.querySelectorAll(SELECTOR));
84
+ if ("matches" in this.root && typeof this.root.matches === "function" && this.root.matches(SELECTOR)) {
85
+ elements.unshift(this.root);
86
+ }
87
+ return elements;
88
+ }
89
+ readConfig(element) {
90
+ const target = element.getAttribute(ATTR.target)?.trim();
91
+ if (!target) return this.fail(`${ATTR.target} must contain a CSS selector.`, element);
92
+ const query = element.getAttribute(ATTR.query)?.trim() || DEFAULT_QUERY;
93
+ const rawPosition = element.getAttribute(ATTR.position)?.trim() || DEFAULT_POSITION;
94
+ const position = this.parsePosition(rawPosition);
95
+ if (position === null) {
96
+ return this.fail(`${ATTR.position} must be "first", "last", or a non-negative integer.`, element);
97
+ }
98
+ try {
99
+ window.matchMedia(query);
100
+ } catch {
101
+ return this.fail(`Invalid media query: "${query}".`, element);
102
+ }
103
+ return {
104
+ target,
105
+ query,
106
+ position,
107
+ signature: `${target}\0${query}\0${position}`
108
+ };
109
+ }
110
+ parsePosition(value) {
111
+ if (value === "first" || value === "last") return value;
112
+ return /^\d+$/.test(value) ? Number(value) : null;
113
+ }
114
+ register(element, config) {
115
+ const parent = element.parentNode;
116
+ if (!parent) {
117
+ this.fail("A managed element must have a parent node.", element);
118
+ return;
119
+ }
120
+ const marker = element.ownerDocument.createComment("dom-relocator");
121
+ parent.insertBefore(marker, element);
122
+ const mediaQuery = window.matchMedia(config.query);
123
+ const record = {
124
+ ...config,
125
+ element,
126
+ marker,
127
+ originalParent: parent,
128
+ originalNextSibling: element.nextSibling,
129
+ mediaQuery,
130
+ listener: () => this.apply(record),
131
+ moved: false,
132
+ lastError: null
133
+ };
134
+ this.records.set(element, record);
135
+ mediaQuery.addEventListener("change", record.listener);
136
+ this.apply(record);
137
+ }
138
+ unregister(record, restore) {
139
+ record.mediaQuery.removeEventListener("change", record.listener);
140
+ this.records.delete(record.element);
141
+ if (restore) this.restore(record);
142
+ record.marker.remove();
143
+ }
144
+ apply(record) {
145
+ if (!record.mediaQuery.matches) {
146
+ if (record.moved) this.restore(record);
147
+ return;
148
+ }
149
+ let target;
150
+ try {
151
+ target = this.root.querySelector(record.target);
152
+ } catch {
153
+ this.reportOnce(record, `Invalid target selector: "${record.target}".`);
154
+ return;
155
+ }
156
+ if (!target) {
157
+ this.reportOnce(record, `Target not found: "${record.target}".`);
158
+ return;
159
+ }
160
+ if (target === record.element || record.element.contains(target)) {
161
+ this.reportOnce(record, "The destination cannot be the element or one of its descendants.");
162
+ return;
163
+ }
164
+ record.lastError = null;
165
+ if (!record.moved || record.element.parentElement !== target) {
166
+ this.insert(record.element, target, record.position);
167
+ }
168
+ if (!record.moved) {
169
+ record.moved = true;
170
+ this.onChange?.({ action: "move", element: record.element, target });
171
+ }
172
+ }
173
+ insert(element, target, position) {
174
+ if (position === "first") return target.prepend(element);
175
+ if (position === "last") return target.append(element);
176
+ const children = Array.from(target.children).filter((child) => child !== element);
177
+ target.insertBefore(element, children[position] ?? null);
178
+ }
179
+ restore(record) {
180
+ const { element, marker, originalParent, originalNextSibling } = record;
181
+ if (marker.parentNode) {
182
+ marker.parentNode.insertBefore(element, marker.nextSibling);
183
+ } else if (originalParent.isConnected) {
184
+ const reference = originalNextSibling?.parentNode === originalParent ? originalNextSibling : null;
185
+ originalParent.insertBefore(element, reference);
186
+ } else {
187
+ return;
188
+ }
189
+ if (record.moved) {
190
+ record.moved = false;
191
+ this.onChange?.({ action: "restore", element, target: null });
192
+ }
193
+ }
194
+ reportOnce(record, message) {
195
+ if (record.lastError === message) return;
196
+ record.lastError = message;
197
+ this.fail(message, record.element);
198
+ }
199
+ fail(message, element) {
200
+ const error = new DOMRelocatorError(message, element);
201
+ if (this.onError) this.onError(error);
202
+ else console.warn(error);
203
+ return null;
204
+ }
205
+ queueRefresh() {
206
+ if (this.refreshPending || this.destroyed) return;
207
+ this.refreshPending = true;
208
+ queueMicrotask(() => {
209
+ this.refreshPending = false;
210
+ this.refresh();
211
+ });
212
+ }
213
+ };
214
+ export {
215
+ DOMRelocatorError,
216
+ DOMRelocator as default
217
+ };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "dom-relocator",
3
+ "version": "0.1.0",
4
+ "description": "Move DOM elements between containers when media queries match, then restore them exactly.",
5
+ "keywords": [
6
+ "dom",
7
+ "responsive",
8
+ "relocate",
9
+ "media-query",
10
+ "typescript"
11
+ ],
12
+ "author": "Dmytro Frolov",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/dmitry-conquer/dom-relocator.git"
16
+ },
17
+ "homepage": "https://github.com/dmitry-conquer/dom-relocator#readme",
18
+ "bugs": {
19
+ "url": "https://github.com/dmitry-conquer/dom-relocator/issues"
20
+ },
21
+ "type": "module",
22
+ "sideEffects": false,
23
+ "packageManager": "pnpm@11.10.0",
24
+ "publishConfig": {
25
+ "access": "public",
26
+ "registry": "https://registry.npmjs.org/"
27
+ },
28
+ "files": [
29
+ "dist"
30
+ ],
31
+ "main": "./dist/index.cjs",
32
+ "module": "./dist/index.js",
33
+ "types": "./dist/index.d.ts",
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/index.d.ts",
37
+ "import": "./dist/index.js",
38
+ "require": "./dist/index.cjs"
39
+ }
40
+ },
41
+ "scripts": {
42
+ "dev": "vite --open demo/index.html",
43
+ "build": "tsup src/index.ts --format esm,cjs --clean && tsc -p tsconfig.build.json",
44
+ "build:docs": "tsup src/index.ts --format esm --out-dir docs --minify",
45
+ "typecheck": "tsc --noEmit",
46
+ "test": "vitest run",
47
+ "test:watch": "vitest",
48
+ "prepack": "pnpm run build",
49
+ "prepublishOnly": "pnpm run typecheck && pnpm test"
50
+ },
51
+ "overrides": {
52
+ "esbuild": "^0.28.1"
53
+ },
54
+ "devDependencies": {
55
+ "happy-dom": "^20.11.0",
56
+ "tsup": "^8.5.1",
57
+ "typescript": "^7.0.2",
58
+ "vite": "^8.1.5",
59
+ "vitest": "^4.1.10"
60
+ }
61
+ }