UNPKG

17.3 kBJavaScriptView Raw
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
4import focusFirst from "./helpers/focusFirst";
5import nestedProperty from "./helpers/nestedProperty";
6import observer from "./helpers/observer";
7import onEvent from "./helpers/onEvent";
8import objectClone from "./helpers/objectClone";
9import { WUPcssHidden } from "./styles";
10// theoritcally such single appending is faster than using :host inside shadowComponent
11const appendedStyles = new Set();
12const appendedRootStyles = new Set();
13let lastUniqueNum = 0;
14const allObservedOptions = new WeakMap();
15/** Basic abstract class for every component in web-ui-pack */
16export 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}