ng-primitives 0.122.0 → 0.123.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,13 +4,13 @@ import * as i0 from '@angular/core';
4
4
  import { InjectionToken, inject, Injector, ViewContainerRef, signal, computed, effect, input, booleanAttribute, Directive, output, numberAttribute } from '@angular/core';
5
5
  import { ngpRovingFocusItem, provideRovingFocusItemState, ngpRovingFocusGroup, provideRovingFocusGroupState } from 'ng-primitives/roving-focus';
6
6
  import { ngpButton } from 'ng-primitives/button';
7
- import { injectElementRef } from 'ng-primitives/internal';
7
+ import { injectElementRef, createHoverBridge } from 'ng-primitives/internal';
8
8
  import { createPrimitive, controlled, attrBinding, dataBinding, listener, deprecatedSetter, styleBinding, emitter } from 'ng-primitives/state';
9
9
  import { Directionality } from '@angular/cdk/bidi';
10
10
  import { ngpFocusTrap, provideFocusTrapState } from 'ng-primitives/focus-trap';
11
11
  import { Subject } from 'rxjs';
12
+ import { injectDisposables, safeTakeUntilDestroyed } from 'ng-primitives/utils';
12
13
  import { FocusMonitor } from '@angular/cdk/a11y';
13
- import { safeTakeUntilDestroyed } from 'ng-primitives/utils';
14
14
 
