@salesforcedevs/dx-components 1.40.4 → 1.40.5-final-alpha

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/lwc.config.json CHANGED
@@ -140,6 +140,7 @@
140
140
  "dxUtils/lwc",
141
141
  "dxUtils/urlWatcher",
142
142
  "dxUtils/normalizers",
143
+ "dxUtils/topicVersion",
143
144
  "dxUtils/queryCoordinator",
144
145
  "dxUtils/recentSearches",
145
146
  "dxUtils/regexps",
package/package.json CHANGED
@@ -1,48 +1,47 @@
1
1
  {
2
- "name": "@salesforcedevs/dx-components",
3
- "version": "1.40.4",
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": "37647f2998066bc5a3e345e3bfdbd70dfefa5adc"
48
- }
2
+ "name": "@salesforcedevs/dx-components",
3
+ "version": "1.40.5-final-alpha",
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,8 +25,14 @@
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(
33
+ --dx-c-dropdown-option-border-radius,
34
+ var(--dx-g-spacing-xs)
35
+ );
30
36
  transition: var(--dx-g-transition-hue-1x);
31
37
  align-items: center;
32
38
  outline: none !important;
@@ -47,7 +53,10 @@
47
53
  }
48
54
 
49
55
  .option-active {
50
- --dx-c-dropdown-option-label-color: var(--dx-g-blue-vibrant-50);
56
+ --dx-c-dropdown-option-label-color: var(
57
+ --dx-c-dropdown-option-active-label-color,
58
+ var(--dx-g-blue-vibrant-50)
59
+ );
51
60
 
52
61
  background: var(--dx-g-cloud-blue-vibrant-95);
53
62
  }
@@ -57,8 +66,12 @@
57
66
  flex-direction: column;
58
67
  justify-content: center;
59
68
  font-family: var(--dx-g-font-sans);
60
- font-size: var(--dx-g-text-sm);
69
+ font-size: var(--dx-c-dropdown-option-font-size, var(--dx-g-text-sm));
70
+ color: var(--dx-c-dropdown-option-label-color, inherit);
61
71
  text-align: left;
72
+
73
+ /* Opt-in: set to 0 to let a long label truncate instead of overflow. */
74
+ min-width: var(--dx-c-dropdown-option-min-width, auto);
62
75
  }
63
76
 
