1 | /* eslint-disable @typescript-eslint/no-empty-function */
|
2 | /* eslint-disable @typescript-eslint/no-unused-vars */
|
3 | // eslint-disable-next-line max-classes-per-file
|
4 | import focusFirst from "./helpers/focusFirst";
|
5 | import nestedProperty from "./helpers/nestedProperty";
|
6 | import observer from "./helpers/observer";
|
7 | import onEvent from "./helpers/onEvent";
|
8 | import objectClone from "./helpers/objectClone";
|
9 | import { WUPcssHidden } from "./styles";
|
10 | // theoritcally such single appending is faster than using :host inside shadowComponent
|
11 | const appendedStyles = new Set();
|
12 | const appendedRootStyles = new Set();
|
13 | let lastUniqueNum = 0;
|
14 | const allObservedOptions = new WeakMap();
|
15 | /** Basic abstract class for every component in web-ui-pack */
|
16 | export default class WUPBaseElement extends HTMLElement {
|
17 | /** Returns this.constructor // watch-fix: https://github.com/Microsoft/TypeScript/issues/3841#issuecomment-337560146 */
|
18 | #ctr = this.constructor;
|
19 | /** Reference to global style element used by web-ui-pack */
|
20 | static get $refStyle() {
|
21 | return window.WUPrefStyle || null;
|
22 | }
|
23 | static set $refStyle(v) {
|
24 | window.WUPrefStyle = v || undefined;
|
25 | }
|
26 | /** StyleContent related to component */
|
27 | static get $style() {
|
28 | return "";
|
29 | }
|
30 | /** StyleContent related to component & inherited components */
|
31 | static get $styleRoot() {
|
32 | return `:root {
|
33 | --base-bg: #fff;
|
34 | --base-text: #232323;
|
35 | --base-focus: #00778d;
|
36 | --base-btn-bg: #009fbc;
|
37 | --base-btn-text: #fff;
|
38 | --base-btn-focus: #005766;
|
39 | --base-btn2-bg: var(--base-btn-text);
|
40 | --base-btn2-text: var(--base-btn-bg);
|
41 | --base-btn3-bg: var(--base-bg);
|
42 | --base-btn3-text: inherit;
|
43 | --base-sep: #e4e4e4;
|
44 | --border-radius: 6px;
|
45 | --anim-time: 200ms;
|
46 | --anim: var(--anim-time) cubic-bezier(0, 0, 0.2, 1) 0ms;
|
47 | }`;
|
48 | }
|
49 | /** Get unique id for html elements; Every getter returns new id */
|
50 | static get $uniqueId() {
|
51 | return `wup${++lastUniqueNum}`;
|
52 | }
|
53 | static get classNameHidden() {
|
54 | return "wup-hidden";
|
55 | }
|
56 | /* Array of options names to listen for changes */
|
57 | static get observedOptions() {
|
58 | return undefined;
|
59 | }
|
60 | /* Array of attribute names to listen for changes */
|
61 | static get observedAttributes() {
|
62 | return undefined;
|
63 | }
|
64 | /** Global default options applied to every element. Change it to configure default behavior OR use `element.$options` to change per item */
|
65 | static $defaults = {};
|
66 | /** Raw part of $options for internal usage (.$options is Proxy object and better avoid useless extra-calles via Proxy) */
|
67 | // @ts-expect-error - TS doesn't see that init happens via constructor.$options = null;
|
68 | _opts; // = objectClone(this.#ctr.$defaults) as TOptions;
|
69 | /* Observed part of $options */
|
70 | #optsObserved;
|
71 | #removeObserved;
|
72 | get $options() {
|
73 | // return observed options
|
74 | if (this.$isReady) {
|
75 | if (!this.#optsObserved) {
|
76 | // get from cache
|
77 | let o = allObservedOptions.get(this.#ctr);
|
78 | if (o === undefined) {
|
79 | const arr = this.#ctr.observedOptions;
|
80 | o = arr?.length ? new Set(arr) : null;
|
81 | allObservedOptions.set(this.#ctr, o);
|
82 | }
|
83 | const watched = o;
|
84 | if (!watched?.size) {
|
85 | return this._opts;
|
86 | }
|
87 | // cast to observed only if option was retrieved: to optimize init-performance
|
88 | this.#optsObserved = observer.make(this._opts, { excludeNested: true });
|
89 | this.#removeObserved = observer.onChanged(this.#optsObserved, (e) => {
|
90 | this.#isReady && e.props.some((p) => watched.has(p)) && this.gotOptionsChanged(e);
|
91 | });
|
92 | }
|
93 | return this.#optsObserved;
|
94 | }
|
95 | // OR return original options if element no appended to body - in this case we don't need to track changes
|
96 | return this._opts;
|
97 | }
|
98 | /** Options inherited from `static.$defauls` and applied to element. Use this to change behavior per item OR use `$defaults` to change globally */
|
99 | set $options(v) {
|
100 | if (this._opts === v) {
|
101 | return;
|
102 | }
|
103 | const prev = this._opts;
|
104 | if (!v) {
|
105 | v = objectClone(this.#ctr.$defaults);
|
106 | }
|
107 | this._opts = v;
|
108 | if (this.$isReady) {
|
109 | // unsubscribe from previous events here
|
110 | this.#removeObserved?.call(this);
|
111 | this.#optsObserved = undefined;
|
112 | this.#removeObserved = undefined;
|
113 | const watched = allObservedOptions.get(this.#ctr);
|
114 | if (!watched?.size) {
|
115 | return; // don't call event if there is nothing to watchFor
|
116 | }
|
117 | // shallow comparison with filter watched options
|
118 | if (prev.valueOf() !== v.valueOf()) {
|
119 | const props = [];
|
120 | // eslint-disable-next-line no-restricted-syntax
|
121 | for (const [k] of watched.entries()) {
|
122 | if (this._opts[k] !== prev[k]) {
|
123 | props.push(k);
|
124 | }
|
125 | }
|
126 | props.length && this.gotOptionsChanged({ props, target: this._opts });
|
127 | }
|
128 | }
|
129 | }
|
130 | /* Return all prototypes till HTMLElement */
|
131 | static findAllProtos(t, protos) {
|
132 | const p = Object.getPrototypeOf(t);
|
133 | if (p !== HTMLElement.prototype) {
|
134 | protos.push(p.constructor);
|
135 | // Object.getOwnPropertyNames(p).forEach((s) => {
|
136 | // const k = s as keyof Omit<WUPBaseElement, keyof HTMLElement | "$isReady">;
|
137 | // const desc = Object.getOwnPropertyDescriptor(p, s) as PropertyDescriptor;
|
138 | // if (typeof desc.value === "function" && s !== "constructor") {
|
139 | // this[k] = (this[k] as Function).bind(this);
|
140 | // }
|
141 | // });
|
142 | this.findAllProtos(p, protos);
|
143 | }
|
144 | return protos;
|
145 | }
|
146 | constructor() {
|
147 | super();
|
148 | // @ts-expect-error - TS doesn't see that init happens in this way;
|
149 | this.$options = null;
|
150 | if (!this.#ctr.$refStyle) {
|
151 | this.#ctr.$refStyle = document.createElement("style");
|
152 | /* from https://snook.ca/archives/html_and_css/hiding-content-for-accessibility */
|
153 | this.#ctr.$refStyle.append(`.${this.#ctr.classNameHidden}, [${this.#ctr.classNameHidden}] {${WUPcssHidden}}`);
|
154 | document.head.prepend(this.#ctr.$refStyle);
|
155 | }
|
156 | const refStyle = this.#ctr.$refStyle;
|
157 | // setup styles
|
158 | if (!appendedStyles.has(this.tagName)) {
|
159 | appendedStyles.add(this.tagName);
|
160 | // NiceToHave refactor to append styles via CDN or somehow else
|
161 | const protos = this.#ctr.findAllProtos(this, []);
|
162 | protos.reverse().forEach((p) => {
|
163 | // append $styleRoot
|
164 | if (!appendedRootStyles.has(p)) {
|
165 | appendedRootStyles.add(p);
|
166 | if (Object.prototype.hasOwnProperty.call(p, "$styleRoot")) {
|
167 | const s = p.$styleRoot;
|
168 | s && refStyle.append(s);
|
169 | }
|
170 | }
|
171 | });
|
172 | const c = protos[protos.length - 1].$style;
|
173 | refStyle.append(c.replace(/:host/g, `${this.tagName}`));
|
174 | }
|
175 | }
|
176 | /** Returns true if element is appended (result of setTimeout on connectedCallback) */
|
177 | get $isReady() {
|
178 | return this.#isReady;
|
179 | }
|
180 | /** Try to focus self or first possible children; returns true if succesful */
|
181 | focus() {
|
182 | return focusFirst(this);
|
183 | }
|
184 | /** Remove focus from element on any nested active element */
|
185 | blur() {
|
186 | const ae = document.activeElement;
|
187 | if (ae === this) {
|
188 | super.blur();
|
189 | }
|
190 | else if (ae instanceof HTMLElement && this.includes(ae)) {
|
191 | ae.blur();
|
192 | }
|
193 | }
|
194 | /** Called when need to parse attribute */
|
195 | parse(text) {
|
196 | return text;
|
197 | }
|
198 | #isReady = false;
|
199 | /** Called when element is added to document (after empty timeout - at the end of call stack) */
|
200 | gotReady() {
|
201 | this.#readyTimeout = undefined;
|
202 | this.#isReady = true;
|
203 | this._isStopChanges = true;
|
204 | this.gotChanges(null);
|
205 | this._isStopChanges = false;
|
206 | this.#readyTimeout = setTimeout(() => {
|
207 | (this.autofocus || this._opts.autoFocus) && this.focus();
|
208 | this.#readyTimeout = undefined;
|
209 | }); // timeout to wait for options
|
210 | }
|
211 | /** Called when element is removed from document */
|
212 | gotRemoved() {
|
213 | this.#isReady = false;
|
214 | this.dispose();
|
215 | }
|
216 | /** Called on Init and every time as options/attributes changed */
|
217 | gotChanges(propsChanged) { }
|
218 | /** Called when element isReady and at least one of observedOptions is changed */
|
219 | gotOptionsChanged(e) {
|
220 | this._isStopChanges = true;
|
221 | e.props.forEach((p) => this.removeAttribute(p)); // remove related attributes otherwise impossible to override
|
222 | this.gotChanges(e.props);
|
223 | this._isStopChanges = false;
|
224 | }
|
225 | /** Called once on Init */
|
226 | gotRender() { }
|
227 | #isFirstConn = true;
|
228 | #readyTimeout;
|
229 | /** Browser calls this method when the element is added to the document */
|
230 | connectedCallback() {
|
231 | // async requires otherwise attributeChangedCallback doesn't set immediately
|
232 | this.#readyTimeout = setTimeout(() => this.gotReady.call(this));
|
233 | if (this.#isFirstConn) {
|
234 | this.#isFirstConn = false;
|
235 | this.gotRender();
|
236 | }
|
237 | }
|
238 | /** Browser calls this method when the element is removed from the document;
|
239 | * (can be called many times if an element is repeatedly added/removed) */
|
240 | disconnectedCallback() {
|
241 | this.#readyTimeout && clearTimeout(this.#readyTimeout);
|
242 | this.#readyTimeout = undefined;
|
243 | this.gotRemoved();
|
244 | }
|
245 | _isStopChanges = true;
|
246 | #attrTimer;
|
247 | #attrChanged;
|
248 | /** Called when element isReady and one of observedAttributes is changed */
|
249 | gotAttributeChanged(name, oldValue, newValue) {
|
250 | // debounce filter
|
251 | if (this.#attrTimer) {
|
252 | this.#attrChanged.push(name);
|
253 | return;
|
254 | }
|
255 | this.#attrChanged = [name];
|
256 | this.#attrTimer = setTimeout(() => {
|
257 | this.#attrTimer = undefined;
|
258 | this._isStopChanges = true;
|
259 | const keys = Object.keys(this._opts);
|
260 | const keysNormalized = []; // cache to boost performance via exlcuding extra-lowerCase
|
261 | this.#attrChanged.forEach((a) => {
|
262 | keys.some((k, i) => {
|
263 | let kn = keysNormalized[i];
|
264 | if (!kn) {
|
265 | kn = k.toLowerCase();
|
266 | keysNormalized.push(kn);
|
267 | }
|
268 | if (kn === a) {
|
269 | delete this._opts[k];
|
270 | return true;
|
271 | }
|
272 | return false;
|
273 | });
|
274 | }); // otherwise attr can't override option if attribute removed
|
275 | this.gotChanges(this.#attrChanged);
|
276 | this._isStopChanges = false;
|
277 | this.#attrChanged = undefined;
|
278 | });
|
279 | }
|
280 | /** Browser calls this method when attrs pointed in observedAttributes is changed */
|
281 | attributeChangedCallback(name, oldValue, newValue) {
|
282 | this.#isReady &&
|
283 | !this._isStopChanges &&
|
284 | oldValue !== newValue &&
|
285 | this.gotAttributeChanged(name, oldValue, newValue);
|
286 | }
|
287 | addEventListener(type, listener, options) {
|
288 | return super.addEventListener(type, listener, options);
|
289 | }
|
290 | removeEventListener(type, listener, options) {
|
291 | return super.removeEventListener(type, listener, options);
|
292 | }
|
293 | /* eslint-enable max-len */
|
294 | /** Inits customEvent & calls dispatchEvent and returns created event
|
295 | * @tutorial Troubleshooting
|
296 | * * Default event bubbling: el.event.click > el.onclick >>> parent.event.click > parent.onclick etc.
|
297 | * * Custom event bubbling: el.$onclick > el.event.$click >>> parent.event.$click otherwise impossible to stop propagation from on['event] of target directly */
|
298 | fireEvent(type, eventInit) {
|
299 | const ev = new CustomEvent(type, eventInit);
|
300 | let sip = false;
|
301 | const isCustom = type.startsWith("$");
|
302 | if (isCustom) {
|
303 | ev.stopImmediatePropagation = () => {
|
304 | sip = true;
|
305 | CustomEvent.prototype.stopImmediatePropagation.call(ev);
|
306 | };
|
307 | const str = type.substring(1, 2).toUpperCase() + type.substring(2);
|
308 | this[`$on${str}`]?.call(this, ev);
|
309 | }
|
310 | !sip && super.dispatchEvent(ev);
|
311 | return ev;
|
312 | }
|
313 | /** Array of removeEventListener() that called on remove */
|
314 | disposeLst = [];
|
315 | /** Add event listener and remove after component removed; @options.passive=true by default */
|
316 | appendEvent(...args) {
|
317 | // self-removing when option.once
|
318 | if (args[3]?.once) {
|
319 | const listener = args[2];
|
320 | args[2] = function wrapper(...args2) {
|
321 | const v = listener.call(this, ...args2);
|
322 | remove();
|
323 | return v;
|
324 | };
|
325 | }
|
326 | // @ts-ignore - different TS versions can throw here
|
327 | const r = onEvent(...args);
|
328 | this.disposeLst.push(r);
|
329 | const remove = () => {
|
330 | const i = this.disposeLst.indexOf(r);
|
331 | if (i !== -1) {
|
332 | r();
|
333 | this.disposeLst.splice(i, 1);
|
334 | }
|
335 | };
|
336 | return remove;
|
337 | }
|
338 | /** Remove events/functions that was appended */
|
339 | dispose() {
|
340 | this.disposeLst.forEach((f) => f()); // remove possible previous event listeners
|
341 | this.disposeLst.length = 0;
|
342 | }
|
343 | /** Returns true if el is instance of Node and contains pointed element
|
344 | * @tutorial Troubleshooting
|
345 | * * if element has position `fixed` or `absolute` then returns false */
|
346 | includes(el) {
|
347 | return el instanceof Node && this.contains(el);
|
348 | }
|
349 | /** Returns true if element contains eventTarget or it's eventTarget
|
350 | * @tutorial Troubleshooting
|
351 | * * if element has position `fixed` or `absolute` then returns false */
|
352 | includesTarget(e) {
|
353 | const t = e.target;
|
354 | return this === t || this.includes(t);
|
355 | }
|
356 | getAttr(attr, type, alt) {
|
357 | const a = this.getAttribute(attr);
|
358 | const nullResult = alt !== undefined ? alt : this._opts[attr];
|
359 | if (a == null) {
|
360 | return nullResult;
|
361 | }
|
362 | switch (type) {
|
363 | case "bool":
|
364 | return a !== "false";
|
365 | case "number": {
|
366 | const v = +a;
|
367 | if (Number.isNaN(v)) {
|
368 | this.throwError(`Expected number for attribute [${attr}] but pointed '${a}'`);
|
369 | return nullResult;
|
370 | }
|
371 | return v;
|
372 | }
|
373 | case "ref": {
|
374 | const v = nestedProperty.get(window, a);
|
375 | if (v === undefined) {
|
376 | this.throwError(`Value not found according to attribute [${attr}] in '${a.startsWith("window.") ? a : `window.${a}`}'`);
|
377 | return nullResult;
|
378 | }
|
379 | return v;
|
380 | }
|
381 | case "obj": {
|
382 | try {
|
383 | return this.parse(a);
|
384 | }
|
385 | catch (err) {
|
386 | this.throwError(err);
|
387 | return nullResult;
|
388 | }
|
389 | }
|
390 | case "boolOrString": {
|
391 | if (a === "" || a === "true") {
|
392 | return true;
|
393 | }
|
394 | if (a === "false") {
|
395 | return false;
|
396 | }
|
397 | return a;
|
398 | }
|
399 | default:
|
400 | return a; // string
|
401 | }
|
402 | }
|
403 | /** Remove attr if value falseOrEmpty; set '' or 'true' if true for HTMLELement
|
404 | * @param isSetEmpty set if need '' instead of 'value' */
|
405 | setAttr(attr, v, isSetEmpty) {
|
406 | v ? this.setAttribute(attr, isSetEmpty ? "" : v) : this.removeAttribute(attr);
|
407 | }
|
408 | /** Remove all children in fastest way */
|
409 | removeChildren() {
|
410 | // benchmark: https://measurethat.net/Benchmarks/Show/23474/2/remove-all-children-from-dom-element-2
|
411 | while (this.firstChild) {
|
412 | this.removeChild(this.firstChild);
|
413 | }
|
414 | }
|
415 | /** Throws unhanled error via empty setTimeout */
|
416 | throwError(err) {
|
417 | const e = new Error(`${this.tagName}. ${err}`, { cause: err });
|
418 | setTimeout(() => {
|
419 | throw e;
|
420 | });
|
421 | }
|
422 | }
|