@ti-engine/web-framework 1.19.0 → 1.19.1

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 (38) hide show
  1. package/.env +4 -4
  2. package/CHANGELOG.md +362 -353
  3. package/README.md +73 -73
  4. package/bin/build/post-install.js +18 -18
  5. package/bin/localization/web-server-labels.json +27 -27
  6. package/bin/static/.well-known/appspecific/com.chrome.devtools.json +5 -5
  7. package/bin/static/fragments/components/component-notification-bar.html +21 -21
  8. package/bin/static/fragments/components/component-sidebar.html +33 -33
  9. package/bin/static/fragments/components/component-tooltip.html +10 -10
  10. package/bin/static/fragments/components/component-topbar.html +5 -5
  11. package/bin/static/fragments/frame-administration.html +2 -2
  12. package/bin/static/fragments/frame-application.html +18 -18
  13. package/bin/static/fragments/frame-dashboard.html +2 -2
  14. package/bin/static/fragments/frame-login.html +119 -119
  15. package/bin/static/fragments/frame-not-found.html +2 -2
  16. package/bin/static/fragments/frame-profile.html +2 -2
  17. package/bin/static/index.html +22 -22
  18. package/bin/static/scripts/ti-charts.js +1591 -1591
  19. package/bin/static/scripts/ti-framework.css +3194 -3194
  20. package/bin/static/scripts/ti-framework.js +1427 -1427
  21. package/bin/static/scripts/ti-theme-black-glass.css +216 -216
  22. package/bin/static/scripts/ti-theme-daylight.css +87 -87
  23. package/bin/web-app-manager.js +663 -663
  24. package/bin/web-server.js +936 -936
  25. package/bin/web-server.json +48 -48
  26. package/components/admin-config-handlers.js +92 -92
  27. package/components/auth-manager.js +441 -441
  28. package/components/authorization.js +135 -135
  29. package/components/config-change-notifier.js +98 -98
  30. package/components/config-registry.js +260 -260
  31. package/components/config-service.js +360 -360
  32. package/components/config-store.js +246 -246
  33. package/components/definitions.types.js +26 -26
  34. package/components/session-store.js +110 -110
  35. package/components/user.js +132 -132
  36. package/components/web-config-env.js +85 -85
  37. package/components/web-handlers.js +800 -800
  38. package/package.json +76 -67