64
77
  .option-indented .option_details {
@@ -68,10 +81,20 @@
68
81
 
69
82
  .option_label {
70
83
  color: var(--dx-c-dropdown-option-label-color, var(--dx-g-blue-vibrant-50));
71
- display: flex;
84
+
85
+ /* Opt-in: set to block so text-overflow:ellipsis applies (ignored on flex). */
86
+ display: var(--dx-c-dropdown-option-label-display, flex);
72
87
  align-items: center;
73
88
  font-weight: var(--dx-c-dropdown-option-font-weight, var(--dx-g-font-bold));
74
89
  font-size: var(--dx-c-dropdown-option-font-size, var(--dx-g-text-base));
90
+
91
+ /* Opt-in truncation: consumers set --dx-c-dropdown-option-white-space:
92
+ nowrap to truncate labels with an ellipsis instead of wrapping.
93
+ Defaults preserve today's wrapping behavior. */
94
+ white-space: var(--dx-c-dropdown-option-white-space, normal);
95
+ overflow: var(--dx-c-dropdown-option-overflow, visible);
96
+ overflow-wrap: var(--dx-c-dropdown-option-overflow-wrap, normal);
97
+ text-overflow: var(--dx-c-dropdown-option-text-overflow, clip);
75
98
  }
76
99
 
77
100
  .option-indented .option_label {
@@ -130,7 +153,10 @@
130
153
  }
131
154
 
132
155
  .option-fit-content.option-active .option_label {
133
- color: var(--dx-g-blue-vibrant-50);
156
+ color: var(
157
+ --dx-c-dropdown-option-active-label-color,
158
+ var(--dx-g-blue-vibrant-50)
159
+ );
134
160
  }
135
161
 
136
162
  .option-fit-content .option_icon {
@@ -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",
@@ -1,6 +1,18 @@
1
1
  import type { InternalTreeNode, TreeNode } from "typings/custom";
2
+ import type { TopicVersionOption } from "dxUtils/topicVersion";
3
+ import {
4
+ applyVersionToHref,
5
+ normalizePath,
6
+ resolveSelectedVersion
7
+ } from "dxUtils/topicVersion";
8
+
9
+ type VersionedTreeNode = TreeNode & { versions?: TopicVersionOption[] };
10
+ type VersionedInternalTreeNode = InternalTreeNode & {
11
+ versions?: TopicVersionOption[];
12
+ };
13
+
2
14
  type MapItem = {
3
- node: InternalTreeNode;
15
+ node: VersionedInternalTreeNode;
4
16
  parent?: MapItem;
5
17
  setExpanded?: Function;
6
18
  };
@@ -21,7 +33,10 @@ export class TreeHandler {
21
33
  }
22
34
 
23
35
  getKeyByName(name: string): string | undefined {
24
- return name && this._nameKeyMap[name];
36
+ if (!name) {
37
+ return undefined;
38
+ }
39
+ return this._nameKeyMap[name] || this._nameKeyMap[normalizePath(name)];
25
40
  }
26
41
 
27
42
  initialize(treeRoot: TreeNode): InternalTreeNode {
@@ -48,12 +63,41 @@ export class TreeHandler {
48
63
  }
49
64
 
50
65
  private convert(
51
- node: TreeNode,
66
+ node: VersionedTreeNode,
52
67
  level: number = 0,
53
68
  parent?: MapItem,
54
- position?: number
55
- ): InternalTreeNode {
56
- const convertedNode: InternalTreeNode = {
69
+ position?: number,
70
+ ancestorVersion?: {
71
+ selected: TopicVersionOption;
72
+ versions: TopicVersionOption[];
73
+ }
74
+ ): VersionedInternalTreeNode {
75
+ const ownSelected = node.versions?.length
76
+ ? resolveSelectedVersion(node)
77
+ : undefined;
78
+ const versionContext =
79
+ ownSelected && node.versions
80
+ ? { selected: ownSelected, versions: node.versions }
81
+ : ancestorVersion;
82
+
83
+ let link = node.link;
84
+ if (
85
+ versionContext &&
86
+ !node.versions?.length &&
87
+ link?.href &&
88
+ link.target !== "_blank"
89
+ ) {
90
+ const rewritten = applyVersionToHref(
91
+ link.href,
92
+ versionContext.selected,
93
+ versionContext.versions
94
+ );
95
+ if (rewritten && rewritten !== link.href) {
96
+ link = { ...link, href: rewritten };
97
+ }
98
+ }
99
+
100
+ const convertedNode: VersionedInternalTreeNode = {
57
101
  label: node.label,
58
102
  name: node.name,
59
103
  key: this.calculateKey(
@@ -63,24 +107,52 @@ export class TreeHandler {
63
107
  level: level,
64
108
  isExpanded: node.isExpanded || false,
65
109
  isChildrenLoading: node.isChildrenLoading || false,
66
- link: node.link,
110
+ link,
67
111
  method: node.method,
68
112
  showForwardArrow: node.showForwardArrow,
69
- redirectToChild: node.redirectToChild
113
+ redirectToChild: node.redirectToChild,
114
+ versions: node.versions
70
115
  };
71
116
 
72
117
  const mapItem = { node: convertedNode, parent };
73
118
  if (node.children && node.children.length > 0) {
74
119
  convertedNode.children = node.children.map((child, index) =>
75
- this.convert(child, level + 1, mapItem, index)
120
+ this.convert(child, level + 1, mapItem, index, versionContext)
76
121
  );
77
122
  }
78
123
 
79
124
  this._nodeMap[convertedNode.key] = mapItem;
80
- this._nameKeyMap[convertedNode.name] = convertedNode.key;
125
+ this.indexName(convertedNode.name, convertedNode.key);
126
+ // A redirect parent can carry a child's href. This index runs after
127
+ // the child, so recording it would make the parent win selection.
128
+ if (!convertedNode.redirectToChild) {
129
+ this.indexName(convertedNode.link?.href, convertedNode.key);
130
+ }
131
+ // Index this topic's own versioned URLs only. Parent-only switching
132
+ // URLs point at a child with content and must not steal that child's
133
+ // selection.
134
+ if (node.name && node.versions) {
135
+ for (const version of node.versions) {
136
+ this.indexName(
137
+ applyVersionToHref(node.name, version, node.versions),
138
+ convertedNode.key
139
+ );
140
+ }
141
+ }
81
142
  return convertedNode;
82
143
  }
83
144
 
145
+ private indexName(name: string | undefined, key: string): void {
146
+ if (!name) {
147
+ return;
148
+ }
149
+ this._nameKeyMap[name] = key;
150
+ const normalized = normalizePath(name);
151
+ if (normalized && normalized !== name) {
152
+ this._nameKeyMap[normalized] = key;
153
+ }
154
+ }
155
+
84
156
  private calculateKey(parentKey: string, position?: number): string {
85
157
  if (position === undefined) {
86
158
  return parentKey;
@@ -2,6 +2,36 @@
2
2
 
3
3
  :host {
4
4
  display: block;
5
+
6
+ /* Keep a stable containing block; z-index still lifts the open menu. */
7
+ position: relative;
8
+ }
9
+
10
+ /* Lift this row above later sibling pickers while its menu is open. */
11
+ :host(:focus-within) {
12
+ z-index: 2;
13
+ }
14
+
15
+ .tree-item-row {
16
+ display: flex;
17
+ align-items: center;
18
+ min-width: 0;
19
+ }
20
+
21
+ .tree-item-row dx-tree-tile {
22
+ flex: 1;
23
+ min-width: 0;
24
+ }
25
+
26
+ .tree-item-version-picker {
27
+ flex: 0 0 auto;
28
+ margin-right: var(--dx-g-spacing-lg);
29
+ overflow: visible;
30
+ }
31
+
32
+ .tree-item-row-selected {
33
+ background: var(--dx-g-cloud-blue-vibrant-95);
34
+ box-shadow: inset var(--dx-g-spacing-xs) 0 0 0 var(--dx-g-blue-vibrant-40);
5
35
  }
6
36
 
7
37
  button {