wrec 0.20.0 → 0.20.2

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/dist/wrec.js DELETED
@@ -1,1105 +0,0 @@
1
- import DOMPurify from 'dompurify';
2
- import { getPathValue, setPathValue } from './paths.js';
3
- // Prevent DOMPurify from removing certain attributes whose names
4
- // begin with "on" because wrec uses those wire up event listeners.
5
- // Do not allow "onerror" because that can be used for XSS attacks.
6
- //TODO: More may need to be added later.
7
- const safeOnAttrNames = new Set([
8
- 'onblur',
9
- 'onchange',
10
- 'onclick',
11
- 'onfocus',
12
- 'oninput',
13
- 'onkeydown',
14
- 'onreset',
15
- 'onsubmit'
16
- ]);
17
- DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
18
- const { attrName } = data;
19
- const lower = attrName.toLowerCase();
20
- if (safeOnAttrNames.has(lower))
21
- data.forceKeepAttr = true;
22
- });
23
- class WrecError extends Error {
24
- }
25
- const CSS_PROPERTY_RE = /([a-zA-Z-]+)\s*:\s*([^;}]+)/g;
26
- const FIRST_CHAR = 'a-zA-Z_$';
27
- const OTHER_CHAR = FIRST_CHAR + '0-9';
28
- const IDENTIFIER = `[${FIRST_CHAR}][${OTHER_CHAR}]*`;
29
- const HTML_COMMENT_TEXT_RE = /<!--\s*(.*?)\s*-->/;
30
- const HTML_ELEMENT_TEXT_RE = /<(\w+)(?:\s[^>]*)?>((?:[^<]|<(?!\w))*?)<\/\1>/g;
31
- const REF_RE = new RegExp(`^this\\.${IDENTIFIER}$`);
32
- const REFS_RE = new RegExp(`this\\.${IDENTIFIER}(\\.${IDENTIFIER})*`, 'g');
33
- const REFS_TEST_RE = new RegExp(`this\\.${IDENTIFIER}(\\.${IDENTIFIER})*`);
34
- // Don't add 'disabled', 'id', or 'name' here!
35
- const RESERVED_ATTRS = new Set(['class', 'style']);
36
- const SKIP = 'this.'.length;
37
- function canDisable(element) {
38
- return (element instanceof HTMLButtonElement ||
39
- element instanceof HTMLFieldSetElement ||
40
- element instanceof HTMLInputElement ||
41
- element instanceof HTMLSelectElement ||
42
- element instanceof HTMLTextAreaElement ||
43
- element instanceof Wrec);
44
- }
45
- // This function is not used in this file,
46
- // but web components created with wrec can use it.
47
- export function createElement(name, attributes, innerHTML) {
48
- const element = document.createElement(name);
49
- if (attributes) {
50
- for (const [attrName, value] of Object.entries(attributes)) {
51
- element.setAttribute(attrName, value);
52
- }
53
- }
54
- if (innerHTML)
55
- element.innerHTML = innerHTML;
56
- return element;
57
- }
58
- const defaultForType = (type) => type === String
59
- ? ''
60
- : type === Number
61
- ? 0
62
- : type === Boolean
63
- ? false
64
- : type === Array
65
- ? []
66
- : type === Object
67
- ? {}
68
- : undefined;
69
- // This returns an array of all descendant elements of a given element,
70
- // including those in nested shadow DOMs.
71
- function getAllDescendants(root) {
72
- const elements = [];
73
- let element = root.firstElementChild;
74
- while (element) {
75
- elements.push(element);
76
- if (element.shadowRoot) {
77
- elements.push(...getAllDescendants(element.shadowRoot));
78
- }
79
- // Process light DOM grandchildren.
80
- if (element.firstElementChild) {
81
- elements.push(...getAllDescendants(element));
82
- }
83
- element = element.nextElementSibling;
84
- }
85
- return elements;
86
- }
87
- // This takes a string like "this.foo.bar" and returns "foo".
88
- const getPropName = (str) => str.substring(SKIP).split('.')[0];
89
- function interpolate(strings, values) {
90
- let result = strings[0];
91
- values.forEach((value, i) => {
92
- result += value + strings[i + 1];
93
- });
94
- return result;
95
- }
96
- function isPrimitive(value) {
97
- const t = typeof value;
98
- return t === 'string' || t === 'number' || t === 'boolean';
99
- }
100
- function isTextArea(element) {
101
- return element.localName === 'textarea';
102
- }
103
- function isValueElement(element) {
104
- const { localName } = element;
105
- return localName === 'input' || localName === 'select';
106
- }
107
- const removeHtmlComments = (str) => str.replace(/<!--[\s\S]*?-->/g, '');
108
- // This returns a new string where a specified substring is replaced.
109
- function replace(full, index, length, replacement) {
110
- return full.slice(0, index) + replacement + full.slice(index + length);
111
- }
112
- // DOMPurify uses the browser HTML parser which removes elements
113
- // that are not nested in the required parent element.
114
- // For example, tr elements are stripped when not inside a table element.
115
- // wrec needs to insert elements from HTML strings that violate this.
116
- // This function wraps HTML strings in the required parent element
117
- // only for the purpose of sanitizing the HTML.
118
- // It then extracts the required portion of the sanitized result.
119
- function sanitize(html) {
120
- let h = html.trim();
121
- let extract = null;
122
- // tr elements must be wrapped in table/tbody.
123
- if (/^\s*<tr[\s>]/i.test(h)) {
124
- h = `<table><tbody>${h}</tbody></table>`;
125
- extract = 'tbody';
126
- // td or th elements must be wrapped in table/tbody/tr.
127
- }
128
- else if (/^\s*<(td|th)[\s>]/i.test(h)) {
129
- h = `<table><tbody><tr>${h}</tr></tbody></table>`;
130
- extract = 'tr';
131
- // option elements must be wrapped in select.
132
- }
133
- else if (/^\s*<option[\s>]/i.test(h)) {
134
- h = `<select>${h}</select>`;
135
- extract = 'select';
136
- // col elements must be wrapped in colgroup.
137
- }
138
- else if (/^\s*<col[\s>]/i.test(h)) {
139
- h = `<table><colgroup>${h}</colgroup></table>`;
140
- extract = 'colgroup';
141
- }
142
- const fragment = DOMPurify.sanitize(h, {
143
- ADD_TAGS: ['#comment'],
144
- ALLOW_UNKNOWN_PROTOCOLS: true,
145
- RETURN_DOM_FRAGMENT: true
146
- });
147
- if (extract) {
148
- const container = fragment.querySelector(extract);
149
- if (container)
150
- return container.childNodes;
151
- }
152
- return fragment.childNodes;
153
- }
154
- function stringToNumber(str) {
155
- const n = Number(str);
156
- if (isNaN(n))
157
- throw new WrecError(`can't convert "${str}" to a number`);
158
- return n;
159
- }
160
- function updateAttribute(element, attrName, value) {
161
- const [realAttrName, _eventName] = attrName.split(':');
162
- // Attributes can only be set to primitive values.
163
- if (isPrimitive(value)) {
164
- if (typeof value === 'boolean') {
165
- if (value) {
166
- element.setAttribute(realAttrName, realAttrName);
167
- }
168
- else {
169
- element.removeAttribute(realAttrName);
170
- }
171
- // Set the corresponding property.
172
- // This is essential for the "disabled" attribute and
173
- // possibly others like "checked".
174
- const propName = Wrec.getPropName(realAttrName);
175
- element[propName] = value;
176
- }
177
- else {
178
- // Set the attribute.
179
- const currentValue = element.getAttribute(attrName);
180
- const newValue = String(value);
181
- if (currentValue !== newValue) {
182
- element.setAttribute(realAttrName, newValue);
183
- if (realAttrName === 'value' && isValueElement(element)) {
184
- element.value = newValue;
185
- }
186
- }
187
- }
188
- }
189
- else {
190
- // Set the corresponding property.
191
- const propName = Wrec.getPropName(attrName);
192
- element[propName] = value;
193
- }
194
- }
195
- function updateValue(element, attrName, value) {
196
- const [realAttrName, _eventName] = attrName.split(':');
197
- if (element instanceof CSSStyleRule) {
198
- element.style.setProperty(realAttrName, value); // CSS variable
199
- }
200
- else {
201
- updateAttribute(element, realAttrName, value);
202
- if (realAttrName === 'value' && isValueElement(element)) {
203
- element.value = value;
204
- }
205
- }
206
- }
207
- // Waits for all custom elements used in a template to be defined.
208
- async function waitForDefines(template) {
209
- // Find all the custom elements used in the template.
210
- const customSet = new Set();
211
- for (const element of getAllDescendants(template.content)) {
212
- const { localName } = element;
213
- if (localName.includes('-'))
214
- customSet.add(localName);
215
- }
216
- function getTimeout(tagName) {
217
- return new Promise((_, reject) => {
218
- setTimeout(() => {
219
- const message = `custom element <${tagName}> not defined`;
220
- reject(new Error(message));
221
- }, 1000);
222
- });
223
- }
224
- // Wait for all the custom elements to be defined.
225
- return Promise.all([...customSet].map(async (tagName) => Promise.race([customElements.whenDefined(tagName), getTimeout(tagName)])));
226
- }
227
- export class Wrec extends HTMLElement {
228
- // There is one instance of `attrToPropMap`, `properties`, `propToAttrMap`,
229
- // `propToComputedMap`, and `propToExprsMap` per Wrec subclass,
230
- // not one for only the Wrec class.
231
- // The instances created here are not used.
232
- // Subclass-specific instances are created in the constructor.
233
- // This is used to lookup the camelCase property name
234
- // that corresponds to a kebab-case attribute name.
235
- static attrToPropMap = new Map();
236
- // This is used to lookup the kebab-case attribute name
237
- // that corresponds to a camelCase property name.
238
- static propToAttrMap = new Map();
239
- // This can be set in each Wrec subclass.
240
- // It describes CSS rules that a web component uses.
241
- static css = '';
242
- static elementName = '';
243
- // Set this to true in Wrec subclasses that need
244
- // the ability to contribute data to form submissions.
245
- static formAssociated = false;
246
- // This must be set in each Wrec subclass.
247
- // It describes HTML that a web component renders.
248
- static html = '';
249
- // This must be set in each Wrec subclass.
250
- // It describes all the properties that a web component supports.
251
- static properties;
252
- // This is a map from properties to arrays of
253
- // computed property expressions that use the property.
254
- // It is used to update computed properties
255
- // when the properties on which they depend are modified.
256
- // See the method #updateComputedProperties.
257
- // This map cannot be private.
258
- static propToComputedMap;
259
- // This is a map from properties to expressions that refer to them.
260
- // It is the sma for all instances of a component.
261
- // This map cannot be private.
262
- static propToExprsMap;
263
- static template = null;
264
- #ctor = this.constructor;
265
- // This is a map from expressions to references to them
266
- // which can be found in element text content,
267
- // attribute values, and CSS property values.
268
- // Each component instance needs its own map.
269
- #exprToRefsMap = new Map();
270
- #formAssoc = {};
271
- #formData;
272
- // For components that set `formAssociated` to true,
273
- // this stores in the initial value of each property
274
- // in the formAssociatedCallback method
275
- // so they can be restored in the formResetCallback method.
276
- #initialValuesMap = {};
277
- #internals = null;
278
- // This is a map from properties in this web component
279
- // to corresponding properties in a parent web component.
280
- // This must be an instance property because
281
- // each component instance can have its properties mapped
282
- // to the properties of different parent components.
283
- // This is used to update a parent property
284
- // when the corresponding child property value changes.
285
- #propToParentPropMap = new Map();
286
- static define(elementName) {
287
- this.elementName = elementName;
288
- customElements.define(elementName, this);
289
- }
290
- constructor() {
291
- super();
292
- this.attachShadow({ mode: 'open' });
293
- // Create one instance of `properties`, `propToComputedMap`,
294
- // and `propToExprsMap` for each Wrec subclass.
295
- const ctor = this.#ctor;
296
- if (!ctor.attrToPropMap)
297
- ctor.attrToPropMap = new Map();
298
- if (!ctor.properties)
299
- ctor.properties = {};
300
- if (!ctor.propToAttrMap)
301
- ctor.propToExprsMap = new Map();
302
- if (!ctor.propToComputedMap)
303
- ctor.propToComputedMap = new Map();
304
- if (!ctor.propToExprsMap)
305
- ctor.propToExprsMap = new Map();
306
- }
307
- attributeChangedCallback(attrName, oldValue, newValue) {
308
- if (attrName === 'disabled')
309
- this.#disableOrEnable();
310
- const propName = Wrec.getPropName(attrName);
311
- if (this.#hasProperty(propName)) {
312
- // Update the corresponding property.
313
- const value = this.#typedValue(propName, String(newValue));
314
- this[propName] = value;
315
- const formKey = this.#formAssoc[propName];
316
- if (formKey)
317
- this.setFormValue(formKey, String(value));
318
- this.propertyChangedCallback(propName, oldValue, newValue);
319
- }
320
- }
321
- async #buildDOM() {
322
- const ctor = this.#ctor;
323
- let { template } = ctor;
324
- if (!template) {
325
- template = ctor.template = document.createElement('template');
326
- // Include a CSS rule that respects the "hidden" attribute.
327
- // This is a web.dev custom element best practice.
328
- let style = `<style>\n :host([hidden]) { display: none; }`;
329
- if (ctor.css)
330
- style += ctor.css;
331
- style += '</style>\n';
332
- let html = ctor.html.trim();
333
- // If the HTML string does not start with <,
334
- // assume it is a JavaScript expression.
335
- if (!html.startsWith('<'))
336
- html = `<span><!--${html}--></span>`;
337
- template.innerHTML = style + html;
338
- }
339
- await waitForDefines(template);
340
- this.shadowRoot.replaceChildren(template.content.cloneNode(true));
341
- }
342
- changed(_statePath, componentProp, newValue) {
343
- this[componentProp] = newValue;
344
- }
345
- connectedCallback() {
346
- this.#validateAttributes();
347
- this.#defineProps();
348
- this.#buildDOM().then(() => {
349
- if (this.hasAttribute('disabled'))
350
- this.#disableOrEnable();
351
- this.#wireEvents(this.shadowRoot);
352
- this.#makeReactive(this.shadowRoot);
353
- this.#computeProps();
354
- });
355
- }
356
- #computeProps() {
357
- const ctor = this.#ctor;
358
- const { properties } = ctor;
359
- for (const [propName, { computed }] of Object.entries(properties)) {
360
- if (computed)
361
- this[propName] = this.#evaluateInContext(computed);
362
- }
363
- }
364
- #defineProps() {
365
- const ctor = this.#ctor;
366
- const { observedAttributes, properties } = ctor;
367
- for (const [propName, config] of Object.entries(properties)) {
368
- this.#defineProp(propName, config, observedAttributes);
369
- }
370
- }
371
- #defineProp(propName, config, observedAttributes) {
372
- const attrName = Wrec.getAttrName(propName);
373
- const has = this.hasAttribute(attrName);
374
- if (config.required && !has) {
375
- this.#throw(this, propName, 'is a required attribute');
376
- }
377
- // This follows the best practice
378
- // "Consider checking for properties that may
379
- // have been set before the element upgraded."
380
- let value = config.value;
381
- if (this.hasOwnProperty(propName)) {
382
- value = this[propName];
383
- delete this[propName];
384
- }
385
- // Copy the property value to a private property.
386
- // The property is replaced below with Object.defineProperty.
387
- const { type } = config;
388
- const typedValue = type === Boolean
389
- ? value || has
390
- : observedAttributes.includes(attrName) && has
391
- ? this.#typedAttribute(propName, attrName)
392
- : value || defaultForType(type);
393
- const privateName = '#' + propName;
394
- this[privateName] = typedValue;
395
- if (config.computed)
396
- this.#registerComputedProp(propName, config);
397
- Object.defineProperty(this, propName, {
398
- enumerable: true,
399
- get() {
400
- return this[privateName];
401
- },
402
- set(value) {
403
- if (type === Number && typeof value === 'string') {
404
- value = stringToNumber(value);
405
- }
406
- const oldValue = this[privateName];
407
- if (value === oldValue)
408
- return;
409
- this.#validateType(propName, type, value);
410
- this[privateName] = value;
411
- const { state, stateProp } = this.#ctor.properties[propName];
412
- if (stateProp)
413
- setPathValue(state, stateProp, value);
414
- this.#updateComputedProperties(propName);
415
- this.#updateAttribute(propName, type, value, attrName);
416
- this.#react(propName);
417
- this.#updateParentProperty(propName, value);
418
- const formKey = this.#formAssoc[propName];
419
- if (formKey)
420
- this.setFormValue(formKey, String(value));
421
- this.propertyChangedCallback(propName, oldValue, value);
422
- if (config.dispatch) {
423
- this.dispatch('change', {
424
- tagName: this.localName,
425
- property: propName,
426
- oldValue,
427
- value
428
- });
429
- }
430
- }
431
- });
432
- }
433
- #disableOrEnable() {
434
- // Update all descendant form control elements.
435
- const isDisabled = this.hasAttribute('disabled');
436
- const elements = getAllDescendants(this.shadowRoot);
437
- for (const element of elements) {
438
- if (canDisable(element))
439
- element.disabled = isDisabled;
440
- }
441
- }
442
- disconnectedCallback() {
443
- //TODO: Should more cleanup be performed here?
444
- this.#exprToRefsMap.clear();
445
- this.#initialValuesMap.clear();
446
- this.#propToParentPropMap.clear();
447
- }
448
- dispatch(name, detail) {
449
- this.dispatchEvent(new CustomEvent(name, {
450
- bubbles: true, // up DOM tree
451
- composed: true, // can pass through shadow DOM
452
- detail
453
- }));
454
- }
455
- displayIfSet(value, display = 'block') {
456
- return `display: ${value ? display : 'none'}`;
457
- }
458
- #evaluateAttributes(element) {
459
- //const isWC = element.localName.includes('-');
460
- const isWC = element instanceof Wrec;
461
- for (const attrName of element.getAttributeNames()) {
462
- const text = element.getAttribute(attrName);
463
- // If the attribute value is a single property reference,
464
- // configure two-way data binding.
465
- const propName = this.#propRefName(element, text);
466
- if (propName) {
467
- const value = this[propName];
468
- if (value === undefined) {
469
- this.#throwInvalidRef(element, attrName, propName);
470
- }
471
- element[propName] = value;
472
- let [realAttrName, eventName] = attrName.split(':');
473
- if (realAttrName === 'value') {
474
- if (eventName) {
475
- if (element['on' + eventName] === undefined) {
476
- const msg = 'refers to an unsupported event name';
477
- this.#throw(element, attrName, msg);
478
- }
479
- element.setAttribute(realAttrName, this[propName]);
480
- }
481
- else {
482
- eventName = 'change';
483
- }
484
- }
485
- // If the element is a wrec web component instance,
486
- // save a mapping from the attribute name in this web component
487
- // to the property name in that web component.
488
- if (isWC) {
489
- element.#propToParentPropMap.set(Wrec.getPropName(realAttrName), propName);
490
- }
491
- }
492
- this.#registerPlaceholders(text, element, attrName);
493
- }
494
- }
495
- #evaluateInContext(expr) {
496
- // This approach is safer than using the eval function.
497
- const result = new Function('return ' + expr).call(this);
498
- return Array.isArray(result) ? result.join('') : result;
499
- }
500
- #evaluateText(element) {
501
- const { localName } = element;
502
- if (localName === 'style') {
503
- const { sheet } = element;
504
- const rules = sheet?.cssRules ?? [];
505
- const ruleArray = Array.from(rules);
506
- for (const rule of ruleArray) {
507
- if (rule.constructor === CSSStyleRule) {
508
- const props = Array.from(rule.style);
509
- for (const prop of props) {
510
- /* WARNING:
511
- * `style` elements in the static html value should be avoided.
512
- * In `style` elements that appear in the static html value,
513
- * property references like `this.value` are not supported.
514
- * They are only supported in the static css value.
515
- * The following does work inside `style` elements:
516
- * .someClass {
517
- * --color: this.value;
518
- * color: var(--color);
519
- * }
520
- */
521
- if (prop.startsWith('--')) {
522
- const value = rule.style.getPropertyValue(prop);
523
- this.#registerPlaceholders(value, rule, prop);
524
- }
525
- }
526
- }
527
- }
528
- }
529
- else {
530
- let commentText = '';
531
- if (isTextArea(element)) {
532
- this.#registerPlaceholders(element.textContent, element);
533
- // When an HTML comment appears in a textarea element,
534
- // a text node is created rather than a comment element.
535
- const match = element.textContent?.match(HTML_COMMENT_TEXT_RE);
536
- if (match)
537
- commentText = match[1];
538
- }
539
- else {
540
- const comment = Array.from(element.childNodes).find(node => node.nodeType === Node.COMMENT_NODE);
541
- if (comment)
542
- commentText = comment.textContent?.trim() ?? '';
543
- }
544
- if (commentText) {
545
- // Only add a binding if the element is a "textarea" and
546
- // its text content is a single property reference.
547
- const propName = this.#propRefName(element, commentText);
548
- if (propName && isTextArea(element)) {
549
- element.textContent = this[propName];
550
- }
551
- else {
552
- this.#registerPlaceholders(commentText, element);
553
- }
554
- }
555
- }
556
- }
557
- // This method is called automatically if
558
- // the component is nested in form element AND
559
- // the static property formAssociated is true.
560
- // It does things that are only necessary in that situation.
561
- formAssociatedCallback() {
562
- let fa = this.getAttribute('form-assoc');
563
- // If the form-assoc attribute is not set,
564
- // but the name attribute is set AND there is a value property,
565
- // use those for form association.
566
- // This matches the behavior for built-in form control elements like input.
567
- if (!fa) {
568
- const name = this.getAttribute('name');
569
- if (name) {
570
- if (this.#hasProperty('value')) {
571
- fa = `value:${name}`;
572
- }
573
- else {
574
- //TODO: Should this be considered an error?
575
- //throw new WrecError(
576
- // `can't submit by name because component has no value property`
577
- //);
578
- return; // nothing to submit
579
- }
580
- }
581
- else {
582
- return; // nothing to submit
583
- }
584
- }
585
- // Build mapping from component property names to form field names.
586
- const formAssoc = {};
587
- const pairs = fa.split(',');
588
- for (const pair of pairs) {
589
- const [key, value] = pair.split(':');
590
- formAssoc[key.trim()] = value.trim();
591
- }
592
- this.#formAssoc = formAssoc;
593
- // Prepare for form submissions.
594
- this.#formData = new FormData();
595
- this.#internals = this.attachInternals();
596
- this.#internals.setFormValue(this.#formData);
597
- // Build mapping from property names to their initial values
598
- // so the containing form can be reset.
599
- const propNames = Object.keys(this.#ctor.properties);
600
- const map = this.#initialValuesMap;
601
- for (const propName of propNames) {
602
- map[propName] = this[propName];
603
- }
604
- }
605
- formResetCallback() {
606
- const map = this.#initialValuesMap;
607
- for (const propName of Object.keys(map)) {
608
- let value = map[propName];
609
- if (REF_RE.test(value))
610
- value = this.#evaluateInContext(value);
611
- this[propName] = value;
612
- }
613
- }
614
- static getAttrName(propName) {
615
- let attrName = this.propToAttrMap.get(propName);
616
- if (!attrName) {
617
- attrName = propName.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
618
- this.propToAttrMap.set(propName, attrName);
619
- }
620
- return attrName;
621
- }
622
- static getPropName(attrName) {
623
- let propName = this.attrToPropMap.get(attrName);
624
- if (!propName) {
625
- propName = attrName.replace(/-([a-z])/g, (_, char) => char.toUpperCase());
626
- this.attrToPropMap.set(attrName, propName);
627
- }
628
- return propName;
629
- }
630
- #handleEvents(element, attrName, matches) {
631
- if (matches.length !== 1)
632
- return;
633
- const [match] = matches;
634
- if (!REF_RE.test(match))
635
- return;
636
- const isFormControl = isValueElement(element) || isTextArea(element);
637
- let [realAttrName, eventName] = (attrName ?? '').split(':');
638
- const shouldListen = (isFormControl && realAttrName === 'value') || isTextArea(element);
639
- if (!shouldListen)
640
- return;
641
- if (eventName) {
642
- if (element['on' + eventName] === undefined) {
643
- const msg = 'refers to an unsupported event name';
644
- this.#throw(element, attrName, msg);
645
- }
646
- }
647
- else {
648
- eventName = 'change';
649
- }
650
- const propName = getPropName(match);
651
- element.addEventListener(eventName, (event) => {
652
- const { target } = event;
653
- if (!target)
654
- return;
655
- const value = target.value;
656
- const { type } = this.#ctor.properties[propName];
657
- this[propName] = type === Number ? stringToNumber(value) : value;
658
- this.#react(propName);
659
- });
660
- }
661
- #hasProperty(propName) {
662
- return Boolean(this.#ctor.properties[propName]);
663
- }
664
- #makeReactive(root) {
665
- const elements = Array.from(root.querySelectorAll('*'));
666
- for (const element of elements) {
667
- this.#evaluateAttributes(element);
668
- // If the element has no child elements, evaluate its text content.
669
- if (!element.firstElementChild)
670
- this.#evaluateText(element);
671
- }
672
- /* These lines are useful for debugging.
673
- if (this.constructor.name === 'ColorPicker') {
674
- console.log('=== this.constructor.name =', this.constructor.name);
675
- console.log('propToExprsMap =', this.#ctor.propToExprsMap);
676
- console.log('#exprToRefsMap =', this.#exprToRefsMap);
677
- console.log('propToComputedMap =', this.#ctor.propToComputedMap);
678
- console.log('#propToParentPropMap =', this.#propToParentPropMap);
679
- console.log('\n');
680
- }
681
- */
682
- }
683
- // formAssociated is only needed when the component is inside a form.
684
- #verifyFormAssociated() {
685
- if (this.#ctor.formAssociated || this.closest('form') === null)
686
- return;
687
- const className = this.#ctor.name;
688
- throw new WrecError(`inside form, class ${className} requires "static formAssociated = true;"`);
689
- }
690
- static get observedAttributes() {
691
- const keys = Object.keys(this.properties || {}).map(key => Wrec.getAttrName(key));
692
- if (!keys.includes('disabled'))
693
- keys.push('disabled');
694
- return keys;
695
- }
696
- // Subclasses can override this to add functionality.
697
- propertyChangedCallback(_propName, _oldValue, _newValue) { }
698
- #propRefName(element, text) {
699
- if (!text || !REF_RE.test(text))
700
- return;
701
- const propName = getPropName(text);
702
- if (this[propName] === undefined) {
703
- this.#throwInvalidRef(element, '', propName);
704
- }
705
- return propName;
706
- }
707
- #react(propName) {
708
- // Update all expression references.
709
- const ctor = this.#ctor;
710
- const map = ctor.propToExprsMap;
711
- const exprs = map.get(propName) || [];
712
- for (const expr of exprs) {
713
- let value = this.#evaluateInContext(expr);
714
- const refs = this.#exprToRefsMap.get(expr) ?? [];
715
- for (const ref of refs) {
716
- if (ref instanceof HTMLElement) {
717
- this.#updateElementContent(ref, value);
718
- }
719
- else if (ref instanceof CSSStyleRule) {
720
- // We need to handle this case for completeness,
721
- // but a ref is never just a CSSStyleRule.
722
- // It can be an object with element and attrName properties
723
- // where the element is a CSSStyleRule.
724
- // The call to the #registerPlaceholders method
725
- // in the #evaluateText method creates those.
726
- // That case is is handled by the else block below.
727
- }
728
- else {
729
- const { element, attrName } = ref;
730
- if (element instanceof CSSStyleRule) {
731
- element.style.setProperty(attrName, value);
732
- }
733
- else {
734
- updateValue(element, attrName, value);
735
- }
736
- }
737
- }
738
- }
739
- }
740
- #registerComputedProp(propName, config) {
741
- const { computed, uses } = config;
742
- const map = this.#ctor.propToComputedMap;
743
- function register(referencedProp, expr) {
744
- let computes = map.get(referencedProp);
745
- if (!computes) {
746
- computes = [];
747
- map.set(referencedProp, computes);
748
- }
749
- // Each element is a tuple of a property name
750
- // and an expression that uses the property.
751
- computes.push([propName, expr]);
752
- }
753
- const matches = computed.match(REFS_RE) || [];
754
- for (const match of matches) {
755
- const referencedProp = match.substring(SKIP);
756
- if (this[referencedProp] === undefined) {
757
- this.#throwInvalidRef(null, propName, referencedProp);
758
- }
759
- if (typeof this[referencedProp] !== 'function') {
760
- register(referencedProp, computed);
761
- }
762
- }
763
- if (uses) {
764
- for (const use of uses.split(',')) {
765
- register(use, computed);
766
- }
767
- }
768
- }
769
- // WARNING: Do not place untrusted JavaScript expressions
770
- // in attribute values or the text content of elements!
771
- #registerPlaceholders(text, element, attrName = undefined) {
772
- if (!text)
773
- return;
774
- const matches = this.#validateExpression(element, attrName, text);
775
- if (!matches) {
776
- // Handle escaped periods in expressions.
777
- const value = text.replaceAll('this..', 'this.');
778
- if (attrName) {
779
- updateValue(element, attrName, value);
780
- }
781
- else if ('textContent' in element) {
782
- element.textContent = value;
783
- }
784
- return;
785
- }
786
- const ctor = this.#ctor;
787
- matches.forEach(capture => {
788
- const propName = getPropName(capture);
789
- if (typeof this[propName] === 'function')
790
- return;
791
- const map = ctor.propToExprsMap;
792
- let exprs = map.get(propName);
793
- if (!exprs) {
794
- exprs = [];
795
- map.set(propName, exprs);
796
- }
797
- if (!exprs.includes(text))
798
- exprs.push(text);
799
- });
800
- // Remove entries for detached elements.
801
- for (const [expr, refs] of this.#exprToRefsMap.entries()) {
802
- for (const ref of refs) {
803
- const element = ref instanceof HTMLElement || ref instanceof CSSStyleRule
804
- ? ref
805
- : ref.element;
806
- if (element instanceof CSSStyleRule)
807
- continue;
808
- if (!element.isConnected) {
809
- this.#exprToRefsMap.set(expr, refs.filter(r => r !== ref));
810
- }
811
- }
812
- }
813
- let refs = this.#exprToRefsMap.get(text);
814
- if (!refs) {
815
- refs = [];
816
- this.#exprToRefsMap.set(text, refs);
817
- }
818
- refs.push(attrName ? { element, attrName } : element);
819
- if (element instanceof HTMLElement) {
820
- this.#handleEvents(element, attrName, matches);
821
- }
822
- const value = this.#evaluateInContext(text);
823
- if (attrName) {
824
- updateValue(element, attrName, value);
825
- }
826
- else {
827
- this.#updateElementContent(element, value);
828
- }
829
- }
830
- // This follows the best practice
831
- // "Do not override author-set, global attributes."
832
- setAttributeSafe(name, value) {
833
- if (!this.hasAttribute(name))
834
- this.setAttribute(name, value);
835
- }
836
- setFormValue(propName, value) {
837
- if (!this.#formData || !isPrimitive(value))
838
- return;
839
- this.#formData.set(propName, value);
840
- this.#internals?.setFormValue(this.#formData);
841
- }
842
- #throw(element, attrName, message) {
843
- const name = element instanceof HTMLElement ? element.localName : 'CSS rule';
844
- throw new WrecError(`component ${this.#ctor.elementName}` +
845
- (element ? `, element "${name}"` : '') +
846
- (attrName ? `, attribute "${attrName}"` : '') +
847
- ` ${message}`);
848
- }
849
- #throwInvalidRef(element, attrName, propName) {
850
- this.#throw(element, attrName, `refers to missing property "${propName}"`);
851
- }
852
- #typedAttribute(propName, attrName) {
853
- return this.#typedValue(propName, this.getAttribute(attrName));
854
- }
855
- #typedValue(propName, stringValue) {
856
- if (stringValue?.match(REFS_RE))
857
- return stringValue;
858
- const ctor = this.#ctor;
859
- const { type } = ctor.properties[propName];
860
- if (!type)
861
- this.#throw(null, propName, 'does not specify its type');
862
- if (type === String)
863
- return stringValue;
864
- if (type === Number)
865
- return stringToNumber(stringValue);
866
- if (type === Boolean) {
867
- if (stringValue === 'true')
868
- return true;
869
- if (stringValue === 'false' || stringValue === 'null')
870
- return false;
871
- if (stringValue && stringValue !== propName) {
872
- this.#throw(null, propName, 'is a Boolean attribute, so its value ' +
873
- 'must match attribute name or be missing');
874
- }
875
- return stringValue === propName;
876
- }
877
- }
878
- // Updates the matching attribute for a property if there is one.
879
- // VS Code thinks this is never called, but it is called by #defineProp.
880
- #updateAttribute(propName, type, value, attrName) {
881
- if (isPrimitive(value) && this.hasAttribute(attrName)) {
882
- const oldValue = type === Boolean
883
- ? this.hasAttribute(attrName)
884
- : this.#typedAttribute(propName, attrName);
885
- if (value !== oldValue)
886
- updateAttribute(this, propName, value);
887
- }
888
- }
889
- // Updates all computed properties that reference this property.
890
- // VS Code thinks this is never called, but it is called by #defineProp.
891
- #updateComputedProperties(propName) {
892
- const map = this.#ctor.propToComputedMap;
893
- const computes = map.get(propName) || [];
894
- for (const [computedName, expr] of computes) {
895
- this[computedName] = this.#evaluateInContext(expr);
896
- }
897
- }
898
- #updateElementContent(element, value) {
899
- if (value === undefined)
900
- return;
901
- const isHTML = element instanceof HTMLElement;
902
- const t = typeof value;
903
- if (t !== 'string' && t !== 'number') {
904
- this.#throw(element, undefined, ` computed content is not a string or number`);
905
- }
906
- if (element instanceof HTMLElement && isTextArea(element)) {
907
- element.value = value;
908
- }
909
- else if (isHTML && t === 'string' && value.trim().startsWith('<')) {
910
- //element.innerHTML = value; // This approach allows XSS attacks!
911
- const safeValue = sanitize(value);
912
- element.replaceChildren(...safeValue);
913
- this.#wireEvents(element);
914
- // This is necessary in case the new HTML contains JavaScript expressions.
915
- this.#makeReactive(element);
916
- }
917
- else if (isHTML) {
918
- element.textContent = value;
919
- }
920
- }
921
- // Update corresponding parent web component property if bound to one.
922
- // VS Code thinks this is never called, but it is called by #defineProp.
923
- #updateParentProperty(propName, value) {
924
- const parentProp = this.#propToParentPropMap.get(propName);
925
- if (!parentProp)
926
- return;
927
- const root = this.getRootNode();
928
- if (!(root instanceof ShadowRoot))
929
- return;
930
- const { host } = root;
931
- if (!host)
932
- return;
933
- const parent = host;
934
- parent[parentProp] = value;
935
- }
936
- /**
937
- * @param state - WrecState object
938
- * @param map - object whose keys are state properties and
939
- * whose values are component properties
940
- */
941
- useState(state, map) {
942
- if (!map) {
943
- // Assume this component has the same properties as the state.
944
- map = {};
945
- for (const prop of Object.keys(state)) {
946
- map[prop] = prop;
947
- }
948
- }
949
- this.#validateStateMap(state, map);
950
- for (const [stateProp, componentProp] of Object.entries(map)) {
951
- if (this.#hasProperty(componentProp)) {
952
- const value = getPathValue(state, stateProp);
953
- if (value !== undefined)
954
- this[componentProp] = value;
955
- const config = this.#ctor.properties[componentProp];
956
- config.state = state;
957
- config.stateProp = stateProp;
958
- }
959
- }
960
- state.addListener(this, map);
961
- }
962
- #validateAttributes() {
963
- const ctor = this.#ctor;
964
- const propNames = new Set(Object.keys(ctor.properties));
965
- for (const propName of propNames) {
966
- if (RESERVED_ATTRS.has(propName)) {
967
- this.#throw(null, '', `property "${propName}" is not allowed because it is a reserved attribute`);
968
- }
969
- }
970
- for (const attrName of this.getAttributeNames()) {
971
- if (attrName === 'class')
972
- continue;
973
- if (attrName === 'id')
974
- continue;
975
- if (attrName === 'disabled')
976
- continue;
977
- if (attrName.startsWith('on'))
978
- continue;
979
- if (attrName === 'form-assoc') {
980
- this.#verifyFormAssociated();
981
- continue;
982
- }
983
- if (!propNames.has(Wrec.getPropName(attrName))) {
984
- if (attrName === 'name') {
985
- this.#verifyFormAssociated();
986
- continue;
987
- }
988
- this.#throw(null, attrName, 'is not a supported attribute');
989
- }
990
- }
991
- }
992
- #validateExpression(element, attrName, expr) {
993
- const matches = expr.match(REFS_RE);
994
- if (!matches)
995
- return;
996
- matches.forEach(capture => {
997
- const propName = getPropName(capture);
998
- if (this[propName] === undefined) {
999
- this.#throwInvalidRef(element, attrName, propName);
1000
- }
1001
- });
1002
- return matches;
1003
- }
1004
- #validateStateMap(state, map) {
1005
- for (const [statePath, componentProp] of Object.entries(map)) {
1006
- let value = getPathValue(state, statePath);
1007
- if (value === undefined) {
1008
- throw new WrecError(`invalid state path "${statePath}"`);
1009
- }
1010
- value = this[componentProp];
1011
- if (!this.#hasProperty(componentProp)) {
1012
- this.#throw(null, componentProp, 'refers to missing property in useState map');
1013
- }
1014
- }
1015
- }
1016
- // When type is an array, this can't validate the type of the array elements.
1017
- // This is called by #defineProp.
1018
- #validateType(propName, type, value) {
1019
- if (value instanceof type)
1020
- return;
1021
- let t = typeof value;
1022
- if (t === 'object') {
1023
- const { constructor } = value;
1024
- t = constructor.name;
1025
- if (constructor !== type) {
1026
- this.#throw(null, propName, `was set to a ${t}, but must be a ${type.name}`);
1027
- }
1028
- }
1029
- // Handle primitive types.
1030
- if (t !== type.name.toLowerCase()) {
1031
- this.#throw(null, propName, `was set to a ${t}, but must be a ${type.name}`);
1032
- }
1033
- }
1034
- #wireEvents(root) {
1035
- const elements = Array.from(root.querySelectorAll('*'));
1036
- for (const element of elements) {
1037
- // We don't want to remove attributes while we are iterating over them.
1038
- const attributesToRemove = [];
1039
- for (const attr of Array.from(element.attributes)) {
1040
- const attrName = attr.name;
1041
- if (attrName.startsWith('on')) {
1042
- let eventName = attrName.slice(2);
1043
- eventName =
1044
- eventName[0].toLowerCase() + eventName.slice(1).toLowerCase();
1045
- const attrValue = attr.value;
1046
- this.#validateExpression(element, attrName, attrValue);
1047
- let fn;
1048
- if (typeof this[attrValue] === 'function') {
1049
- fn = (event) => this[attrValue](event);
1050
- }
1051
- else {
1052
- this.#validateExpression(element, attrName, attrValue);
1053
- // oxlint-disable-next-line no-eval no-unused-vars
1054
- fn = () => this.#evaluateInContext(attrValue);
1055
- }
1056
- element.addEventListener(eventName, fn);
1057
- attributesToRemove.push(attrName);
1058
- }
1059
- }
1060
- for (const attrName of attributesToRemove) {
1061
- element.removeAttribute(attrName);
1062
- }
1063
- }
1064
- }
1065
- }
1066
- export function css(strings, ...values) {
1067
- let result = interpolate(strings, values);
1068
- // Replace JavaScript expressions in CSS property values
1069
- // with a reference to a CSS variable whose value is the expression.
1070
- while (true) {
1071
- const match = CSS_PROPERTY_RE.exec(result);
1072
- if (!match)
1073
- break;
1074
- const propValue = match[2];
1075
- if (REFS_TEST_RE.test(propValue)) {
1076
- const propName = match[1];
1077
- if (!propName.startsWith('--')) {
1078
- const replacement = `--${propName}: ${propValue};
1079
- ${propName}: var(--${propName})`;
1080
- result = replace(result, match.index, match[0].length, replacement);
1081
- }
1082
- }
1083
- }
1084
- return result;
1085
- }
1086
- export function html(strings, ...values) {
1087
- let result = interpolate(strings, values);
1088
- // Replace JavaScript expressions in HTML element text content
1089
- // with an HTML comment containing the expression.
1090
- while (true) {
1091
- const match = HTML_ELEMENT_TEXT_RE.exec(result);
1092
- // Don't do this in style elements.
1093
- if (!match || match[1] === 'style')
1094
- break;
1095
- const textContent = removeHtmlComments(match[2]);
1096
- if (REFS_TEST_RE.test(textContent)) {
1097
- const comment = `<!-- ${textContent.trim()} -->`;
1098
- const index = match.index + match[0].indexOf('>') + 1;
1099
- result = replace(result, index, textContent.length, comment);
1100
- }
1101
- }
1102
- return result;
1103
- }
1104
- export { WrecState } from './wrec-state';
1105
- //# sourceMappingURL=wrec.js.map