@@ -1,1427 +1,1427 @@
1
- /*
2
- * The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
3
- * Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
4
- * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
5
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
6
- * You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
7
- */
8
-
9
- /**
10
- * @typedef {Object} SidebarFlyoutConfig
11
- * @property {string} menuTitle
12
- * @property {number} [offset]
13
- * @property {string} [icon]
14
- * @property {string} [placement]
15
- * @property {boolean} [fixed]
16
- * @property {Array<SidebarFlyoutButtonConfig>} [buttonConfigs]
17
- */
18
-
19
- /**
20
- * @typedef {Object} SidebarFlyoutButtonConfig
21
- * @property {string} title
22
- * @property {string} icon
23
- * @property {Object} action
24
- * @property {string} action.href
25
- * @property {string} [action.method]
26
- * @property {string} action.target
27
- * @property {string} action.swap
28
- */
29
-
30
- /**
31
- * Default configuration applied as a base when an application-supplied flyout config is merged into the store.
32
- *
33
- * @constant
34
- * @type {SidebarFlyoutConfig}
35
- */
36
- const defaultSidebarFlyoutConfig = {
37
- menuTitle: "Menu",
38
- placement: "right-start",
39
- offset: 10,
40
- fixed: true,
41
- buttonConfigs: []
42
- };
43
-
44
- /**
45
- * Returns a configuration object for the toolbox.
46
- *
47
- * @method
48
- * @returns {Object}
49
- * @public
50
- */
51
- const configureToolbox = () => {
52
- /**
53
- * @typedef {Object} TiToolbox
54
- */
55
- return {
56
-
57
- /**
58
- * Used to clamp a position to a box.
59
- *
60
- * @method
61
- * @param {number} x
62
- * @param {number} y
63
- * @param {number} w
64
- * @param {number} h
65
- * @param {Object} box
66
- * @param {number} [edgePadding=0]
67
- * @returns {{x: number, y: number}}
68
- * @public
69
- */
70
- clampToBox( x, y, w, h, box, edgePadding = 0 ) {
71
- // If the box has an offset (visualViewport on some platforms), normalize appropriately.
72
- // For fixed elements, x/y are in viewport coordinates; for absolute, in page coordinates.
73
- const minX = box.left + edgePadding;
74
- const minY = box.top + edgePadding;
75
- const maxX = box.left + box.width - w - edgePadding;
76
- const maxY = box.top + box.height - h - edgePadding;
77
- return {
78
- x: Math.min( Math.max( x, minX ), Math.max( minX, maxX ) ),
79
- y: Math.min( Math.max( y, minY ), Math.max( minY, maxY ) )
80
- };
81
- },
82
-
83
- /**
84
- * Used to deep-freeze an object.
85
- *
86
- * @method
87
- * @param {Object} object
88
- * @param {WeakSet} [seen]
89
- * @returns {Object}
90
- * @public
91
- */
92
- deepFreeze( object, seen = new WeakSet() ) {
93
- if ( object === null || typeof object !== "object" || seen.has( object ) ) {
94
- return object;
95
- } else {
96
- seen.add( object );
97
- Object.keys( object ).forEach( ( key ) => {
98
- this.deepFreeze( object[ key ], seen );
99
- } );
100
- return Object.freeze( object );
101
- }
102
- },
103
-
104
- /**
105
- * Used to perform a deep merge of two objects. 'base' is the object that will be modified.
106
- * <br/>
107
- * NOTE: If 'structuredClone' is not available, fall back to JSON.parse/JSON.stringify. The later will not preserve non-JSON-serializable
108
- * values (functions, undefined, symbols) and will convert Dates to strings, RegExp to empty objects, etc.
109
- *
110
- * @method
111
- * @param {Object} base
112
- * @param {Object} source
113
- * @returns {Object}
114
- * @public
115
- */
116
- deepMerge( base, source ) {
117
- if ( !this.isPlainObject( base ) || !this.isPlainObject( source ) ) {
118
- return this.structuredClone( source );
119
- } else {
120
- const out = { ...base };
121
- for ( const key of Object.keys( source ) ) {
122
- const b = base[ key ];
123
- const s = source[ key ];
124
-
125
- if ( Array.isArray( s ) ) {
126
- out[ key ] = s.slice();
127
- } else if ( this.isPlainObject( s ) && this.isPlainObject( b ) ) {
128
- out[ key ] = this.deepMerge( b, s );
129
- } else if ( this.isPlainObject( s ) ) {
130
- out[ key ] = this.deepMerge( {}, s );
131
- } else {
132
- out[ key ] = s;
133
- }
134
- }
135
- return out;
136
- }
137
- },
138
-
139
- /**
140
- * Used to format a system string date value into a display string.
141
- *
142
- * @method
143
- * @param {string} value
144
- * @param {string} placeholder
145
- * @returns {string}
146
- * @public
147
- */
148
- formatDate( value, placeholder = "" ) {
149
- if ( !value ) return placeholder;
150
- const normalized = /^\d{4}-\d{2}-\d{2}$/.test( value )
151
- ? `${ value }T00:00:00`
152
- : value;
153
- const date = new Date( normalized );
154
- return this.isValidDate( date ) ? date.toLocaleDateString() : placeholder;
155
- },
156
-
157
- /**
158
- * Used to get a cookie value by name.
159
- *
160
- * @method
161
- * @param {string} name
162
- * @returns {string}
163
- * @public
164
- */
165
- getCookie( name ) {
166
- const cookie = document.cookie.match( new RegExp( "(?:^|; )" + name.replace( /[$()*+.?[\]\\^{}|]/g, "\\$&" ) + "=([^;]*)" ) );
167
- return cookie ? decodeURIComponent( cookie[ 1 ] ) : "";
168
- },
169
-
170
- /**
171
- * Used to get the Monday of the week for a given date.
172
- *
173
- * @method
174
- * @param {Date} date
175
- * @returns {Date}
176
- * @public
177
- */
178
- getMonday( date ) {
179
- const d = new Date( date );
180
- const day = d.getDay();
181
- d.setDate( d.getDate() + ( day === 0 ? -6 : 1 - day ) );
182
- d.setHours( 0, 0, 0, 0 );
183
- return d;
184
- },
185
-
186
- /**
187
- * Used to get a URL query parameter value by name.
188
- *
189
- * @method
190
- * @param {string} name
191
- * @returns {string|null}
192
- * @public
193
- */
194
- getUrlParam( name ) {
195
- return new URLSearchParams( window.location.search ).get( name );
196
- },
197
-
198
- /**
199
- * Used to get the visible box of the document.
200
- *
201
- * @method
202
- * @param {boolean} isFixed
203
- * @returns {Object}
204
- * @public
205
- */
206
- getVisibleBox( isFixed ) {
207
- // Fixed coordinates are viewport-based (top/left = 0/0):
208
- if ( isFixed ) {
209
- if ( window.visualViewport ) {
210
- const viewport = window.visualViewport;
211
- return {
212
- left: viewport.offsetLeft, // CSS px, where the viewport begins relative to a layout viewport
213
- top: viewport.offsetTop,
214
- width: viewport.width,
215
- height: viewport.height,
216
- pageLeft: viewport.pageLeft, // document coords
217
- pageTop: viewport.pageTop
218
- };
219
- } else {
220
- return { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight, pageLeft: window.scrollX, pageTop: window.scrollY };
221
- }
222
- } else {
223
- // Absolute coordinates are document-based (top/left = scroll position):
224
- return {
225
- left: window.scrollX,
226
- top: window.scrollY,
227
- width: document.documentElement.clientWidth,
228
- height: document.documentElement.clientHeight,
229
- pageLeft: window.scrollX,
230
- pageTop: window.scrollY
231
- };
232
- }
233
- },
234
-
235
- /**
236
- * Used to check if a value is a plain object.
237
- *
238
- * @method
239
- * @param {*} value
240
- * @returns {boolean}
241
- * @public
242
- */
243
- isPlainObject( value ) {
244
- return Object.prototype.toString.call( value ) === "[object Object]";
245
- },
246
-
247
- /**
248
- * Used to check if a value is a valid Date object.
249
- *
250
- * @method
251
- * @param {*} value
252
- * @returns {boolean}
253
- * @public
254
- */
255
- isValidDate( value ) {
256
- return value instanceof Date && !isNaN( value );
257
- },
258
-
259
- /**
260
- * Used to perform a structured clone operation.
261
- * <br/>
262
- * NOTE: If 'structuredClone' is not available — or throws DataCloneError on a reactive Proxy
263
- * (Alpine/Vue reactivity wraps assigned objects in Proxies that the browser's structured-clone
264
- * algorithm can refuse) — fall back to JSON.parse/JSON.stringify. The fallback will not preserve
265
- * non-JSON-serializable values (functions, undefined, symbols, Dates, RegExp).
266
- *
267
- * @method
268
- * @param {Object} value
269
- * @param {Object} [options]
270
- * @returns {Object}
271
- * @public
272
- */
273
- structuredClone( value, options ) {
274
- if ( typeof structuredClone === "function" ) {
275
- try {
276
- return structuredClone( value, options );
277
- } catch {
278
- return JSON.parse( JSON.stringify( value ) );
279
- }
280
- }
281
- return JSON.parse( JSON.stringify( value ) );
282
- },
283
-
284
- /**
285
- * Used to convert a Date object to an ISO date string (YYYY-MM-DD).
286
- *
287
- * @method
288
- * @param {Date} date
289
- * @returns {string}
290
- * @public
291
- */
292
- toDateString( date ) {
293
- const y = date.getFullYear();
294
- const m = String( date.getMonth() + 1 ).padStart( 2, "0" );
295
- const d = String( date.getDate() ).padStart( 2, "0" );
296
- return `${ y }-${ m }-${ d }`;
297
- },
298
-
299
- /**
300
- * Deterministically maps a seed value (e.g. employeeID) to an HSL color string.
301
- * The result is stable across sessions and consistent for the same seed.
302
- *
303
- * @method
304
- * @param {string|number} id
305
- * @param {string} name
306
- * @returns {Object} HSL color string
307
- * @public
308
- */
309
- generateAvatarStyle( id, name ) {
310
- const seed = String( id ?? "" ) + "\x00" + String( name ?? "" );
311
- const djb2 = ( s ) => {
312
- let h = 5381;
313
- for ( let i = 0; i < s.length; i++ ) {
314
- h = ( ( h << 5 ) + h + s.charCodeAt( i ) ) | 0;
315
- }
316
- return Math.abs( h );
317
- };
318
- const h0 = djb2( seed + "A" ) % 360;
319
- const h1 = djb2( seed + "B" ) % 360;
320
- const h2 = djb2( seed + "C" ) % 360;
321
- return {
322
- "--avatar-bg": `linear-gradient( 135deg, hsl( ${ h0 }, 70%, 48% ) 0%, hsl( ${ h1 }, 62%, 54% ) 50%, hsl( ${ h2 }, 65%, 44% ) 100% )`
323
- };
324
- }
325
-
326
- };
327
- };
328
-
329
- /**
330
- * Returns a configuration object for the sidebar flyout component "component-sidebar-flyout.html".
331
- * <br/>
332
- * The component looks up its menu definition from the "tiComponentsConfig" Alpine store using the supplied
333
- * configuration key. This allows each application to register its own flyout menus via the application
334
- * configuration response without modifying the framework.
335
- *
336
- * @method
337
- * @param {string} configKey Key into the "tiComponentsConfig" store identifying the flyout configuration to use.
338
- * @returns {Object}
339
- * @public
340
- */
341
- const configureComponentSidebarFlyout = ( configKey ) => {
342
- const tiToolbox = Alpine.store( "tiToolbox" );
343
- const TI_EVENT_CLOSE_ALL_FLYOUT = "ti-close-all-flyout";
344
-
345
- /**
346
- * @typedef {Object} TiSidebarFlyout
347
- */
348
- return {
349
- configKey: configKey || "",
350
- isOpen: false,
351
-
352
- get config() {
353
- const store = Alpine.store( "tiComponentsConfig" ) || {};
354
- return store[ this.configKey ] || defaultSidebarFlyoutConfig;
355
- },
356
- get menuTitle() {
357
- return this.config.menuTitle || "";
358
- },
359
- get placement() {
360
- return this.config.placement || "right-start";
361
- },
362
- get side() {
363
- if ( this.config.side ) return this.config.side;
364
- return this.placement.split( "-" )[ 0 ] || "right";
365
- },
366
- get align() {
367
- if ( this.config.align ) return this.config.align;
368
- return this.placement.split( "-" )[ 1 ] || "start";
369
- },
370
- get offset() {
371
- return ( typeof this.config.offset === "number" ) ? this.config.offset : 10;
372
- },
373
- get fixed() {
374
- return ( typeof this.config.fixed === "boolean" ) ? this.config.fixed : true;
375
- },
376
- get icon() {
377
- return this.config.icon || "";
378
- },
379
- get buttonConfigs() {
380
- return Array.isArray( this.config.buttonConfigs ) ? this.config.buttonConfigs : [];
381
- },
382
-
383
- /**
384
- * Used to initialize the sidebar flyout component.
385
- *
386
- * @method
387
- * @public
388
- */
389
- init() {
390
- this._reflow = this.reposition.bind( this );
391
- this._close = this.close.bind( this );
392
- window.addEventListener( "resize", this._reflow, { passive: true } );
393
- window.addEventListener( "scroll", this._reflow, { passive: true } );
394
- window.addEventListener( TI_EVENT_CLOSE_ALL_FLYOUT, this._close );
395
- },
396
-
397
- /**
398
- * Used to destroy the sidebar flyout component.
399
- *
400
- * @method
401
- * @public
402
- */
403
- destroy() {
404
- window.removeEventListener( "resize", this._reflow );
405
- window.removeEventListener( "scroll", this._reflow );
406
- window.removeEventListener( TI_EVENT_CLOSE_ALL_FLYOUT, this._close );
407
- },
408
-
409
- /**
410
- * Used to toggle the sidebar flyout panel.
411
- *
412
- * @method
413
- * @public
414
- */
415
- toggle() {
416
- this.isOpen ? this.close() : this.open();
417
- },
418
-
419
- /**
420
- * Used to open the sidebar flyout panel.
421
- *
422
- * @method
423
- * @public
424
- */
425
- open() {
426
- if ( !this.isOpen ) {
427
- window.dispatchEvent( new CustomEvent( TI_EVENT_CLOSE_ALL_FLYOUT ) );
428
- this.isOpen = true;
429
- this.$nextTick( () => {
430
- this.setAria();
431
- this.reposition();
432
- // The menu items bind their hx-* attributes via Alpine (x-bind), which HTMX does not pick up on its
433
- // initial document scan — so without this the buttons close the flyout but never fire the request.
434
- // Processing the panel attaches HTMX behaviour to the now-rendered buttons (idempotent on re-open).
435
- if ( window.htmx && this.$refs.flyoutPanel ) {
436
- window.htmx.process( this.$refs.flyoutPanel );
437
- }
438
- } );
439
- }
440
- },
441
-
442
- /**
443
- * Used to close the flyout panel.
444
- *
445
- * @method
446
- * @public
447
- */
448
- close() {
449
- if ( this.isOpen ) {
450
- this.isOpen = false;
451
- this.$nextTick( () => this.setAria() );
452
- }
453
- },
454
-
455
- /**
456
- * Used to set the ARIA attributes for the flyout button.
457
- *
458
- * @method
459
- * @public
460
- */
461
- setAria() {
462
- if ( !this.$refs.flyoutButton ) return;
463
- this.$refs.flyoutButton.setAttribute( "aria-expanded", String( this.isOpen ) );
464
- },
465
-
466
- /**
467
- * Used to reposition the flyout panel.
468
- *
469
- * @method
470
- * @public
471
- */
472
- reposition() {
473
- if ( !this.isOpen || !this.$refs.flyoutButton || !this.$refs.flyoutPanel ) return;
474
-
475
- const flyoutButton = this.$refs.flyoutButton;
476
- const flyoutPanel = this.$refs.flyoutPanel;
477
-
478
- const rect = flyoutButton.getBoundingClientRect();
479
- const scrollX = window.scrollX;
480
- const scrollY = window.scrollY;
481
-
482
- const pw = flyoutPanel.scrollWidth;
483
- const ph = flyoutPanel.scrollHeight;
484
-
485
- let top = rect.top + ( this.fixed ? 0 : scrollY );
486
- let left = rect.left + ( this.fixed ? 0 : scrollX );
487
-
488
- const side = this.side;
489
- const align = this.align;
490
- if ( side === "right" ) {
491
- left = rect.right + ( this.fixed ? 0 : scrollX ) + this.offset;
492
- } else if ( side === "left" ) {
493
- left = rect.left + ( this.fixed ? 0 : scrollX ) - pw - this.offset;
494
- } else if ( side === "bottom" ) {
495
- top = rect.bottom + ( this.fixed ? 0 : scrollY ) + this.offset;
496
- } else if ( side === "top" ) {
497
- top = rect.top + ( this.fixed ? 0 : scrollY ) - ph - this.offset;
498
- }
499
- // Alignment for horizontal sides (left/right) adjusts the vertical position:
500
- if ( side === "left" || side === "right" ) {
501
- if ( align === "start" ) {
502
- top = rect.top + ( this.fixed ? 0 : scrollY );
503
- } else if ( align === "center" ) {
504
- top = rect.top + ( this.fixed ? 0 : scrollY ) + ( rect.height - ph ) / 2;
505
- } else if ( align === "end" ) {
506
- top = rect.bottom + ( this.fixed ? 0 : scrollY ) - ph;
507
- }
508
- }
509
- // Alignment for vertical sides (top/bottom) adjusts the horizontal position:
510
- else if ( side === "top" || side === "bottom" ) {
511
- if ( align === "start" ) {
512
- left = rect.left + ( this.fixed ? 0 : scrollX );
513
- } else if ( align === "center" ) {
514
- left = rect.left + ( this.fixed ? 0 : scrollX ) + ( rect.width - pw ) / 2;
515
- } else if ( align === "end" ) {
516
- left = rect.right + ( this.fixed ? 0 : scrollX ) - pw;
517
- }
518
- }
519
-
520
- const box = tiToolbox.getVisibleBox( this.fixed );
521
- const coords = tiToolbox.clampToBox( left, top, pw, ph, box, 10 );
522
-
523
- flyoutPanel.style.position = this.fixed ? "fixed" : "absolute";
524
- flyoutPanel.style.top = Math.round( coords.y ) + "px";
525
- flyoutPanel.style.left = Math.round( coords.x ) + "px";
526
- }
527
-
528
- };
529
- };
530
-
531
- /**
532
- * Returns a configuration object for the sidebar navigation component.
533
- * The screen-to-active-key mapping is read at runtime from tiApplication.configuration.sidebarNavMapping,
534
- * allowing each application to define its own mapping without changing the framework.
535
- *
536
- * @method
537
- * @returns {Object}
538
- * @public
539
- */
540
- const configureSidebarNav = () => {
541
- const tiApplication = Alpine.store( "tiApplication" );
542
-
543
- const getActiveFromScreen = ( screen ) => {
544
- const mapping = ( tiApplication.configuration && tiApplication.configuration.sidebarNavMapping ) || {};
545
- return mapping[ screen ] || "";
546
- };
547
-
548
- const getActiveFromUrl = () => {
549
- const match = window.location.pathname.match( /^\/app\/([\w-]+)/ );
550
- return match ? getActiveFromScreen( match[ 1 ] ) : "";
551
- };
552
-
553
- return {
554
- active: "",
555
-
556
- init() {
557
- this.$watch( () => tiApplication.isInitialized, ( isInitialized ) => {
558
- if ( isInitialized ) {
559
- const fromUrl = getActiveFromUrl();
560
- if ( fromUrl ) {
561
- this.active = fromUrl;
562
- }
563
- }
564
- } );
565
- this.$watch( () => tiApplication.currentScreen, ( screen ) => {
566
- const mapped = getActiveFromScreen( screen );
567
- if ( mapped ) {
568
- this.active = mapped;
569
- }
570
- } );
571
- if ( tiApplication.isInitialized ) {
572
- const fromUrl = getActiveFromUrl();
573
- if ( fromUrl ) {
574
- this.active = fromUrl;
575
- }
576
- }
577
- },
578
-
579
- navigate( activeKey, screen ) {
580
- this.active = activeKey;
581
- tiApplication.openScreen( screen );
582
- }
583
- };
584
- };
585
-
586
- /**
587
- * Returns a configuration object for the topbar component "component-topbar.html".
588
- *
589
- * @method
590
- * @returns {Object}
591
- * @public
592
- */
593
- const configureComponentTopbar = () => {
594
- /**
595
- * @typedef {Object} TiTopbar
596
- */
597
- return {
598
-
599
- screenTitle: "",
600
-
601
- init() {
602
- const tiApplication = Alpine.store( "tiApplication" );
603
-
604
- const updateTitle = () => {
605
- const screen = ( tiApplication && tiApplication.currentScreen ) || "";
606
- const override = ( tiApplication && tiApplication.screenTitleOverride ) || "";
607
- const title = override || ( screen ? tiApplication.getLabel( `interface.topbar.${ screen }`, "" ) : "" );
608
- this.screenTitle = title;
609
- if ( title ) {
610
- document.title = title;
611
- }
612
- };
613
-
614
- // If the htmx:afterSwap listener was registered after the initial swap already fired,
615
- // currentScreen won't be set yet — fall back to reading the URL that hx-push-url already updated:
616
- if ( tiApplication && !tiApplication.currentScreen ) {
617
- const match = window.location.pathname.match( /^\/app\/([\w-]+)/ );
618
- if ( match ) {
619
- tiApplication.setCurrentScreen( match[ 1 ] );
620
- }
621
- }
622
-
623
- updateTitle();
624
- this.$watch( () => tiApplication.currentScreen, updateTitle );
625
- this.$watch( () => tiApplication.screenTitleOverride, updateTitle );
626
- this.$watch( () => tiApplication.isInitialized, updateTitle );
627
- }
628
-
629
- };
630
- };
631
-
632
- /**
633
- * Returns a configuration object for the notification component "component-notification-bar.html".
634
- *
635
- * @method
636
- * @returns {Object}
637
- * @public
638
- */
639
- const configureComponentNotificationBar = () => {
640
- /**
641
- * @typedef {Object} TiNotificationBar
642
- */
643
- return {
644
- notifications: [],
645
- timers: {},
646
-
647
- /**
648
- * Used to add a notification to the notification bar.
649
- *
650
- * @method
651
- * @param {Object} notification
652
- * @public
653
- */
654
- add( notification ) {
655
- if ( notification && notification.id ) {
656
- this.remove( notification.id );
657
- this.notifications.push( notification );
658
- if ( typeof notification.timeout === "number" && notification.timeout > 0 ) {
659
- this.timers[ notification.id ] = setTimeout( () => this.remove( notification.id ), notification.timeout );
660
- }
661
- }
662
- },
663
-
664
- /**
665
- * Used to remove a notification by its ID.
666
- *
667
- * @method
668
- * @param {string} id
669
- * @public
670
- */
671
- remove( id ) {
672
- if ( id ) {
673
- this.notifications = this.notifications.filter( notification => notification.id !== id );
674
- if ( this.timers[ id ] ) {
675
- clearTimeout( this.timers[ id ] );
676
- delete this.timers[ id ];
677
- }
678
- }
679
- },
680
-
681
- /**
682
- * Used to clear all notifications.
683
- *
684
- * @method
685
- * @public
686
- */
687
- destroy() {
688
- Object.keys( this.timers ).forEach( ( id ) => clearTimeout( this.timers[ id ] ) );
689
- this.timers = {};
690
- this.notifications = [];
691
- }
692
-
693
- };
694
- };
695
-
696
- /**
697
- * Returns a configuration object for the tooltip component "component-tooltip.html".
698
- *
699
- * @method
700
- * @returns {Object}
701
- * @public
702
- */
703
- const configureComponentTooltip = () => {
704
- /**
705
- * @typedef {Object} TiTooltip
706
- */
707
- return {
708
- isVisible: false,
709
- text: "This is a default tooltip. To change that, define a 'x-bind:data-ti-tooltip' attribute in the target element to set the tooltip text.",
710
-
711
- /**
712
- * Used to get the tooltip message from the target element.
713
- *
714
- * @method
715
- * @param {HTMLElement} target
716
- * @returns {string}
717
- * @public
718
- */
719
- getTooltipMessage( target ) {
720
- if ( !target || typeof target.closest !== "function" ) return "";
721
- const selector = "[data-ti-tooltip], [data-tooltip]";
722
- const element = target.closest( selector );
723
- if ( !element || !this.$el.contains( element ) ) return "";
724
- return element.getAttribute( "data-ti-tooltip" ) || element.getAttribute( "data-tooltip" ) || "";
725
- },
726
-
727
- /**
728
- * Used to handle mouse enter events on the target element.
729
- *
730
- * @method
731
- * @param {MouseEvent} event
732
- * @public
733
- */
734
- handleEnter( event ) {
735
- const message = this.getTooltipMessage( event?.target );
736
- if ( message ) {
737
- this.showTooltip( message );
738
- }
739
- },
740
-
741
- /**
742
- * Used to handle mouse leave events on the target element.
743
- *
744
- * @method
745
- * @param {MouseEvent} event
746
- * @public
747
- */
748
- handleLeave( event ) {
749
- const related = event?.relatedTarget;
750
- if ( related && this.$el.contains( related ) ) return;
751
- this.hideTooltip();
752
- },
753
-
754
- /**
755
- * Used to show the tooltip.
756
- *
757
- * @method
758
- * @param {string} message
759
- * @public
760
- */
761
- showTooltip( message ) {
762
- this.text = message;
763
- this.isVisible = true;
764
- },
765
-
766
- /**
767
- * Used to hide the tooltip.
768
- *
769
- * @method
770
- * @public
771
- */
772
- hideTooltip() {
773
- this.isVisible = false;
774
- }
775
-
776
- };
777
- }
778
-
779
- /**
780
- * Returns a configuration object for the application management instance.
781
- *
782
- * @method
783
- * @returns {Object}
784
- * @public
785
- */
786
- const configureApplication = () => {
787
- const STORAGE_KEY_COLLAPSED = "ti-sidebar-collapsed";
788
- const STORAGE_KEY_THEME = "ti-theme";
789
- const DEFAULT_THEME = "daylight";
790
-
791
- const tiToolbox = Alpine.store( "tiToolbox" );
792
-
793
- /**
794
- * Used to extract a label from a nested labels object.
795
- *
796
- * @method
797
- * @param {Object} labels
798
- * @param {String[]} keys
799
- * @param {String} fallback
800
- * @returns {String}
801
- * @private
802
- */
803
- const extractLabel = ( labels, keys, fallback ) => {
804
- let key = keys.shift();
805
- if ( labels && typeof labels === "object" && key && Object.prototype.hasOwnProperty.call( labels, key ) ) {
806
- const value = labels[ key ];
807
- if ( typeof value === "string" ) {
808
- return keys.length === 0 ? value : fallback;
809
- }
810
- if ( value && typeof value === "object" ) {
811
- return extractLabel( value, keys, fallback );
812
- }
813
- }
814
- return fallback;
815
- };
816
-
817
- /**
818
- * @typedef {Object} TiApplication
819
- */
820
- return {
821
- isInitialized: false,
822
- user: null,
823
- configuration: {},
824
- currentScreen: "",
825
- topbarSubtitle: "",
826
- screenTitleOverride: "",
827
- topbarPrimaryCta: null,
828
- notificationIDCounter: 1,
829
- requestControllers: new Map(),
830
- collapsed: false,
831
- theme: DEFAULT_THEME,
832
-
833
- /**
834
- * Used to initialize the web application.
835
- */
836
- init() {
837
- document.addEventListener( "ti:error", ( event ) => {
838
- this.notify( this.formatException( event.detail ) );
839
- } );
840
-
841
- try {
842
- const savedCollapsed = localStorage.getItem( STORAGE_KEY_COLLAPSED );
843
- if ( savedCollapsed !== null ) this.collapsed = savedCollapsed === "true";
844
-
845
- const savedTheme = localStorage.getItem( STORAGE_KEY_THEME );
846
- if ( savedTheme ) this.theme = savedTheme;
847
- } catch {
848
- // localStorage may be unavailable (private browsing, security policy).
849
- }
850
- this._applyTheme( this.theme );
851
-
852
- // Use application settings to configure the application at load-time:
853
- this.sendRequest( "/app/config" ).then( ( result ) => {
854
- this.configuration = result?.data || {};
855
- this._mergeComponentsConfig( this.configuration?.componentsConfig );
856
- return ( this.configuration?.auth?.isAuthenticated ) ? this.sendRequest( "/me" ) : {};
857
- } ).then( ( result ) => {
858
- this.user = result?.data?.user || null;
859
- this.isInitialized = true;
860
- } ).catch( ( error ) => {
861
- if ( error?.name === "AbortError" || error?.isAborted ) {
862
- return;
863
- }
864
-
865
- this.user = null;
866
- this.isInitialized = false;
867
- const formatted = this.formatException( error );
868
- this.notify( { message: this.getLabel( "error.application.init-failed" ) + " " + formatted.message, details: formatted.details } );
869
- } );
870
- },
871
-
872
- /**
873
- * Used to send a request to the application server.
874
- *
875
- * @method
876
- * @param {string} url
877
- * @param {"POST"|"GET"|"PUT"|"DELETE"} [method="GET"]
878
- * @param {Object} [data=null]
879
- * @returns {Promise<Object>}
880
- * @public
881
- */
882
- sendRequest( url, method = "GET", data = null ) {
883
- return new Promise( ( resolve, reject ) => {
884
- const xsrf = tiToolbox.getCookie( "ti-xsrf-token" ) || "";
885
- const normalizedMethod = String( method || "GET" ).toUpperCase();
886
- const requestKey = `${ normalizedMethod } ${ String( url || "" ).split( "?" )[ 0 ] }`;
887
- const abortController = ( typeof AbortController === "function" ) ? new AbortController() : null;
888
-
889
- if ( abortController && normalizedMethod === "GET" ) {
890
- const existing = this.requestControllers.get( requestKey );
891
- if ( existing ) {
892
- existing.abort();
893
- }
894
- this.requestControllers.set( requestKey, abortController );
895
- }
896
-
897
- const cleanup = () => {
898
- if ( !abortController || normalizedMethod !== "GET" ) return;
899
- const active = this.requestControllers.get( requestKey );
900
- if ( active === abortController ) {
901
- this.requestControllers.delete( requestKey );
902
- }
903
- };
904
-
905
- const options = {
906
- method: normalizedMethod,
907
- headers: {
908
- "Accept": "application/json",
909
- "x-xsrf-token": xsrf
910
- },
911
- credentials: "same-origin",
912
- cache: "no-store",
913
- signal: abortController?.signal,
914
- };
915
-
916
- if ( data ) {
917
- options.headers[ "Content-Type" ] = "application/json";
918
- options.body = JSON.stringify( data );
919
- }
920
-
921
- fetch( url, options ).then( ( response ) => {
922
- const contentType = ( response.headers.get( "content-type" ) || "" ).toLowerCase();
923
- if ( contentType.includes( "application/json" ) ) {
924
- return response.json().then( ( body ) => ( {
925
- isSuccessful: response.ok,
926
- ...body
927
- } ) );
928
- } else {
929
- return { isSuccessful: response.ok, message: response.statusText };
930
- }
931
- } ).then( ( result ) => {
932
- if ( !result || result.isSuccessful === false ) {
933
- reject( result || {} );
934
- } else {
935
- resolve( result );
936
- }
937
- } ).catch( ( error ) => {
938
- reject( error );
939
- } ).finally( () => {
940
- cleanup();
941
- } );
942
- } );
943
- },
944
-
945
- /**
946
- * Redirect the user to the specified screen.
947
- *
948
- * @method
949
- * @param {string} screen
950
- * @public
951
- */
952
- openScreen( screen ) {
953
- const [ basePath, ...queryParts ] = ( screen || "" ).split( "?" );
954
- const query = queryParts.length ? "?" + queryParts.join( "?" ) : "";
955
- if ( !basePath || !/^[\w-]+$/.test( basePath ) || !window.htmx ) {
956
- window.location.href = "/";
957
- } else {
958
- const screenUrl = "/app/" + basePath + query;
959
- // Push the URL before the HTMX swap so that any Alpine component initialized
960
- // during the swap (via MutationObserver microtask) already sees the correct URL.
961
- window.history.pushState( null, "", screenUrl );
962
- this.screenTitleOverride = "";
963
- this.currentScreen = basePath;
964
- window.htmx.ajax( "get", screenUrl, { target: "#ti-content", swap: "innerHTML" } ).catch( () => {
965
- window.location.href = "/";
966
- } );
967
- }
968
- },
969
-
970
- /**
971
- * Used to update the current screen name and push the URL to history.
972
- *
973
- * @method
974
- * @param {string} screen
975
- * @public
976
- */
977
- setCurrentScreen( screen ) {
978
- if ( screen ) {
979
- this.currentScreen = screen;
980
- this.topbarSubtitle = "";
981
- this.screenTitleOverride = "";
982
- this.topbarPrimaryCta = null;
983
- }
984
- },
985
-
986
- /**
987
- * Used to override the topbar/document title for the current screen, replacing the default
988
- * `interface.topbar.<screen>` label. Pass an empty string to clear it and fall back to the label.
989
- * Automatically cleared on screen navigation.
990
- *
991
- * @method
992
- * @param {string} title
993
- * @public
994
- */
995
- setScreenTitle( title ) {
996
- this.screenTitleOverride = String( title || "" ).trim();
997
- },
998
-
999
- /**
1000
- * Used to set a per-screen subtitle in the topbar, overriding the default cycle name.
1001
- * Automatically cleared on screen navigation.
1002
- *
1003
- * @method
1004
- * @param {string} subtitle
1005
- * @public
1006
- */
1007
- setTopbarSubtitle( subtitle ) {
1008
- this.topbarSubtitle = String( subtitle || "" ).trim();
1009
- },
1010
-
1011
- /**
1012
- * Used to register a primary call-to-action button in the topbar for the current screen. The CTA is
1013
- * automatically cleared on screen navigation so each screen owns its own slot. Pass {@link null} to remove
1014
- * the CTA without leaving the screen.
1015
- *
1016
- * @typedef {Object} TiTopbarPrimaryCta
1017
- * @property {string} labelKey Localization key for the button text.
1018
- * @property {string} [icon] Optional icon class name (e.g. "plus", "send"); rendered as a leading glyph.
1019
- * @property {string} [tone] Button tone class — "primary" (default), "danger", "ghost".
1020
- * @property {Function} handler Click handler invoked when the button is activated.
1021
- * @property {boolean} [disabled] Optional disabled flag.
1022
- *
1023
- * @method
1024
- * @param {TiTopbarPrimaryCta|null} cta
1025
- * @public
1026
- */
1027
- setTopbarPrimaryCta( cta ) {
1028
- this.topbarPrimaryCta = ( cta && typeof cta === "object" ) ? cta : null;
1029
- },
1030
-
1031
- /**
1032
- * Used to mutate just the disabled flag of the active topbar CTA without re-registering the full object.
1033
- * Useful when validation state changes while the screen is open (e.g. cycle setup becoming lockable).
1034
- *
1035
- * @method
1036
- * @param {boolean} disabled
1037
- * @public
1038
- */
1039
- setTopbarPrimaryCtaDisabled( disabled ) {
1040
- if ( this.topbarPrimaryCta ) {
1041
- this.topbarPrimaryCta = { ...this.topbarPrimaryCta, disabled: disabled === true };
1042
- }
1043
- },
1044
-
1045
- /**
1046
- * Used to format an exception into a notification payload: a generic localized `message` plus optional, more
1047
- * specific `details`. The returned object stringifies to its `message`, so existing string usages
1048
- * (concatenation, `x-text`) keep working, while {@link notify} can surface the `details` on a second line.
1049
- *
1050
- * @method
1051
- * @param {Object} error
1052
- * @returns {{message: string, details: string, toString: function(): string}}
1053
- * @public
1054
- */
1055
- formatException( error ) {
1056
- const exception = error && error.exception;
1057
- const message = ( error && error.message ) || this.getLabel( exception && exception.label );
1058
- const rawDetails = exception && exception.data && exception.data.details;
1059
- // Resolve the details as a label when it is a known label key; otherwise show the raw text (using it as its
1060
- // own fallback so dynamic, non-localized messages pass through unchanged).
1061
- const details = rawDetails ? this.getLabel( rawDetails, rawDetails ) : "";
1062
- return {
1063
- message: message, details: details, toString() {
1064
- return this.message;
1065
- }
1066
- };
1067
- },
1068
-
1069
- /**
1070
- * Used to display a notification in the notification bar. Accepts either a plain message string or a payload
1071
- * object `{ message, details }` (e.g. the result of {@link formatException}); the optional `details` render on
1072
- * a smaller second line.
1073
- *
1074
- * @method
1075
- * @param {string|{message: string, details?: string}} message
1076
- * @param {number} [timeout=6000]
1077
- * @public
1078
- */
1079
- notify( message, timeout = 6000 ) {
1080
- const notificationBar = document.querySelector( "#ti-notifications" );
1081
- if ( notificationBar ) {
1082
- const payload = ( message && typeof message === "object" ) ? message : { message: message };
1083
- Alpine.$data( notificationBar ).add( {
1084
- id: this.notificationIDCounter++,
1085
- message: payload.message || this.getLabel( "error.application.unexpected" ),
1086
- details: payload.details || "",
1087
- timeout: timeout
1088
- } );
1089
- }
1090
- },
1091
-
1092
- /**
1093
- * Used to extract a label from the application configuration.
1094
- *
1095
- * @method
1096
- * @param {string} label
1097
- * @param {string} fallback
1098
- * @returns {string}
1099
- * @public
1100
- */
1101
- getLabel( label, fallback = "LABEL NOT FOUND" ) {
1102
- if ( !label ) {
1103
- return fallback;
1104
- } else {
1105
- return extractLabel( this.configuration.labels || {}, label.split( "." ).filter( Boolean ), fallback );
1106
- }
1107
- },
1108
-
1109
- /**
1110
- * Returns true when the current session user holds the given role code. Safe to call before the session has
1111
- * loaded (returns false). Provided as a helper because the Alpine CSP evaluator does not expose `Array`, so
1112
- * `Array.isArray( ... )` cannot be written inline in templates.
1113
- *
1114
- * @method
1115
- * @param {number|string} roleCode
1116
- * @returns {boolean}
1117
- * @public
1118
- */
1119
- hasRole( roleCode ) {
1120
- const roles = this.user && this.user.roles;
1121
- if ( !roles || typeof roles.indexOf !== "function" ) {
1122
- return false;
1123
- }
1124
- return roles.indexOf( roleCode ) >= 0;
1125
- },
1126
-
1127
- /**
1128
- * Toggle the sidebar between expanded and collapsed states.
1129
- *
1130
- * @method
1131
- * @public
1132
- */
1133
- toggleCollapse() {
1134
- this.collapsed = !this.collapsed;
1135
- try {
1136
- localStorage.setItem( STORAGE_KEY_COLLAPSED, String( this.collapsed ) );
1137
- } catch { /* ignore */
1138
- }
1139
- },
1140
-
1141
- /**
1142
- * Toggle between daylight and glass themes.
1143
- *
1144
- * @method
1145
- * @public
1146
- */
1147
- toggleTheme() {
1148
- this.theme = ( this.theme === "daylight" ) ? "glass" : "daylight";
1149
- this._applyTheme( this.theme );
1150
- try {
1151
- localStorage.setItem( STORAGE_KEY_THEME, this.theme );
1152
- } catch { /* ignore */
1153
- }
1154
- },
1155
-
1156
- /**
1157
- * Apply a theme by setting the data-theme attribute on <html>.
1158
- *
1159
- * @method
1160
- * @param {string} theme
1161
- * @private
1162
- */
1163
- _applyTheme( theme ) {
1164
- document.documentElement.dataset.theme = theme;
1165
- },
1166
-
1167
- /**
1168
- * Merges app-supplied component configurations into the "tiComponentsConfig" Alpine store.
1169
- * Each entry is deep-merged on top of the framework's default flyout config, so apps only need
1170
- * to supply the differences.
1171
- *
1172
- * @method
1173
- * @param {Object} [componentsConfig]
1174
- * @private
1175
- */
1176
- _mergeComponentsConfig( componentsConfig ) {
1177
- if ( !componentsConfig || typeof componentsConfig !== "object" ) {
1178
- return;
1179
- }
1180
- const store = Alpine.store( "tiComponentsConfig" );
1181
- Object.keys( componentsConfig ).forEach( ( key ) => {
1182
- store[ key ] = tiToolbox.deepMerge( defaultSidebarFlyoutConfig, componentsConfig[ key ] || {} );
1183
- } );
1184
- }
1185
-
1186
- };
1187
- };
1188
-
1189
- /**
1190
- * Returns a callback function for the Alpine.js "text-label" directive.
1191
- * This directive can be used to localize the text content of an element or its attributes.
1192
- *
1193
- * Usage instructions:
1194
- * - To localize text content:
1195
- * `<span x-text-label="translation.key">Fallback Text</span>`
1196
- * - To localize an element attribute (e.g., aria-label, placeholder, title):
1197
- * `<button x-text-label:aria-label="translation.key" aria-label="Fallback Text">...</button>`
1198
- *
1199
- * @method
1200
- * @returns {Function}
1201
- * @public
1202
- */
1203
- const configureDirectiveTextLabel = () => {
1204
- return ( element, { value, expression }, { effect } ) => {
1205
- const targetAttribute = value;
1206
- const fallback = targetAttribute ? ( element.getAttribute( targetAttribute ) || "" ) : ( element.textContent || "" );
1207
-
1208
- effect( () => {
1209
- const tiApplication = Alpine.store( "tiApplication" );
1210
- if ( !tiApplication || typeof tiApplication.getLabel !== "function" ) {
1211
- return;
1212
- }
1213
- let path = ( expression || "" ).trim();
1214
- if (
1215
- ( path.startsWith( "'" ) && path.endsWith( "'" ) ) ||
1216
- ( path.startsWith( "\"" ) && path.endsWith( "\"" ) )
1217
- ) {
1218
- path = path.slice( 1, -1 );
1219
- }
1220
- const translatedText = tiApplication.getLabel( path, fallback );
1221
- if ( targetAttribute ) {
1222
- element.setAttribute( targetAttribute, translatedText );
1223
- } else {
1224
- element.textContent = translatedText;
1225
- }
1226
- } );
1227
- };
1228
- };
1229
-
1230
- /**
1231
- * Returns a callback for the Alpine.js "ti-chart" directive. Renders a themeable SVG chart from a
1232
- * reactive spec property path into the host <figure class="ti-chart"> element.
1233
- *
1234
- * Usage (CSP-legal — the expression is a bare reactive property path or a registered method call):
1235
- * <figure class="ti-chart" x-ti-chart="coverageSpec"
1236
- * role="img" x-bind:aria-label="coverageSpec.a11yLabel"></figure>
1237
- *
1238
- * The geometry/format math and the SVG assembly live in ti-charts.js (window.TiCharts); this
1239
- * directive only wires the reactive spec to TiCharts.renderChart.
1240
- *
1241
- * @method
1242
- * @returns {Function}
1243
- * @public
1244
- */
1245
- const configureDirectiveTiChart = () => {
1246
- return ( element, { expression }, { effect, evaluateLater } ) => {
1247
- const getSpec = evaluateLater( expression );
1248
- effect( () => {
1249
- getSpec( ( spec ) => {
1250
- if ( typeof window !== "undefined" && window.TiCharts && typeof window.TiCharts.renderChart === "function" ) {
1251
- window.TiCharts.renderChart( element, spec );
1252
- }
1253
- } );
1254
- } );
1255
- };
1256
- };
1257
-
1258
- /**
1259
- * Perform a one-time configuration of the HTMX framework.
1260
- */
1261
- document.addEventListener( "htmx:configRequest", ( event ) => {
1262
- const tiToolbox = Alpine.store( "tiToolbox" );
1263
- event.detail.headers[ 'x-xsrf-token' ] = tiToolbox?.getCookie( "ti-xsrf-token" ) || "";
1264
- // Reuse the existing nonce from the active document:
1265
- const styleNonce = ( htmx?.config?.inlineStyleNonce ) || "";
1266
- const scriptNonce = ( htmx?.config?.inlineScriptNonce ) || "";
1267
- event.detail.headers[ 'x-csp-nonce' ] = styleNonce || scriptNonce || "";
1268
- } );
1269
-
1270
- /**
1271
- * Add a custom event listener to the HTMX framework.
1272
- */
1273
- document.addEventListener( "htmx:afterSwap", ( event ) => {
1274
- const target = event.detail.target;
1275
- if ( target.id !== "ti-content" && target.tagName !== "TI-NESTED-FRAME-PLACEHOLDER" ) return;
1276
- const path = ( event.detail.pathInfo && event.detail.pathInfo.requestPath ) || "";
1277
- const match = path.match( /^\/app\/([\w-]+)/ );
1278
- if ( match ) {
1279
- const tiApplication = Alpine.store( "tiApplication" );
1280
- if ( tiApplication ) {
1281
- tiApplication.setCurrentScreen( match[ 1 ] );
1282
- }
1283
- }
1284
- } );
1285
-
1286
- document.addEventListener( "htmx:responseError", ( event ) => {
1287
- // If the server sent HX-Trigger with our payload, it will also emit a separate event,
1288
- // But here we parse body as fallback when body is JSON
1289
- try {
1290
- const xhr = event.detail.xhr;
1291
- const contentType = xhr.getResponseHeader( "Content-Type" ) || "";
1292
- if ( contentType.includes( "application/json" ) && xhr.responseText ) {
1293
- const data = JSON.parse( xhr.responseText );
1294
- const tiApplication = Alpine.store( "tiApplication" );
1295
- if ( tiApplication && tiApplication.isInitialized ) {
1296
- tiApplication.notify( tiApplication.formatException( data ) );
1297
- }
1298
- }
1299
- } catch {
1300
- // Do nothing here...
1301
- }
1302
- } );
1303
-
1304
- /**
1305
- * Returns a configuration object for the login screen test user pill panel.
1306
- * <br/>
1307
- * NOTE: This is a TEMPORARY testing aid that injects an employeeID into the session via a cookie which the
1308
- * server-side {@link augmentSession} reads; roles are derived by the app unless the opt-in "override roles (dev)"
1309
- * toggle is on, in which case the profile's roles are written too. Remove together with the panel HTML once real
1310
- * identity propagation is in place.
1311
- *
1312
- * @method
1313
- * @returns {Object}
1314
- * @public
1315
- */
1316
- const configureLoginTestUserPanel = () => {
1317
- const COOKIE_NAME = "ti-test-user";
1318
- const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
1319
-
1320
- const readCookie = () => {
1321
- try {
1322
- const tiToolbox = Alpine.store( "tiToolbox" );
1323
- const raw = tiToolbox.getCookie( COOKIE_NAME );
1324
- if ( !raw ) return null;
1325
- const parsed = JSON.parse( raw );
1326
- return ( parsed && parsed.employeeID ) ? parsed : null;
1327
- } catch {
1328
- return null;
1329
- }
1330
- };
1331
-
1332
- const writeCookie = ( value ) => {
1333
- const encoded = encodeURIComponent( JSON.stringify( value ) );
1334
- document.cookie = `${ COOKIE_NAME }=${ encoded }; path=/; max-age=${ COOKIE_MAX_AGE_SECONDS }; SameSite=Lax`;
1335
- };
1336
-
1337
- const clearCookie = () => {
1338
- document.cookie = `${ COOKIE_NAME }=; path=/; max-age=0; SameSite=Lax`;
1339
- };
1340
-
1341
- return {
1342
- profiles: [
1343
- { employeeID: "22", roles: [ 1, 2, 3 ] },
1344
- { employeeID: "20", roles: [ 1, 2 ] },
1345
- { employeeID: "11", roles: [ 1, 2 ] },
1346
- { employeeID: "1", roles: [ 1 ] },
1347
- { employeeID: "3", roles: [ 1 ] },
1348
- { employeeID: "4", roles: [ 1 ] },
1349
- { employeeID: "8", roles: [ 1, 2 ] },
1350
- { employeeID: "9", roles: [ 1 ] }
1351
- ],
1352
- selected: null,
1353
- overrideRoles: false,
1354
-
1355
- init() {
1356
- this.selected = readCookie();
1357
- this.overrideRoles = Boolean( this.selected && Array.isArray( this.selected.roles ) && this.selected.roles.length > 0 );
1358
- },
1359
-
1360
- isSelected( profile ) {
1361
- return Boolean( this.selected && this.selected.employeeID === profile.employeeID );
1362
- },
1363
-
1364
- select( profile ) {
1365
- this.selected = this.overrideRoles
1366
- ? { employeeID: profile.employeeID, roles: profile.roles.slice() }
1367
- : { employeeID: profile.employeeID };
1368
- writeCookie( this.selected );
1369
- },
1370
-
1371
- onOverrideChanged() {
1372
- // `overrideRoles` is already updated by x-model; just re-write the cookie for the current selection
1373
- // so the new override setting takes effect immediately.
1374
- if ( this.selected ) {
1375
- // Turning the override OFF must always strip any persisted roles from the cookie — even when the
1376
- // selected employee is no longer in `profiles` (cookie from an older profile list or set manually) —
1377
- // so a stale roles array can't keep overriding the org-derived roles on the next login.
1378
- if ( !this.overrideRoles ) {
1379
- this.selected = { employeeID: this.selected.employeeID };
1380
- writeCookie( this.selected );
1381
- return;
1382
- }
1383
- const profile = this.profiles.find( ( candidate ) => candidate.employeeID === this.selected.employeeID );
1384
- if ( profile ) {
1385
- this.select( profile );
1386
- }
1387
- }
1388
- },
1389
-
1390
- clear() {
1391
- this.selected = null;
1392
- clearCookie();
1393
- }
1394
- };
1395
- };
1396
-
1397
- /**
1398
- * Register on-initialization tasks for the Alpine.js framework.
1399
- */
1400
- document.addEventListener( "alpine:init", () => {
1401
- // Note: Sequence here is important!
1402
- Alpine.directive( "text-label", configureDirectiveTextLabel() );
1403
- Alpine.directive( "ti-chart", configureDirectiveTiChart() );
1404
- Alpine.store( "tiToolbox", configureToolbox() );
1405
- Alpine.store( "tiApplication", configureApplication() );
1406
- Alpine.store( "tiComponentsConfig", {} );
1407
- Alpine.data( "tiApplication", () => ( {
1408
- get collapsed() {
1409
- return Alpine.store( "tiApplication" ).collapsed;
1410
- },
1411
- get theme() {
1412
- return Alpine.store( "tiApplication" ).theme;
1413
- },
1414
- toggleCollapse() {
1415
- Alpine.store( "tiApplication" ).toggleCollapse();
1416
- },
1417
- toggleTheme() {
1418
- Alpine.store( "tiApplication" ).toggleTheme();
1419
- }
1420
- } ) );
1421
- Alpine.data( "tiComponentSidebarNav", configureSidebarNav );
1422
- Alpine.data( "tiComponentTopbar", configureComponentTopbar );
1423
- Alpine.data( "tiComponentSidebarFlyout", configureComponentSidebarFlyout );
1424
- Alpine.data( "tiComponentNotificationBar", configureComponentNotificationBar );
1425
- Alpine.data( "tiComponentTooltip", configureComponentTooltip );
1426
- Alpine.data( "tiLoginTestUserPanel", configureLoginTestUserPanel );
1427
- } );
1
+ /*
2
+ * The ti-engine is an open source, free to use—both for personal and commercial projects—framework for the creation of microservice-based solutions using node.js.
3
+ * Copyright © 2021-2026 Boris Kostadinov <kostadinov.boris@gmail.com>
4
+ * This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
5
+ * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
6
+ * You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
7
+ */
8
+
9
+ /**
10
+ * @typedef {Object} SidebarFlyoutConfig
11
+ * @property {string} menuTitle
12
+ * @property {number} [offset]
13
+ * @property {string} [icon]
14
+ * @property {string} [placement]
15
+ * @property {boolean} [fixed]
16
+ * @property {Array<SidebarFlyoutButtonConfig>} [buttonConfigs]
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} SidebarFlyoutButtonConfig
21
+ * @property {string} title
22
+ * @property {string} icon
23
+ * @property {Object} action
24
+ * @property {string} action.href
25
+ * @property {string} [action.method]
26
+ * @property {string} action.target
27
+ * @property {string} action.swap
28
+ */
29
+
30
+ /**
31
+ * Default configuration applied as a base when an application-supplied flyout config is merged into the store.
32
+ *
33
+ * @constant
34
+ * @type {SidebarFlyoutConfig}
35
+ */
36
+ const defaultSidebarFlyoutConfig = {
37
+ menuTitle: "Menu",
38
+ placement: "right-start",
39
+ offset: 10,
40
+ fixed: true,
41
+ buttonConfigs: []
42
+ };
43
+
44
+ /**
45
+ * Returns a configuration object for the toolbox.
46
+ *
47
+ * @method
48
+ * @returns {Object}
49
+ * @public
50
+ */
51
+ const configureToolbox = () => {
52
+ /**
53
+ * @typedef {Object} TiToolbox
54
+ */
55
+ return {
56
+
57
+ /**
58
+ * Used to clamp a position to a box.
59
+ *
60
+ * @method
61
+ * @param {number} x
62
+ * @param {number} y
63
+ * @param {number} w
64
+ * @param {number} h
65
+ * @param {Object} box
66
+ * @param {number} [edgePadding=0]
67
+ * @returns {{x: number, y: number}}
68
+ * @public
69
+ */
70
+ clampToBox( x, y, w, h, box, edgePadding = 0 ) {
71
+ // If the box has an offset (visualViewport on some platforms), normalize appropriately.
72
+ // For fixed elements, x/y are in viewport coordinates; for absolute, in page coordinates.
73
+ const minX = box.left + edgePadding;
74
+ const minY = box.top + edgePadding;
75
+ const maxX = box.left + box.width - w - edgePadding;
76
+ const maxY = box.top + box.height - h - edgePadding;
77
+ return {
78
+ x: Math.min( Math.max( x, minX ), Math.max( minX, maxX ) ),
79
+ y: Math.min( Math.max( y, minY ), Math.max( minY, maxY ) )
80
+ };
81
+ },
82
+
83
+ /**
84
+ * Used to deep-freeze an object.
85
+ *
86
+ * @method
87
+ * @param {Object} object
88
+ * @param {WeakSet} [seen]
89
+ * @returns {Object}
90
+ * @public
91
+ */
92
+ deepFreeze( object, seen = new WeakSet() ) {
93
+ if ( object === null || typeof object !== "object" || seen.has( object ) ) {
94
+ return object;
95
+ } else {
96
+ seen.add( object );
97
+ Object.keys( object ).forEach( ( key ) => {
98
+ this.deepFreeze( object[ key ], seen );
99
+ } );
100
+ return Object.freeze( object );
101
+ }
102
+ },
103
+
104
+ /**
105
+ * Used to perform a deep merge of two objects. 'base' is the object that will be modified.
106
+ * <br/>
107
+ * NOTE: If 'structuredClone' is not available, fall back to JSON.parse/JSON.stringify. The later will not preserve non-JSON-serializable
108
+ * values (functions, undefined, symbols) and will convert Dates to strings, RegExp to empty objects, etc.
109
+ *
110
+ * @method
111
+ * @param {Object} base
112
+ * @param {Object} source
113
+ * @returns {Object}
114
+ * @public
115
+ */
116
+ deepMerge( base, source ) {
117
+ if ( !this.isPlainObject( base ) || !this.isPlainObject( source ) ) {
118
+ return this.structuredClone( source );
119
+ } else {
120
+ const out = { ...base };
121
+ for ( const key of Object.keys( source ) ) {
122
+ const b = base[ key ];
123
+ const s = source[ key ];
124
+
125
+ if ( Array.isArray( s ) ) {
126
+ out[ key ] = s.slice();
127
+ } else if ( this.isPlainObject( s ) && this.isPlainObject( b ) ) {
128
+ out[ key ] = this.deepMerge( b, s );
129
+ } else if ( this.isPlainObject( s ) ) {
130
+ out[ key ] = this.deepMerge( {}, s );
131
+ } else {
132
+ out[ key ] = s;
133
+ }
134
+ }
135
+ return out;
136
+ }
137
+ },
138
+
139
+ /**
140
+ * Used to format a system string date value into a display string.
141
+ *
142
+ * @method
143
+ * @param {string} value
144
+ * @param {string} placeholder
145
+ * @returns {string}
146
+ * @public
147
+ */
148
+ formatDate( value, placeholder = "" ) {
149
+ if ( !value ) return placeholder;
150
+ const normalized = /^\d{4}-\d{2}-\d{2}$/.test( value )
151
+ ? `${ value }T00:00:00`
152
+ : value;
153
+ const date = new Date( normalized );
154
+ return this.isValidDate( date ) ? date.toLocaleDateString() : placeholder;
155
+ },
156
+
157
+ /**
158
+ * Used to get a cookie value by name.
159
+ *
160
+ * @method
161
+ * @param {string} name
162
+ * @returns {string}
163
+ * @public
164
+ */
165
+ getCookie( name ) {
166
+ const cookie = document.cookie.match( new RegExp( "(?:^|; )" + name.replace( /[$()*+.?[\]\\^{}|]/g, "\\$&" ) + "=([^;]*)" ) );
167
+ return cookie ? decodeURIComponent( cookie[ 1 ] ) : "";
168
+ },
169
+
170
+ /**
171
+ * Used to get the Monday of the week for a given date.
172
+ *
173
+ * @method
174
+ * @param {Date} date
175
+ * @returns {Date}
176
+ * @public
177
+ */
178
+ getMonday( date ) {
179
+ const d = new Date( date );
180
+ const day = d.getDay();
181
+ d.setDate( d.getDate() + ( day === 0 ? -6 : 1 - day ) );
182
+ d.setHours( 0, 0, 0, 0 );
183
+ return d;
184
+ },
185
+
186
+ /**
187
+ * Used to get a URL query parameter value by name.
188
+ *
189
+ * @method
190
+ * @param {string} name
191
+ * @returns {string|null}
192
+ * @public
193
+ */
194
+ getUrlParam( name ) {
195
+ return new URLSearchParams( window.location.search ).get( name );
196
+ },
197
+
198
+ /**
199
+ * Used to get the visible box of the document.
200
+ *
201
+ * @method
202
+ * @param {boolean} isFixed
203
+ * @returns {Object}
204
+ * @public
205
+ */
206
+ getVisibleBox( isFixed ) {
207
+ // Fixed coordinates are viewport-based (top/left = 0/0):
208
+ if ( isFixed ) {
209
+ if ( window.visualViewport ) {
210
+ const viewport = window.visualViewport;
211
+ return {
212
+ left: viewport.offsetLeft, // CSS px, where the viewport begins relative to a layout viewport
213
+ top: viewport.offsetTop,
214
+ width: viewport.width,
215
+ height: viewport.height,
216
+ pageLeft: viewport.pageLeft, // document coords
217
+ pageTop: viewport.pageTop
218
+ };
219
+ } else {
220
+ return { left: 0, top: 0, width: window.innerWidth, height: window.innerHeight, pageLeft: window.scrollX, pageTop: window.scrollY };
221
+ }
222
+ } else {
223
+ // Absolute coordinates are document-based (top/left = scroll position):
224
+ return {
225
+ left: window.scrollX,
226
+ top: window.scrollY,
227
+ width: document.documentElement.clientWidth,
228
+ height: document.documentElement.clientHeight,
229
+ pageLeft: window.scrollX,
230
+ pageTop: window.scrollY
231
+ };
232
+ }
233
+ },
234
+
235
+ /**
236
+ * Used to check if a value is a plain object.
237
+ *
238
+ * @method
239
+ * @param {*} value
240
+ * @returns {boolean}
241
+ * @public
242
+ */
243
+ isPlainObject( value ) {
244
+ return Object.prototype.toString.call( value ) === "[object Object]";
245
+ },
246
+
247
+ /**
248
+ * Used to check if a value is a valid Date object.
249
+ *
250
+ * @method
251
+ * @param {*} value
252
+ * @returns {boolean}
253
+ * @public
254
+ */
255
+ isValidDate( value ) {
256
+ return value instanceof Date && !isNaN( value );
257
+ },
258
+
259
+ /**
260
+ * Used to perform a structured clone operation.
261
+ * <br/>
262
+ * NOTE: If 'structuredClone' is not available — or throws DataCloneError on a reactive Proxy
263
+ * (Alpine/Vue reactivity wraps assigned objects in Proxies that the browser's structured-clone
264
+ * algorithm can refuse) — fall back to JSON.parse/JSON.stringify. The fallback will not preserve
265
+ * non-JSON-serializable values (functions, undefined, symbols, Dates, RegExp).
266
+ *
267
+ * @method
268
+ * @param {Object} value
269
+ * @param {Object} [options]
270
+ * @returns {Object}
271
+ * @public
272
+ */
273
+ structuredClone( value, options ) {
274
+ if ( typeof structuredClone === "function" ) {
275
+ try {
276
+ return structuredClone( value, options );
277
+ } catch {
278
+ return JSON.parse( JSON.stringify( value ) );
279
+ }
280
+ }
281
+ return JSON.parse( JSON.stringify( value ) );
282
+ },
283
+
284
+ /**
285
+ * Used to convert a Date object to an ISO date string (YYYY-MM-DD).
286
+ *
287
+ * @method
288
+ * @param {Date} date
289
+ * @returns {string}
290
+ * @public
291
+ */
292
+ toDateString( date ) {
293
+ const y = date.getFullYear();
294
+ const m = String( date.getMonth() + 1 ).padStart( 2, "0" );
295
+ const d = String( date.getDate() ).padStart( 2, "0" );
296
+ return `${ y }-${ m }-${ d }`;
297
+ },
298
+
299
+ /**
300
+ * Deterministically maps a seed value (e.g. employeeID) to an HSL color string.
301
+ * The result is stable across sessions and consistent for the same seed.
302
+ *
303
+ * @method
304
+ * @param {string|number} id
305
+ * @param {string} name
306
+ * @returns {Object} HSL color string
307
+ * @public
308
+ */
309
+ generateAvatarStyle( id, name ) {
310
+ const seed = String( id ?? "" ) + "\x00" + String( name ?? "" );
311
+ const djb2 = ( s ) => {
312
+ let h = 5381;
313
+ for ( let i = 0; i < s.length; i++ ) {
314
+ h = ( ( h << 5 ) + h + s.charCodeAt( i ) ) | 0;
315
+ }
316
+ return Math.abs( h );
317
+ };
318
+ const h0 = djb2( seed + "A" ) % 360;
319
+ const h1 = djb2( seed + "B" ) % 360;
320
+ const h2 = djb2( seed + "C" ) % 360;
321
+ return {
322
+ "--avatar-bg": `linear-gradient( 135deg, hsl( ${ h0 }, 70%, 48% ) 0%, hsl( ${ h1 }, 62%, 54% ) 50%, hsl( ${ h2 }, 65%, 44% ) 100% )`
323
+ };
324
+ }
325
+
326
+ };
327
+ };
328
+
329
+ /**
330
+ * Returns a configuration object for the sidebar flyout component "component-sidebar-flyout.html".
331
+ * <br/>
332
+ * The component looks up its menu definition from the "tiComponentsConfig" Alpine store using the supplied
333
+ * configuration key. This allows each application to register its own flyout menus via the application
334
+ * configuration response without modifying the framework.
335
+ *
336
+ * @method
337
+ * @param {string} configKey Key into the "tiComponentsConfig" store identifying the flyout configuration to use.
338
+ * @returns {Object}
339
+ * @public
340
+ */
341
+ const configureComponentSidebarFlyout = ( configKey ) => {
342
+ const tiToolbox = Alpine.store( "tiToolbox" );
343
+ const TI_EVENT_CLOSE_ALL_FLYOUT = "ti-close-all-flyout";
344
+
345
+ /**
346
+ * @typedef {Object} TiSidebarFlyout
347
+ */
348
+ return {
349
+ configKey: configKey || "",
350
+ isOpen: false,
351
+
352
+ get config() {
353
+ const store = Alpine.store( "tiComponentsConfig" ) || {};
354
+ return store[ this.configKey ] || defaultSidebarFlyoutConfig;
355
+ },
356
+ get menuTitle() {
357
+ return this.config.menuTitle || "";
358
+ },
359
+ get placement() {
360
+ return this.config.placement || "right-start";
361
+ },
362
+ get side() {
363
+ if ( this.config.side ) return this.config.side;
364
+ return this.placement.split( "-" )[ 0 ] || "right";
365
+ },
366
+ get align() {
367
+ if ( this.config.align ) return this.config.align;
368
+ return this.placement.split( "-" )[ 1 ] || "start";
369
+ },
370
+ get offset() {
371
+ return ( typeof this.config.offset === "number" ) ? this.config.offset : 10;
372
+ },
373
+ get fixed() {
374
+ return ( typeof this.config.fixed === "boolean" ) ? this.config.fixed : true;
375
+ },
376
+ get icon() {
377
+ return this.config.icon || "";
378
+ },
379
+ get buttonConfigs() {
380
+ return Array.isArray( this.config.buttonConfigs ) ? this.config.buttonConfigs : [];
381
+ },
382
+
383
+ /**
384
+ * Used to initialize the sidebar flyout component.
385
+ *
386
+ * @method
387
+ * @public
388
+ */
389
+ init() {
390
+ this._reflow = this.reposition.bind( this );
391
+ this._close = this.close.bind( this );
392
+ window.addEventListener( "resize", this._reflow, { passive: true } );
393
+ window.addEventListener( "scroll", this._reflow, { passive: true } );
394
+ window.addEventListener( TI_EVENT_CLOSE_ALL_FLYOUT, this._close );
395
+ },
396
+
397
+ /**
398
+ * Used to destroy the sidebar flyout component.
399
+ *
400
+ * @method
401
+ * @public
402
+ */
403
+ destroy() {
404
+ window.removeEventListener( "resize", this._reflow );
405
+ window.removeEventListener( "scroll", this._reflow );
406
+ window.removeEventListener( TI_EVENT_CLOSE_ALL_FLYOUT, this._close );
407
+ },
408
+
409
+ /**
410
+ * Used to toggle the sidebar flyout panel.
411
+ *
412
+ * @method
413
+ * @public
414
+ */
415
+ toggle() {
416
+ this.isOpen ? this.close() : this.open();
417
+ },
418
+
419
+ /**
420
+ * Used to open the sidebar flyout panel.
421
+ *
422
+ * @method
423
+ * @public
424
+ */
425
+ open() {
426
+ if ( !this.isOpen ) {
427
+ window.dispatchEvent( new CustomEvent( TI_EVENT_CLOSE_ALL_FLYOUT ) );
428
+ this.isOpen = true;
429
+ this.$nextTick( () => {
430
+ this.setAria();
431
+ this.reposition();
432
+ // The menu items bind their hx-* attributes via Alpine (x-bind), which HTMX does not pick up on its
433
+ // initial document scan — so without this the buttons close the flyout but never fire the request.
434
+ // Processing the panel attaches HTMX behaviour to the now-rendered buttons (idempotent on re-open).
435
+ if ( window.htmx && this.$refs.flyoutPanel ) {
436
+ window.htmx.process( this.$refs.flyoutPanel );
437
+ }
438
+ } );
439
+ }
440
+ },
441
+
442
+ /**
443
+ * Used to close the flyout panel.
444
+ *
445
+ * @method
446
+ * @public
447
+ */
448
+ close() {
449
+ if ( this.isOpen ) {
450
+ this.isOpen = false;
451
+ this.$nextTick( () => this.setAria() );
452
+ }
453
+ },
454
+
455
+ /**
456
+ * Used to set the ARIA attributes for the flyout button.
457
+ *
458
+ * @method
459
+ * @public
460
+ */
461
+ setAria() {
462
+ if ( !this.$refs.flyoutButton ) return;
463
+ this.$refs.flyoutButton.setAttribute( "aria-expanded", String( this.isOpen ) );
464
+ },
465
+
466
+ /**
467
+ * Used to reposition the flyout panel.
468
+ *
469
+ * @method
470
+ * @public
471
+ */
472
+ reposition() {
473
+ if ( !this.isOpen || !this.$refs.flyoutButton || !this.$refs.flyoutPanel ) return;
474
+
475
+ const flyoutButton = this.$refs.flyoutButton;
476
+ const flyoutPanel = this.$refs.flyoutPanel;
477
+
478
+ const rect = flyoutButton.getBoundingClientRect();
479
+ const scrollX = window.scrollX;
480
+ const scrollY = window.scrollY;
481
+
482
+ const pw = flyoutPanel.scrollWidth;
483
+ const ph = flyoutPanel.scrollHeight;
484
+
485
+ let top = rect.top + ( this.fixed ? 0 : scrollY );
486
+ let left = rect.left + ( this.fixed ? 0 : scrollX );
487
+
488
+ const side = this.side;
489
+ const align = this.align;
490
+ if ( side === "right" ) {
491
+ left = rect.right + ( this.fixed ? 0 : scrollX ) + this.offset;
492
+ } else if ( side === "left" ) {
493
+ left = rect.left + ( this.fixed ? 0 : scrollX ) - pw - this.offset;
494
+ } else if ( side === "bottom" ) {
495
+ top = rect.bottom + ( this.fixed ? 0 : scrollY ) + this.offset;
496
+ } else if ( side === "top" ) {
497
+ top = rect.top + ( this.fixed ? 0 : scrollY ) - ph - this.offset;
498
+ }
499
+ // Alignment for horizontal sides (left/right) adjusts the vertical position:
500
+ if ( side === "left" || side === "right" ) {
501
+ if ( align === "start" ) {
502
+ top = rect.top + ( this.fixed ? 0 : scrollY );
503
+ } else if ( align === "center" ) {
504
+ top = rect.top + ( this.fixed ? 0 : scrollY ) + ( rect.height - ph ) / 2;
505
+ } else if ( align === "end" ) {
506
+ top = rect.bottom + ( this.fixed ? 0 : scrollY ) - ph;
507
+ }
508
+ }
509
+ // Alignment for vertical sides (top/bottom) adjusts the horizontal position:
510
+ else if ( side === "top" || side === "bottom" ) {
511
+ if ( align === "start" ) {
512
+ left = rect.left + ( this.fixed ? 0 : scrollX );
513
+ } else if ( align === "center" ) {
514
+ left = rect.left + ( this.fixed ? 0 : scrollX ) + ( rect.width - pw ) / 2;
515
+ } else if ( align === "end" ) {
516
+ left = rect.right + ( this.fixed ? 0 : scrollX ) - pw;
517
+ }
518
+ }
519
+
520
+ const box = tiToolbox.getVisibleBox( this.fixed );
521
+ const coords = tiToolbox.clampToBox( left, top, pw, ph, box, 10 );
522
+
523
+ flyoutPanel.style.position = this.fixed ? "fixed" : "absolute";
524
+ flyoutPanel.style.top = Math.round( coords.y ) + "px";
525
+ flyoutPanel.style.left = Math.round( coords.x ) + "px";
526
+ }
527
+
528
+ };
529
+ };
530
+
531
+ /**
532
+ * Returns a configuration object for the sidebar navigation component.
533
+ * The screen-to-active-key mapping is read at runtime from tiApplication.configuration.sidebarNavMapping,
534
+ * allowing each application to define its own mapping without changing the framework.
535
+ *
536
+ * @method
537
+ * @returns {Object}
538
+ * @public
539
+ */
540
+ const configureSidebarNav = () => {
541
+ const tiApplication = Alpine.store( "tiApplication" );
542
+
543
+ const getActiveFromScreen = ( screen ) => {
544
+ const mapping = ( tiApplication.configuration && tiApplication.configuration.sidebarNavMapping ) || {};
545
+ return mapping[ screen ] || "";
546
+ };
547
+
548
+ const getActiveFromUrl = () => {
549
+ const match = window.location.pathname.match( /^\/app\/([\w-]+)/ );
550
+ return match ? getActiveFromScreen( match[ 1 ] ) : "";
551
+ };
552
+
553
+ return {
554
+ active: "",
555
+
556
+ init() {
557
+ this.$watch( () => tiApplication.isInitialized, ( isInitialized ) => {
558
+ if ( isInitialized ) {
559
+ const fromUrl = getActiveFromUrl();
560
+ if ( fromUrl ) {
561
+ this.active = fromUrl;
562
+ }
563
+ }
564
+ } );
565
+ this.$watch( () => tiApplication.currentScreen, ( screen ) => {
566
+ const mapped = getActiveFromScreen( screen );
567
+ if ( mapped ) {
568
+ this.active = mapped;
569
+ }
570
+ } );
571
+ if ( tiApplication.isInitialized ) {
572
+ const fromUrl = getActiveFromUrl();
573
+ if ( fromUrl ) {
574
+ this.active = fromUrl;
575
+ }
576
+ }
577
+ },
578
+
579
+ navigate( activeKey, screen ) {
580
+ this.active = activeKey;
581
+ tiApplication.openScreen( screen );
582
+ }
583
+ };
584
+ };
585
+
586
+ /**
587
+ * Returns a configuration object for the topbar component "component-topbar.html".
588
+ *
589
+ * @method
590
+ * @returns {Object}
591
+ * @public
592
+ */
593
+ const configureComponentTopbar = () => {
594
+ /**
595
+ * @typedef {Object} TiTopbar
596
+ */
597
+ return {
598
+
599
+ screenTitle: "",
600
+
601
+ init() {
602
+ const tiApplication = Alpine.store( "tiApplication" );
603
+
604
+ const updateTitle = () => {
605
+ const screen = ( tiApplication && tiApplication.currentScreen ) || "";
606
+ const override = ( tiApplication && tiApplication.screenTitleOverride ) || "";
607
+ const title = override || ( screen ? tiApplication.getLabel( `interface.topbar.${ screen }`, "" ) : "" );
608
+ this.screenTitle = title;
609
+ if ( title ) {
610
+ document.title = title;
611
+ }
612
+ };
613
+
614
+ // If the htmx:afterSwap listener was registered after the initial swap already fired,
615
+ // currentScreen won't be set yet — fall back to reading the URL that hx-push-url already updated:
616
+ if ( tiApplication && !tiApplication.currentScreen ) {
617
+ const match = window.location.pathname.match( /^\/app\/([\w-]+)/ );
618
+ if ( match ) {
619
+ tiApplication.setCurrentScreen( match[ 1 ] );
620
+ }
621
+ }
622
+
623
+ updateTitle();
624
+ this.$watch( () => tiApplication.currentScreen, updateTitle );
625
+ this.$watch( () => tiApplication.screenTitleOverride, updateTitle );
626
+ this.$watch( () => tiApplication.isInitialized, updateTitle );
627
+ }
628
+
629
+ };
630
+ };
631
+
632
+ /**
633
+ * Returns a configuration object for the notification component "component-notification-bar.html".
634
+ *
635
+ * @method
636
+ * @returns {Object}
637
+ * @public
638
+ */
639
+ const configureComponentNotificationBar = () => {
640
+ /**
641
+ * @typedef {Object} TiNotificationBar
642
+ */
643
+ return {
644
+ notifications: [],
645
+ timers: {},
646
+
647
+ /**
648
+ * Used to add a notification to the notification bar.
649
+ *
650
+ * @method
651
+ * @param {Object} notification
652
+ * @public
653
+ */
654
+ add( notification ) {
655
+ if ( notification && notification.id ) {
656
+ this.remove( notification.id );
657
+ this.notifications.push( notification );
658
+ if ( typeof notification.timeout === "number" && notification.timeout > 0 ) {
659
+ this.timers[ notification.id ] = setTimeout( () => this.remove( notification.id ), notification.timeout );
660
+ }
661
+ }
662
+ },
663
+
664
+ /**
665
+ * Used to remove a notification by its ID.
666
+ *
667
+ * @method
668
+ * @param {string} id
669
+ * @public
670
+ */
671
+ remove( id ) {
672
+ if ( id ) {
673
+ this.notifications = this.notifications.filter( notification => notification.id !== id );
674
+ if ( this.timers[ id ] ) {
675
+ clearTimeout( this.timers[ id ] );
676
+ delete this.timers[ id ];
677
+ }
678
+ }
679
+ },
680
+
681
+ /**
682
+ * Used to clear all notifications.
683
+ *
684
+ * @method
685
+ * @public
686
+ */
687
+ destroy() {
688
+ Object.keys( this.timers ).forEach( ( id ) => clearTimeout( this.timers[ id ] ) );
689
+ this.timers = {};
690
+ this.notifications = [];
691
+ }
692
+
693
+ };
694
+ };
695
+
696
+ /**
697
+ * Returns a configuration object for the tooltip component "component-tooltip.html".
698
+ *
699
+ * @method
700
+ * @returns {Object}
701
+ * @public
702
+ */
703
+ const configureComponentTooltip = () => {
704
+ /**
705
+ * @typedef {Object} TiTooltip
706
+ */
707
+ return {
708
+ isVisible: false,
709
+ text: "This is a default tooltip. To change that, define a 'x-bind:data-ti-tooltip' attribute in the target element to set the tooltip text.",
710
+
711
+ /**
712
+ * Used to get the tooltip message from the target element.
713
+ *
714
+ * @method
715
+ * @param {HTMLElement} target
716
+ * @returns {string}
717
+ * @public
718
+ */
719
+ getTooltipMessage( target ) {
720
+ if ( !target || typeof target.closest !== "function" ) return "";
721
+ const selector = "[data-ti-tooltip], [data-tooltip]";
722
+ const element = target.closest( selector );
723
+ if ( !element || !this.$el.contains( element ) ) return "";
724
+ return element.getAttribute( "data-ti-tooltip" ) || element.getAttribute( "data-tooltip" ) || "";
725
+ },
726
+
727
+ /**
728
+ * Used to handle mouse enter events on the target element.
729
+ *
730
+ * @method
731
+ * @param {MouseEvent} event
732
+ * @public
733
+ */
734
+ handleEnter( event ) {
735
+ const message = this.getTooltipMessage( event?.target );
736
+ if ( message ) {
737
+ this.showTooltip( message );
738
+ }
739
+ },
740
+
741
+ /**
742
+ * Used to handle mouse leave events on the target element.
743
+ *
744
+ * @method
745
+ * @param {MouseEvent} event
746
+ * @public
747
+ */
748
+ handleLeave( event ) {
749
+ const related = event?.relatedTarget;
750
+ if ( related && this.$el.contains( related ) ) return;
751
+ this.hideTooltip();
752
+ },
753
+
754
+ /**
755
+ * Used to show the tooltip.
756
+ *
757
+ * @method
758
+ * @param {string} message
759
+ * @public
760
+ */
761
+ showTooltip( message ) {
762
+ this.text = message;
763
+ this.isVisible = true;
764
+ },
765
+
766
+ /**
767
+ * Used to hide the tooltip.
768
+ *
769
+ * @method
770
+ * @public
771
+ */
772
+ hideTooltip() {
773
+ this.isVisible = false;
774
+ }
775
+
776
+ };
777
+ }
778
+
779
+ /**
780
+ * Returns a configuration object for the application management instance.
781
+ *
782
+ * @method
783
+ * @returns {Object}
784
+ * @public
785
+ */
786
+ const configureApplication = () => {
787
+ const STORAGE_KEY_COLLAPSED = "ti-sidebar-collapsed";
788
+ const STORAGE_KEY_THEME = "ti-theme";
789
+ const DEFAULT_THEME = "daylight";
790
+
791
+ const tiToolbox = Alpine.store( "tiToolbox" );
792
+
793
+ /**
794
+ * Used to extract a label from a nested labels object.
795
+ *
796
+ * @method
797
+ * @param {Object} labels
798
+ * @param {String[]} keys
799
+ * @param {String} fallback
800
+ * @returns {String}
801
+ * @private
802
+ */
803
+ const extractLabel = ( labels, keys, fallback ) => {
804
+ let key = keys.shift();
805
+ if ( labels && typeof labels === "object" && key && Object.prototype.hasOwnProperty.call( labels, key ) ) {
806
+ const value = labels[ key ];
807
+ if ( typeof value === "string" ) {
808
+ return keys.length === 0 ? value : fallback;
809
+ }
810
+ if ( value && typeof value === "object" ) {
811
+ return extractLabel( value, keys, fallback );
812
+ }
813
+ }
814
+ return fallback;
815
+ };
816
+
817
+ /**
818
+ * @typedef {Object} TiApplication
819
+ */
820
+ return {
821
+ isInitialized: false,
822
+ user: null,
823
+ configuration: {},
824
+ currentScreen: "",
825
+ topbarSubtitle: "",
826
+ screenTitleOverride: "",
827
+ topbarPrimaryCta: null,
828
+ notificationIDCounter: 1,
829
+ requestControllers: new Map(),
830
+ collapsed: false,
831
+ theme: DEFAULT_THEME,
832
+
833
+ /**
834
+ * Used to initialize the web application.
835
+ */
836
+ init() {
837
+ document.addEventListener( "ti:error", ( event ) => {
838
+ this.notify( this.formatException( event.detail ) );
839
+ } );
840
+
841
+ try {
842
+ const savedCollapsed = localStorage.getItem( STORAGE_KEY_COLLAPSED );
843
+ if ( savedCollapsed !== null ) this.collapsed = savedCollapsed === "true";
844
+
845
+ const savedTheme = localStorage.getItem( STORAGE_KEY_THEME );
846
+ if ( savedTheme ) this.theme = savedTheme;
847
+ } catch {
848
+ // localStorage may be unavailable (private browsing, security policy).
849
+ }
850
+ this._applyTheme( this.theme );
851
+
852
+ // Use application settings to configure the application at load-time:
853
+ this.sendRequest( "/app/config" ).then( ( result ) => {
854
+ this.configuration = result?.data || {};
855
+ this._mergeComponentsConfig( this.configuration?.componentsConfig );
856
+ return ( this.configuration?.auth?.isAuthenticated ) ? this.sendRequest( "/me" ) : {};
857
+ } ).then( ( result ) => {
858
+ this.user = result?.data?.user || null;
859
+ this.isInitialized = true;
860
+ } ).catch( ( error ) => {
861
+ if ( error?.name === "AbortError" || error?.isAborted ) {
862
+ return;
863
+ }
864
+
865
+ this.user = null;
866
+ this.isInitialized = false;
867
+ const formatted = this.formatException( error );
868
+ this.notify( { message: this.getLabel( "error.application.init-failed" ) + " " + formatted.message, details: formatted.details } );
869
+ } );
870
+ },
871
+
872
+ /**
873
+ * Used to send a request to the application server.
874
+ *
875
+ * @method
876
+ * @param {string} url
877
+ * @param {"POST"|"GET"|"PUT"|"DELETE"} [method="GET"]
878
+ * @param {Object} [data=null]
879
+ * @returns {Promise<Object>}
880
+ * @public
881
+ */
882
+ sendRequest( url, method = "GET", data = null ) {
883
+ return new Promise( ( resolve, reject ) => {
884
+ const xsrf = tiToolbox.getCookie( "ti-xsrf-token" ) || "";
885
+ const normalizedMethod = String( method || "GET" ).toUpperCase();
886
+ const requestKey = `${ normalizedMethod } ${ String( url || "" ).split( "?" )[ 0 ] }`;
887
+ const abortController = ( typeof AbortController === "function" ) ? new AbortController() : null;
888
+
889
+ if ( abortController && normalizedMethod === "GET" ) {
890
+ const existing = this.requestControllers.get( requestKey );
891
+ if ( existing ) {
892
+ existing.abort();
893
+ }
894
+ this.requestControllers.set( requestKey, abortController );
895
+ }
896
+
897
+ const cleanup = () => {
898
+ if ( !abortController || normalizedMethod !== "GET" ) return;
899
+ const active = this.requestControllers.get( requestKey );
900
+ if ( active === abortController ) {
901
+ this.requestControllers.delete( requestKey );
902
+ }
903
+ };
904
+
905
+ const options = {
906
+ method: normalizedMethod,
907
+ headers: {
908
+ "Accept": "application/json",
909
+ "x-xsrf-token": xsrf
910
+ },
911
+ credentials: "same-origin",
912
+ cache: "no-store",
913
+ signal: abortController?.signal,
914
+ };
915
+
916
+ if ( data ) {
917
+ options.headers[ "Content-Type" ] = "application/json";
918
+ options.body = JSON.stringify( data );
919
+ }
920
+
921
+ fetch( url, options ).then( ( response ) => {
922
+ const contentType = ( response.headers.get( "content-type" ) || "" ).toLowerCase();
923
+ if ( contentType.includes( "application/json" ) ) {
924
+ return response.json().then( ( body ) => ( {
925
+ isSuccessful: response.ok,
926
+ ...body
927
+ } ) );
928
+ } else {
929
+ return { isSuccessful: response.ok, message: response.statusText };
930
+ }
931
+ } ).then( ( result ) => {
932
+ if ( !result || result.isSuccessful === false ) {
933
+ reject( result || {} );
934
+ } else {
935
+ resolve( result );
936
+ }
937
+ } ).catch( ( error ) => {
938
+ reject( error );
939
+ } ).finally( () => {
940
+ cleanup();
941
+ } );
942
+ } );
943
+ },
944
+
945
+ /**
946
+ * Redirect the user to the specified screen.
947
+ *
948
+ * @method
949
+ * @param {string} screen
950
+ * @public
951
+ */
952
+ openScreen( screen ) {
953
+ const [ basePath, ...queryParts ] = ( screen || "" ).split( "?" );
954
+ const query = queryParts.length ? "?" + queryParts.join( "?" ) : "";
955
+ if ( !basePath || !/^[\w-]+$/.test( basePath ) || !window.htmx ) {
956
+ window.location.href = "/";
957
+ } else {
958
+ const screenUrl = "/app/" + basePath + query;
959
+ // Push the URL before the HTMX swap so that any Alpine component initialized
960
+ // during the swap (via MutationObserver microtask) already sees the correct URL.
961
+ window.history.pushState( null, "", screenUrl );
962
+ this.screenTitleOverride = "";
963
+ this.currentScreen = basePath;
964
+ window.htmx.ajax( "get", screenUrl, { target: "#ti-content", swap: "innerHTML" } ).catch( () => {
965
+ window.location.href = "/";
966
+ } );
967
+ }
968
+ },
969
+
970
+ /**
971
+ * Used to update the current screen name and push the URL to history.
972
+ *
973
+ * @method
974
+ * @param {string} screen
975
+ * @public
976
+ */
977
+ setCurrentScreen( screen ) {
978
+ if ( screen ) {
979
+ this.currentScreen = screen;
980
+ this.topbarSubtitle = "";
981
+ this.screenTitleOverride = "";
982
+ this.topbarPrimaryCta = null;
983
+ }
984
+ },
985
+
986
+ /**
987
+ * Used to override the topbar/document title for the current screen, replacing the default
988
+ * `interface.topbar.<screen>` label. Pass an empty string to clear it and fall back to the label.
989
+ * Automatically cleared on screen navigation.
990
+ *
991
+ * @method
992
+ * @param {string} title
993
+ * @public
994
+ */
995
+ setScreenTitle( title ) {
996
+ this.screenTitleOverride = String( title || "" ).trim();
997
+ },
998
+
999
+ /**
1000
+ * Used to set a per-screen subtitle in the topbar, overriding the default cycle name.
1001
+ * Automatically cleared on screen navigation.
1002
+ *
1003
+ * @method
1004
+ * @param {string} subtitle
1005
+ * @public
1006
+ */
1007
+ setTopbarSubtitle( subtitle ) {
1008
+ this.topbarSubtitle = String( subtitle || "" ).trim();
1009
+ },
1010
+
1011
+ /**
1012
+ * Used to register a primary call-to-action button in the topbar for the current screen. The CTA is
1013
+ * automatically cleared on screen navigation so each screen owns its own slot. Pass {@link null} to remove
1014
+ * the CTA without leaving the screen.
1015
+ *
1016
+ * @typedef {Object} TiTopbarPrimaryCta
1017
+ * @property {string} labelKey Localization key for the button text.
1018
+ * @property {string} [icon] Optional icon class name (e.g. "plus", "send"); rendered as a leading glyph.
1019
+ * @property {string} [tone] Button tone class — "primary" (default), "danger", "ghost".
1020
+ * @property {Function} handler Click handler invoked when the button is activated.
1021
+ * @property {boolean} [disabled] Optional disabled flag.
1022
+ *
1023
+ * @method
1024
+ * @param {TiTopbarPrimaryCta|null} cta
1025
+ * @public
1026
+ */
1027
+ setTopbarPrimaryCta( cta ) {
1028
+ this.topbarPrimaryCta = ( cta && typeof cta === "object" ) ? cta : null;
1029
+ },
1030
+
1031
+ /**
1032
+ * Used to mutate just the disabled flag of the active topbar CTA without re-registering the full object.
1033
+ * Useful when validation state changes while the screen is open (e.g. cycle setup becoming lockable).
1034
+ *
1035
+ * @method
1036
+ * @param {boolean} disabled
1037
+ * @public
1038
+ */
1039
+ setTopbarPrimaryCtaDisabled( disabled ) {
1040
+ if ( this.topbarPrimaryCta ) {
1041
+ this.topbarPrimaryCta = { ...this.topbarPrimaryCta, disabled: disabled === true };
1042
+ }
1043
+ },
1044
+
1045
+ /**
1046
+ * Used to format an exception into a notification payload: a generic localized `message` plus optional, more
1047
+ * specific `details`. The returned object stringifies to its `message`, so existing string usages
1048
+ * (concatenation, `x-text`) keep working, while {@link notify} can surface the `details` on a second line.
1049
+ *
1050
+ * @method
1051
+ * @param {Object} error
1052
+ * @returns {{message: string, details: string, toString: function(): string}}
1053
+ * @public
1054
+ */
1055
+ formatException( error ) {
1056
+ const exception = error && error.exception;
1057
+ const message = ( error && error.message ) || this.getLabel( exception && exception.label );
1058
+ const rawDetails = exception && exception.data && exception.data.details;
1059
+ // Resolve the details as a label when it is a known label key; otherwise show the raw text (using it as its
1060
+ // own fallback so dynamic, non-localized messages pass through unchanged).
1061
+ const details = rawDetails ? this.getLabel( rawDetails, rawDetails ) : "";
1062
+ return {
1063
+ message: message, details: details, toString() {
1064
+ return this.message;
1065
+ }
1066
+ };
1067
+ },
1068
+
1069
+ /**
1070
+ * Used to display a notification in the notification bar. Accepts either a plain message string or a payload
1071
+ * object `{ message, details }` (e.g. the result of {@link formatException}); the optional `details` render on
1072
+ * a smaller second line.
1073
+ *
1074
+ * @method
1075
+ * @param {string|{message: string, details?: string}} message
1076
+ * @param {number} [timeout=6000]
1077
+ * @public
1078
+ */
1079
+ notify( message, timeout = 6000 ) {
1080
+ const notificationBar = document.querySelector( "#ti-notifications" );
1081
+ if ( notificationBar ) {
1082
+ const payload = ( message && typeof message === "object" ) ? message : { message: message };
1083
+ Alpine.$data( notificationBar ).add( {
1084
+ id: this.notificationIDCounter++,
1085
+ message: payload.message || this.getLabel( "error.application.unexpected" ),
1086
+ details: payload.details || "",
1087
+ timeout: timeout
1088
+ } );
1089
+ }
1090
+ },
1091
+
1092
+ /**
1093
+ * Used to extract a label from the application configuration.
1094
+ *
1095
+ * @method
1096
+ * @param {string} label
1097
+ * @param {string} fallback
1098
+ * @returns {string}
1099
+ * @public
1100
+ */
1101
+ getLabel( label, fallback = "LABEL NOT FOUND" ) {
1102
+ if ( !label ) {
1103
+ return fallback;
1104
+ } else {
1105
+ return extractLabel( this.configuration.labels || {}, label.split( "." ).filter( Boolean ), fallback );
1106
+ }
1107
+ },
1108
+
1109
+ /**
1110
+ * Returns true when the current session user holds the given role code. Safe to call before the session has
1111
+ * loaded (returns false). Provided as a helper because the Alpine CSP evaluator does not expose `Array`, so
1112
+ * `Array.isArray( ... )` cannot be written inline in templates.
1113
+ *
1114
+ * @method
1115
+ * @param {number|string} roleCode
1116
+ * @returns {boolean}
1117
+ * @public
1118
+ */
1119
+ hasRole( roleCode ) {
1120
+ const roles = this.user && this.user.roles;
1121
+ if ( !roles || typeof roles.indexOf !== "function" ) {
1122
+ return false;
1123
+ }
1124
+ return roles.indexOf( roleCode ) >= 0;
1125
+ },
1126
+
1127
+ /**
1128
+ * Toggle the sidebar between expanded and collapsed states.
1129
+ *
1130
+ * @method
1131
+ * @public
1132
+ */
1133
+ toggleCollapse() {
1134
+ this.collapsed = !this.collapsed;
1135
+ try {
1136
+ localStorage.setItem( STORAGE_KEY_COLLAPSED, String( this.collapsed ) );
1137
+ } catch { /* ignore */
1138
+ }
1139
+ },
1140
+
1141
+ /**
1142
+ * Toggle between daylight and glass themes.
1143
+ *
1144
+ * @method
1145
+ * @public
1146
+ */
1147
+ toggleTheme() {
1148
+ this.theme = ( this.theme === "daylight" ) ? "glass" : "daylight";
1149
+ this._applyTheme( this.theme );
1150
+ try {
1151
+ localStorage.setItem( STORAGE_KEY_THEME, this.theme );
1152
+ } catch { /* ignore */
1153
+ }
1154
+ },
1155
+
1156
+ /**
1157
+ * Apply a theme by setting the data-theme attribute on <html>.
1158
+ *
1159
+ * @method
1160
+ * @param {string} theme
1161
+ * @private
1162
+ */
1163
+ _applyTheme( theme ) {
1164
+ document.documentElement.dataset.theme = theme;
1165
+ },
1166
+
1167
+ /**
1168
+ * Merges app-supplied component configurations into the "tiComponentsConfig" Alpine store.
1169
+ * Each entry is deep-merged on top of the framework's default flyout config, so apps only need
1170
+ * to supply the differences.
1171
+ *
1172
+ * @method
1173
+ * @param {Object} [componentsConfig]
1174
+ * @private
1175
+ */
1176
+ _mergeComponentsConfig( componentsConfig ) {
1177
+ if ( !componentsConfig || typeof componentsConfig !== "object" ) {
1178
+ return;
1179
+ }
1180
+ const store = Alpine.store( "tiComponentsConfig" );
1181
+ Object.keys( componentsConfig ).forEach( ( key ) => {
1182
+ store[ key ] = tiToolbox.deepMerge( defaultSidebarFlyoutConfig, componentsConfig[ key ] || {} );
1183
+ } );
1184
+ }
1185
+
1186
+ };
1187
+ };
1188
+
1189
+ /**
1190
+ * Returns a callback function for the Alpine.js "text-label" directive.
1191
+ * This directive can be used to localize the text content of an element or its attributes.
1192
+ *
1193
+ * Usage instructions:
1194
+ * - To localize text content:
1195
+ * `<span x-text-label="translation.key">Fallback Text</span>`
1196
+ * - To localize an element attribute (e.g., aria-label, placeholder, title):
1197
+ * `<button x-text-label:aria-label="translation.key" aria-label="Fallback Text">...</button>`
1198
+ *
1199
+ * @method
1200
+ * @returns {Function}
1201
+ * @public
1202
+ */
1203
+ const configureDirectiveTextLabel = () => {
1204
+ return ( element, { value, expression }, { effect } ) => {
1205
+ const targetAttribute = value;
1206
+ const fallback = targetAttribute ? ( element.getAttribute( targetAttribute ) || "" ) : ( element.textContent || "" );
1207
+
1208
+ effect( () => {
1209
+ const tiApplication = Alpine.store( "tiApplication" );
1210
+ if ( !tiApplication || typeof tiApplication.getLabel !== "function" ) {
1211
+ return;
1212
+ }
1213
+ let path = ( expression || "" ).trim();
1214
+ if (
1215
+ ( path.startsWith( "'" ) && path.endsWith( "'" ) ) ||
1216
+ ( path.startsWith( "\"" ) && path.endsWith( "\"" ) )
1217
+ ) {
1218
+ path = path.slice( 1, -1 );
1219
+ }
1220
+ const translatedText = tiApplication.getLabel( path, fallback );
1221
+ if ( targetAttribute ) {
1222
+ element.setAttribute( targetAttribute, translatedText );
1223
+ } else {
1224
+ element.textContent = translatedText;
1225
+ }
1226
+ } );
1227
+ };
1228
+ };
1229
+
1230
+ /**
1231
+ * Returns a callback for the Alpine.js "ti-chart" directive. Renders a themeable SVG chart from a
1232
+ * reactive spec property path into the host <figure class="ti-chart"> element.
1233
+ *
1234
+ * Usage (CSP-legal — the expression is a bare reactive property path or a registered method call):
1235
+ * <figure class="ti-chart" x-ti-chart="coverageSpec"
1236
+ * role="img" x-bind:aria-label="coverageSpec.a11yLabel"></figure>
1237
+ *
1238
+ * The geometry/format math and the SVG assembly live in ti-charts.js (window.TiCharts); this
1239
+ * directive only wires the reactive spec to TiCharts.renderChart.
1240
+ *
1241
+ * @method
1242
+ * @returns {Function}
1243
+ * @public
1244
+ */
1245
+ const configureDirectiveTiChart = () => {
1246
+ return ( element, { expression }, { effect, evaluateLater } ) => {
1247
+ const getSpec = evaluateLater( expression );
1248
+ effect( () => {
1249
+ getSpec( ( spec ) => {
1250
+ if ( typeof window !== "undefined" && window.TiCharts && typeof window.TiCharts.renderChart === "function" ) {
1251
+ window.TiCharts.renderChart( element, spec );
1252
+ }
1253
+ } );
1254
+ } );
1255
+ };
1256
+ };
1257
+
1258
+ /**
1259
+ * Perform a one-time configuration of the HTMX framework.
1260
+ */
1261
+ document.addEventListener( "htmx:configRequest", ( event ) => {
1262
+ const tiToolbox = Alpine.store( "tiToolbox" );
1263
+ event.detail.headers[ 'x-xsrf-token' ] = tiToolbox?.getCookie( "ti-xsrf-token" ) || "";
1264
+ // Reuse the existing nonce from the active document:
1265
+ const styleNonce = ( htmx?.config?.inlineStyleNonce ) || "";
1266
+ const scriptNonce = ( htmx?.config?.inlineScriptNonce ) || "";
1267
+ event.detail.headers[ 'x-csp-nonce' ] = styleNonce || scriptNonce || "";
1268
+ } );
1269
+
1270
+ /**
1271
+ * Add a custom event listener to the HTMX framework.
1272
+ */
1273
+ document.addEventListener( "htmx:afterSwap", ( event ) => {
1274
+ const target = event.detail.target;
1275
+ if ( target.id !== "ti-content" && target.tagName !== "TI-NESTED-FRAME-PLACEHOLDER" ) return;
1276
+ const path = ( event.detail.pathInfo && event.detail.pathInfo.requestPath ) || "";
1277
+ const match = path.match( /^\/app\/([\w-]+)/ );
1278
+ if ( match ) {
1279
+ const tiApplication = Alpine.store( "tiApplication" );
1280
+ if ( tiApplication ) {
1281
+ tiApplication.setCurrentScreen( match[ 1 ] );
1282
+ }
1283
+ }
1284
+ } );
1285
+
1286
+ document.addEventListener( "htmx:responseError", ( event ) => {
1287
+ // If the server sent HX-Trigger with our payload, it will also emit a separate event,
1288
+ // But here we parse body as fallback when body is JSON
1289
+ try {
1290
+ const xhr = event.detail.xhr;
1291
+ const contentType = xhr.getResponseHeader( "Content-Type" ) || "";
1292
+ if ( contentType.includes( "application/json" ) && xhr.responseText ) {
1293
+ const data = JSON.parse( xhr.responseText );
1294
+ const tiApplication = Alpine.store( "tiApplication" );
1295
+ if ( tiApplication && tiApplication.isInitialized ) {
1296
+ tiApplication.notify( tiApplication.formatException( data ) );
1297
+ }
1298
+ }
1299
+ } catch {
1300
+ // Do nothing here...
1301
+ }
1302
+ } );
1303
+
1304
+ /**
1305
+ * Returns a configuration object for the login screen test user pill panel.
1306
+ * <br/>
1307
+ * NOTE: This is a TEMPORARY testing aid that injects an employeeID into the session via a cookie which the
1308
+ * server-side {@link augmentSession} reads; roles are derived by the app unless the opt-in "override roles (dev)"
1309
+ * toggle is on, in which case the profile's roles are written too. Remove together with the panel HTML once real
1310
+ * identity propagation is in place.
1311
+ *
1312
+ * @method
1313
+ * @returns {Object}
1314
+ * @public
1315
+ */
1316
+ const configureLoginTestUserPanel = () => {
1317
+ const COOKIE_NAME = "ti-test-user";
1318
+ const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 30;
1319
+
1320
+ const readCookie = () => {
1321
+ try {
1322
+ const tiToolbox = Alpine.store( "tiToolbox" );
1323
+ const raw = tiToolbox.getCookie( COOKIE_NAME );
1324
+ if ( !raw ) return null;
1325
+ const parsed = JSON.parse( raw );
1326
+ return ( parsed && parsed.employeeID ) ? parsed : null;
1327
+ } catch {
1328
+ return null;
1329
+ }
1330
+ };
1331
+
1332
+ const writeCookie = ( value ) => {
1333
+ const encoded = encodeURIComponent( JSON.stringify( value ) );
1334
+ document.cookie = `${ COOKIE_NAME }=${ encoded }; path=/; max-age=${ COOKIE_MAX_AGE_SECONDS }; SameSite=Lax`;
1335
+ };
1336
+
1337
+ const clearCookie = () => {
1338
+ document.cookie = `${ COOKIE_NAME }=; path=/; max-age=0; SameSite=Lax`;
1339
+ };
1340
+
1341
+ return {
1342
+ profiles: [
1343
+ { employeeID: "22", roles: [ 1, 2, 3 ] },
1344
+ { employeeID: "20", roles: [ 1, 2 ] },
1345
+ { employeeID: "11", roles: [ 1, 2 ] },
1346
+ { employeeID: "1", roles: [ 1 ] },
1347
+ { employeeID: "3", roles: [ 1 ] },
1348
+ { employeeID: "4", roles: [ 1 ] },
1349
+ { employeeID: "8", roles: [ 1, 2 ] },
1350
+ { employeeID: "9", roles: [ 1 ] }
1351
+ ],
1352
+ selected: null,
1353
+ overrideRoles: false,
1354
+
1355
+ init() {
1356
+ this.selected = readCookie();
1357
+ this.overrideRoles = Boolean( this.selected && Array.isArray( this.selected.roles ) && this.selected.roles.length > 0 );
1358
+ },
1359
+
1360
+ isSelected( profile ) {
1361
+ return Boolean( this.selected && this.selected.employeeID === profile.employeeID );
1362
+ },
1363
+
1364
+ select( profile ) {
1365
+ this.selected = this.overrideRoles
1366
+ ? { employeeID: profile.employeeID, roles: profile.roles.slice() }
1367
+ : { employeeID: profile.employeeID };
1368
+ writeCookie( this.selected );
1369
+ },
1370
+
1371
+ onOverrideChanged() {
1372
+ // `overrideRoles` is already updated by x-model; just re-write the cookie for the current selection
1373
+ // so the new override setting takes effect immediately.
1374
+ if ( this.selected ) {
1375
+ // Turning the override OFF must always strip any persisted roles from the cookie — even when the
1376
+ // selected employee is no longer in `profiles` (cookie from an older profile list or set manually) —
1377
+ // so a stale roles array can't keep overriding the org-derived roles on the next login.
1378
+ if ( !this.overrideRoles ) {
1379
+ this.selected = { employeeID: this.selected.employeeID };
1380
+ writeCookie( this.selected );
1381
+ return;
1382
+ }
1383
+ const profile = this.profiles.find( ( candidate ) => candidate.employeeID === this.selected.employeeID );
1384
+ if ( profile ) {
1385
+ this.select( profile );
1386
+ }
1387
+ }
1388
+ },
1389
+
1390
+ clear() {
1391
+ this.selected = null;
1392
+ clearCookie();
1393
+ }
1394
+ };
1395
+ };
1396
+
1397
+ /**
1398
+ * Register on-initialization tasks for the Alpine.js framework.
1399
+ */
1400
+ document.addEventListener( "alpine:init", () => {
1401
+ // Note: Sequence here is important!
1402
+ Alpine.directive( "text-label", configureDirectiveTextLabel() );
1403
+ Alpine.directive( "ti-chart", configureDirectiveTiChart() );
1404
+ Alpine.store( "tiToolbox", configureToolbox() );
1405
+ Alpine.store( "tiApplication", configureApplication() );
1406
+ Alpine.store( "tiComponentsConfig", {} );
1407
+ Alpine.data( "tiApplication", () => ( {
1408
+ get collapsed() {
1409
+ return Alpine.store( "tiApplication" ).collapsed;
1410
+ },
1411
+ get theme() {
1412
+ return Alpine.store( "tiApplication" ).theme;
1413
+ },
1414
+ toggleCollapse() {
1415
+ Alpine.store( "tiApplication" ).toggleCollapse();
1416
+ },
1417
+ toggleTheme() {
1418
+ Alpine.store( "tiApplication" ).toggleTheme();
1419
+ }
1420
+ } ) );
1421
+ Alpine.data( "tiComponentSidebarNav", configureSidebarNav );
1422
+ Alpine.data( "tiComponentTopbar", configureComponentTopbar );
1423
+ Alpine.data( "tiComponentSidebarFlyout", configureComponentSidebarFlyout );
1424
+ Alpine.data( "tiComponentNotificationBar", configureComponentNotificationBar );
1425
+ Alpine.data( "tiComponentTooltip", configureComponentTooltip );
1426
+ Alpine.data( "tiLoginTestUserPanel", configureLoginTestUserPanel );
1427
+ } );