@salesforcedevs/dx-components 1.40.11 → 1.42.0-final-test

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 (43) hide show
  1. package/lwc.config.json +4 -2
  2. package/package.json +46 -47
  3. package/src/modules/dx/button/button.css +2 -2
  4. package/src/modules/dx/dropdown/dropdown.css +29 -1
  5. package/src/modules/dx/dropdown/dropdown.html +1 -1
  6. package/src/modules/dx/dropdown/dropdown.ts +16 -1
  7. package/src/modules/dx/dropdownOption/dropdownOption.css +30 -6
  8. package/src/modules/dx/filterMenu/filterMenu.css +19 -0
  9. package/src/modules/dx/filterMenu/filterMenu.html +17 -2
  10. package/src/modules/dx/filterMenu/filterMenu.ts +43 -0
  11. package/src/modules/dx/popover/popover.ts +119 -7
  12. package/src/modules/dx/searchHybrid/searchHybrid.css +56 -61
  13. package/src/modules/dx/searchHybrid/searchHybrid.html +133 -112
  14. package/src/modules/dx/searchHybrid/searchHybrid.mock.ts +244 -0
  15. package/src/modules/dx/searchHybrid/searchHybrid.ts +81 -26
  16. package/src/modules/dx/searchResults/searchResults.css +119 -6
  17. package/src/modules/dx/searchResults/searchResults.html +89 -91
  18. package/src/modules/dx/searchResults/searchResults.ts +200 -473
  19. package/src/modules/dx/sidebar/sidebar.css +1 -0
  20. package/src/modules/dx/sidebar/sidebar.html +2 -5
  21. package/src/modules/dx/sidebar/sidebar.ts +44 -29
  22. package/src/modules/dx/sidebarOld/sidebarOld.html +1 -0
  23. package/src/modules/dx/sidebarSearch/sidebarSearch.ts +163 -342
  24. package/src/modules/dx/sidebarSearchResult/sidebarSearchResult.css +61 -23
  25. package/src/modules/dx/sidebarSearchResult/sidebarSearchResult.ts +12 -4
  26. package/src/modules/dx/tree/tree.html +1 -0
  27. package/src/modules/dx/tree/tree.ts +5 -1
  28. package/src/modules/dx/tree/treeHandler.ts +92 -13
  29. package/src/modules/dx/treeItem/treeItem.css +41 -0
  30. package/src/modules/dx/treeItem/treeItem.html +17 -2
  31. package/src/modules/dx/treeItem/treeItem.ts +109 -11
  32. package/src/modules/dx/treeTile/treeTile.css +57 -0
  33. package/src/modules/dx/treeTile/treeTile.ts +12 -3
  34. package/src/modules/dx/typeBadge/typeBadge.ts +23 -2
  35. package/src/modules/dx/versionPicker/versionPicker.css +248 -0
  36. package/src/modules/dx/versionPicker/versionPicker.html +73 -0
  37. package/src/modules/dx/versionPicker/versionPicker.ts +157 -0
  38. package/src/modules/dxHelpers/commonSidebar/commonSidebar.css +3 -3
  39. package/src/modules/dxHelpers/commonTreeItem/commonTreeItem.css +4 -0
  40. package/src/modules/dxHelpers/typeBadge/typeBadge.css +22 -0
  41. package/src/modules/dxUtils/openSearch/openSearch.ts +343 -0
  42. package/src/modules/dxUtils/topicVersion/topicVersion.ts +758 -0
  43. package/LICENSE +0 -12
