@liwe3/webcomponents 1.0.2 → 1.0.14

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.
@@ -0,0 +1,595 @@
1
+ /**
2
+ * PopoverMenu Web Component
3
+ * A customizable menu component using fixed positioning with support for nested submenus
4
+ */
5
+
6
+ export type PopoverMenuItem = {
7
+ label: string;
8
+ enabled?: boolean;
9
+ items?: PopoverMenuItem[];
10
+ onclick?: () => void;
11
+ };
12
+
13
+ export type PopoverMenuConfig = {
14
+ label: string;
15
+ items: PopoverMenuItem[];
16
+ };
17
+
18
+ export class PopoverMenuElement extends HTMLElement {
19
+ declare shadowRoot: ShadowRoot;
20
+ private items: PopoverMenuConfig[] = [];
21
+ private openPopovers: Map<string, HTMLElement> = new Map();
22
+ private hoverTimeouts: Map<string, number> = new Map();
23
+ private initialized = false;
24
+ private globalClickHandler: ( ( e: MouseEvent ) => void ) | null = null;
25
+
26
+ constructor () {
27
+ super();
28
+ this.attachShadow( { mode: 'open' } );
29
+ }
30
+
31
+ connectedCallback (): void {
32
+ if ( !this.initialized ) {
33
+ this.render();
34
+ this.setupGlobalListeners();
35
+ this.initialized = true;
36
+ }
37
+ }
38
+
39
+ disconnectedCallback (): void {
40
+ this.cleanupGlobalListeners();
41
+ }
42
+
43
+ /**
44
+ * Set up global event listeners
45
+ */
46
+ private setupGlobalListeners (): void {
47
+ // Add global click listener to close menus when clicking outside
48
+ this.globalClickHandler = ( e: MouseEvent ) => {
49
+ if ( !this.contains( e.target as Node ) && !this.shadowRoot.contains( e.target as Node ) ) {
50
+ this.closeAllMenus();
51
+ }
52
+ };
53
+ document.addEventListener( 'click', this.globalClickHandler );
54
+ }
55
+
56
+ /**
57
+ * Clean up global event listeners
58
+ */
59
+ private cleanupGlobalListeners (): void {
60
+ if ( this.globalClickHandler ) {
61
+ document.removeEventListener( 'click', this.globalClickHandler );
62
+ this.globalClickHandler = null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Set menu items
68
+ */
69
+ setItems ( items: PopoverMenuConfig[] ): void {
70
+ this.items = items;
71
+ this.render();
72
+ }
73
+
74
+ /**
75
+ * Get current menu items
76
+ */
77
+ getItems (): PopoverMenuConfig[] {
78
+ return this.items;
79
+ }
80
+
81
+ /**
82
+ * Add a new menu item
83
+ */
84
+ addMenuItem ( item: PopoverMenuConfig, index: number | null = null ): void {
85
+ if ( index === null ) {
86
+ this.items.push( item );
87
+ } else {
88
+ this.items.splice( index, 0, item );
89
+ }
90
+ this.render();
91
+ }
92
+
93
+ /**
94
+ * Remove a menu item
95
+ */
96
+ removeMenuItem ( index: number ): void {
97
+ if ( index >= 0 && index < this.items.length ) {
98
+ this.items.splice( index, 1 );
99
+ this.render();
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Update a menu item
105
+ */
106
+ updateMenuItem ( index: number, item: PopoverMenuConfig ): void {
107
+ if ( index >= 0 && index < this.items.length ) {
108
+ this.items[ index ] = item;
109
+ this.render();
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Render the menu component
115
+ */
116
+ private render (): void {
117
+ // Clear all existing content
118
+ const existingContainer = this.shadowRoot.querySelector( '.popover-menu-bar' );
119
+ if ( existingContainer ) {
120
+ existingContainer.remove();
121
+ }
122
+
123
+ // Clear popover tracking
124
+ this.openPopovers.clear();
125
+
126
+ // Clear any existing hover timeouts
127
+ this.hoverTimeouts.forEach( timeout => clearTimeout( timeout ) );
128
+ this.hoverTimeouts.clear();
129
+
130
+ this.shadowRoot.innerHTML = `
131
+ <style>
132
+ :host {
133
+ display: block;
134
+ font-family: var(--font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
135
+ }
136
+
137
+ /* PopoverMenu Component Styles */
138
+ .popover-menu-bar {
139
+ display: flex;
140
+ background: var(--popover-menu-bar-background, #fff);
141
+ border: 1px solid var(--popover-menu-bar-border, #ddd);
142
+ border-radius: var(--popover-menu-bar-radius, 6px);
143
+ padding: var(--popover-menu-bar-padding, 4px);
144
+ box-shadow: var(--popover-menu-bar-shadow, 0 1px 3px rgba(0,0,0,0.1));
145
+ }
146
+
147
+ .popover-menu-trigger {
148
+ background: none;
149
+ border: none;
150
+ padding: 8px 16px;
151
+ cursor: pointer;
152
+ border-radius: 4px;
153
+ font-size: 14px;
154
+ transition: background-color 0.2s;
155
+ font-family: inherit;
156
+ color: var(--popover-menu-trigger-color, #333);
157
+ }
158
+
159
+ .popover-menu-trigger:hover {
160
+ background: var(--popover-menu-trigger-hover-bg, #f0f0f0);
161
+ }
162
+
163
+ .popover-menu-trigger.active {
164
+ background: var(--popover-menu-trigger-active-bg, #e3f2fd);
165
+ color: var(--popover-menu-trigger-active-color, #1976d2);
166
+ }
167
+
168
+ .popover-menu-popover {
169
+ margin: 0;
170
+ padding: 4px;
171
+ border: 1px solid var(--popover-menu-border, #ccc);
172
+ border-radius: var(--popover-menu-radius, 6px);
173
+ background: var(--popover-menu-background, white);
174
+ box-shadow: var(--popover-menu-shadow, 0 4px 12px rgba(0,0,0,0.15));
175
+ min-width: 180px;
176
+ z-index: 1000;
177
+ font-family: inherit;
178
+ position: fixed;
179
+ display: none;
180
+ }
181
+
182
+ .popover-menu-item {
183
+ display: flex;
184
+ align-items: center;
185
+ justify-content: space-between;
186
+ padding: 8px 12px;
187
+ cursor: pointer;
188
+ border-radius: 4px;
189
+ font-size: 14px;
190
+ transition: background-color 0.2s;
191
+ position: relative;
192
+ color: var(--popover-menu-item-color, #333);
193
+ }
194
+
195
+ .popover-menu-item:hover {
196
+ background: var(--popover-menu-item-hover-bg, #f5f5f5);
197
+ }
198
+
199
+ .popover-menu-item.disabled {
200
+ color: var(--popover-menu-item-disabled-color, #999);
201
+ cursor: not-allowed;
202
+ }
203
+
204
+ .popover-menu-item.disabled:hover {
205
+ background: transparent;
206
+ }
207
+
208
+ .popover-menu-item.has-submenu::after {
209
+ content: '▶';
210
+ font-size: 10px;
211
+ color: var(--popover-menu-submenu-arrow-color, #666);
212
+ }
213
+
214
+ .popover-menu-separator {
215
+ height: 1px;
216
+ background: var(--popover-menu-separator-color, #e0e0e0);
217
+ margin: 4px 0;
218
+ }
219
+
220
+ .popover-submenu-popover {
221
+ margin: 0;
222
+ padding: 4px;
223
+ border: 1px solid var(--popover-menu-border, #ccc);
224
+ border-radius: var(--popover-menu-radius, 6px);
225
+ background: var(--popover-menu-background, white);
226
+ box-shadow: var(--popover-menu-shadow, 0 4px 12px rgba(0,0,0,0.15));
227
+ min-width: 160px;
228
+ z-index: 1001;
229
+ font-family: inherit;
230
+ position: fixed;
231
+ display: none;
232
+ }
233
+ </style>
234
+ <div class="popover-menu-bar"></div>
235
+ `;
236
+
237
+ const menuBar = this.shadowRoot.querySelector( '.popover-menu-bar' ) as HTMLElement;
238
+
239
+ this.items.forEach( ( item, index ) => {
240
+ const trigger = this.createMenuTrigger( item, index );
241
+ menuBar.appendChild( trigger );
242
+ } );
243
+ }
244
+
245
+ /**
246
+ * Create a menu trigger button
247
+ */
248
+ private createMenuTrigger ( item: PopoverMenuConfig, index: number ): HTMLElement {
249
+ const trigger = document.createElement( 'button' );
250
+ trigger.className = 'popover-menu-trigger';
251
+ trigger.textContent = item.label;
252
+ trigger.dataset.menuIndex = index.toString();
253
+
254
+ trigger.addEventListener( 'click', ( e ) => {
255
+ e.preventDefault();
256
+ e.stopPropagation();
257
+ this.handleMenuTriggerClick( trigger, item.items, index );
258
+ } );
259
+
260
+ return trigger;
261
+ }
262
+
263
+ /**
264
+ * Create a popover if it doesn't exist
265
+ */
266
+ private createPopoverIfNeeded ( items: PopoverMenuItem[], id: string, isSubmenu = false ): HTMLElement {
267
+ let popover = this.shadowRoot.querySelector( `#${ id }` ) as HTMLElement;
268
+
269
+ if ( !popover ) {
270
+ popover = document.createElement( 'div' );
271
+ popover.id = id;
272
+ popover.className = isSubmenu ? 'popover-submenu-popover' : 'popover-menu-popover';
273
+ popover.style.display = 'none';
274
+ popover.style.position = 'fixed';
275
+ this.shadowRoot.appendChild( popover );
276
+
277
+ // Track this popover
278
+ this.openPopovers.set( id, popover );
279
+ }
280
+
281
+ // Always repopulate with current items to ensure content is up-to-date
282
+ this.populatePopover( popover, items, id );
283
+
284
+ return popover;
285
+ }
286
+
287
+ /**
288
+ * Populate a popover with menu items
289
+ */
290
+ private populatePopover ( popover: HTMLElement, items: PopoverMenuItem[], baseId: string ): void {
291
+ popover.innerHTML = '';
292
+
293
+ items.forEach( ( item, index ) => {
294
+ if ( item.label === '---sep' ) {
295
+ const separator = document.createElement( 'div' );
296
+ separator.className = 'popover-menu-separator';
297
+ popover.appendChild( separator );
298
+ } else {
299
+ const menuItem = this.createMenuItem( item, `${ baseId }-item-${ index }` );
300
+ popover.appendChild( menuItem );
301
+ }
302
+ } );
303
+ }
304
+
305
+ /**
306
+ * Create a menu item element
307
+ */
308
+ private createMenuItem ( item: PopoverMenuItem, id: string ): HTMLElement {
309
+ const menuItem = document.createElement( 'div' );
310
+ menuItem.className = 'popover-menu-item';
311
+ menuItem.textContent = item.label;
312
+
313
+ if ( item.enabled === false ) {
314
+ menuItem.classList.add( 'disabled' );
315
+ return menuItem;
316
+ }
317
+
318
+ if ( item.items && item.items.length > 0 ) {
319
+ // Has submenu
320
+ menuItem.classList.add( 'has-submenu' );
321
+ const submenuId = `${ id }-submenu`;
322
+
323
+ let hoverTimeout: number;
324
+
325
+ menuItem.addEventListener( 'mouseenter', ( e ) => {
326
+ e.stopPropagation();
327
+
328
+ // Clear any existing timeout
329
+ if ( this.hoverTimeouts.has( submenuId ) ) {
330
+ clearTimeout( this.hoverTimeouts.get( submenuId ) );
331
+ }
332
+
333
+ hoverTimeout = window.setTimeout( () => {
334
+ this.closeOtherSubmenus( submenuId );
335
+ this.showSubmenu( item.items!, submenuId, menuItem );
336
+ }, 100 );
337
+
338
+ this.hoverTimeouts.set( submenuId, hoverTimeout );
339
+ } );
340
+
341
+ menuItem.addEventListener( 'mouseleave', ( e ) => {
342
+ e.stopPropagation();
343
+ } );
344
+
345
+ menuItem.addEventListener( 'click', ( e ) => {
346
+ e.stopPropagation();
347
+ this.closeOtherSubmenus( submenuId );
348
+ this.showSubmenu( item.items!, submenuId, menuItem );
349
+ } );
350
+ } else if ( item.onclick ) {
351
+ // Regular menu item with click handler
352
+ menuItem.addEventListener( 'click', ( e ) => {
353
+ e.stopPropagation();
354
+ item.onclick!();
355
+ this.closeAllMenus();
356
+ } );
357
+ }
358
+
359
+ return menuItem;
360
+ }
361
+
362
+ /**
363
+ * Show a submenu
364
+ */
365
+ private showSubmenu ( items: PopoverMenuItem[], submenuId: string, parentItem: HTMLElement ): void {
366
+ const submenu = this.createPopoverIfNeeded( items, submenuId, true );
367
+
368
+ // Show the menu first so it's in the DOM and can be measured
369
+ submenu.style.display = 'block';
370
+
371
+ // Get the parent item's position AFTER showing the menu
372
+ const rect = parentItem.getBoundingClientRect();
373
+ submenu.style.position = 'fixed';
374
+ submenu.style.left = `${ rect.right + 5 }px`;
375
+ submenu.style.top = `${ rect.top }px`;
376
+
377
+ // Adjust position with comprehensive overflow handling
378
+ requestAnimationFrame( () => {
379
+ const newRect = parentItem.getBoundingClientRect();
380
+ this.adjustPopoverPosition( submenu, newRect, 'submenu' );
381
+ } );
382
+ }
383
+
384
+ /**
385
+ * Close other submenus except the specified one and its ancestors
386
+ */
387
+ private closeOtherSubmenus ( exceptSubmenuId: string | null = null ): void {
388
+ // Build a set of IDs to keep open (the submenu being opened and all its ancestors)
389
+ const idsToKeep = new Set<string>();
390
+ if ( exceptSubmenuId ) {
391
+ idsToKeep.add( exceptSubmenuId );
392
+
393
+ // Add all ancestor IDs by traversing up the ID hierarchy
394
+ let currentId = exceptSubmenuId;
395
+ while ( currentId.includes( '-submenu' ) ) {
396
+ // Remove the last submenu part to get the parent
397
+ const lastSubmenuIndex = currentId.lastIndexOf( '-submenu' );
398
+ if ( lastSubmenuIndex > 0 ) {
399
+ // Find the item part before this submenu
400
+ const beforeSubmenu = currentId.substring( 0, lastSubmenuIndex );
401
+ const lastItemIndex = beforeSubmenu.lastIndexOf( '-item-' );
402
+ if ( lastItemIndex > 0 ) {
403
+ currentId = currentId.substring( 0, lastItemIndex );
404
+ if ( currentId.endsWith( '-submenu' ) ) {
405
+ idsToKeep.add( currentId );
406
+ }
407
+ } else {
408
+ break;
409
+ }
410
+ } else {
411
+ break;
412
+ }
413
+ }
414
+ }
415
+
416
+ // Close all submenus that are not in the keep set
417
+ this.openPopovers.forEach( ( popover, id ) => {
418
+ if ( !idsToKeep.has( id ) && popover.classList.contains( 'popover-submenu-popover' ) ) {
419
+ popover.style.display = 'none';
420
+ }
421
+ } );
422
+ }
423
+
424
+ /**
425
+ * Close all menus
426
+ */
427
+ private closeAllMenus (): void {
428
+ // Clear all hover timeouts
429
+ this.hoverTimeouts.forEach( timeout => clearTimeout( timeout ) );
430
+ this.hoverTimeouts.clear();
431
+
432
+ this.openPopovers.forEach( popover => {
433
+ popover.style.display = 'none';
434
+ } );
435
+
436
+ // Remove active class from all triggers
437
+ this.shadowRoot.querySelectorAll( '.popover-menu-trigger' ).forEach( t => t.classList.remove( 'active' ) );
438
+ }
439
+
440
+ /**
441
+ * Handle menu trigger click
442
+ */
443
+ private handleMenuTriggerClick ( trigger: HTMLElement, items: PopoverMenuItem[], index: number ): void {
444
+ const popoverId = `menu-${ index }`;
445
+ const popover = this.shadowRoot.querySelector( `#${ popoverId }` ) as HTMLElement;
446
+
447
+ if ( popover && popover.style.display === 'block' ) {
448
+ // Menu is open, close it
449
+ this.closeAllMenus();
450
+ } else {
451
+ // Close other menus first
452
+ this.closeAllMenus();
453
+
454
+ // Create the popover and show it
455
+ const mainPopover = this.createPopoverIfNeeded( items, popoverId );
456
+
457
+ // Add active class to clicked trigger
458
+ trigger.classList.add( 'active' );
459
+
460
+ // Position and show the popover
461
+ this.showMainMenu( mainPopover, trigger );
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Show the main menu
467
+ */
468
+ private showMainMenu ( popover: HTMLElement, trigger: HTMLElement ): void {
469
+ // Show the menu first so it's in the DOM and can be measured
470
+ popover.style.display = 'block';
471
+
472
+ const rect = trigger.getBoundingClientRect();
473
+ popover.style.position = 'fixed';
474
+ popover.style.left = `${ rect.left }px`;
475
+ popover.style.top = `${ rect.bottom + 2 }px`;
476
+
477
+ // Adjust position with comprehensive overflow handling
478
+ requestAnimationFrame( () => {
479
+ const updatedRect = trigger.getBoundingClientRect();
480
+ this.adjustPopoverPosition( popover, updatedRect, 'main' );
481
+ } );
482
+ }
483
+
484
+ /**
485
+ * Adjust popover position to handle overflow
486
+ */
487
+ private adjustPopoverPosition ( popover: HTMLElement, triggerRect: DOMRect, type: 'main' | 'submenu' ): void {
488
+ const popoverRect = popover.getBoundingClientRect();
489
+ const viewportWidth = window.innerWidth;
490
+ const viewportHeight = window.innerHeight;
491
+ const margin = 10; // Minimum margin from screen edges
492
+
493
+ let newLeft = parseFloat( popover.style.left );
494
+ let newTop = parseFloat( popover.style.top );
495
+
496
+ if ( type === 'main' ) {
497
+ // Main menu positioning
498
+
499
+ // Handle horizontal overflow - check right edge first
500
+ if ( popoverRect.right > viewportWidth - margin ) {
501
+ // If overflowing right, align right edge of popover with right edge of trigger
502
+ newLeft = triggerRect.right - popoverRect.width;
503
+ }
504
+
505
+ // Then ensure minimum margin from left edge
506
+ if ( newLeft < margin ) {
507
+ newLeft = margin;
508
+ }
509
+
510
+ // If popover is wider than viewport, center it
511
+ if ( popoverRect.width > viewportWidth - ( 2 * margin ) ) {
512
+ newLeft = margin;
513
+ }
514
+
515
+ // Handle vertical overflow - check bottom first
516
+ if ( popoverRect.bottom > viewportHeight - margin ) {
517
+ // Try positioning above the trigger
518
+ newTop = triggerRect.top - popoverRect.height - 2;
519
+
520
+ // If positioning above would go off-screen, position at bottom margin
521
+ if ( newTop < margin ) {
522
+ newTop = viewportHeight - popoverRect.height - margin;
523
+ }
524
+ }
525
+
526
+ // Ensure minimum margin from top
527
+ if ( newTop < margin ) {
528
+ newTop = margin;
529
+ }
530
+
531
+ // If popover is taller than viewport, position at top margin
532
+ if ( popoverRect.height > viewportHeight - ( 2 * margin ) ) {
533
+ newTop = margin;
534
+ }
535
+
536
+ } else if ( type === 'submenu' ) {
537
+ // Submenu positioning
538
+
539
+ // Handle horizontal overflow
540
+ if ( popoverRect.right > viewportWidth - margin ) {
541
+ // Position to the left of the parent item
542
+ newLeft = triggerRect.left - popoverRect.width - 5;
543
+
544
+ // If still overflowing left, position at margin from left edge
545
+ if ( newLeft < margin ) {
546
+ newLeft = margin;
547
+ }
548
+ }
549
+
550
+ // Ensure minimum margin from left edge
551
+ if ( newLeft < margin ) {
552
+ newLeft = margin;
553
+ }
554
+
555
+ // Handle vertical overflow
556
+ if ( popoverRect.bottom > viewportHeight - margin ) {
557
+ // Try aligning bottom of submenu with bottom of parent item
558
+ newTop = triggerRect.bottom - popoverRect.height;
559
+
560
+ // If that would go off-screen at top, position at bottom margin
561
+ if ( newTop < margin ) {
562
+ newTop = viewportHeight - popoverRect.height - margin;
563
+ }
564
+ }
565
+
566
+ // Ensure minimum margin from top
567
+ if ( newTop < margin ) {
568
+ newTop = margin;
569
+ }
570
+
571
+ // If submenu is taller than viewport, position at top margin
572
+ if ( popoverRect.height > viewportHeight - ( 2 * margin ) ) {
573
+ newTop = margin;
574
+ }
575
+ }
576
+
577
+ // Apply the new position
578
+ popover.style.left = `${ newLeft }px`;
579
+ popover.style.top = `${ newTop }px`;
580
+ }
581
+ }
582
+
583
+ /**
584
+ * Conditionally defines the custom element if in a browser environment.
585
+ */
586
+ const definePopoverMenu = ( tagName = 'liwe3-popover-menu' ): void => {
587
+ if ( typeof window !== 'undefined' && !window.customElements.get( tagName ) ) {
588
+ customElements.define( tagName, PopoverMenuElement );
589
+ }
590
+ };
591
+
592
+ // Auto-register with default tag name
593
+ definePopoverMenu();
594
+
595
+ export { definePopoverMenu };