@yuneta/gobj-ui 5.5.0 → 5.7.0

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/src/c_yui_form.js CHANGED
@@ -57,6 +57,7 @@ import { createJSONEditor } from 'vanilla-jsoneditor';
57
57
  import "vanilla-jsoneditor/themes/jse-theme-dark.css";
58
58
  import "./c_yui_form.css";
59
59
  import {attach_clear, refresh_clear} from "./yui_inputs.js";
60
+ import {plan_toolbar} from "./form_toolbar_plan.js";
60
61
  /* Read at field-build time only — deliberately NOT watched: the hosting
61
62
  * dialog rebuilds the form on every open, and re-rendering a form under
62
63
  * the user mid-edit would throw away what they typed. */
@@ -96,6 +97,20 @@ SDATA(data_type_t.DTP_BOOLEAN, "selectable", 0, true, "Rows selectable
96
97
  SDATA(data_type_t.DTP_BOOLEAN, "with_checkbox", 0, false, "Show a first column with checkbox to select rows"),
97
98
  SDATA(data_type_t.DTP_BOOLEAN, "with_rownum", 0, false, "Print first column with row number"),
98
99
  SDATA(data_type_t.DTP_BOOLEAN, "with_delete_row_btn", 0, false, "Print last column with delete row button"),
100
+ /*
101
+ * Which buttons the bottom toolbar shows, and in which order. The
102
+ * default is the five it has always shown, so nothing moves for a
103
+ * caller that does not set it.
104
+ *
105
+ * It exists because the toolbar was hardcoded: a three-field sign-up
106
+ * dialog with ONE action still got save + undo + clear + copy + paste,
107
+ * and the only way out was to not use this gclass at all. `[]` hides
108
+ * the toolbar entirely, for a caller that supplies its own buttons.
109
+ *
110
+ * Unknown names are ignored, with a warning: a typo must not silently
111
+ * drop the save button.
112
+ */
113
+ SDATA(data_type_t.DTP_JSON, "toolbar", 0, ["save", "undo", "clear", "copy", "paste"], "Toolbar buttons to show, in order: save, undo, clear, copy, paste. [] hides the toolbar"),
99
114
  /*
100
115
  * Defaults for tabulator
101
116
  */
@@ -283,6 +298,90 @@ async function readClipboard(gobj)
283
298
  }
284
299
  }
285
300
 
