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 +151 -0
- package/dist/DOMRelocator.d.ts +61 -0
- package/dist/DOMRelocator.d.ts.map +1 -0
- package/dist/index.cjs +244 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +217 -0
- package/package.json +61 -0
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
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|