@vaadin/popover 25.2.0-alpha8 → 25.2.0-beta1

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.
@@ -6,13 +6,7 @@
6
6
  import './vaadin-popover-overlay.js';
7
7
  import { css, html, LitElement } from 'lit';
8
8
  import { ifDefined } from 'lit/directives/if-defined.js';
9
- import { getActiveTrappingNode } from '@vaadin/a11y-base/src/focus-trap-controller.js';
10
- import {
11
- getDeepActiveElement,
12
- getFocusableElements,
13
- isElementFocused,
14
- isKeyboardActive,
15
- } from '@vaadin/a11y-base/src/focus-utils.js';
9
+ import { isKeyboardActive } from '@vaadin/a11y-base/src/focus-utils.js';
16
10
  import { defineCustomElement } from '@vaadin/component-base/src/define.js';
17
11
  import { ElementMixin } from '@vaadin/component-base/src/element-mixin.js';
18
12
  import { PolylitMixin } from '@vaadin/component-base/src/polylit-mixin.js';
@@ -22,6 +16,7 @@ import {
22
16
  isLastOverlay as isLastOverlayBase,
23
17
  } from '@vaadin/overlay/src/vaadin-overlay-stack-mixin.js';
24
18
  import { ThemePropertyMixin } from '@vaadin/vaadin-themable-mixin/vaadin-theme-property-mixin.js';
19
+ import { PopoverFocusController } from './vaadin-popover-focus-controller.js';
25
20
  import { PopoverPositionMixin } from './vaadin-popover-position-mixin.js';
26
21
  import { PopoverTargetMixin } from './vaadin-popover-target-mixin.js';
27
22
 