301
+ /************************************************************
302
+ * Bottom toolbar, from the `toolbar` attr.
303
+ *
304
+ * The five buttons it can show, and their order, are the
305
+ * caller's: a dialog with a single action asks for ["save"],
306
+ * and [] leaves no toolbar at all. Default is the five it
307
+ * always had, so nothing moves for a caller that says nothing.
308
+ ************************************************************/
309
+ function toolbar_button(gobj, name)
310
+ {
311
+ switch(name) {
312
+ case "save":
313
+ return ['button', {class: 'button p-1 button-save', title: 'save', 'aria-label': 'save', disabled: true}, [
314
+ ['span', {class: 'icon m-0'}, '<i class="yi-floppy-disk"></i>'],
315
+ ['span', {class: 'is-hidden-mobile pl-1 pr-1', i18n: 'save'}, 'save']
316
+ ], {
317
+ click: function(evt) {
318
+ evt.stopPropagation();
319
+ gobj_send_event(gobj, "EV_SAVE_RECORD", {}, gobj);
320
+ }
321
+ }];
322
+ case "undo":
323
+ return ['button', {class: 'button p-1 button-undo', title: 'undo', 'aria-label': 'undo', disabled: true}, [
324
+ ['span', {class: 'icon m-0'}, '<i class="yi-arrow-rotate-left"></i>'],
325
+ ['span', {class: 'is-hidden-mobile pl-1 pr-1', i18n: 'undo'}, 'undo']
326
+ ], {
327
+ click: function(evt) {
328
+ evt.stopPropagation();
329
+ gobj_send_event(gobj, "EV_UNDO_RECORD", {}, gobj);
330
+ }
331
+ }];
332
+ case "clear":
333
+ return ['button', {class: 'button p-1', title: 'clear', 'aria-label': 'clear'}, [
334
+ ['span', {class: 'icon m-0'}, '<i class="yi-broom-wide"></i>'],
335
+ ['span', {class: 'is-hidden-mobile pl-1 pr-1', i18n: 'clear'}, 'clear']
336
+ ], {
337
+ click: function(evt) {
338
+ evt.stopPropagation();
339
+ gobj_send_event(gobj, "EV_CLEAR_RECORD", {}, gobj);
340
+ }
341
+ }];
342
+ case "copy":
343
+ return ['button', {class: 'button p-1', title: 'copy', 'aria-label': 'copy'}, [
344
+ ['span', {class: 'icon m-0'}, '<i class="yi-copy"></i>'],
345
+ ['span', {class: 'is-hidden-mobile pl-1 pr-1', i18n: 'copy'}, 'copy']
346
+ ], {click: function(evt) {
347
+ evt.stopPropagation();
348
+ gobj_send_event(gobj, "EV_COPY_RECORD", {}, gobj);
349
+ }
350
+ }];
351
+ case "paste":
352
+ return ['button', {class: 'button p-1', title: 'paste', 'aria-label': 'paste'}, [
353
+ ['span', {class: 'icon m-0'}, '<i class="yi-paste"></i>'],
354
+ ['span', {class: 'is-hidden-mobile pl-1 pr-1', i18n: 'paste'}, 'paste']
355
+ ], {click: async function(evt) {
356
+ evt.stopPropagation();
357
+ await readClipboard(gobj);
358
+ }
359
+ }];
360
+ default:
361
+ return null; /* plan_toolbar() already warned */
362
+ }
363
+ }
364
+
365
+ function build_toolbar(gobj)
366
+ {
367
+ const plan = plan_toolbar(gobj_read_attr(gobj, "toolbar"));
368
+
369
+ for(let name of plan.unknown) {
370
+ /* A typo must not silently drop the save button. */
371
+ log_warning(`${gobj_short_name(gobj)}: unknown toolbar button '${name}'`);
372
+ }
373
+ if(!plan.left.length && !plan.right.length) {
374
+ return ['span', {class: 'yui-toolbar-form-empty', style: 'display:none;'}, ''];
375
+ }
376
+
377
+ return ['div', {class: 'yui-toolbar-form is-flex-shrink-0 is-flex is-flex-direction-row is-justify-content-space-between is-align-items-center', style: 'width:100%; flex-wrap:wrap; row-gap:6px;'}, [
378
+ ['div', {class: 'p-1 is-flex is-flex-direction-row is-align-items-center', style:'column-gap:6px;'},
379
+ plan.left.map((n) => toolbar_button(gobj, n))],
380
+ ['div', {class: 'p-1 is-flex is-flex-direction-row is-align-items-center', style:'column-gap:6px;'},
381
+ plan.right.map((n) => toolbar_button(gobj, n))]
382
+ ]];
383
+ }
384
+
286
385
  /************************************************************
287
386
  * Build UI
288
387
  ************************************************************/
@@ -314,81 +413,7 @@ function build_ui(gobj)
314
413
  /*----------------------------*
315
414
  * Bottom toolbar
316
415
  *----------------------------*/
