@panoramax/web-viewer 3.0.2-develop-a8ea8e60

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.
Files changed (125) hide show
  1. package/.dockerignore +6 -0
  2. package/.gitlab-ci.yml +71 -0
  3. package/CHANGELOG.md +428 -0
  4. package/CODE_OF_CONDUCT.md +134 -0
  5. package/Dockerfile +14 -0
  6. package/LICENSE +21 -0
  7. package/README.md +39 -0
  8. package/build/editor.html +1 -0
  9. package/build/index.css +36 -0
  10. package/build/index.css.map +1 -0
  11. package/build/index.html +1 -0
  12. package/build/index.js +25 -0
  13. package/build/index.js.map +1 -0
  14. package/build/map.html +1 -0
  15. package/build/viewer.html +1 -0
  16. package/config/env.js +104 -0
  17. package/config/getHttpsConfig.js +66 -0
  18. package/config/getPackageJson.js +25 -0
  19. package/config/jest/babelTransform.js +29 -0
  20. package/config/jest/cssTransform.js +14 -0
  21. package/config/jest/fileTransform.js +40 -0
  22. package/config/modules.js +134 -0
  23. package/config/paths.js +72 -0
  24. package/config/pnpTs.js +35 -0
  25. package/config/webpack/persistentCache/createEnvironmentHash.js +9 -0
  26. package/config/webpack.config.js +885 -0
  27. package/config/webpackDevServer.config.js +127 -0
  28. package/docs/01_Start.md +149 -0
  29. package/docs/02_Usage.md +828 -0
  30. package/docs/03_URL_settings.md +140 -0
  31. package/docs/04_Advanced_examples.md +214 -0
  32. package/docs/05_Compatibility.md +85 -0
  33. package/docs/09_Develop.md +62 -0
  34. package/docs/90_Releases.md +27 -0
  35. package/docs/images/class_diagram.drawio +129 -0
  36. package/docs/images/class_diagram.jpg +0 -0
  37. package/docs/images/screenshot.jpg +0 -0
  38. package/mkdocs.yml +45 -0
  39. package/package.json +254 -0
  40. package/public/editor.html +54 -0
  41. package/public/favicon.ico +0 -0
  42. package/public/index.html +59 -0
  43. package/public/map.html +53 -0
  44. package/public/viewer.html +67 -0
  45. package/scripts/build.js +217 -0
  46. package/scripts/start.js +176 -0
  47. package/scripts/test.js +52 -0
  48. package/src/Editor.css +37 -0
  49. package/src/Editor.js +359 -0
  50. package/src/StandaloneMap.js +114 -0
  51. package/src/Viewer.css +203 -0
  52. package/src/Viewer.js +1186 -0
  53. package/src/components/CoreView.css +64 -0
  54. package/src/components/CoreView.js +159 -0
  55. package/src/components/Loader.css +56 -0
  56. package/src/components/Loader.js +111 -0
  57. package/src/components/Map.css +65 -0
  58. package/src/components/Map.js +841 -0
  59. package/src/components/Photo.css +36 -0
  60. package/src/components/Photo.js +687 -0
  61. package/src/img/arrow_360.svg +14 -0
  62. package/src/img/arrow_flat.svg +11 -0
  63. package/src/img/arrow_triangle.svg +10 -0
  64. package/src/img/arrow_turn.svg +9 -0
  65. package/src/img/bg_aerial.jpg +0 -0
  66. package/src/img/bg_streets.jpg +0 -0
  67. package/src/img/loader_base.jpg +0 -0
  68. package/src/img/loader_hd.jpg +0 -0
  69. package/src/img/logo_dead.svg +91 -0
  70. package/src/img/marker.svg +17 -0
  71. package/src/img/marker_blue.svg +20 -0
  72. package/src/img/switch_big.svg +44 -0
  73. package/src/img/switch_mini.svg +48 -0
  74. package/src/index.js +10 -0
  75. package/src/translations/de.json +163 -0
  76. package/src/translations/en.json +164 -0
  77. package/src/translations/eo.json +6 -0
  78. package/src/translations/es.json +164 -0
  79. package/src/translations/fi.json +1 -0
  80. package/src/translations/fr.json +164 -0
  81. package/src/translations/hu.json +133 -0
  82. package/src/translations/nl.json +1 -0
  83. package/src/translations/zh_Hant.json +136 -0
  84. package/src/utils/API.js +709 -0
  85. package/src/utils/Exif.js +198 -0
  86. package/src/utils/I18n.js +75 -0
  87. package/src/utils/Map.js +382 -0
  88. package/src/utils/PhotoAdapter.js +45 -0
  89. package/src/utils/Utils.js +568 -0
  90. package/src/utils/Widgets.js +477 -0
  91. package/src/viewer/URLHash.js +334 -0
  92. package/src/viewer/Widgets.css +711 -0
  93. package/src/viewer/Widgets.js +1196 -0
  94. package/tests/Editor.test.js +125 -0
  95. package/tests/StandaloneMap.test.js +44 -0
  96. package/tests/Viewer.test.js +363 -0
  97. package/tests/__snapshots__/Editor.test.js.snap +300 -0
  98. package/tests/__snapshots__/StandaloneMap.test.js.snap +30 -0
  99. package/tests/__snapshots__/Viewer.test.js.snap +195 -0
  100. package/tests/components/CoreView.test.js +91 -0
  101. package/tests/components/Loader.test.js +38 -0
  102. package/tests/components/Map.test.js +230 -0
  103. package/tests/components/Photo.test.js +335 -0
  104. package/tests/components/__snapshots__/Loader.test.js.snap +15 -0
  105. package/tests/components/__snapshots__/Map.test.js.snap +767 -0
  106. package/tests/components/__snapshots__/Photo.test.js.snap +205 -0
  107. package/tests/data/Map_geocoder_ban.json +36 -0
  108. package/tests/data/Map_geocoder_nominatim.json +56 -0
  109. package/tests/data/Viewer_pictures_1.json +148 -0
  110. package/tests/setupTests.js +5 -0
  111. package/tests/utils/API.test.js +906 -0
  112. package/tests/utils/Exif.test.js +124 -0
  113. package/tests/utils/I18n.test.js +28 -0
  114. package/tests/utils/Map.test.js +105 -0
  115. package/tests/utils/Utils.test.js +300 -0
  116. package/tests/utils/Widgets.test.js +107 -0
  117. package/tests/utils/__snapshots__/API.test.js.snap +132 -0
  118. package/tests/utils/__snapshots__/Exif.test.js.snap +43 -0
  119. package/tests/utils/__snapshots__/Map.test.js.snap +48 -0
  120. package/tests/utils/__snapshots__/Utils.test.js.snap +41 -0
  121. package/tests/utils/__snapshots__/Widgets.test.js.snap +44 -0
  122. package/tests/viewer/URLHash.test.js +537 -0
  123. package/tests/viewer/Widgets.test.js +127 -0
  124. package/tests/viewer/__snapshots__/URLHash.test.js.snap +98 -0
  125. package/tests/viewer/__snapshots__/Widgets.test.js.snap +393 -0
