@vaadin/popover 25.0.4 → 25.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaadin/popover",
3
- "version": "25.0.4",
3
+ "version": "25.0.7",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -36,24 +36,24 @@
36
36
  ],
37
37
  "dependencies": {
38
38
  "@open-wc/dedupe-mixin": "^1.3.0",
39
- "@vaadin/a11y-base": "~25.0.4",
40
- "@vaadin/component-base": "~25.0.4",
41
- "@vaadin/lit-renderer": "~25.0.4",
42
- "@vaadin/overlay": "~25.0.4",
43
- "@vaadin/vaadin-themable-mixin": "~25.0.4",
39
+ "@vaadin/a11y-base": "~25.0.7",
40
+ "@vaadin/component-base": "~25.0.7",
41
+ "@vaadin/lit-renderer": "~25.0.7",
42
+ "@vaadin/overlay": "~25.0.7",
43
+ "@vaadin/vaadin-themable-mixin": "~25.0.7",
44
44
  "lit": "^3.0.0"
45
45
  },
46
46
  "devDependencies": {
47
- "@vaadin/aura": "~25.0.4",
48
- "@vaadin/chai-plugins": "~25.0.4",
49
- "@vaadin/test-runner-commands": "~25.0.4",
47
+ "@vaadin/aura": "~25.0.7",
48
+ "@vaadin/chai-plugins": "~25.0.7",
49
+ "@vaadin/test-runner-commands": "~25.0.7",
50
50
  "@vaadin/testing-helpers": "^2.0.0",
51
- "@vaadin/vaadin-lumo-styles": "~25.0.4",
51
+ "@vaadin/vaadin-lumo-styles": "~25.0.7",
52
52
  "sinon": "^21.0.0"
53
53
  },
54
54
  "web-types": [
55
55
  "web-types.json",
56
56
  "web-types.lit.json"
57
57
  ],
58
- "gitHead": "17170982c3efa1d426af4fabb70ea36b70151347"
58
+ "gitHead": "8f3a780477d7dbe30fd473470ede41458e5217b9"
59
59
  }