15
15
  const defaultMenuConfig = {
16
16
  offset: 4,
@@ -52,6 +52,7 @@ const [NgpMenuTriggerStateToken, ngpMenuTrigger, _injectMenuTriggerState, provid
52
52
  const injector = inject(Injector);
53
53
  const viewContainerRef = inject(ViewContainerRef);
54
54
  const directionality = inject(Directionality);
55
+ const disposables = injectDisposables();
55
56
  // Controlled properties
56
57
  const menu = controlled(_menu);
57
58
  const disabled = controlled(_disabled);
@@ -70,11 +71,21 @@ const [NgpMenuTriggerStateToken, ngpMenuTrigger, _injectMenuTriggerState, provid
70
71
  const pointerOverTrigger = signal(false, ...(ngDevMode ? [{ debugName: "pointerOverTrigger" }] : /* istanbul ignore next */ []));
71
72
  const pointerOverContent = signal(false, ...(ngDevMode ? [{ debugName: "pointerOverContent" }] : /* istanbul ignore next */ []));
72
73
  const isPointerOverMenuArea = computed(() => pointerOverTrigger() || pointerOverContent(), ...(ngDevMode ? [{ debugName: "isPointerOverMenuArea" }] : /* istanbul ignore next */ []));
74
+ // Safe-polygon hover intent: while the pointer travels inside a corridor from
75
+ // the trigger exit point toward the open menu, the menu stays open. Reversing
76
+ // away closes it (requireForwardMovement).
77
+ const hoverBridge = createHoverBridge({
78
+ isPointerInAnchor: isPointerOverMenuArea,
79
+ close: () => hide(),
80
+ requireForwardMovement: true,
81
+ });
73
82
  // Reset pointer tracking when menu closes
74
83
  effect(() => {
75
84
  const isOpen = open();
76
- // When menu closes, reset pointer tracking state
85
+ // When menu closes, reset pointer tracking state and tear down any
86
+ // in-progress hover bridge (and its global listener).
77
87
  if (!isOpen) {
88
+ hoverBridge.clear();
78
89
  pointerOverTrigger.set(false);
79
90
  pointerOverContent.set(false);
80
91
  }
@@ -110,8 +121,10 @@ const [NgpMenuTriggerStateToken, ngpMenuTrigger, _injectMenuTriggerState, provid
110
121
  return;
111
122
  }
112
123
  pointerOverTrigger.set(true);
113
- // If already open, cancel any pending hide
124
+ // If already open, cancel any pending hide - the pointer is back on the
125
+ // trigger, so any in-progress hover bridge is no longer needed.
114
126
  if (open()) {
127
+ hoverBridge.clear();
115
128
  overlay()?.cancelPendingClose();
116
129
  return;
117
130
  }
@@ -126,17 +139,31 @@ const [NgpMenuTriggerStateToken, ngpMenuTrigger, _injectMenuTriggerState, provid
126
139
  return;
127
140
  }
128
141
  pointerOverTrigger.set(false);
142
+ const currentOverlay = overlay();
129
143
  // If the overlay hasn't been created, there's nothing to cancel
130
- if (!overlay()) {
144
+ if (!currentOverlay) {
145
+ return;
146
+ }
147
+ // Build a safe-polygon corridor from the pointer exit point toward the
148
+ // open menu. While the pointer stays inside it the menu stays open, so
149
+ // diagonal travel across the offset gap doesn't collapse the menu.
150
+ const menuElement = open() ? currentOverlay.getElements()[0] : undefined;
151
+ const started = !!menuElement &&
152
+ hoverBridge.track({
153
+ triggerRect: element.nativeElement.getBoundingClientRect(),
154
+ targetRect: menuElement.getBoundingClientRect(),
155
+ exitPoint: { x: event.clientX, y: event.clientY },
156
+ });
157
+ if (started) {
158
+ currentOverlay.cancelPendingClose();
131
159
  return;
132
160
  }
133
- // Use a small delay to allow moving to content
134
- setTimeout(() => {
135
- // Only hide if pointer is not over trigger or content
161
+ // Fall back to a small grace period when we can't build the corridor.
162
+ disposables.setTimeout(() => {
136
163
  if (!isPointerOverMenuArea()) {
137
164
  hide();
138
165
  }
139
- }, 50); // Small grace period for moving between trigger and content
166
+ }, 50);
140
167
  }
141
168
  function onFocus() {
142
169
  if (disabled?.() || !triggers().includes('focus')) {
@@ -309,9 +336,14 @@ const [NgpMenuTriggerStateToken, ngpMenuTrigger, _injectMenuTriggerState, provid
309
336
  */
310
337
  function setPointerOverContent(isOver) {
311
338
  pointerOverContent.set(isOver);
312
- if (!isOver && open() && triggers().includes('hover')) {
339
+ if (isOver) {
340
+ // The pointer reached the menu - the hover bridge has served its purpose.
341
+ hoverBridge.clear();
342
+ return;
343
+ }
344
+ if (open() && triggers().includes('hover')) {
313
345
  // Use a small delay to allow pointer to move back to trigger
314
- setTimeout(() => {
346
+ disposables.setTimeout(() => {
315
347
  // Only hide if pointer is not over trigger or content
316
348
  if (!isPointerOverMenuArea()) {
317
349
  hide();
@@ -432,21 +464,48 @@ const [NgpMenuStateToken, ngpMenu, injectMenuState, provideMenuState] = createPr
432
464
  };
433
465
  });
434
466
 
435
- const [NgpSubmenuTriggerStateToken, ngpSubmenuTrigger, injectSubmenuTriggerState, provideSubmenuTriggerState,] = createPrimitive('NgpSubmenuTrigger', ({ disabled: _disabled = signal(false), menu: _menu = signal(undefined), placement: _placement = signal('right-start'), offset: _offset = signal(0), flip: _flip = signal(true), }) => {
467
+ const [NgpSubmenuTriggerStateToken, ngpSubmenuTrigger, injectSubmenuTriggerState, provideSubmenuTriggerState,] = createPrimitive('NgpSubmenuTrigger', ({ disabled: _disabled = signal(false), menu: _menu = signal(undefined), placement: _placement = signal('right-start'), offset: _offset = signal(0), flip: _flip = signal(true), container: _container, }) => {
436
468
  const element = injectElementRef();
437
469
  const injector = inject(Injector);
438
470
  const viewContainerRef = inject(ViewContainerRef);
439
471
  const parentMenu = injectMenuState({ optional: true });
440
472
  const focusMonitor = inject(FocusMonitor);
473
+ const disposables = injectDisposables();
441
474
  // Controlled properties
442
475
  const menu = controlled(_menu);
443
476
  const disabled = controlled(_disabled);
444
477
  const placement = controlled(_placement);
445
478
  const flip = controlled(_flip);
446
479
  const offset = controlled(_offset);
480
+ const container = controlled(_container, 'body');
447
481
  const overlay = signal(null, ...(ngDevMode ? [{ debugName: "overlay" }] : /* istanbul ignore next */ []));
448
482
  const open = computed(() => overlay()?.isOpen() ?? false, ...(ngDevMode ? [{ debugName: "open" }] : /* istanbul ignore next */ []));
449
483
  const openOrigin = signal('program', ...(ngDevMode ? [{ debugName: "openOrigin" }] : /* istanbul ignore next */ []));
484
+ // Track pointer presence for the safe-polygon hover bridge.
485
+ const pointerOverTrigger = signal(false, ...(ngDevMode ? [{ debugName: "pointerOverTrigger" }] : /* istanbul ignore next */ []));
486
+ const pointerOverContent = signal(false, ...(ngDevMode ? [{ debugName: "pointerOverContent" }] : /* istanbul ignore next */ []));
487
+ const isPointerOverSubmenu = computed(() => pointerOverTrigger() || pointerOverContent(), ...(ngDevMode ? [{ debugName: "isPointerOverSubmenu" }] : /* istanbul ignore next */ []));
488
+ // Safe-polygon hover intent: while the pointer travels inside a corridor from
489
+ // the submenu trigger toward the open submenu, sibling hover events are
490
+ // ignored so the submenu doesn't collapse mid-traversal.
491
+ const hoverBridge = createHoverBridge({
492
+ isPointerInAnchor: isPointerOverSubmenu,
493
+ close: () => hide('mouse'),
494
+ requireForwardMovement: true,
495
+ });
496
+ // Tear down any hover bridge whenever the submenu closes - including close
497
+ // paths that bypass hide() (e.g. an outside click on the overlay), so a
498
+ // stale corridor can't linger or wrongly suppress the next sibling hover.
499
+ // Also reset the pointer flags: destroying the panel under the pointer never
500
+ // fires its pointerleave, and a stuck pointerOverContent would make every
501
+ // future corridor treat the pointer as anchored and never close.
502
+ effect(() => {
503
+ if (!open()) {
504
+ hoverBridge.clear();
505
+ pointerOverTrigger.set(false);
506
+ pointerOverContent.set(false);
507
+ }
508
+ });
450
509
  // Subscribe to parent menu's closeSubmenus
451
510
  parentMenu()
452
511
  ?.closeSubmenus.pipe(safeTakeUntilDestroyed())
@@ -455,6 +514,12 @@ const [NgpSubmenuTriggerStateToken, ngpSubmenuTrigger, injectSubmenuTriggerState
455
514
  if (submenuElement === element.nativeElement) {
456
515
  return;
457
516
  }
517
+ // While the pointer is still inside the safe-polygon corridor toward this
518
+ // submenu, ignore sibling hover-driven closes. The pointer-move listener
519
+ // closes the submenu itself once the pointer actually leaves the corridor.
520
+ if (hoverBridge.isActive()) {
521
+ return;
522
+ }
458
523
  hide('mouse');
459
524
  });
460
525
  // Host bindings
@@ -465,6 +530,7 @@ const [NgpSubmenuTriggerStateToken, ngpSubmenuTrigger, injectSubmenuTriggerState
465
530
  listener(element, 'click', onClick);
466
531
  listener(element, 'keydown', handleArrowKey);
467
532
  listener(element, 'pointerenter', showSubmenuOnHover);
533
+ listener(element, 'pointerleave', onPointerLeave);
468
534
  // Methods
469
535
  function onClick(event) {
470
536
  if (disabled?.()) {
@@ -502,6 +568,8 @@ const [NgpSubmenuTriggerStateToken, ngpSubmenuTrigger, injectSubmenuTriggerState
502
568
  if (disabled?.() || !open()) {
503
569
  return;
504
570
  }
571
+ // The submenu is closing - any active hover bridge is no longer relevant.
572
+ hoverBridge.clear();
505
573
  // Hide the overlay
506
574
  overlay()?.hide({ origin });
507
575
  }
@@ -520,6 +588,7 @@ const [NgpSubmenuTriggerStateToken, ngpSubmenuTrigger, injectSubmenuTriggerState
520
588
  content: menuContent,
521
589
  triggerElement: element.nativeElement,
522
590
  injector,
591
+ container: container(),
523
592
  placement,
524
593
  offset: offset(),
525
594
  flip: flip(),
@@ -552,8 +621,38 @@ const [NgpSubmenuTriggerStateToken, ngpSubmenuTrigger, injectSubmenuTriggerState
552
621
  if (event.pointerType === 'touch') {
553
622
  return;
554
623
  }
624
+ pointerOverTrigger.set(true);
625
+ // The pointer is back on the trigger - drop any in-progress hover bridge.
626
+ hoverBridge.clear();
555
627
  show('mouse');
556
628
  }
629
+ function onPointerLeave(event) {
630
+ if (event instanceof PointerEvent === false || event.pointerType === 'touch') {
631
+ return;
632
+ }
633
+ pointerOverTrigger.set(false);
634
+ const currentOverlay = overlay();
635
+ // Only build a corridor while the submenu is actually open.
636
+ if (!open() || !currentOverlay) {
637
+ return;
638
+ }
639
+ const submenuElement = currentOverlay.getElements()[0];
640
+ const started = !!submenuElement &&
641
+ hoverBridge.track({
642
+ triggerRect: element.nativeElement.getBoundingClientRect(),
643
+ targetRect: submenuElement.getBoundingClientRect(),
644
+ exitPoint: { x: event.clientX, y: event.clientY },
645
+ });
646
+ if (!started) {
647
+ // Defensive fallback: if the corridor can't be built, close after a short
648
+ // grace period rather than leaving the submenu stuck open.
649
+ disposables.setTimeout(() => {
650
+ if (!isPointerOverSubmenu()) {
651
+ hide('mouse');
652
+ }
653
+ }, 50);
654
+ }
655
+ }
557
656
  function setDisabled(isDisabled) {
558
657
  disabled.set(isDisabled);
559
658
  if (isDisabled && open()) {
@@ -572,14 +671,19 @@ const [NgpSubmenuTriggerStateToken, ngpSubmenuTrigger, injectSubmenuTriggerState
572
671
  function setFlip(shouldFlip) {
573
672
  flip.set(shouldFlip);
574
673
  }
674
+ function setContainer(newContainer) {
675
+ container.set(newContainer);
676
+ }
575
677
  function focus(origin) {
576
678
  focusMonitor.focusVia(element.nativeElement, origin, { preventScroll: true });
577
679
  }
578
- // No-op for submenus - hover behavior is handled via showSubmenuOnHover on the trigger element
579
- function setPointerOverContent(_isOver) {
580
- // Submenus don't need pointer tracking on content because:
581
- // 1. The submenu trigger handles hover via showSubmenuOnHover
582
- // 2. Closing on hover-out is handled by the parent menu's closeSubmenus mechanism
680
+ function setPointerOverContent(isOver) {
681
+ pointerOverContent.set(isOver);
682
+ // Reaching the submenu content means the pointer arrived safely, so the
683
+ // hover bridge corridor is no longer needed.
684
+ if (isOver) {
685
+ hoverBridge.clear();
686
+ }
583
687
  }
584
688
  return {
585
689
  placement: deprecatedSetter(placement, 'setPlacement'),
@@ -597,8 +701,10 @@ const [NgpSubmenuTriggerStateToken, ngpSubmenuTrigger, injectSubmenuTriggerState
597
701
  setFlip,
598
702
  setPlacement,
599
703
  setOffset,
704
+ setContainer,
600
705
  focus,
601
706
  setPointerOverContent,
707
+ container: deprecatedSetter(container, 'setContainer', setContainer),
602
708
  };
603
709
  });
604
710
 
@@ -633,6 +739,11 @@ class NgpSubmenuTrigger {
633
739
  */
634
740
  this.flip = input(true, { ...(ngDevMode ? { debugName: "flip" } : /* istanbul ignore next */ {}), alias: 'ngpSubmenuTriggerFlip',
635
741
  transform: coerceFlip });
742
+ /**
743
+ * Define the container in which the menu should be attached.
744
+ * @default document.body
745
+ */
746
+ this.container = input(null, { ...(ngDevMode ? { debugName: "container" } : /* istanbul ignore next */ {}), alias: 'ngpSubmenuTriggerContainer' });
636
747
  /**
637
748
  * Access the menu trigger state.
638
749
  */
@@ -642,6 +753,7 @@ class NgpSubmenuTrigger {
642
753
  placement: this.placement,
643
754
  offset: this.offset,
644
755
  flip: this.flip,
756
+ container: this.container,
645
757
  });
646
758
  }
647
759
  /**
@@ -671,7 +783,7 @@ class NgpSubmenuTrigger {
671
783
  this.state.focus(origin);
672
784
  }
673
785
  static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: NgpSubmenuTrigger, deps: [], target: i0.ɵɵFactoryTarget.Directive }); }
674
- static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.14", type: NgpSubmenuTrigger, isStandalone: true, selector: "[ngpSubmenuTrigger]", inputs: { menu: { classPropertyName: "menu", publicName: "ngpSubmenuTrigger", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "ngpSubmenuTriggerDisabled", isSignal: true, isRequired: false, transformFunction: null }, placement: { classPropertyName: "placement", publicName: "ngpSubmenuTriggerPlacement", isSignal: true, isRequired: false, transformFunction: null }, offset: { classPropertyName: "offset", publicName: "ngpSubmenuTriggerOffset", isSignal: true, isRequired: false, transformFunction: null }, flip: { classPropertyName: "flip", publicName: "ngpSubmenuTriggerFlip", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
786
+ static { this.ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.14", type: NgpSubmenuTrigger, isStandalone: true, selector: "[ngpSubmenuTrigger]", inputs: { menu: { classPropertyName: "menu", publicName: "ngpSubmenuTrigger", isSignal: true, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "ngpSubmenuTriggerDisabled", isSignal: true, isRequired: false, transformFunction: null }, placement: { classPropertyName: "placement", publicName: "ngpSubmenuTriggerPlacement", isSignal: true, isRequired: false, transformFunction: null }, offset: { classPropertyName: "offset", publicName: "ngpSubmenuTriggerOffset", isSignal: true, isRequired: false, transformFunction: null }, flip: { classPropertyName: "flip", publicName: "ngpSubmenuTriggerFlip", isSignal: true, isRequired: false, transformFunction: null }, container: { classPropertyName: "container", publicName: "ngpSubmenuTriggerContainer", isSignal: true, isRequired: false, transformFunction: null } }, providers: [
675
787
  provideSubmenuTriggerState(),
676
788
  // Also provide as NgpMenuTriggerStateToken so the submenu's menu-state
677
789
  // can find this trigger's openOrigin for :focus-visible styling
@@ -690,7 +802,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
690
802
  { provide: NgpMenuTriggerStateToken, useExisting: NgpSubmenuTriggerStateToken },
691
803
  ],
692
804
  }]
693
- }], propDecorators: { menu: [{ type: i0.Input, args: [{ isSignal: true, alias: "ngpSubmenuTrigger", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "ngpSubmenuTriggerDisabled", required: false }] }], placement: [{ type: i0.Input, args: [{ isSignal: true, alias: "ngpSubmenuTriggerPlacement", required: false }] }], offset: [{ type: i0.Input, args: [{ isSignal: true, alias: "ngpSubmenuTriggerOffset", required: false }] }], flip: [{ type: i0.Input, args: [{ isSignal: true, alias: "ngpSubmenuTriggerFlip", required: false }] }] } });
805
+ }], propDecorators: { menu: [{ type: i0.Input, args: [{ isSignal: true, alias: "ngpSubmenuTrigger", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "ngpSubmenuTriggerDisabled", required: false }] }], placement: [{ type: i0.Input, args: [{ isSignal: true, alias: "ngpSubmenuTriggerPlacement", required: false }] }], offset: [{ type: i0.Input, args: [{ isSignal: true, alias: "ngpSubmenuTriggerOffset", required: false }] }], flip: [{ type: i0.Input, args: [{ isSignal: true, alias: "ngpSubmenuTriggerFlip", required: false }] }], container: [{ type: i0.Input, args: [{ isSignal: true, alias: "ngpSubmenuTriggerContainer", required: false }] }] } });
694
806
 
695
807
  const [NgpMenuItemStateToken, ngpMenuItem, injectMenuItemState, provideMenuItemState] = createPrimitive('NgpMenuItem', ({ disabled = signal(false), closeOnSelect = signal(true), role = 'menuitem', }) => {
696
808
  const element = injectElementRef();