317
- ['div', {class: 'yui-toolbar-form is-flex-shrink-0 is-flex is-flex-direction-row is-justify-content-space-between is-align-items-center', style: 'width:100%; flex-wrap:wrap; row-gap:6px;'}, [
318
-
319
- /*----------------------------*
320
- * Left buttons
321
- *----------------------------*/
322
- ['div', {class: 'p-1 is-flex is-flex-direction-row is-align-items-center', style:'column-gap:6px;'}, [
323
- /*----------------------------*
324
- * Save
325
- *----------------------------*/
326
- ['button', {class: 'button p-1 button-save', title: 'save', 'aria-label': 'save', disabled: true}, [
327
- ['span', {class: 'icon m-0'}, '<i class="yi-floppy-disk"></i>'],
328
- ['span', {class: 'is-hidden-mobile pl-1 pr-1', i18n: 'save'}, 'save']
329
- ], {
330
- click: function(evt) {
331
- evt.stopPropagation();
332
- gobj_send_event(gobj, "EV_SAVE_RECORD", {}, gobj);
333
- }
334
- }],
335
-
336
- /*----------------------------*
337
- * Undo
338
- *----------------------------*/
339
- ['button', {class: 'button p-1 button-undo', title: 'undo', 'aria-label': 'undo', disabled: true}, [
340
- ['span', {class: 'icon m-0'}, '<i class="yi-arrow-rotate-left"></i>'],
341
- ['span', {class: 'is-hidden-mobile pl-1 pr-1', i18n: 'undo'}, 'undo']
342
- ], {
343
- click: function(evt) {
344
- evt.stopPropagation();
345
- gobj_send_event(gobj, "EV_UNDO_RECORD", {}, gobj);
346
- }
347
- }],
348
-
349
- /*----------------------------*
350
- * Clear
351
- *----------------------------*/
352
- ['button', {class: 'button p-1', title: 'clear', 'aria-label': 'clear'}, [
353
- ['span', {class: 'icon m-0'}, '<i class="yi-broom-wide"></i>'],
354
- ['span', {class: 'is-hidden-mobile pl-1 pr-1', i18n: 'clear'}, 'clear']
355
- ], {
356
- click: function(evt) {
357
- evt.stopPropagation();
358
- gobj_send_event(gobj, "EV_CLEAR_RECORD", {}, gobj);
359
- }
360
- }],
361
- ]],
362
-
363
- /*----------------------------*
364
- * Right buttons
365
- *----------------------------*/
366
- ['div', {class: 'p-1 is-flex is-flex-direction-row is-align-items-center', style:'column-gap:6px;'}, [
367
- /*----------------------------*
368
- * Copy
369
- *----------------------------*/
370
- ['button', {class: 'button p-1', title: 'copy', 'aria-label': 'copy'}, [
371
- ['span', {class: 'icon m-0'}, '<i class="yi-copy"></i>'],
372
- ['span', {class: 'is-hidden-mobile pl-1 pr-1', i18n: 'copy'}, 'copy']
373
- ], {click: function(evt) {
374
- evt.stopPropagation();
375
- gobj_send_event(gobj, "EV_COPY_RECORD", {}, gobj);
376
- }
377
- }],
378
-
379
- /*----------------------------*
380
- * Paste
381
- *----------------------------*/
382
- ['button', {class: 'button p-1', title: 'paste', 'aria-label': 'paste'}, [
383
- ['span', {class: 'icon m-0'}, '<i class="yi-paste"></i>'],
384
- ['span', {class: 'is-hidden-mobile pl-1 pr-1', i18n: 'paste'}, 'paste']
385
- ], {click: async function(evt) {
386
- evt.stopPropagation();
387
- await readClipboard(gobj);
388
- }
389
- }]
390
- ]]
391
- ]]
416
+ build_toolbar(gobj)
392
417
  ]]
393
418
  );
394
419
 
package/src/c_yui_nav.js CHANGED
@@ -32,7 +32,12 @@
32
32
  * container; build_ui re-applies them on every rebuild.
33
33
  *
34
34
  * Each item supports:
35
- * id, name, icon (CSS class or svg id), route, badge, disabled
35
+ * id, name, icon (CSS class or svg id), route, disabled
36
+ * (`badge` was listed here for a long time and NOTHING ever
37
+ * rendered it — a menu item cannot carry a count today. The
38
+ * toolbar can: `badge` on a toolbar item plus
39
+ * yui_shell_set_toolbar_item_badge(). Implement it here the
40
+ * day a menu entry needs one, rather than re-listing it.)
36
41
  * class — extra CSS class(es) on the item (tabs layout), e.g.
37
42
  * a per-item state colour like "yui-nav-disconnected"
38
43
  * closable — render a trailing ✕ that emits EV_NAV_ITEM_CLOSE
@@ -449,6 +449,53 @@
449
449
  background-color: var(--bulma-scheme-main-bis, rgba(0,0,0,0.04));
450
450
  }
451
451
 
