@andreyshpigunov/x 0.3.72
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/.editorconfig +12 -0
- package/.github/workflows/publish.yml +28 -0
- package/README.md +37 -0
- package/assets/alpha.png +0 -0
- package/assets/apple-touch-icon.png +0 -0
- package/assets/github-mark-white.png +0 -0
- package/assets/github-mark.png +0 -0
- package/assets/logo-inverse.png +0 -0
- package/assets/logo.png +0 -0
- package/assets/logo.svg +9 -0
- package/babel.config.cjs +4 -0
- package/dist/app.css +167 -0
- package/dist/app.js +1 -0
- package/dist/x.css +167 -0
- package/dist/x.js +1 -0
- package/favicon.ico +0 -0
- package/favicon.svg +9 -0
- package/index.html +2214 -0
- package/index.js +1 -0
- package/jest.config.mjs +7 -0
- package/jsdoc.json +11 -0
- package/package.json +50 -0
- package/src/components/x/animate.js +296 -0
- package/src/components/x/appear.js +158 -0
- package/src/components/x/autocomplete.js +150 -0
- package/src/components/x/buttons.css +265 -0
- package/src/components/x/colors.css +64 -0
- package/src/components/x/debug.css +55 -0
- package/src/components/x/device.js +265 -0
- package/src/components/x/dropdown.css +164 -0
- package/src/components/x/dropdown.js +463 -0
- package/src/components/x/flex.css +163 -0
- package/src/components/x/flow.css +52 -0
- package/src/components/x/form.css +138 -0
- package/src/components/x/form.js +180 -0
- package/src/components/x/grid.css +109 -0
- package/src/components/x/helpers.css +928 -0
- package/src/components/x/hover.js +93 -0
- package/src/components/x/icons.css +58 -0
- package/src/components/x/lazyload.js +153 -0
- package/src/components/x/lib.js +679 -0
- package/src/components/x/links.css +114 -0
- package/src/components/x/loadmore.js +191 -0
- package/src/components/x/modal.css +286 -0
- package/src/components/x/modal.js +346 -0
- package/src/components/x/reset.css +213 -0
- package/src/components/x/scroll.css +100 -0
- package/src/components/x/scroll.js +301 -0
- package/src/components/x/sheets.css +15 -0
- package/src/components/x/sheets.js +147 -0
- package/src/components/x/slider.css +83 -0
- package/src/components/x/slider.js +330 -0
- package/src/components/x/space.css +56 -0
- package/src/components/x/sticky.css +28 -0
- package/src/components/x/sticky.js +156 -0
- package/src/components/x/typo.css +318 -0
- package/src/css/app.css +407 -0
- package/src/css/x.css +252 -0
- package/src/js/app.js +47 -0
- package/src/js/x.js +81 -0
package/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import './dist/x.js';
|
package/jest.config.mjs
ADDED
package/jsdoc.json
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@andreyshpigunov/x",
|
|
3
|
+
"version": "0.3.72",
|
|
4
|
+
"devDependencies": {
|
|
5
|
+
"@babel/preset-env": "^7.26.9",
|
|
6
|
+
"@jest/globals": "^29.7.0",
|
|
7
|
+
"@parcel/logger": "*",
|
|
8
|
+
"autoprefixer": "^10.1.0",
|
|
9
|
+
"babel-jest": "^29.7.0",
|
|
10
|
+
"cssnano": "^5.0.2",
|
|
11
|
+
"docdash": "^2.0.2",
|
|
12
|
+
"jest": "^29.7.0",
|
|
13
|
+
"jest-environment-jsdom": "^29.7.0",
|
|
14
|
+
"jsdoc": "^4.0.4",
|
|
15
|
+
"parcel": "^2.0.1",
|
|
16
|
+
"postcss": "^8.5",
|
|
17
|
+
"postcss-cli": "^8.3.0",
|
|
18
|
+
"postcss-custom-media": "^11.0.1",
|
|
19
|
+
"postcss-custom-selectors": "^8.0.1",
|
|
20
|
+
"postcss-each": "^1.1.0",
|
|
21
|
+
"postcss-extend": "^1.0.5",
|
|
22
|
+
"postcss-for": "^2.1.1",
|
|
23
|
+
"postcss-import": "^14.0.2",
|
|
24
|
+
"postcss-nesting": "^10.2.0",
|
|
25
|
+
"postcss-simple-vars": "^7.0.1"
|
|
26
|
+
},
|
|
27
|
+
"type": "module",
|
|
28
|
+
"scripts": {
|
|
29
|
+
"build-css": "postcss src/css/*.css -u postcss-import -u postcss-nesting -u postcss-custom-media -u postcss-custom-selectors -u postcss-extend -u postcss-for -u postcss-each -u autoprefixer -u cssnano --no-map -d dist/",
|
|
30
|
+
"build-js": "parcel build src/js/*.js --no-source-maps --no-cache --dist-dir dist/",
|
|
31
|
+
"build": "npm run build-css && npm run build-js",
|
|
32
|
+
"test": "exit 0"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "https://github.com/andreyshpigunov/x.git"
|
|
37
|
+
},
|
|
38
|
+
"author": "Andrey Shpigunov",
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/andreyshpigunov/x/issues"
|
|
42
|
+
},
|
|
43
|
+
"homepage": "https://andreyshpigunov.github.io/x/",
|
|
44
|
+
"browserslist": [
|
|
45
|
+
"defaults"
|
|
46
|
+
],
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"alpinejs": "^3.14.1"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Scroll-based animation controller.
|
|
3
|
+
*
|
|
4
|
+
* Observes elements with `[x-animate]` attribute and applies classes or executes functions
|
|
5
|
+
* based on the element's position in the viewport or parent container.
|
|
6
|
+
*
|
|
7
|
+
* Exported singleton: `animate`
|
|
8
|
+
*
|
|
9
|
+
* Public API:
|
|
10
|
+
*
|
|
11
|
+
* - `animate.init()` – Initialize/reinitialize animation tracking for `[x-animate]` elements.
|
|
12
|
+
*
|
|
13
|
+
* Example usage:
|
|
14
|
+
*
|
|
15
|
+
* <div x-animate='{
|
|
16
|
+
* "parent": "#scroll-container",
|
|
17
|
+
* "trigger": ".trigger",
|
|
18
|
+
* "start": "120vh",
|
|
19
|
+
* "end": "0vh",
|
|
20
|
+
* "functionName": "coverOut",
|
|
21
|
+
* "class": "fixed",
|
|
22
|
+
* "classRemove": true
|
|
23
|
+
* }'></div>
|
|
24
|
+
*
|
|
25
|
+
* @author Andrey Shpigunov
|
|
26
|
+
* @version 0.3
|
|
27
|
+
* @since 2025-07-18
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { lib } from './lib';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Scroll-based animation controller.
|
|
34
|
+
*/
|
|
35
|
+
class Animate {
|
|
36
|
+
|
|
37
|
+
constructor() {
|
|
38
|
+
/**
|
|
39
|
+
* Prevents multiple `requestAnimationFrame` calls.
|
|
40
|
+
* @type {boolean}
|
|
41
|
+
* @private
|
|
42
|
+
*/
|
|
43
|
+
this._ticking = false;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Array of animation items parsed from `[x-animate]` elements.
|
|
47
|
+
* @type {Object[]}
|
|
48
|
+
* @private
|
|
49
|
+
*/
|
|
50
|
+
this._animations = [];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Bound scroll handler for `requestAnimationFrame`.
|
|
54
|
+
* @type {Function}
|
|
55
|
+
* @private
|
|
56
|
+
*/
|
|
57
|
+
this._scroll = this._scroll.bind(this);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Bound raw scroll/resize handler.
|
|
61
|
+
* @type {Function}
|
|
62
|
+
* @private
|
|
63
|
+
*/
|
|
64
|
+
this._scrollHandler = this._scrollHandler.bind(this);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Indicates whether `init()` was called.
|
|
68
|
+
* @type {boolean}
|
|
69
|
+
* @private
|
|
70
|
+
*/
|
|
71
|
+
this._initialized = false;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Set of parent elements being listened to for scroll.
|
|
75
|
+
* @type {Set<HTMLElement|Window>}
|
|
76
|
+
* @private
|
|
77
|
+
*/
|
|
78
|
+
this._parents = new Set();
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* NodeList of elements with `[x-animate]`.
|
|
82
|
+
* @type {NodeListOf<HTMLElement>|null}
|
|
83
|
+
* @private
|
|
84
|
+
*/
|
|
85
|
+
this._elements = null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Initializes or reinitializes animation tracking for `[x-animate]` elements.
|
|
90
|
+
*/
|
|
91
|
+
init() {
|
|
92
|
+
this._cleanup();
|
|
93
|
+
|
|
94
|
+
this._elements = lib.qsa('[x-animate]');
|
|
95
|
+
if (!this._elements?.length) return;
|
|
96
|
+
|
|
97
|
+
this._parseElementsAnimations();
|
|
98
|
+
if (!this._animations.length) return;
|
|
99
|
+
|
|
100
|
+
this._setupListeners();
|
|
101
|
+
this._initialized = true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Removes all listeners and resets internal state.
|
|
106
|
+
*
|
|
107
|
+
* @private
|
|
108
|
+
*/
|
|
109
|
+
_cleanup() {
|
|
110
|
+
if (!this._initialized) return;
|
|
111
|
+
|
|
112
|
+
this._parents.forEach(parent => {
|
|
113
|
+
parent.removeEventListener('scroll', this._scrollHandler);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
window.removeEventListener('resize', this._scrollHandler);
|
|
117
|
+
|
|
118
|
+
this._ticking = false;
|
|
119
|
+
this._animations = [];
|
|
120
|
+
this._parents = new Set();
|
|
121
|
+
this._elements = null;
|
|
122
|
+
this._initialized = false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Parses `[x-animate]` attributes and creates animation configuration for each element.
|
|
127
|
+
*
|
|
128
|
+
* @private
|
|
129
|
+
*/
|
|
130
|
+
_parseElementsAnimations() {
|
|
131
|
+
this._elements.forEach(element => {
|
|
132
|
+
try {
|
|
133
|
+
const json = JSON.parse(element.getAttribute('x-animate'));
|
|
134
|
+
const item = {
|
|
135
|
+
element,
|
|
136
|
+
trigger: lib.qs(json.trigger) || element,
|
|
137
|
+
parent: lib.qs(json.parent) || window,
|
|
138
|
+
start: json.start,
|
|
139
|
+
end: json.end || false,
|
|
140
|
+
class: json.class,
|
|
141
|
+
classRemove: json.classRemove !== false,
|
|
142
|
+
functionName: json.functionName,
|
|
143
|
+
lockedIn: false,
|
|
144
|
+
lockedOut: false,
|
|
145
|
+
log: json.log || false
|
|
146
|
+
};
|
|
147
|
+
this._animations.push(item);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
console.error('Invalid JSON in x-animate attribute:', element, err);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Sets up scroll and resize event listeners for unique parent containers.
|
|
156
|
+
*
|
|
157
|
+
* @private
|
|
158
|
+
*/
|
|
159
|
+
_setupListeners() {
|
|
160
|
+
for (const item of this._animations) {
|
|
161
|
+
if (this._parents.has(item.parent)) continue;
|
|
162
|
+
this._parents.add(item.parent);
|
|
163
|
+
item.parent.addEventListener('scroll', this._scrollHandler);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
window.addEventListener('resize', this._scrollHandler);
|
|
167
|
+
|
|
168
|
+
if (document.readyState === 'complete') {
|
|
169
|
+
requestAnimationFrame(() => this._scroll());
|
|
170
|
+
} else {
|
|
171
|
+
window.addEventListener('load', () => this._scroll(), { once: true });
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Raw scroll/resize event handler with throttling via `requestAnimationFrame`.
|
|
177
|
+
*
|
|
178
|
+
* @private
|
|
179
|
+
*/
|
|
180
|
+
_scrollHandler() {
|
|
181
|
+
if (!this._ticking) {
|
|
182
|
+
this._ticking = true;
|
|
183
|
+
window.requestAnimationFrame(() => {
|
|
184
|
+
this._scroll();
|
|
185
|
+
this._ticking = false;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Main animation logic executed on scroll or resize.
|
|
192
|
+
*
|
|
193
|
+
* Calculates element position, progress, adds/removes classes, and calls custom functions.
|
|
194
|
+
*
|
|
195
|
+
* @private
|
|
196
|
+
*/
|
|
197
|
+
_scroll() {
|
|
198
|
+
this._animations.forEach(item => {
|
|
199
|
+
const triggerRect = item.trigger.getBoundingClientRect();
|
|
200
|
+
const parentRect = item.parent !== window ? item.parent.getBoundingClientRect() : null;
|
|
201
|
+
|
|
202
|
+
const top = triggerRect.top - (parentRect ? parentRect.top : 0);
|
|
203
|
+
const start = this._2px(item.start, item.parent);
|
|
204
|
+
const end = this._2px(item.end, item.parent);
|
|
205
|
+
item.duration = isNaN(end) ? 0 : start - end;
|
|
206
|
+
|
|
207
|
+
if (item.log) console.log(top, start, end, item);
|
|
208
|
+
|
|
209
|
+
if (!isNaN(start) && !isNaN(end)) {
|
|
210
|
+
// Case: both start and end defined
|
|
211
|
+
if (top <= start && top >= end) {
|
|
212
|
+
item.lockedOut = false;
|
|
213
|
+
if (item.class) item.element.classList.add(item.class);
|
|
214
|
+
|
|
215
|
+
if (typeof window[item.functionName] === 'function') {
|
|
216
|
+
item.progress = ((start - top) / item.duration).toFixed(4);
|
|
217
|
+
window[item.functionName](item);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
} else {
|
|
221
|
+
if (item.class && item.classRemove === true && item.element.classList.contains(item.class)) {
|
|
222
|
+
item.element.classList.remove(item.class);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (!item.lockedOut && typeof window[item.functionName] === 'function') {
|
|
226
|
+
if (top >= start) {
|
|
227
|
+
item.progress = 0;
|
|
228
|
+
window[item.functionName](item);
|
|
229
|
+
item.lockedOut = true;
|
|
230
|
+
}
|
|
231
|
+
if (top <= end) {
|
|
232
|
+
item.progress = 1;
|
|
233
|
+
window[item.functionName](item);
|
|
234
|
+
item.lockedOut = true;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
} else if (!isNaN(start)) {
|
|
240
|
+
// Case: only start defined
|
|
241
|
+
if (top <= start) {
|
|
242
|
+
item.lockedOut = false;
|
|
243
|
+
if (item.class) item.element.classList.add(item.class);
|
|
244
|
+
|
|
245
|
+
if (!item.lockedIn && typeof window[item.functionName] === 'function') {
|
|
246
|
+
item.progress = 1;
|
|
247
|
+
window[item.functionName](item);
|
|
248
|
+
item.lockedIn = true;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
} else {
|
|
252
|
+
item.lockedIn = false;
|
|
253
|
+
|
|
254
|
+
if (item.class && item.classRemove === true && item.element.classList.contains(item.class)) {
|
|
255
|
+
item.element.classList.remove(item.class);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!item.lockedOut && typeof window[item.functionName] === 'function') {
|
|
259
|
+
if (top >= start) {
|
|
260
|
+
item.progress = 0;
|
|
261
|
+
window[item.functionName](item);
|
|
262
|
+
item.lockedOut = true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Converts a value like '120vh', '50%' or '300' into pixels.
|
|
272
|
+
*
|
|
273
|
+
* @param {string|number} value - The value to convert.
|
|
274
|
+
* @param {HTMLElement|Window} [parent=window] - The context for percentage calculations.
|
|
275
|
+
* @returns {number} Pixel value.
|
|
276
|
+
* @private
|
|
277
|
+
*/
|
|
278
|
+
_2px(value, parent = window) {
|
|
279
|
+
if (/(%|vh)/.test(value)) {
|
|
280
|
+
const height = parent === window
|
|
281
|
+
? document.documentElement.clientHeight
|
|
282
|
+
: parent.clientHeight;
|
|
283
|
+
|
|
284
|
+
value = value.replace(/(vh|%)/, '');
|
|
285
|
+
return (height * parseFloat(value)) / 100;
|
|
286
|
+
} else {
|
|
287
|
+
return parseFloat(value);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Singleton export of the Animate controller.
|
|
294
|
+
* @type {Animate}
|
|
295
|
+
*/
|
|
296
|
+
export const animate = new Animate();
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Element appearing and visibility observer.
|
|
3
|
+
*
|
|
4
|
+
* Detects when elements enter or leave the viewport and applies CSS classes accordingly.
|
|
5
|
+
* Intended for triggering animations, lazy loading, or visual effects.
|
|
6
|
+
*
|
|
7
|
+
* Exported singleton: `appear`
|
|
8
|
+
*
|
|
9
|
+
* Public API:
|
|
10
|
+
*
|
|
11
|
+
* - `appear.init(config)` – Initializes or reinitializes the observer.
|
|
12
|
+
*
|
|
13
|
+
* Usage example:
|
|
14
|
+
*
|
|
15
|
+
* HTML:
|
|
16
|
+
* <div x-appear>Hello!</div>
|
|
17
|
+
*
|
|
18
|
+
* Behavior:
|
|
19
|
+
* - Adds `appeared` class once when the element first appears.
|
|
20
|
+
* - Adds `visible` class while the element is currently visible.
|
|
21
|
+
*
|
|
22
|
+
* Configuration via `init()`:
|
|
23
|
+
* - `appearedClass` – Custom class for the first appearance (default: 'appeared').
|
|
24
|
+
* - `visibleClass` – Custom class for current visibility (default: 'visible').
|
|
25
|
+
* - `once` – If `true`, stops observing the element after first appearance.
|
|
26
|
+
*
|
|
27
|
+
* Events:
|
|
28
|
+
* - `visible` – Dispatched when the element becomes visible.
|
|
29
|
+
* - `invisible` – Dispatched when the element leaves the viewport.
|
|
30
|
+
*
|
|
31
|
+
* @author Andrey Shpigunov
|
|
32
|
+
* @version 0.3
|
|
33
|
+
* @since 2025-07-18
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { lib } from './lib';
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Element appearing and visibility observer.
|
|
40
|
+
*
|
|
41
|
+
* Uses IntersectionObserver to track elements with [x-appear] attribute and manage classes.
|
|
42
|
+
*/
|
|
43
|
+
class Appear {
|
|
44
|
+
|
|
45
|
+
constructor() {
|
|
46
|
+
/**
|
|
47
|
+
* List of currently observed elements.
|
|
48
|
+
* @type {HTMLElement[]}
|
|
49
|
+
* @private
|
|
50
|
+
*/
|
|
51
|
+
this._targets = [];
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Instance of IntersectionObserver.
|
|
55
|
+
* @type {IntersectionObserver|null}
|
|
56
|
+
* @private
|
|
57
|
+
*/
|
|
58
|
+
this._observer = null;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Observer options and behavior configuration.
|
|
62
|
+
* @type {{
|
|
63
|
+
* appearedClass: string,
|
|
64
|
+
* visibleClass: string,
|
|
65
|
+
* once: boolean
|
|
66
|
+
* }}
|
|
67
|
+
* @private
|
|
68
|
+
*/
|
|
69
|
+
this._options = {
|
|
70
|
+
appearedClass: 'appeared',
|
|
71
|
+
visibleClass: 'visible',
|
|
72
|
+
once: false
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Initializes or reinitializes the observer and starts observing elements.
|
|
78
|
+
*
|
|
79
|
+
* @param {Object} [config={}] - Optional configuration object.
|
|
80
|
+
* @param {string} [config.appearedClass='appeared'] - Class added once when the element first appears.
|
|
81
|
+
* @param {string} [config.visibleClass='visible'] - Class added while the element is visible.
|
|
82
|
+
* @param {boolean} [config.once=false] - If true, stops observing after first appearance.
|
|
83
|
+
*
|
|
84
|
+
* @example
|
|
85
|
+
* appear.init({ once: true });
|
|
86
|
+
*/
|
|
87
|
+
init(config = {}) {
|
|
88
|
+
if (!('IntersectionObserver' in window)) return;
|
|
89
|
+
|
|
90
|
+
this._options = { ...this._options, ...config };
|
|
91
|
+
|
|
92
|
+
// Disconnect previous observer to prevent leaks
|
|
93
|
+
if (this._observer) {
|
|
94
|
+
this._observer.disconnect();
|
|
95
|
+
this._observer = null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
this._targets = lib.qsa('[x-appear]');
|
|
99
|
+
|
|
100
|
+
if (this._targets.length) {
|
|
101
|
+
this._observer = new IntersectionObserver(this._observerCallback.bind(this));
|
|
102
|
+
|
|
103
|
+
this._targets.forEach(item => {
|
|
104
|
+
this._observer.observe(item);
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* IntersectionObserver callback. Handles visibility changes for tracked elements.
|
|
111
|
+
*
|
|
112
|
+
* @param {IntersectionObserverEntry[]} entries - Array of observer entries.
|
|
113
|
+
* @private
|
|
114
|
+
*/
|
|
115
|
+
_observerCallback(entries) {
|
|
116
|
+
const { appearedClass, visibleClass, once } = this._options;
|
|
117
|
+
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
const target = entry.target;
|
|
120
|
+
|
|
121
|
+
const hasAppeared = appearedClass != null;
|
|
122
|
+
const hasVisible = visibleClass != null;
|
|
123
|
+
|
|
124
|
+
if (entry.isIntersecting) {
|
|
125
|
+
// First time visibility — add appeared class
|
|
126
|
+
if (hasAppeared && !target.classList.contains(appearedClass)) {
|
|
127
|
+
target.classList.add(appearedClass);
|
|
128
|
+
|
|
129
|
+
if (once) {
|
|
130
|
+
this._observer.unobserve(target);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// While visible — add visible class and dispatch event
|
|
135
|
+
if (hasVisible) {
|
|
136
|
+
target.classList.add(visibleClass);
|
|
137
|
+
target.dispatchEvent(new CustomEvent('visible', {
|
|
138
|
+
detail: { appeared: true }
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
// When leaving viewport — remove visible class and dispatch event
|
|
143
|
+
if (hasVisible && target.classList.contains(visibleClass)) {
|
|
144
|
+
target.classList.remove(visibleClass);
|
|
145
|
+
target.dispatchEvent(new CustomEvent('invisible', {
|
|
146
|
+
detail: { appeared: true }
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Singleton export of the Appear observer.
|
|
156
|
+
* @type {Appear}
|
|
157
|
+
*/
|
|
158
|
+
export const appear = new Appear();
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { lib } from './lib';
|
|
2
|
+
|
|
3
|
+
class Autocomplete {
|
|
4
|
+
|
|
5
|
+
emptyStateHtml = `<li><span class="op4">Ничего не найдено</span></li>`;
|
|
6
|
+
defaultStateHtml = `<li><span class="op4">Начните печатать</span></li>`;
|
|
7
|
+
loadingStateHtml = `<li><span class="op4">Загрузка...</span></li>`;
|
|
8
|
+
|
|
9
|
+
init(dropdownId, options) {
|
|
10
|
+
this.emptyStateHtml = options.emptyStateHtml || this.emptyStateHtml;
|
|
11
|
+
this.defaultStateHtml = options.defaultStateHtml || this.defaultStateHtml;
|
|
12
|
+
this.loadingStateHtml = options.loadingStateHtml || this.loadingStateHtml;
|
|
13
|
+
|
|
14
|
+
this.dropdown = x.id(dropdownId);
|
|
15
|
+
this.field = x.qs('[x-dropdown-open]', this.dropdown);
|
|
16
|
+
this.list = x.qs('[x-dropdown]', this.dropdown);
|
|
17
|
+
|
|
18
|
+
this.data = options.data || null;
|
|
19
|
+
this.loadData = options.loadData || null;
|
|
20
|
+
this.mapData = options.mapData || null;
|
|
21
|
+
this.renderItem = options.renderItem || null;
|
|
22
|
+
this.onSelect = options.onSelect || null;
|
|
23
|
+
this.resetFunc = options.resetFunc || null;
|
|
24
|
+
|
|
25
|
+
this._loadData = this._loadData.bind(this);
|
|
26
|
+
this.debouncedLoadData = x.lib.debounce(this._loadData, 400);
|
|
27
|
+
|
|
28
|
+
this.keyHandler = this._keyHandler.bind(this);
|
|
29
|
+
this.clickHandler = this._clickHandler.bind(this);
|
|
30
|
+
this.hideHandler = this._hideHandler.bind(this);
|
|
31
|
+
|
|
32
|
+
if (this.field?.value == '') {
|
|
33
|
+
this._reset();
|
|
34
|
+
} else {
|
|
35
|
+
this._defaultState();
|
|
36
|
+
}
|
|
37
|
+
this.field.addEventListener('focus', this.debouncedLoadData);
|
|
38
|
+
this.field.addEventListener('input', this.debouncedLoadData);
|
|
39
|
+
this.field.addEventListener('keydown', this.keyHandler);
|
|
40
|
+
this.list.addEventListener('click', this.clickHandler);
|
|
41
|
+
this.dropdown.addEventListener('dropdown:afterhide', this.hideHandler);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
destroy() {
|
|
45
|
+
this._reset();
|
|
46
|
+
this.field.removeEventListener('focus', this.debouncedLoadData);
|
|
47
|
+
this.field.removeEventListener('input', this.debouncedLoadData);
|
|
48
|
+
this.field.removeEventListener('keydown', this.keyHandler);
|
|
49
|
+
this.list.removeEventListener('click', this.clickHandler);
|
|
50
|
+
this.dropdown.removeEventListener('dropdown:afterhide', this.hideHandler);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_keyHandler(e) {
|
|
54
|
+
if (e.key === 'Enter') {
|
|
55
|
+
e.preventDefault();
|
|
56
|
+
this.field.blur();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
_clickHandler(e) {
|
|
61
|
+
if (this._clicked) return;
|
|
62
|
+
this._clicked = true;
|
|
63
|
+
|
|
64
|
+
const el = e.target.closest('[data-item]');
|
|
65
|
+
if (el) {
|
|
66
|
+
try {
|
|
67
|
+
const item = JSON.parse(el.dataset.item);
|
|
68
|
+
this.onSelect(item);
|
|
69
|
+
} catch(err) {
|
|
70
|
+
console.error('Error parsing JSON');
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
console.log('Empty data-item');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_hideHandler() {
|
|
78
|
+
if (this._clicked) {
|
|
79
|
+
this._clicked = false;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!this.data || !this.data.length) {
|
|
84
|
+
this._reset();
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
this._loadingState();
|
|
88
|
+
this.onSelect(this.data[0]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
_loadingState() {
|
|
92
|
+
x.lib.render(this.list, this.loadingStateHtml);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
_emptyState() {
|
|
96
|
+
x.lib.render(this.list, this.emptyStateHtml);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_defaultState() {
|
|
100
|
+
x.lib.render(this.list, this.defaultStateHtml);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
_reset() {
|
|
104
|
+
this.resetFunc();
|
|
105
|
+
this.data = null;
|
|
106
|
+
this._defaultState();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async _loadData() {
|
|
110
|
+
if (this.field?.value == '') {
|
|
111
|
+
this._reset();
|
|
112
|
+
return;
|
|
113
|
+
} else {
|
|
114
|
+
this._loadingState();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
this._currentLoadId = Date.now();
|
|
119
|
+
const loadId = this._currentLoadId;
|
|
120
|
+
|
|
121
|
+
this.data = await this.loadData(this);
|
|
122
|
+
|
|
123
|
+
if (loadId !== this._currentLoadId) return;
|
|
124
|
+
|
|
125
|
+
this.data = this.mapData(this.data);
|
|
126
|
+
this.render();
|
|
127
|
+
} catch(err) {
|
|
128
|
+
this._reset();
|
|
129
|
+
console.error('Data loading error:', err);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async render() {
|
|
134
|
+
if (!this.data) {
|
|
135
|
+
this._reset();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!this.data.length) {
|
|
140
|
+
this._emptyState();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const html = this.data.map(this.renderItem).join('');
|
|
145
|
+
|
|
146
|
+
x.lib.render(this.list, html);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const autocomplete = new Autocomplete();
|