@@ -6,6 +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';
9
10
  import {
10
11
  getDeepActiveElement,
11
12
  getFocusableElements,
@@ -741,24 +742,53 @@ class Popover extends PopoverPositionMixin(
741
742
  return;
742
743
  }
743
744
 
744
- // Move focus to the next element after target on content Tab
745
- const lastFocusable = this.__getLastFocusable(this);
746
- if (lastFocusable && isElementFocused(lastFocusable)) {
747
- const focusable = this.__getNextBodyFocusable(this.__getTargetFocusable());
748
- if (focusable && focusable !== this) {
745
+ // Cache filtered focusable list for this keystroke to avoid redundant DOM traversals
746
+ const focusables = this.__getScopeFocusables();
747
+
748
+ // Move focus to the next element after target on last content Tab,
749
+ // or when popover itself is focused and has no focusable content
750
+ const lastFocusable = this.__getLastFocusable();
751
+ const isFocusOut = lastFocusable ? isElementFocused(lastFocusable) : isElementFocused(this);
752
+ if (isFocusOut) {
753
+ let focusable = this.__getNextScopeFocusable(this.__getTargetFocusable(), focusables);
754
+ // If the next element after the target is the popover itself (DOM position
755
+ // differs from logical position), skip past it to the actual next element.
756
+ if (focusable === this) {
757
+ focusable = this.__getNextScopeFocusable(this, focusables);
758
+ }
759
+ if (focusable) {
749
760
  event.preventDefault();
750
761
  focusable.focus();
751
762
  return;
752
763
  }
764
+ // No next element after the target in the scope. When inside a focus trap,
765
+ // wrap explicitly to the first focusable. Don't fall through - the
766
+ // FocusTrapController uses DOM order which may differ from the popover's
767
+ // logical tab position.
768
+ if (getActiveTrappingNode(this) && focusables[0]) {
769
+ event.preventDefault();
770
+ focusables[0].focus();
771
+ return;
772
+ }
753
773
  }
754
774
 
755
- // Prevent focusing the popover content on previous element Tab
775
+ // Handle cases where Tab from the current element would land on the popover
756
776
  const activeElement = getDeepActiveElement();
757
- const nextFocusable = this.__getNextBodyFocusable(activeElement);
758
- if (nextFocusable === this && lastFocusable) {
759
- // Move focus to the last overlay focusable and do NOT prevent keydown
760
- // to move focus outside the popover content (e.g. to the URL bar).
761
- lastFocusable.focus();
777
+ const nextFocusable = this.__getNextScopeFocusable(activeElement, focusables);
778
+ if (nextFocusable === this) {
779
+ // The popover should only be Tab-reachable from its target (handled above).
780
+ // Skip the popover when Tab from any other element would land on it
781
+ // due to its DOM position.
782
+ const focusableAfterPopover = this.__getNextScopeFocusable(this, focusables);
783
+ if (focusableAfterPopover) {
784
+ event.preventDefault();
785
+ focusableAfterPopover.focus();
786
+ } else if (getActiveTrappingNode(this) && focusables[0]) {
787
+ // Popover is last in DOM scope but shouldn't be Tab-reachable from
788
+ // non-target elements. Wrap to first focusable in focus trap.
789
+ event.preventDefault();
790
+ focusables[0].focus();
791
+ }
762
792
  }
763
793
  }
764
794
 
@@ -777,30 +807,125 @@ class Popover extends PopoverPositionMixin(
777
807
  return;
778
808
  }
779
809
 
780
- // Move focus back to the popover on next element Shift + Tab
781
- const nextFocusable = this.__getNextBodyFocusable(this.__getTargetFocusable());
782
- if (nextFocusable && isElementFocused(nextFocusable)) {
783
- const lastFocusable = this.__getLastFocusable(this);
784
- if (lastFocusable) {
810
+ // Don't intercept if focus is inside the popover content.
811
+ // The browser's native Shift+Tab handles navigation within
812
+ // the overlay (e.g. between focusable content and the popover element itself).
813
+ const activeElement = getDeepActiveElement();
814
+ if (this.contains(activeElement)) {
815
+ return;
816
+ }
817
+
818
+ // Cache filtered focusable list for this keystroke to avoid redundant DOM traversals
819
+ const focusables = this.__getScopeFocusables();
820
+
821
+ // Get previous focusable element excluding the popover
822
+ const prevFocusable = this.__getPrevScopeFocusable(activeElement, focusables);
823
+ const targetFocusable = this.__getTargetFocusable();
824
+
825
+ // Intercept Shift+Tab when the previous focusable (excluding the popover)
826
+ // is the target. Instead of moving to the target, redirect focus into
827
+ // the popover's last focusable content (or the popover itself).
828
+ if (prevFocusable === targetFocusable) {
829
+ event.preventDefault();
830
+ this.__focusLastOrSelf();
831
+ return;
832
+ }
833
+
834
+ // Move focus into the popover when:
835
+ // 1. There is no previous focusable element in the focus trap (at the
836
+ // beginning, would wrap around), and
837
+ // 2. The target is the last focusable in the focus trap (making the
838
+ // popover logically last).
839
+ // Don't fall through - the FocusTrapController uses DOM order which
840
+ // may differ from the popover's logical tab position.
841
+ if (!prevFocusable && getActiveTrappingNode(this)) {
842
+ const list = focusables.filter((el) => el !== this);
843
+ if (list.at(-1) === targetFocusable) {
844
+ event.preventDefault();
845
+ this.__focusLastOrSelf();
846
+ return;
847
+ }
848
+ // Popover is last in DOM but target is not the last focusable.
849
+ // Wrap to last non-popover focusable to prevent FocusTrapController
850
+ // from landing on the popover.
851
+ const last = list.at(-1);
852
+ if (last) {
853
+ event.preventDefault();
854
+ last.focus();
855
+ return;
856
+ }
857
+ }
858
+
859
+ // Get previous focusable element including the popover (simulates native Tab order)
860
+ const prevFocusableNative = this.__getPrevScopeFocusable(activeElement, focusables, true);
861
+ // Skip the popover when native Shift+Tab would land on it
862
+ // and redirect to the actual previous element
863
+ if (prevFocusableNative === this) {
864
+ if (prevFocusable) {
785
865
  event.preventDefault();
786
- lastFocusable.focus();
866
+ prevFocusable.focus();
867
+ } else if (getActiveTrappingNode(this)) {
868
+ // Popover is first in DOM scope but shouldn't be Shift+Tab-reachable
869
+ // from non-target elements. Wrap to last non-popover focusable.
870
+ const list = focusables.filter((el) => el !== this);
871
+ const last = list.at(-1);
872
+ if (last) {
873
+ event.preventDefault();
874
+ last.focus();
875
+ }
787
876
  }
788
877
  }
789
878
  }
790
879
 
880
+ /**
881
+ * Returns whether the element is a light DOM child of this popover
882
+ * (i.e. slotted popover content, excluding the popover element itself).
883
+ * @param {Element} el
884
+ * @return {boolean}
885
+ * @private
886
+ */
887
+ __isPopoverContent(el) {
888
+ return el !== this && this.contains(el);
889
+ }
890
+
891
+ /**
892
+ * Returns focusable elements within the current scope (active focus trap or
893
+ * document body) with popover light DOM children filtered out.
894
+ * @return {Element[]}
895
+ * @private
896
+ */
897
+ __getScopeFocusables() {
898
+ const scope = getActiveTrappingNode(this) || document.body;
899
+ return getFocusableElements(scope).filter((el) => !this.__isPopoverContent(el));
900
+ }
901
+
791
902
  /** @private */
792
- __getNextBodyFocusable(target) {
793
- const focusables = getFocusableElements(document.body);
903
+ __getNextScopeFocusable(target, focusables = this.__getScopeFocusables()) {
794
904
  const idx = focusables.findIndex((el) => el === target);
795
- return focusables[idx + 1];
905
+ return idx >= 0 ? focusables[idx + 1] : undefined;
906
+ }
907
+
908
+ /** @private */
909
+ __getPrevScopeFocusable(target, focusables = this.__getScopeFocusables(), includePopover = false) {
910
+ const list = includePopover ? focusables : focusables.filter((el) => el !== this);
911
+ const idx = list.findIndex((el) => el === target);
912
+ // Returns null both when target is the first element (idx === 0)
913
+ // and when target is not found in the list (idx === -1)
914
+ return idx > 0 ? list[idx - 1] : null;
796
915
  }
797
916
 
798
917
  /** @private */
799
- __getLastFocusable(container) {
800
- const focusables = getFocusableElements(container);
918
+ __getLastFocusable() {
919
+ // Search within the overlay's content area to avoid returning the popover element itself
920
+ const focusables = getFocusableElements(this._overlayElement.$.content);
801
921
  return focusables.pop();
802
922
  }
803
923
 
924
+ /** @private */
925
+ __focusLastOrSelf() {
926
+ (this.__getLastFocusable() || this).focus();
927
+ }
928
+
804
929
  /** @private */
805
930
  __getTargetFocusable() {
806
931
  if (!this.target) {
package/web-types.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/web-types",
3
3
  "name": "@vaadin/popover",
4
- "version": "25.0.4",
4
+ "version": "25.0.7",
5
5
  "description-markup": "markdown",
6
6
  "contributions": {
7
7
  "html": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/web-types",
3
3
  "name": "@vaadin/popover",
4
- "version": "25.0.4",
4
+ "version": "25.0.7",
5
5
  "description-markup": "markdown",
6
6
  "framework": "lit",
7
7
  "framework-config": {