452
+ /*------------------------------------------------------------
453
+ * Toolbar badge — the count pinned to an item's icon
454
+ *
455
+ * Anchored to the ICON and not to the button: with its label on
456
+ * (desktop) the button is wide, and a badge in its corner would
457
+ * float away from the glyph it counts.
458
+ *
459
+ * `hidden` is honoured explicitly: the element is always in the
460
+ * DOM (so the runtime setter has somewhere to write) and
461
+ * [hidden] loses to `display:inline-flex` without this rule.
462
+ *
463
+ * Danger colour on purpose — a badge exists to pull the eye. It
464
+ * is its own colour pair in both schemes rather than a Bulma
465
+ * helper, so it keeps its contrast when the toolbar background
466
+ * changes with the theme. No transition: house rule.
467
+ *------------------------------------------------------------*/
468
+ .yui-toolbar-icon {
469
+ position: relative;
470
+ overflow: visible;
471
+ }
472
+ .yui-toolbar-badge {
473
+ position: absolute;
474
+ top: -0.35em;
475
+ right: -0.55em;
476
+ display: inline-flex;
477
+ align-items: center;
478
+ justify-content: center;
479
+ box-sizing: border-box;
480
+ /* A round dot for one digit, a pill from two on. */
481
+ min-width: 1.15em;
482
+ height: 1.15em;
483
+ padding: 0 0.3em;
484
+ border-radius: 999px;
485
+ font-size: 0.62em;
486
+ font-weight: 700;
487
+ line-height: 1;
488
+ font-family: inherit;
489
+ background-color: var(--bulma-danger, #f14668);
490
+ color: var(--bulma-danger-invert, #fff);
491
+ /* Separates the pill from the glyph when they overlap. */
492
+ box-shadow: 0 0 0 0.12em var(--bulma-scheme-main, #fff);
493
+ pointer-events: none;
494
+ }
495
+ .yui-toolbar-badge[hidden] {
496
+ display: none;
497
+ }
498
+
452
499
  /*============================================================
453
500
  * Toolbar — type:"brand"
454
501
  * Logo image + wordmark text. Renders as <button> when
@@ -1641,6 +1641,51 @@ function build_toolbar(gobj, config)
1641
1641
  $zone.appendChild($bar);
1642
1642
  }
1643
1643
 
1644
+ /************************************************************
1645
+ * The badge node of a toolbar item — the little count pinned to
1646
+ * the corner of its icon (unread notifications, active alarms).
1647
+ *
1648
+ * ALWAYS rendered, hidden when empty, so the runtime setter has a
1649
+ * node to write into and does not have to rebuild the button.
1650
+ *
1651
+ * EMPTY IS 0, "" AND null ALIKE: a badge reading "0" is worse than
1652
+ * no badge — it draws the eye to say nothing. A count is only
1653
+ * worth a pixel when there IS something.
1654
+ *
1655
+ * Capped at 99+: the toolbar is a fixed-width row, and a four
1656
+ * digit pill pushes the items next to it off a phone screen.
1657
+ *
1658
+ * role="status" and NOT aria-hidden, deliberately. The button
1659
+ * carries an explicit aria-label, and an explicit label REPLACES
1660
+ * the element's content for a screen reader — so a badge inside
1661
+ * it would be silent. As its own live region it is both read and
1662
+ * announced when the count changes, which is the whole point of a
1663
+ * badge that means "something needs you".
1664
+ ************************************************************/
1665
+ function badge_text(value)
1666
+ {
1667
+ if(value === null || value === undefined || value === false) {
1668
+ return "";
1669
+ }
1670
+ if(typeof value === "number") {
1671
+ if(!isFinite(value) || value <= 0) {
1672
+ return "";
1673
+ }
1674
+ return (value > 99) ? "99+" : String(Math.floor(value));
1675
+ }
1676
+ return String(value).trim();
1677
+ }
1678
+
1679
+ function badge_node(value)
1680
+ {
1681
+ let text = badge_text(value);
1682
+ let attrs = {class: "yui-toolbar-badge", role: "status"};
1683
+ if(!text) {
1684
+ attrs.hidden = "hidden";
1685
+ }
1686
+ return ["span", attrs, text];
1687
+ }
1688
+
1644
1689
  /************************************************************
1645
1690
  * Renderer for the default ("action") item kind.
1646
1691
  ************************************************************/
@@ -1648,8 +1693,15 @@ function build_toolbar_action_item(gobj, it)
1648
1693
  {
1649
1694
  let children = [];
1650
1695
  if(!empty_string(it.icon)) {
1651
- children.push(["span", {class: "icon"},
1652
- ["i", {class: it.icon, "aria-hidden": "true"}]]);
1696
+ /* The badge is anchored to the ICON, not to the button: with a
1697
+ * label on (desktop) the button is wide, and a badge pinned to
1698
+ * its corner would float far from the glyph it counts. */
1699
+ children.push(["span", {class: "icon yui-toolbar-icon"},
1700
+ [
1701
+ ["i", {class: it.icon, "aria-hidden": "true"}],
1702
+ badge_node(it.badge)
1703
+ ]
1704
+ ]);
1653
1705
  }
1654
1706
  if(!empty_string(it.name)) {
1655
1707
  children.push(["span", {class: "yui-toolbar-item-label", i18n: it.name},
@@ -3368,6 +3420,57 @@ function yui_shell_set_toolbar_item_icon(shell_gobj, item_id, icon_class)
3368
3420
  }
3369
3421
  }
3370
3422
 
3423
+ /************************************************************
3424
+ * Set (or clear) a toolbar item's badge — the count pinned to
3425
+ * its icon.
3426
+ *
3427
+ * yui_shell_set_toolbar_item_badge(shell, "alarms", 3);
3428
+ * yui_shell_set_toolbar_item_badge(shell, "alarms", 0); // hidden
3429
+ *
3430
+ * This is the API that matters: a count is a RUNTIME fact. The
3431
+ * `badge` field of a toolbar item only seeds the first paint, and
3432
+ * an app whose number never changes did not need a badge.
3433
+ *
3434
+ * 0 / "" / null / false all clear it, and a number over 99 renders
3435
+ * "99+" — see badge_node(). Accepts a string for the states that
3436
+ * are not counts ("!", "…").
3437
+ *
3438
+ * Silent no-op on an unknown item id: a toolbar that does not
3439
+ * declare the item (an app without alarms) must not be an error
3440
+ * at every tick of whatever feeds the number.
3441
+ ************************************************************/
3442
+ function yui_shell_set_toolbar_item_badge(shell_gobj, item_id, value)
3443
+ {
3444
+ if(!shell_gobj || !is_gobj(shell_gobj) || empty_string(item_id)) {
3445
+ return;
3446
+ }
3447
+ let $container = gobj_read_attr(shell_gobj, "$container");
3448
+ if(!$container) {
3449
+ return;
3450
+ }
3451
+ let $badge = $container.querySelector(
3452
+ `[data-toolbar-item-id="${item_id}"] .yui-toolbar-badge`
3453
+ );
3454
+ if(!$badge) {
3455
+ return;
3456
+ }
3457
+
3458
+ let text = badge_text(value);
3459
+ if(!text) {
3460
+ $badge.textContent = "";
3461
+ $badge.setAttribute("hidden", "hidden");
3462
+ return;
3463
+ }
3464
+ /* Do not touch the DOM when nothing changed: role="status" is a
3465
+ * live region, and rewriting the same number would have a screen
3466
+ * reader announce it again on every tick. */
3467
+ if($badge.textContent === text && !$badge.hasAttribute("hidden")) {
3468
+ return;
3469
+ }
3470
+ $badge.textContent = text;
3471
+ $badge.removeAttribute("hidden");
3472
+ }
3473
+
3371
3474
  /************************************************************
3372
3475
  * Programmatic close of any open toolbar dropdown. Useful for
3373
3476
  * external triggers (e.g. EV_LOGOUT firing from elsewhere) that
@@ -3414,6 +3517,7 @@ export {
3414
3517
  yui_shell_language_changed,
3415
3518
  yui_shell_set_connection_state,
3416
3519
  yui_shell_set_toolbar_item_icon,
3520
+ yui_shell_set_toolbar_item_badge,
3417
3521
  yui_shell_close_dropdown,
3418
3522
  yui_shell_set_submenu
3419
3523
  };
@@ -0,0 +1,59 @@
1
+ /***********************************************************************
2
+ * form_toolbar_plan.js
3
+ *
4
+ * Which buttons the C_YUI_FORM toolbar shows, and where.
5
+ *
6
+ * Pure, so it can be tested without a DOM: the gclass turns the
7
+ * plan into elements, this decides the plan.
8
+ *
9
+ * Copyright (c) 2026, ArtGins.
10
+ * All Rights Reserved.
11
+ ***********************************************************************/
12
+
13
+ /* Save/undo/clear sit on the left of the bar, copy/paste on the right.
14
+ * That split is what the layout has always drawn, and keeping it means
15
+ * a caller who drops one group does not leave a hole in the middle. */
16
+ const LEFT_BUTTONS = ["save", "undo", "clear"];
17
+ const RIGHT_BUTTONS = ["copy", "paste"];
18
+
19
+ const DEFAULT_TOOLBAR = ["save", "undo", "clear", "copy", "paste"];
20
+
21
+
22
+ /***************************************************************
23
+ * plan_toolbar(wanted) -> {left, right, unknown}
24
+ *
25
+ * wanted an array of button names, in the order asked for.
26
+ * Anything that is not an array means "the default
27
+ * five", so a caller that sets nothing keeps exactly
28
+ * the toolbar this gclass always had.
29
+ * [] means no toolbar at all.
30
+ *
31
+ * unknown names that match no button. They are reported and
32
+ * NOT silently dropped: a typo in the list would
33
+ * otherwise remove the save button with no trace.
34
+ ***************************************************************/
35
+ function plan_toolbar(wanted)
36
+ {
37
+ if(!Array.isArray(wanted)) {
38
+ wanted = DEFAULT_TOOLBAR;
39
+ }
40
+
41
+ let left = [];
42
+ let right = [];
43
+ let unknown = [];
44
+
45
+ for(let name of wanted) {
46
+ if(LEFT_BUTTONS.indexOf(name) >= 0) {
47
+ left.push(name);
48
+ } else if(RIGHT_BUTTONS.indexOf(name) >= 0) {
49
+ right.push(name);
50
+ } else {
51
+ unknown.push(name);
52
+ }
53
+ }
54
+
55
+ return {left, right, unknown};
56
+ }
57
+
58
+
59
+ export {plan_toolbar, DEFAULT_TOOLBAR};
@@ -0,0 +1,54 @@
1
+ import {describe, it, expect} from "vitest";
2
+ import {plan_toolbar, DEFAULT_TOOLBAR} from "./form_toolbar_plan.js";
3
+
4
+ describe("plan_toolbar", () => {
5
+
6
+ it("says nothing -> the five buttons this gclass always had", () => {
7
+ const p = plan_toolbar(undefined);
8
+ expect(p.left).toEqual(["save", "undo", "clear"]);
9
+ expect(p.right).toEqual(["copy", "paste"]);
10
+ expect(p.unknown).toEqual([]);
11
+ expect(DEFAULT_TOOLBAR.length).toBe(5);
12
+ });
13
+
14
+ it("a non-array is not an empty toolbar: it is the default", () => {
15
+ /* Reading a JSON attr that was never set must not silently
16
+ * leave a form with no save button. */
17
+ for(const v of [null, "save", 0, {}]) {
18
+ expect(plan_toolbar(v).left).toContain("save");
19
+ }
20
+ });
21
+
22
+ it("[] leaves no toolbar at all", () => {
23
+ const p = plan_toolbar([]);
24
+ expect(p.left).toEqual([]);
25
+ expect(p.right).toEqual([]);
26
+ expect(p.unknown).toEqual([]);
27
+ });
28
+
29
+ it("one action: a dialog asks for save only", () => {
30
+ const p = plan_toolbar(["save"]);
31
+ expect(p.left).toEqual(["save"]);
32
+ expect(p.right).toEqual([]);
33
+ });
34
+
35
+ it("keeps the order asked for inside each side", () => {
36
+ const p = plan_toolbar(["clear", "save", "paste", "copy"]);
37
+ expect(p.left).toEqual(["clear", "save"]);
38
+ expect(p.right).toEqual(["paste", "copy"]);
39
+ });
40
+
41
+ it("an unknown name is REPORTED, never silently dropped", () => {
42
+ /* A typo here would otherwise remove a button and leave no
43
+ * trace of why it is missing. */
44
+ const p = plan_toolbar(["save", "sav", "undo"]);
45
+ expect(p.left).toEqual(["save", "undo"]);
46
+ expect(p.unknown).toEqual(["sav"]);
47
+ });
48
+
49
+ it("drops one whole side without disturbing the other", () => {
50
+ const p = plan_toolbar(["save", "undo", "clear"]);
51
+ expect(p.left).toEqual(["save", "undo", "clear"]);
52
+ expect(p.right).toEqual([]);
53
+ });
54
+ });