package/lwc.config.json CHANGED
@@ -1,8 +1,7 @@
1
1
  {
2
2
  "modules": [
3
3
  { "dir": "src/modules" },
4
- { "npm": "@salesforcedevs/dw-components" },
5
- { "npm": "@salesforcedevs/docs-components" }
4
+ { "npm": "@salesforcedevs/dw-components" }
6
5
  ],
7
6
  "expose": [
8
7
  "dx/agenda",
@@ -111,6 +110,7 @@
111
110
  "dx/tooltip",
112
111
  "dx/trafficLabeler",
113
112
  "dx/tree",
113
+ "dx/versionPicker",
114
114
  "dx/typeBadge",
115
115
  "dx/vimeoPlayer",
116
116
  "dxBaseElements/headerBase",
@@ -131,6 +131,7 @@
131
131
  "dxUtils/analytics",
132
132
  "dxUtils/async",
133
133
  "dxUtils/constants",
134
+ "dxUtils/openSearch",
134
135
  "dxUtils/contentTypes",
135
136
  "dxUtils/coveo",
136
137
  "dxUtils/dates",
@@ -140,6 +141,7 @@
140
141
  "dxUtils/lwc",
141
142
  "dxUtils/urlWatcher",
142
143
  "dxUtils/normalizers",
144
+ "dxUtils/topicVersion",
143
145
  "dxUtils/queryCoordinator",
144
146
  "dxUtils/recentSearches",
145
147
  "dxUtils/regexps",
package/package.json CHANGED
@@ -1,48 +1,47 @@
1
1
  {
2
- "name": "@salesforcedevs/dx-components",
3
- "version": "1.40.11",
4
- "description": "DX Lightning web components",
5
- "license": "MIT",
6
- "engines": {
7
- "node": "22.x"
8
- },
9
- "publishConfig": {
10
- "access": "public"
11
- },
12
- "dependencies": {
13
- "@coveo/headless": "3.22.2",
14
- "@floating-ui/dom": "1.5.1",
15
- "@optimizely/optimizely-sdk": "5.3.0",
16
- "@shikijs/colorized-brackets": "^3.12.2",
17
- "@types/throttle-debounce": "5.0.2",
18
- "@vimeo/player": "2.22.0",
19
- "classnames": "2.5.1",
20
- "composed-offset-position": "0.0.4",
21
- "coveo-search-ui": "2.10113.0",
22
- "debounce": "1.2.1",
23
- "js-cookie": "3.0.5",
24
- "lodash.defaults": "4.2.0",
25
- "lodash.get": "4.4.2",
26
- "lodash.kebabcase": "4.1.1",
27
- "memoize-one": "6.0.0",
28
- "microtip": "0.2.2",
29
- "shiki": "^3.12.2",
30
- "throttle-debounce": "5.0.0",
31
- "uuid": "9.0.1"
32
- },
33
- "devDependencies": {
34
- "@types/classnames": "2.3.1",
35
- "@types/debounce": "1.2.4",
36
- "@types/js-cookie": "3.0.6",
37
- "@types/lodash.defaults": "4.2.9",
38
- "@types/lodash.get": "4.4.9",
39
- "@types/lodash.kebabcase": "4.1.9",
40
- "@types/luxon": "3.4.2",
41
- "@types/uuid": "8.3.4",
42
- "@types/vimeo__player": "2.18.3",
43
- "eventsourcemock": "2.0.0",
44
- "luxon": "3.4.4",
45
- "msw": "^2.12.4"
46
- },
47
- "gitHead": "5fe30fa8dd59e05dac6be5f8f91d22fe76254d8a"
48
- }
2
+ "name": "@salesforcedevs/dx-components",
3
+ "version": "1.42.0-final-test",
4
+ "description": "DX Lightning web components",
5
+ "license": "MIT",
6
+ "engines": {
7
+ "node": "22.x"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "dependencies": {
13
+ "@coveo/headless": "3.22.2",
14
+ "@floating-ui/dom": "1.5.1",
15
+ "@optimizely/optimizely-sdk": "5.3.0",
16
+ "@shikijs/colorized-brackets": "^3.12.2",
17
+ "@types/throttle-debounce": "5.0.2",
18
+ "@vimeo/player": "2.22.0",
19
+ "classnames": "2.5.1",
20
+ "composed-offset-position": "0.0.4",
21
+ "coveo-search-ui": "2.10113.0",
22
+ "debounce": "1.2.1",
23
+ "js-cookie": "3.0.5",
24
+ "lodash.defaults": "4.2.0",
25
+ "lodash.get": "4.4.2",
26
+ "lodash.kebabcase": "4.1.1",
27
+ "memoize-one": "6.0.0",
28
+ "microtip": "0.2.2",
29
+ "shiki": "^3.12.2",
30
+ "throttle-debounce": "5.0.0",
31
+ "uuid": "9.0.1"
32
+ },
33
+ "devDependencies": {
34
+ "@types/classnames": "2.3.1",
35
+ "@types/debounce": "1.2.4",
36
+ "@types/js-cookie": "3.0.6",
37
+ "@types/lodash.defaults": "4.2.9",
38
+ "@types/lodash.get": "4.4.9",
39
+ "@types/lodash.kebabcase": "4.1.9",
40
+ "@types/luxon": "3.4.2",
41
+ "@types/uuid": "8.3.4",
42
+ "@types/vimeo__player": "2.18.3",
43
+ "eventsourcemock": "2.0.0",
44
+ "luxon": "3.4.4",
45
+ "msw": "^2.12.4"
46
+ }
47
+ }
@@ -86,11 +86,11 @@
86
86
  }
87
87
 
88
88
  .button.icon-right:not(.slot-empty) > *:nth-child(2) {
89
- margin-left: var(--dx-g-spacing-sm);
89
+ margin-left: var(--dx-c-button-icon-gap, var(--dx-g-spacing-sm));
90
90
  }
91
91
 
92
92
  .button.icon-left:not(.slot-empty) > *:nth-child(2) {
93
- margin-right: var(--dx-g-spacing-sm);
93
+ margin-right: var(--dx-c-button-icon-gap, var(--dx-g-spacing-sm));
94
94
  }
95
95
 
96
96
  .button.style-icon.slot-empty {
@@ -14,12 +14,40 @@
14
14
  display: none;
15
15
  }
16
16
 
17
+ /* Opt-in fit-content menu: hugs the labels (so it can be as narrow as the
18
+ shortest one) and stops at --dx-c-dropdown-menu-max-width. Defaults still
19
+ truncate; set --dx-c-dropdown-fit-white-space: normal to wrap instead. */
20
+ .menu-fit-content::part(content) {
21
+ box-sizing: border-box;
22
+ width: max-content;
23
+ max-width: var(
24
+ --dx-c-dropdown-menu-max-width,
25
+ calc(100vw - var(--dx-g-spacing-lg))
26
+ );
27
+ }
28
+
17
29
  .menu-fit-content {
30
+ --dx-c-dropdown-option-min-width: 0;
31
+ --dx-c-dropdown-option-label-display: block;
32
+ --dx-c-dropdown-option-white-space: var(
33
+ --dx-c-dropdown-fit-white-space,
34
+ nowrap
35
+ );
36
+ --dx-c-dropdown-option-overflow: var(--dx-c-dropdown-fit-overflow, hidden);
37
+ --dx-c-dropdown-option-text-overflow: var(
38
+ --dx-c-dropdown-fit-text-overflow,
39
+ ellipsis
40
+ );
41
+ }
42
+
43
+ /* Nested fit-content menus (content action toolbar). Flat fit-content menus,
44
+ including the version picker, keep the option rules above. */
45
+ .menu-fit-content.menu-nested {
18
46
  --dx-c-popover-width: max-content;
19
47
  --dx-c-popover-padding: var(--dx-g-spacing-sm) 0;
20
48
  }
21
49
 
22
- .menu-fit-content .menu_list {
50
+ .menu-fit-content.menu-nested .menu_list {
23
51
  width: 100%;
24
52
  align-items: flex-start;
25
53
  gap: var(--dx-g-spacing-sm);
@@ -9,7 +9,7 @@
9
9
  open-on-hover={openOnHover}
10
10
  open={open}
11
11
  page-padding={pagePadding}
12
- placement={placement}
12
+ placement={effectivePlacement}
13
13
  small={small}
14
14
  width={effectiveWidth}
15
15
  >
@@ -82,20 +82,35 @@ export default class Dropdown extends LightningElement {
82
82
  return this.areOptionsEmpty ? null : "menu";
83
83
  }
84
84
 
85
+ // "fit-end" is fit-content, aligned to the trigger's end edge.
86
+ // "full" matches the trigger width. "fixed" and "fit-content" are unchanged.
85
87
  private get isFitContent() {
86
- return this.widthMode === "fit-content";
88
+ return this.widthMode === "fit-content" || this.widthMode === "fit-end";
87
89
  }
88
90
 
91
+ private get effectivePlacement(): PopperPlacement | undefined {
92
+ return this.widthMode === "fit-end" ? "bottom-end" : this.placement;
93
+ }
94
+
95
+ // fit-content takes precedence; the misuse is surfaced in className() below.
89
96
  private get effectiveWidth() {
90
97
  return this.isFitContent ? null : this.width;
91
98
  }
92
99
 
93
100
  private get effectiveFullWidth() {
101
+ if (this.widthMode === "full") {
102
+ return true;
103
+ }
94
104
  return this.isFitContent ? false : this.fullWidth;
95
105
  }
96
106
 
97
107
  private get className() {
98
108
  const fitContent = this.isFitContent;
109
+ if (fitContent && (this.width || this.fullWidth)) {
110
+ console.error(
111
+ 'The "width-mode=fit-content" and "width"/"full-width" properties cannot be provided simultaneously. "width-mode=fit-content" is prioritized; "width"/"full-width" are ignored.'
112
+ );
113
+ }
99
114
  return cx(
100
115
  this.isNested && "menu-nested",
101
116
  this.isNested &&
@@ -25,11 +25,15 @@
25
25
  display: flex;
26
26
  font-family: var(--dx-g-font-sans);
27
27
  width: 100%;
28
- padding: var(--dx-g-spacing-sm);
29
- font-size: var(--dx-g-text-sm);
28
+
29
+ /* Opt-in hooks; defaults preserve today's look. */
30
+ padding: var(--dx-c-dropdown-option-padding, var(--dx-g-spacing-sm));
31
+ font-size: var(--dx-c-dropdown-option-font-size, var(--dx-g-text-sm));
32
+ border-radius: var(--dx-c-dropdown-option-border-radius, 0);
30
33
  transition: var(--dx-g-transition-hue-1x);
31
34
  align-items: center;
32
35
  outline: none !important;
36
+ background: white;
33
37
  }
34
38
 
35
39
  .option:active {
@@ -47,7 +51,10 @@
47
51
  }
48
52
 
49
53
  .option-active {
50
- --dx-c-dropdown-option-label-color: var(--dx-g-blue-vibrant-50);
54
+ --dx-c-dropdown-option-label-color: var(
55
+ --dx-c-dropdown-option-active-label-color,
56
+ var(--dx-g-blue-vibrant-50)
57
+ );
51
58
 
52
59
  background: var(--dx-g-cloud-blue-vibrant-95);
53
60
  }
@@ -57,8 +64,12 @@
57
64
  flex-direction: column;
58
65
  justify-content: center;
59
66
  font-family: var(--dx-g-font-sans);
60
- font-size: var(--dx-g-text-sm);
67
+ font-size: var(--dx-c-dropdown-option-font-size, var(--dx-g-text-sm));
68
+ color: var(--dx-c-dropdown-option-label-color, inherit);
61
69
  text-align: left;
70
+
71
+ /* Opt-in: set to 0 to let a long label truncate instead of overflow. */
72
+ min-width: var(--dx-c-dropdown-option-min-width, auto);
62
73
  }
63
74
 
64
75
  .option-indented .option_details {
@@ -68,10 +79,20 @@
68
79
 
69
80
  .option_label {
70
81
  color: var(--dx-c-dropdown-option-label-color, var(--dx-g-blue-vibrant-50));
71
- display: flex;
82
+
83
+ /* Opt-in: set to block so text-overflow:ellipsis applies (ignored on flex). */
84
+ display: var(--dx-c-dropdown-option-label-display, flex);
72
85
  align-items: center;
73
86
  font-weight: var(--dx-c-dropdown-option-font-weight, var(--dx-g-font-bold));
74
87
  font-size: var(--dx-c-dropdown-option-font-size, var(--dx-g-text-base));
88
+
89
+ /* Opt-in truncation: consumers set --dx-c-dropdown-option-white-space:
90
+ nowrap to truncate labels with an ellipsis instead of wrapping.
91
+ Defaults preserve today's wrapping behavior. */
92
+ white-space: var(--dx-c-dropdown-option-white-space, normal);
93
+ overflow: var(--dx-c-dropdown-option-overflow, visible);
94
+ overflow-wrap: var(--dx-c-dropdown-option-overflow-wrap, normal);
95
+ text-overflow: var(--dx-c-dropdown-option-text-overflow, clip);
75
96
  }
76
97
 
77
98
  .option-indented .option_label {
@@ -130,7 +151,10 @@
130
151
  }
131
152
 
132
153
  .option-fit-content.option-active .option_label {
133
- color: var(--dx-g-blue-vibrant-50);
154
+ color: var(
155
+ --dx-c-dropdown-option-active-label-color,
156
+ var(--dx-g-blue-vibrant-50)
157
+ );
134
158
  }
135
159
 
136
160
  .option-fit-content .option_icon {
@@ -132,3 +132,22 @@ svg,
132
132
  display: inline-block;
133
133
  content: "(.\00a0\00a0)";
134
134
  }
135
+
136
+ .single-option {
137
+ display: block;
138
+ width: 100%;
139
+ padding: 6px 0;
140
+ text-align: left;
141
+ font-family: var(--dx-g-font-sans);
142
+ font-size: 14px;
143
+ color: rgb(24 24 24);
144
+ }
145
+
146
+ .single-option:hover,
147
+ .single-option-selected {
148
+ color: var(--dx-g-blue-vibrant-50);
149
+ }
150
+
151
+ .single-option-selected {
152
+ font-weight: bold;
153
+ }
@@ -1,7 +1,7 @@
1
1
  <template>
2
2
  <form class="container">
3
3
  <span class="title-group-main title-group" onclick={dropdownToggle}>
4
- <p class="filter-menu-title">{header}</p>
4
+ <p class="filter-menu-title">{displayTitle}</p>
5
5
  <svg
6
6
  xmlns="http://www.w3.org/2000/svg"
7
7
  width="28"
@@ -18,7 +18,22 @@
18
18
  </span>
19
19
  <ul class="nested first-layer always-visible-scrollbar">
20
20
  <li>
21
- <template if:true={isBaseVariant}>
21
+ <template if:true={singleSelect}>
22
+ <template for:each={singleOptions} for:item="option">
23
+ <button
24
+ key={option.id}
25
+ type="button"
26
+ class={option.className}
27
+ role="option"
28
+ aria-selected={option.selected}
29
+ data-id={option.id}
30
+ onclick={onSingleSelect}
31
+ >
32
+ {option.name}
33
+ </button>
34
+ </template>
35
+ </template>
36
+ <template if:true={isCheckboxList}>
22
37
  <template for:each={filterMenuOptions} for:item="option">
23
38
  <dx-checkbox
24
39
  key={option.id}
@@ -11,6 +11,10 @@ export default class FilterMenu extends LightningElement {
11
11
  @api header: string = "Filter Menu";
12
12
  @api variant: string = "base";
13
13
  @api filtersDatesLoading?: boolean;
14
+ /** Render a single-choice list; the header shows the selected option. */
15
+ @api singleSelect: boolean = false;
16
+ /** Id of the selected option when singleSelect is set. */
17
+ @api value?: string;
14
18
 
15
19
  @api
16
20
  get options(): Option[] {
@@ -31,6 +35,31 @@ export default class FilterMenu extends LightningElement {
31
35
  return this.variant === "nested";
32
36
  }
33
37
 
38
+ get isCheckboxList() {
39
+ return this.isBaseVariant && !this.singleSelect;
40
+ }
41
+
42
+ get selectedOption() {
43
+ return this.options.find((o) => String(o.id) === this.value);
44
+ }
45
+
46
+ get displayTitle() {
47
+ return (this.singleSelect && this.selectedOption?.name) || this.header;
48
+ }
49
+
50
+ get singleOptions() {
51
+ return this.options.map((o) => {
52
+ const selected = String(o.id) === this.value;
53
+ return {
54
+ ...o,
55
+ selected,
56
+ className: selected
57
+ ? "single-option single-option-selected"
58
+ : "single-option"
59
+ };
60
+ });
61
+ }
62
+
34
63
  get showViewMoreBtn() {
35
64
  return this.options.length > 20;
36
65
  }
@@ -93,6 +122,20 @@ export default class FilterMenu extends LightningElement {
93
122
  this.showMore = !this.showMore;
94
123
  }
95
124
 
125
+ private onSingleSelect(e: any) {
126
+ const id = e.currentTarget.dataset.id;
127
+ const value = this.options.find((o) => String(o.id) === id);
128
+ this.template.querySelector(".nested")?.classList.remove("active");
129
+ this.template
130
+ .querySelector(".header-caret")
131
+ ?.classList.remove("caret-down");
132
+ this.dispatchEvent(
133
+ new CustomEvent("change", {
134
+ detail: { name: this.name, value, checked: true }
135
+ })
136
+ );
137
+ }
138
+
96
139
  private onChange(e: any) {
97
140
  const { checked, value } = e.detail;
98
141
  this.dispatchEvent(
@@ -89,7 +89,15 @@ export default class Popover extends LightningElement {
89
89
 
90
90
  this.dispatchEvent(new CustomEvent("open"));
91
91
 
92
- setTimeout(() => {
92
+ this.clearOpenListenersTimer();
93
+ // Defer so the opening click is not treated as an outside click.
94
+ // Drop the callback if the popover closes before it runs, or the
95
+ // document listeners attach with nothing left to remove them.
96
+ this.openListenersTimer = setTimeout(() => {
97
+ this.openListenersTimer = undefined;
98
+ if (!this._open) {
99
+ return;
100
+ }
93
101
  this.setPosition();
94
102
  this.attachListenersTopopover();
95
103
  });
@@ -98,7 +106,15 @@ export default class Popover extends LightningElement {
98
106
 
99
107
  @api
100
108
  closePopover(focusControl: boolean = false) {
109
+ this.clearOpenListenersTimer();
110
+ if (!this._open) {
111
+ return;
112
+ }
101
113
  this._open = false;
114
+ // Hide before aria-expanded changes. :has([aria-expanded]) / :focus-within
115
+ // stacking rules would otherwise drop position:relative while this menu
116
+ // is still visible, flashing it against a different offset parent.
117
+ this.popover?.classList.remove("popover-container_open");
102
118
  this.autoUpdateCleanup?.();
103
119
  if (focusControl && this.control && this.control.focus) {
104
120
  this.control.focus();
@@ -106,20 +122,28 @@ export default class Popover extends LightningElement {
106
122
  if (this.control) {
107
123
  this.control.setAttribute("aria-expanded", "false");
108
124
  }
109
- document.removeEventListener("click", this.interactOutsideListener);
125
+ document.removeEventListener(
126
+ "click",
127
+ this.interactOutsideListener,
128
+ true
129
+ );
110
130
  document.removeEventListener("focusout", this.interactOutsideListener);
131
+ document.removeEventListener("keydown", this.onDocumentKeydown, true);
111
132
  if (this.openOnHover) {
112
133
  document.removeEventListener(
113
134
  "mousemove",
114
135
  this.interactOutsideListener
115
136
  );
116
137
  }
138
+ this.pointerOpened = false;
117
139
  this.dispatchEvent(new CustomEvent("close"));
118
140
  }
119
141
 
120
142
  // PRIVATE VARS
121
143
 
122
144
  private controlled: boolean = false;
145
+ private pointerOpened: boolean = false;
146
+ private openListenersTimer?: ReturnType<typeof setTimeout>;
123
147
  private _open: boolean = false;
124
148
  private _rendered: boolean = isPrerender();
125
149
  private _role: string | null = null;
@@ -132,9 +156,15 @@ export default class Popover extends LightningElement {
132
156
  // LIFECYCLE METHODS
133
157
 
134
158
  disconnectedCallback() {
135
- document.removeEventListener("click", this.interactOutsideListener);
159
+ this.clearOpenListenersTimer();
160
+ document.removeEventListener(
161
+ "click",
162
+ this.interactOutsideListener,
163
+ true
164
+ );
136
165
  document.removeEventListener("mousemove", this.interactOutsideListener);
137
166
  document.removeEventListener("focusout", this.interactOutsideListener);
167
+ document.removeEventListener("keydown", this.onDocumentKeydown, true);
138
168
  this.removeControlListeners();
139
169
  }
140
170
 
@@ -226,17 +256,91 @@ export default class Popover extends LightningElement {
226
256
  switch (e.key) {
227
257
  case "ArrowDown": {
228
258
  e.preventDefault();
259
+ this.pointerOpened = false;
229
260
  if (!this.open) {
230
261
  this.openPopover();
231
262
  }
232
263
  this.dispatchEvent(new CustomEvent("controlarrowdown"));
233
264
  break;
234
265
  }
266
+ case "Esc":
267
+ case "Escape":
268
+ this.closeOnEscape(e);
269
+ break;
235
270
  default:
236
271
  }
237
272
  };
238
273
 
239
- private onControlClick = () => {
274
+ // Capture on document: click-open leaves focus on the trigger, and slotted
275
+ // menu options live outside this shadow tree, so the panel's onkeydown
276
+ // never sees Escape. Only the innermost popover in the event path handles
277
+ // it, so Escape in another control does not close this menu or cancel that
278
+ // control's own Escape behavior.
279
+ private onDocumentKeydown = (e: KeyboardEvent) => {
280
+ if ((e.key !== "Escape" && e.key !== "Esc") || !this.handlesEscape(e)) {
281
+ return;
282
+ }
283
+ this.closeOnEscape(e);
284
+ };
285
+
286
+ private handlesEscape(e: KeyboardEvent): boolean {
287
+ if (!this._open || e.defaultPrevented) {
288
+ return false;
289
+ }
290
+ const host = this.template.host;
291
+ const path = e.composedPath();
292
+ for (const node of path) {
293
+ if (node instanceof Element && node.localName === "dx-popover") {
294
+ return node === host;
295
+ }
296
+ }
297
+ // A non-composed event fired in this shadow or on the trigger does
298
+ // not list the host. The panel and control listeners still apply.
299
+ return (
300
+ (this.popover !== null && path.includes(this.popover)) ||
301
+ (this.control !== null && path.includes(this.control))
302
+ );
303
+ }
304
+
305
+ private clearOpenListenersTimer() {
306
+ if (this.openListenersTimer !== undefined) {
307
+ clearTimeout(this.openListenersTimer);
308
+ this.openListenersTimer = undefined;
309
+ }
310
+ }
311
+
312
+ private closeOnEscape(e: KeyboardEvent) {
313
+ if (!this.open) {
314
+ return;
315
+ }
316
+ e.preventDefault();
317
+ this.dispatchRequestCloseEvent("keypress-escape");
318
+ // Click-open + Escape upgrades :focus-visible on the trigger. Blur so
319
+ // the closed picker shows hover (pointer still over it) or default,
320
+ // not the keyboard halo. Keyboard-opened menus still restore focus.
321
+ const openedWithPointer = this.pointerOpened;
322
+ this.closePopover(!openedWithPointer);
323
+ if (openedWithPointer) {
324
+ this.blurControl();
325
+ }
326
+ }
327
+
328
+ private blurControl() {
329
+ const host = this.control;
330
+ if (!host) {
331
+ return;
332
+ }
333
+ const inner = host.shadowRoot?.activeElement as HTMLElement | null;
334
+ if (inner && typeof inner.blur === "function") {
335
+ inner.blur();
336
+ return;
337
+ }
338
+ if (typeof host.blur === "function") {
339
+ host.blur();
340
+ }
341
+ }
342
+
343
+ private onControlClick = (e: MouseEvent) => {
240
344
  if (this.controlled && this.open) {
241
345
  this.dispatchRequestCloseEvent("click-control");
242
346
  return;
@@ -257,6 +361,10 @@ export default class Popover extends LightningElement {
257
361
  return;
258
362
  }
259
363
 
364
+ // Enter/Space on a button synthesizes a click with detail 0. Only a
365
+ // pointer click should blur the trigger on Escape; keyboard activation
366
+ // must leave focus on the control.
367
+ this.pointerOpened = e.detail > 0;
260
368
  this.openPopover();
261
369
  };
262
370
 
@@ -287,16 +395,20 @@ export default class Popover extends LightningElement {
287
395
  switch (e.key) {
288
396
  case "Esc":
289
397
  case "Escape":
290
- this.dispatchRequestCloseEvent("keypress-escape");
291
- this.closePopover(true);
398
+ this.closeOnEscape(e);
292
399
  break;
293
400
  default:
294
401
  }
295
402
  }
296
403
 
297
404
  private attachListenersTopopover() {
298
- document.addEventListener("click", this.interactOutsideListener);
405
+ // Capture so a click still counts as outside when a descendant
406
+ // calls stopPropagation (topic version pickers do, so the tree row
407
+ // does not navigate). Without this, opening a second picker leaves
408
+ // the first menu open.
409
+ document.addEventListener("click", this.interactOutsideListener, true);
299
410
  document.addEventListener("focusout", this.interactOutsideListener);
411
+ document.addEventListener("keydown", this.onDocumentKeydown, true);
300
412
  if (this.openOnHover) {
301
413
  document.addEventListener(
302
414
  "mousemove",