@@ -214,10 +209,6 @@ const isLastOverlay = (overlay) => {
214
209
  *
215
210
  * @customElement vaadin-popover
216
211
  * @extends HTMLElement
217
- * @mixes ElementMixin
218
- * @mixes PopoverPositionMixin
219
- * @mixes PopoverTargetMixin
220
- * @mixes ThemePropertyMixin
221
212
  */
222
213
  class Popover extends PopoverPositionMixin(
223
214
  PopoverTargetMixin(ThemePropertyMixin(ElementMixin(PolylitMixin(LitElement)))),
@@ -403,6 +394,22 @@ class Popover extends PopoverPositionMixin(
403
394
  value: false,
404
395
  },
405
396
 
397
+ /**
398
+ * When true, pressing Tab on the target or a sibling element does not move
399
+ * focus into the popover's content (including any nested popovers), and
400
+ * Shift+Tab does not move focus into the popover's last focusable. Focus
401
+ * still moves out of the popover on Tab / Shift+Tab if it was placed
402
+ * inside programmatically.
403
+ *
404
+ * Has no effect on modal popovers, which use their own focus trap.
405
+ *
406
+ * @attr {boolean} no-tab-focus
407
+ */
408
+ noTabFocus: {
409
+ type: Boolean,
410
+ value: false,
411
+ },
412
+
406
413
  /**
407
414
  * Popover trigger mode, used to configure how the popover is opened or closed.
408
415
  * Could be set to multiple by providing an array, e.g. `trigger = ['hover', 'focus']`.
@@ -486,7 +493,6 @@ class Popover extends PopoverPositionMixin(
486
493
 
487
494
  this.__generatedId = `vaadin-popover-${generateUniqueId()}`;
488
495
 
489
- this.__onGlobalKeyDown = this.__onGlobalKeyDown.bind(this);
490
496
  this.__onTargetClick = this.__onTargetClick.bind(this);
491
497
  this.__onTargetFocusIn = this.__onTargetFocusIn.bind(this);
492
498
  this.__onTargetFocusOut = this.__onTargetFocusOut.bind(this);
@@ -494,6 +500,7 @@ class Popover extends PopoverPositionMixin(
494
500
  this.__onTargetMouseLeave = this.__onTargetMouseLeave.bind(this);
495
501
 
496
502
  this._openedStateController = new PopoverOpenedStateController(this);
503
+ this._focusController = new PopoverFocusController(this);
497
504
  }
498
505
 
499
506
  /** @protected */
@@ -667,9 +674,9 @@ class Popover extends PopoverPositionMixin(
667
674
  /** @private */
668
675
  __openedChanged(opened, oldOpened) {
669
676
  if (opened) {
670
- document.addEventListener('keydown', this.__onGlobalKeyDown, true);
677
+ this._focusController.activate();
671
678
  } else if (oldOpened) {
672
- document.removeEventListener('keydown', this.__onGlobalKeyDown, true);
679
+ this._focusController.deactivate();
673
680
  }
674
681
  }
675
682
 
@@ -714,230 +721,6 @@ class Popover extends PopoverPositionMixin(
714
721
  }
715
722
  }
716
723
 
717
- /**
718
- * Overlay's global Escape press listener doesn't work when
719
- * the overlay is modeless, so we use a separate listener.
720
- * @private
721
- */
722
- __onGlobalKeyDown(event) {
723
- // Modal popover uses overlay logic focus trap.
724
- if (this.modal) {
725
- return;
726
- }
727
-
728
- // Include popover content in the Tab order after the target.
729
- if (event.key === 'Tab') {
730
- if (event.shiftKey) {
731
- this.__onGlobalShiftTab(event);
732
- } else {
733
- this.__onGlobalTab(event);
734
- }
735
- }
736
- }
737
-
738
- /** @private */
739
- __onGlobalTab(event) {
740
- // Move focus to the popover on target element Tab
741
- if (this.target && isElementFocused(this.__getTargetFocusable())) {
742
- event.preventDefault();
743
- this.focus();
744
- return;
745
- }
746
-
747
- // Cache filtered focusable list for this keystroke to avoid redundant DOM traversals
748
- const focusables = this.__getScopeFocusables();
749
-
750
- // Move focus to the next element after target on last content Tab,
751
- // or when popover itself is focused and has no focusable content
752
- const lastFocusable = this.__getLastFocusable();
753
- const isFocusOut = lastFocusable ? isElementFocused(lastFocusable) : isElementFocused(this);
754
- if (isFocusOut) {
755
- let focusable = this.__getNextScopeFocusable(this.__getTargetFocusable(), focusables);
756
- // If the next element after the target is the popover itself (DOM position
757
- // differs from logical position), skip past it to the actual next element.
758
- if (focusable === this) {
759
- focusable = this.__getNextScopeFocusable(this, focusables);
760
- }
761
- if (focusable) {
762
- event.preventDefault();
763
- focusable.focus();
764
- return;
765
- }
766
- // No next element after the target in the scope. When inside a focus trap,
767
- // wrap explicitly to the first focusable. Don't fall through - the
768
- // FocusTrapController uses DOM order which may differ from the popover's
769
- // logical tab position.
770
- if (getActiveTrappingNode(this) && focusables[0]) {
771
- event.preventDefault();
772
- focusables[0].focus();
773
- return;
774
- }
775
- }
776
-
777
- // Handle cases where Tab from the current element would land on the popover
778
- const activeElement = getDeepActiveElement();
779
- const nextFocusable = this.__getNextScopeFocusable(activeElement, focusables);
780
- if (nextFocusable === this) {
781
- // The popover should only be Tab-reachable from its target (handled above).
782
- // Skip the popover when Tab from any other element would land on it
783
- // due to its DOM position.
784
- const focusableAfterPopover = this.__getNextScopeFocusable(this, focusables);
785
- if (focusableAfterPopover) {
786
- event.preventDefault();
787
- focusableAfterPopover.focus();
788
- } else if (getActiveTrappingNode(this) && focusables[0]) {
789
- // Popover is last in DOM scope but shouldn't be Tab-reachable from
790
- // non-target elements. Wrap to first focusable in focus trap.
791
- event.preventDefault();
792
- focusables[0].focus();
793
- }
794
- }
795
- }
796
-
797
- /** @private */
798
- __onGlobalShiftTab(event) {
799
- // Prevent restoring focus after target blur on Shift + Tab
800
- if (this.target && isElementFocused(this.__getTargetFocusable()) && this.__shouldRestoreFocus) {
801
- this.__shouldRestoreFocus = false;
802
- return;
803
- }
804
-
805
- // Move focus back to the target on popover Shift + Tab
806
- if (this.target && isElementFocused(this)) {
807
- event.preventDefault();
808
- this.__getTargetFocusable().focus();
809
- return;
810
- }
811
-
812
- // Don't intercept if focus is inside the popover content.
813
- // The browser's native Shift+Tab handles navigation within
814
- // the overlay (e.g. between focusable content and the popover element itself).
815
- const activeElement = getDeepActiveElement();
816
- if (this.contains(activeElement)) {
817
- return;
818
- }
819
-
820
- // Cache filtered focusable list for this keystroke to avoid redundant DOM traversals
821
- const focusables = this.__getScopeFocusables();
822
-
823
- // Get previous focusable element excluding the popover
824
- const prevFocusable = this.__getPrevScopeFocusable(activeElement, focusables);
825
- const targetFocusable = this.__getTargetFocusable();
826
-
827
- // Intercept Shift+Tab when the previous focusable (excluding the popover)
828
- // is the target. Instead of moving to the target, redirect focus into
829
- // the popover's last focusable content (or the popover itself).
830
- if (prevFocusable === targetFocusable) {
831
- event.preventDefault();
832
- this.__focusLastOrSelf();
833
- return;
834
- }
835
-
836
- // Move focus into the popover when:
837
- // 1. There is no previous focusable element in the focus trap (at the
838
- // beginning, would wrap around), and
839
- // 2. The target is the last focusable in the focus trap (making the
840
- // popover logically last).
841
- // Don't fall through - the FocusTrapController uses DOM order which
842
- // may differ from the popover's logical tab position.
843
- if (!prevFocusable && getActiveTrappingNode(this)) {
844
- const list = focusables.filter((el) => el !== this);
845
- if (list.at(-1) === targetFocusable) {
846
- event.preventDefault();
847
- this.__focusLastOrSelf();
848
- return;
849
- }
850
- // Popover is last in DOM but target is not the last focusable.
851
- // Wrap to last non-popover focusable to prevent FocusTrapController
852
- // from landing on the popover.
853
- const last = list.at(-1);
854
- if (last) {
855
- event.preventDefault();
856
- last.focus();
857
- return;
858
- }
859
- }
860
-
861
- // Get previous focusable element including the popover (simulates native Tab order)
862
- const prevFocusableNative = this.__getPrevScopeFocusable(activeElement, focusables, true);
863
- // Skip the popover when native Shift+Tab would land on it
864
- // and redirect to the actual previous element
865
- if (prevFocusableNative === this) {
866
- if (prevFocusable) {
867
- event.preventDefault();
868
- prevFocusable.focus();
869
- } else if (getActiveTrappingNode(this)) {
870
- // Popover is first in DOM scope but shouldn't be Shift+Tab-reachable
871
- // from non-target elements. Wrap to last non-popover focusable.
872
- const list = focusables.filter((el) => el !== this);
873
- const last = list.at(-1);
874
- if (last) {
875
- event.preventDefault();
876
- last.focus();
877
- }
878
- }
879
- }
880
- }
881
-
882
- /**
883
- * Returns whether the element is a light DOM child of this popover
884
- * (i.e. slotted popover content, excluding the popover element itself).
885
- * @param {Element} el
886
- * @return {boolean}
887
- * @private
888
- */
889
- __isPopoverContent(el) {
890
- return el !== this && this.contains(el);
891
- }
892
-
893
- /**
894
- * Returns focusable elements within the current scope (active focus trap or
895
- * document body) with popover light DOM children filtered out.
896
- * @return {Element[]}
897
- * @private
898
- */
899
- __getScopeFocusables() {
900
- const scope = getActiveTrappingNode(this) || document.body;
901
- return getFocusableElements(scope).filter((el) => !this.__isPopoverContent(el));
902
- }
903
-
904
- /** @private */
905
- __getNextScopeFocusable(target, focusables = this.__getScopeFocusables()) {
906
- const idx = focusables.findIndex((el) => el === target);
907
- return idx >= 0 ? focusables[idx + 1] : undefined;
908
- }
909
-
910
- /** @private */
911
- __getPrevScopeFocusable(target, focusables = this.__getScopeFocusables(), includePopover = false) {
912
- const list = includePopover ? focusables : focusables.filter((el) => el !== this);
913
- const idx = list.findIndex((el) => el === target);
914
- // Returns null both when target is the first element (idx === 0)
915
- // and when target is not found in the list (idx === -1)
916
- return idx > 0 ? list[idx - 1] : null;
917
- }
918
-
919
- /** @private */
920
- __getLastFocusable() {
921
- // Search within the overlay's content area to avoid returning the popover element itself
922
- const focusables = getFocusableElements(this._overlayElement.$.content);
923
- return focusables.pop();
924
- }
925
-
926
- /** @private */
927
- __focusLastOrSelf() {
928
- (this.__getLastFocusable() || this).focus();
929
- }
930
-
931
- /** @private */
932
- __getTargetFocusable() {
933
- if (!this.target) {
934
- return null;
935
- }
936
-
937
- // If target has `focusElement`, check if that one is focused.
938
- return this.target.focusElement || this.target;
939
- }
940
-
941
724
  /** @private */
942
725
  __onTargetFocusIn() {
943
726
  this.__focusInside = true;
@@ -1165,12 +948,6 @@ class Popover extends PopoverPositionMixin(
1165
948
  __hasTrigger(trigger) {
1166
949
  return Array.isArray(this.trigger) && this.trigger.includes(trigger);
1167
950
  }
1168
-
1169
- /**
1170
- * Fired when the popover is closed.
1171
- *
1172
- * @event closed
1173
- */
1174
951
  }
1175
952
 
1176
953
  defineCustomElement(Popover);