@@ -0,0 +1,477 @@
1
+ // Every single icon imported separately to reduce bundle size
2
+ import { icon } from "@fortawesome/fontawesome-svg-core";
3
+ import { faXmark } from "@fortawesome/free-solid-svg-icons/faXmark";
4
+ import { faCircleExclamation } from "@fortawesome/free-solid-svg-icons/faCircleExclamation";
5
+ import { faChevronDown } from "@fortawesome/free-solid-svg-icons/faChevronDown";
6
+ import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons/faMagnifyingGlass";
7
+ import { faCircleNotch } from "@fortawesome/free-solid-svg-icons/faCircleNotch";
8
+ import { faCheck } from "@fortawesome/free-solid-svg-icons/faCheck";
9
+ import { faCopy } from "@fortawesome/free-solid-svg-icons/faCopy";
10
+
11
+
12
+ /**
13
+ * Creates a new button, already styled
14
+ * @param {string} id The component ID
15
+ * @param {string|Element} content The text content
16
+ * @param {string} [title] A title label on overlay
17
+ * @param {string[]} [classes] List of CSS classes to add
18
+ * @returns {Element} The created button
19
+ * @private
20
+ */
21
+ export function createButton(id, content = null, title = null, classes = []) {
22
+ const btn = document.createElement("button");
23
+ if(content) {
24
+ if(content instanceof HTMLElement || content instanceof Node) {
25
+ btn.appendChild(content);
26
+ }
27
+ else {
28
+ btn.innerHTML = content;
29
+ }
30
+ }
31
+ btn.id = id;
32
+ if(Array.isArray(classes)) {
33
+ classes = classes.filter(c => c != null && c.length > 0);
34
+ }
35
+ btn.classList.add("gvs-btn", "gvs-widget-bg", ...classes);
36
+ if(title) { btn.title = title; }
37
+ return btn;
38
+ }
39
+
40
+ /**
41
+ * Creates a new "expandable" button, already styled
42
+ * @param {string} id The component ID
43
+ * @param {object} icon The FontAwesome icon definition
44
+ * @param {string} label The label text
45
+ * @param {Widgets} container The widgets container
46
+ * @param {string[]} [classes] List of CSS classes to add
47
+ * @returns {Element} The created button
48
+ * @private
49
+ */
50
+ export function createExpandableButton(id, icon, label, container, classes = []) {
51
+ const btn = document.createElement("button");
52
+ btn.id = id;
53
+ btn.appendChild(fa(icon));
54
+ if(!container._viewer.isWidthSmall()) {
55
+ btn.appendChild(document.createTextNode(label));
56
+ btn.appendChild(fa(faChevronDown));
57
+ }
58
+ else {
59
+ btn.title = label;
60
+ }
61
+ btn.classList.add("gvs-btn", "gvs-widget-bg", "gvs-btn-expandable", ...classes);
62
+ return btn;
63
+ }
64
+
65
+ /**
66
+ * Creates a new search bar
67
+ * @param {string} id The bar ID
68
+ * @param {string} placeholder The default label to display when search field is empty
69
+ * @param {function} onInput Event handler for search text input (should return a Promise)
70
+ * @param {function} onResultClick Event handler for result entry click
71
+ * @param {Widgets} container The widgets container
72
+ * @param {boolean} [nonClosingPanel] Should the search result closes other panels
73
+ * @param {boolean} [reduced] Should the search bar be reduced by default ?
74
+ * @param {Element} [preContent] DOM element to insert before search input
75
+ * @returns {Element} The search bar
76
+ * @private
77
+ */
78
+ export function createSearchBar(
79
+ id, placeholder, onInput, onResultClick,
80
+ container, nonClosingPanel = false, reduced = false,
81
+ preContent = null,
82
+ ) {
83
+ // Container
84
+ const bar = document.createElement("div");
85
+ bar.classList.add("gvs-widget-bg", "gvs-search-bar");
86
+ bar.id = id;
87
+ if(reduced) { bar.classList.add("gvs-search-bar-reducable"); }
88
+
89
+ // Pre-content
90
+ if(preContent) {
91
+ bar.appendChild(preContent);
92
+ }
93
+
94
+ // Input field
95
+ const input = document.createElement("input");
96
+ input.type = "text";
97
+ input.placeholder = placeholder;
98
+ bar.appendChild(input);
99
+ const extendInput = () => {
100
+ bar.classList.remove("gvs-search-bar-reduced");
101
+ };
102
+ const reduceInput = () => {
103
+ bar.classList.add("gvs-search-bar-reduced");
104
+ };
105
+ if(reduced) { reduceInput(); }
106
+
107
+ // Status icon
108
+ const icon = document.createElement("span");
109
+ icon.classList.add("gvs-search-bar-icon");
110
+ const iconSearch = fa(faMagnifyingGlass);
111
+ const iconLoading = fa(faCircleNotch, { classes: ["fa-spin"] });
112
+ const iconEmpty = fa(faXmark);
113
+ const iconWarn = fa(faCircleExclamation);
114
+ icon.appendChild(iconSearch);
115
+ bar.appendChild(icon);
116
+
117
+ // List of results
118
+ const list = createPanel(container, bar, [], ["gvs-search-bar-results"], nonClosingPanel);
119
+ bar.appendChild(list);
120
+
121
+ // Change status icon
122
+ const switchIcon = newStatusIcon => {
123
+ icon.innerHTML = "";
124
+ icon.appendChild(newStatusIcon);
125
+ };
126
+
127
+ // Reset search bar
128
+ const resetSearch = () => {
129
+ if(bar._throttler) { clearTimeout(bar._throttler); }
130
+ input.value = "";
131
+ list.innerHTML = "";
132
+ list._toggle(false);
133
+ delete bar._lastSearch;
134
+ switchIcon(iconSearch);
135
+ onResultClick(null);
136
+ if(reduced) { reduceInput(); }
137
+ };
138
+ bar.resetSearch = resetSearch;
139
+
140
+ // Handle result item click
141
+ const goItem = (entry) => {
142
+ if(reduced) {
143
+ onResultClick(entry);
144
+ resetSearch();
145
+ }
146
+ else {
147
+ if(bar._throttler) { clearTimeout(bar._throttler); }
148
+ input.value = entry.title;
149
+ list.innerHTML = "";
150
+ list._toggle(false);
151
+ onResultClick(entry);
152
+ }
153
+ };
154
+
155
+ // Force item selection
156
+ input.setItem = (text) => {
157
+ if(bar._throttler) { clearTimeout(bar._throttler); }
158
+ input.value = text;
159
+ list.innerHTML = "";
160
+ list._toggle(false);
161
+ switchIcon(iconEmpty);
162
+ if(reduced) { extendInput(); }
163
+ };
164
+
165
+ // Handle search
166
+ const goSearch = () => {
167
+ if(bar._throttler) { clearTimeout(bar._throttler); }
168
+
169
+ if(input.value.length === 0) {
170
+ list.innerHTML = "";
171
+ list._toggle(false);
172
+ return;
173
+ }
174
+
175
+ bar._throttler = setTimeout(() => {
176
+ list.innerHTML = "";
177
+ list._toggle(false);
178
+ switchIcon(iconLoading);
179
+
180
+ onInput(input.value).then(data => {
181
+ switchIcon(iconEmpty);
182
+ list._toggle(true);
183
+
184
+ if(!data || data.length == 0) {
185
+ list.innerHTML = `<div class="gvs-search-empty">${container._t.gvs.search_empty}</li>`;
186
+ return;
187
+ }
188
+
189
+ data.forEach(entry => {
190
+ const listEntry = document.createElement("div");
191
+ listEntry.classList.add("gvs-search-bar-result");
192
+ listEntry.innerHTML = `${entry.title}<br /><small>${entry?.subtitle || ""}</small>`;
193
+ list.appendChild(listEntry);
194
+ listEntry.addEventListener("click", () => goItem(entry));
195
+ });
196
+ }).catch(e => {
197
+ console.error(e);
198
+ switchIcon(iconWarn);
199
+ });
200
+ }, 250);
201
+ };
202
+
203
+ input.addEventListener("change", goSearch);
204
+ input.addEventListener("keypress", goSearch);
205
+ input.addEventListener("paste", goSearch);
206
+ input.addEventListener("input", goSearch);
207
+ icon.addEventListener("click", () => {
208
+ if(icon.firstChild == iconEmpty || icon.firstChild == iconWarn) {
209
+ resetSearch();
210
+ }
211
+ if(reduced && icon.firstChild == iconSearch) {
212
+ if(!bar.classList.contains("gvs-search-bar-reduced")) { reduceInput(); }
213
+ else { extendInput(); }
214
+ }
215
+ });
216
+
217
+ return bar;
218
+ }
219
+
220
+ /**
221
+ * Creates a panel associated to a button
222
+ * @param {Widgets} container The widgets container
223
+ * @param {Element} btn The component to associate to
224
+ * @param {Element[]} [elements] DOM elements to append into
225
+ * @param {str[]} [classes] CSS classes to add
226
+ * @param {boolean} [nonClosingPanel] Should this panel closes other when opened
227
+ * @returns {Element} The created panel
228
+ * @private
229
+ */
230
+ export function createPanel(container, btn, elements = [], classes = [], nonClosingPanel = false) {
231
+ const panel = document.createElement("div");
232
+ panel.id = btn.id + "-panel";
233
+ if(Array.isArray(classes)) {
234
+ classes = classes.filter(c => c != null && c.length > 0);
235
+ }
236
+ panel.classList.add("gvs-panel", "gvs-widget-bg", "gvs-hidden", ...classes);
237
+ for(let e of elements) {
238
+ panel.appendChild(e);
239
+ }
240
+
241
+ const togglePanel = (e, visible) => {
242
+ if(e) { e.stopPropagation(); }
243
+ if(visible === true) { panel.classList.remove("gvs-hidden"); }
244
+ else if(visible === false) { panel.classList.add("gvs-hidden"); }
245
+ else {
246
+ panel.classList.toggle("gvs-hidden");
247
+ visible = !panel.classList.contains("gvs-hidden");
248
+ }
249
+
250
+ // Hide all other panels
251
+ if(visible && !nonClosingPanel) {
252
+ closeOtherPanels(panel, container._viewer.container);
253
+ }
254
+ };
255
+ panel._toggle = v => togglePanel(null, v);
256
+
257
+ if(btn.tagName == "BUTTON") {
258
+ btn.addEventListener("click", togglePanel);
259
+ btn.addEventListener("hover", togglePanel);
260
+ }
261
+
262
+ return panel;
263
+ }
264
+
265
+ /**
266
+ * Makes all previously opened panels closed if clicked outside of one.
267
+ * @param {Element} target The DOM element which has been clicked
268
+ * @param {Element} container The viewer container
269
+ * @private
270
+ */
271
+ export function closeOtherPanels(target, container) {
272
+ const isPanel = p => (
273
+ p.classList.contains("gvs-panel")
274
+ || p.classList.contains("gvs-search-bar-result")
275
+ || p.classList.contains("gvs-search-empty")
276
+ || p.classList.contains("gvs-search-bar-reducable")
277
+ );
278
+
279
+ // Find nearest panel
280
+ if(!isPanel(target) && target?.parentNode) {
281
+ target = target.parentNode;
282
+ while(target instanceof Element) {
283
+ if(isPanel(target)) { break; }
284
+ else { target = target.parentNode; }
285
+ }
286
+ }
287
+
288
+ // Click outside of open panel = closing
289
+ for(const p of container.getElementsByClassName("gvs-panel")) {
290
+ if(p != target && !p.contains(target) && !p.classList.contains("gvs-hidden")) {
291
+ p.classList.add("gvs-hidden");
292
+ }
293
+ }
294
+ for(const p of container.getElementsByClassName("gvs-search-bar-reducable")) {
295
+ if(p != target && !p.contains(target) && !p.classList.contains("gvs-search-bar-reduced")) {
296
+ p.resetSearch();
297
+ }
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Creates a new group of elements, already styled
303
+ * @param {str} id
304
+ * @param {str} position (format: component-corner, with component = main/mini, and corner = top-left, top-right, top, bottom-left, bottom, bottom-right)
305
+ * @param {Element[]} [elements] The children elements to add
306
+ * @param {str[]} [classes] The CSS classes to add
307
+ * @returns {Element} The created group
308
+ * @private
309
+ */
310
+ export function createGroup(id, position, container, elements = [], classes = []) {
311
+ const group = document.createElement("div");
312
+ group.id = id;
313
+ if(Array.isArray(classes)) {
314
+ classes = classes.filter(c => c != null && c.length > 0);
315
+ }
316
+ group.classList.add("gvs-group", ...classes);
317
+ for(let e of elements) {
318
+ group.appendChild(e);
319
+ }
320
+ container._corners[position].appendChild(group);
321
+ return group;
322
+ }
323
+
324
+ /**
325
+ * Make all buttons with data-copy=* or data-input=* attributes able to copy to clipboard.
326
+ *
327
+ * @param {Element} container The parent container
328
+ * @param {object} t The translation container
329
+ * @private
330
+ */
331
+ export function enableCopyButton(container, t) {
332
+ for(let btn of container.getElementsByTagName("button")) {
333
+ const field = btn.getAttribute("data-input");
334
+ const copy = btn.getAttribute("data-copy");
335
+ if(field || copy) {
336
+ btn.addEventListener("click", () => {
337
+ let text;
338
+ if(field) {
339
+ const inputField = document.getElementById(field);
340
+ text = inputField.innerText || inputField.value;
341
+ }
342
+ else if(copy) {
343
+ text = btn.getAttribute("data-copy");
344
+ }
345
+ navigator.clipboard.writeText(text);
346
+ const btnOrigContent = btn.innerHTML;
347
+ btn.innerHTML = `${t.gvs.copied} ${fat(faCheck)}`;
348
+ btn.classList.add("gvs-btn-active");
349
+ setTimeout(() => {
350
+ btn.innerHTML = btnOrigContent;
351
+ btn.classList.remove("gvs-btn-active");
352
+ }, 2000);
353
+ });
354
+ }
355
+ }
356
+ }
357
+
358
+ /**
359
+ * Make a button usable
360
+ * @param {Element} btn
361
+ * @private
362
+ */
363
+ export function enableButton(btn) {
364
+ btn.removeAttribute("disabled");
365
+ }
366
+
367
+ /**
368
+ * Make a button unusable
369
+ * @param {Element} btn
370
+ * @private
371
+ */
372
+ export function disableButton(btn) {
373
+ btn.setAttribute("disabled", "");
374
+ }
375
+
376
+ /**
377
+ * Transform Font Awesome icon definition into HTML element
378
+ * @param {IconDefinition} i The icon to use
379
+ * @param {object} [o] [FontAwesome icon parameters](https://origin.fontawesome.com/docs/apis/javascript/methods#icon-icondefinition-params)
380
+ * @returns {Element} HTML element
381
+ * @private
382
+ */
383
+ export function fa(i, o) {
384
+ return icon(i, o).node[0];
385
+ }
386
+
387
+ /**
388
+ * Transform Font Awesome icon definition into HTML text
389
+ * @param {IconDefinition} i The icon to use
390
+ * @param {object} [o] [FontAwesome icon parameters](https://origin.fontawesome.com/docs/apis/javascript/methods#icon-icondefinition-params)
391
+ * @returns {string} HTML element as text
392
+ * @private
393
+ */
394
+ export function fat(i, o) {
395
+ return icon(i, o).html[0];
396
+ }
397
+
398
+ /**
399
+ * Table cell with a copy link
400
+ * @private
401
+ */
402
+ export function createLinkCell(id, url, title, buttonTitle) {
403
+ const link = document.createElement("a");
404
+ link.href = url;
405
+ link.target = "_blank";
406
+ link.title = title;
407
+ link.textContent = id;
408
+
409
+ const buttonContainer = createButtonSpan(`${fat(faCopy)} ${buttonTitle}`, id);
410
+ return [link, buttonContainer];
411
+ }
412
+
413
+ /**
414
+ * Create a light table
415
+ * @private
416
+ */
417
+ export function createTable(className, rows) {
418
+ const table = document.createElement("table");
419
+ table.className = className;
420
+
421
+ rows.forEach(({ section, value, values, classes }) => {
422
+ const tr = document.createElement("tr");
423
+ const th = document.createElement("th");
424
+ th.scope = "row";
425
+ th.textContent = section;
426
+ tr.appendChild(th);
427
+
428
+ const td = document.createElement("td");
429
+ if(classes) { td.classList.add(...classes); }
430
+ if(values) { values.forEach(v => td.appendChild(v)); }
431
+ else if(value instanceof HTMLElement) { td.appendChild(value); }
432
+ else { td.innerHTML = value; }
433
+ tr.appendChild(td);
434
+
435
+ table.appendChild(tr);
436
+ });
437
+
438
+ return table;
439
+ }
440
+
441
+ /**
442
+ * Create block header
443
+ * @private
444
+ */
445
+ export function createHeader(tag, innerHTML) {
446
+ const header = document.createElement(tag);
447
+ header.innerHTML = innerHTML;
448
+ return header;
449
+ }
450
+
451
+ /**
452
+ * Create copy to clipboard button
453
+ * @private
454
+ */
455
+ export function createButtonSpan(innerHTML, dataCopy = null) {
456
+ const button = document.createElement("button");
457
+ button.innerHTML = innerHTML;
458
+ if (dataCopy) button.setAttribute("data-copy", dataCopy);
459
+
460
+ const span = document.createElement("span");
461
+ span.className = "gvs-input-btn";
462
+ span.appendChild(button);
463
+
464
+ return span;
465
+ }
466
+
467
+ /**
468
+ * Create an input label
469
+ * @private
470
+ */
471
+ export function createLabel(forAttr, text, faIcon = null) {
472
+ const label = document.createElement("label");
473
+ label.htmlFor = forAttr;
474
+ if(faIcon) { label.appendChild(fa(faIcon)); }
475
+ label.appendChild(document.createTextNode(text));
476
+ return label;
477
+ }