@salesforcedevs/dx-components 1.40.0-tlv-alpha → 1.40.0-topic-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,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforcedevs/dx-components",
3
- "version": "1.40.0-tlv-alpha",
3
+ "version": "1.40.0-topic-alpha",
4
4
  "description": "DX Lightning web components",
5
5
  "license": "MIT",
6
6
  "engines": {
@@ -14,6 +14,32 @@
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
+
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
+
17
43
  @media screen and (max-width: 1000px) {
18
44
  .menu-nested .menu_option:not(:first-child) {
19
45
  border-top: var(--border);
@@ -2,15 +2,15 @@
2
2
  <dx-popover
3
3
  aria-label={ariaLabel}
4
4
  class={className}
5
- full-width={fullWidth}
5
+ full-width={effectiveFullWidth}
6
6
  offset={offset}
7
7
  onclose={onClose}
8
8
  open-on-hover={openOnHover}
9
9
  open={open}
10
10
  page-padding={pagePadding}
11
- placement={placement}
11
+ placement={effectivePlacement}
12
12
  small={small}
13
- width={width}
13
+ width={effectiveWidth}
14
14
  >
15
15
  <slot slot="control"></slot>
16
16
  <div
@@ -2,7 +2,8 @@ import {
2
2
  AnalyticsPayload,
3
3
  OptionWithNested,
4
4
  PopperPlacement,
5
- DropdownVariant
5
+ DropdownVariant,
6
+ DropdownWidthMode
6
7
  } from "typings/custom";
7
8
  import { LightningElement, api, track } from "lwc";
8
9
  import cx from "classnames";
@@ -37,6 +38,7 @@ export default class Dropdown extends LightningElement {
37
38
  @api fullWidth?: boolean | null;
38
39
  @api navItemLabel: string | null = null;
39
40
  @api variant: DropdownVariant = "base";
41
+ @api widthMode: DropdownWidthMode = "fixed";
40
42
 
41
43
  // props forwarded to dropdown option
42
44
  @api analyticsEvent?: string;
@@ -80,12 +82,41 @@ export default class Dropdown extends LightningElement {
80
82
  return this.areOptionsEmpty ? null : "menu";
81
83
  }
82
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.
87
+ private get isFitContent() {
88
+ return this.widthMode === "fit-content" || this.widthMode === "fit-end";
89
+ }
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.
96
+ private get effectiveWidth() {
97
+ return this.isFitContent ? null : this.width;
98
+ }
99
+
100
+ private get effectiveFullWidth() {
101
+ if (this.widthMode === "full") {
102
+ return true;
103
+ }
104
+ return this.isFitContent ? false : this.fullWidth;
105
+ }
106
+
83
107
  private get className() {
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
+ }
84
114
  return cx(
85
115
  this.isNested && "menu-nested",
86
116
  this.isNested && this.options.length >= 2 && "menu-nested-2-col",
87
117
  this.offset && `menu-offset-${this.offset}`,
88
- this.areOptionsEmpty && "menu-hidden"
118
+ this.areOptionsEmpty && "menu-hidden",
119
+ fitContent && "menu-fit-content"
89
120
  );
90
121
  }
91
122
 
@@ -63,6 +63,9 @@
63
63
  font-family: var(--dx-g-font-sans);
64
64
  font-size: var(--dx-g-text-sm);
65
65
  text-align: left;
66
+
67
+ /* Opt-in: set to 0 to let a long label truncate instead of overflow. */
68
+ min-width: var(--dx-c-dropdown-option-min-width, auto);
66
69
  }
67
70
 
68
71
  .option-indented .option_details {
@@ -72,7 +75,9 @@
72
75
 
73
76
  .option_label {
74
77
  color: var(--dx-c-dropdown-option-label-color, var(--dx-g-blue-vibrant-50));
75
- display: flex;
78
+
79
+ /* Opt-in: set to block so text-overflow:ellipsis applies (ignored on flex). */
80
+ display: var(--dx-c-dropdown-option-label-display, flex);
76
81
  align-items: center;
77
82
  font-weight: var(--dx-c-dropdown-option-font-weight, var(--dx-g-font-bold));
78
83
  font-size: var(--dx-c-dropdown-option-font-size, var(--dx-g-text-base));
@@ -82,6 +87,7 @@
82
87
  Defaults preserve today's wrapping behavior. */
83
88
  white-space: var(--dx-c-dropdown-option-white-space, normal);
84
89
  overflow: var(--dx-c-dropdown-option-overflow, visible);
90
+ overflow-wrap: var(--dx-c-dropdown-option-overflow-wrap, normal);
85
91
  text-overflow: var(--dx-c-dropdown-option-text-overflow, clip);
86
92
  }
87
93
 
@@ -80,7 +80,11 @@ export default class Popover extends LightningElement {
80
80
  this.control.setAttribute("aria-expanded", "true");
81
81
 
82
82
  if (this.popover) {
83
- this.autoUpdateCleanup = autoUpdate(this.control, this.popover, this.setPosition);
83
+ this.autoUpdateCleanup = autoUpdate(
84
+ this.control,
85
+ this.popover,
86
+ this.setPosition
87
+ );
84
88
  }
85
89
 
86
90
  this.dispatchEvent(new CustomEvent("open"));
@@ -102,7 +106,11 @@ export default class Popover extends LightningElement {
102
106
  if (this.control) {
103
107
  this.control.setAttribute("aria-expanded", "false");
104
108
  }
105
- document.removeEventListener("click", this.interactOutsideListener);
109
+ document.removeEventListener(
110
+ "click",
111
+ this.interactOutsideListener,
112
+ true
113
+ );
106
114
  document.removeEventListener("focusout", this.interactOutsideListener);
107
115
  if (this.openOnHover) {
108
116
  document.removeEventListener(
@@ -128,7 +136,11 @@ export default class Popover extends LightningElement {
128
136
  // LIFECYCLE METHODS
129
137
 
130
138
  disconnectedCallback() {
131
- document.removeEventListener("click", this.interactOutsideListener);
139
+ document.removeEventListener(
140
+ "click",
141
+ this.interactOutsideListener,
142
+ true
143
+ );
132
144
  document.removeEventListener("mousemove", this.interactOutsideListener);
133
145
  document.removeEventListener("focusout", this.interactOutsideListener);
134
146
  this.removeControlListeners();
@@ -291,7 +303,11 @@ export default class Popover extends LightningElement {
291
303
  }
292
304
 
293
305
  private attachListenersTopopover() {
294
- document.addEventListener("click", this.interactOutsideListener);
306
+ // Capture so a click still counts as outside when a descendant
307
+ // calls stopPropagation (topic version pickers do, so the tree row
308
+ // does not navigate). Without this, opening a second picker leaves
309
+ // the first menu open.
310
+ document.addEventListener("click", this.interactOutsideListener, true);
295
311
  document.addEventListener("focusout", this.interactOutsideListener);
296
312
  if (this.openOnHover) {
297
313
  document.addEventListener(
@@ -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,23 +107,47 @@ 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
- showForwardArrow: node.showForwardArrow
112
+ showForwardArrow: node.showForwardArrow,
113
+ versions: node.versions
69
114
  };
70
115
 
71
116
  const mapItem = { node: convertedNode, parent };
72
117
  if (node.children && node.children.length > 0) {
73
118
  convertedNode.children = node.children.map((child, index) =>
74
- this.convert(child, level + 1, mapItem, index)
119
+ this.convert(child, level + 1, mapItem, index, versionContext)
75
120
  );
76
121
  }
77
122
 
78
123
  this._nodeMap[convertedNode.key] = mapItem;
79
- this._nameKeyMap[convertedNode.name] = convertedNode.key;
124
+ this.indexName(convertedNode.name, convertedNode.key);
125
+ this.indexName(convertedNode.link?.href, convertedNode.key);
126
+ // Index this topic's own versioned URLs only. Parent-only switching
127
+ // URLs point at a child with content and must not steal that child's
128
+ // selection.
129
+ if (node.name && node.versions) {
130
+ for (const version of node.versions) {
131
+ this.indexName(
132
+ applyVersionToHref(node.name, version, node.versions),
133
+ convertedNode.key
134
+ );
135
+ }
136
+ }
80
137
  return convertedNode;
81
138
  }
82
139
 
140
+ private indexName(name: string | undefined, key: string): void {
141
+ if (!name) {
142
+ return;
143
+ }
144
+ this._nameKeyMap[name] = key;
145
+ const normalized = normalizePath(name);
146
+ if (normalized && normalized !== name) {
147
+ this._nameKeyMap[normalized] = key;
148
+ }
149
+ }
150
+
83
151
  private calculateKey(parentKey: string, position?: number): string {
84
152
  if (position === undefined) {
85
153
  return parentKey;
@@ -4,6 +4,33 @@
4
4
  display: block;
5
5
  }
6
6
 
7
+ /* Lift this row above later sibling pickers while its menu is open. */
8
+ :host(:focus-within) {
9
+ position: relative;
10
+ z-index: 2;
11
+ }
12
+
13
+ .tree-item-row {
14
+ display: flex;
15
+ align-items: center;
16
+ min-width: 0;
17
+ }
18
+
19
+ .tree-item-row dx-tree-tile {
20
+ flex: 1;
21
+ min-width: 0;
22
+ }
23
+
24
+ .tree-item-version-picker {
25
+ flex: 0 0 auto;
26
+ margin-right: var(--dx-g-spacing-lg);
27
+ }
28
+
29
+ .tree-item-row-selected {
30
+ background: var(--dx-g-cloud-blue-vibrant-95);
31
+ box-shadow: inset var(--dx-g-spacing-xs) 0 0 0 var(--dx-g-blue-vibrant-40);
32
+ }
33
+
7
34
  button {
8
35
  width: 100%;
9
36
  text-align: left;
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <a href={href} target={target} onclick={onLinkClick} tabindex="-1">
2
+ <div class={rowClass}>
3
3
  <dx-tree-tile
4
4
  href={href}
5
5
  is-root={isRoot}
@@ -7,12 +7,26 @@
7
7
  is-expanded={isExpanded}
8
8
  has-children={hasChildren}
9
9
  is-selected={isSelected}
10
+ is-contained-selection={isContainedSelection}
10
11
  oniconclick={onIconClick}
11
12
  show-arrow={isParent}
12
13
  target={target}
13
14
  is-external-link={isExternalLink}
15
+ has-version-picker={showVersionPicker}
16
+ onclick={onLinkClick}
14
17
  ></dx-tree-tile>
15
- </a>
18
+ <doc-version-picker
19
+ if:true={showVersionPicker}
20
+ class="tree-item-version-picker"
21
+ small="true"
22
+ versions={versionPickerOptions}
23
+ selected-version={selectedVersion}
24
+ latest-version={isLatestVersion}
25
+ onchange={onVersionChange}
26
+ onclick={onVersionPickerClick}
27
+ onmousedown={onVersionPickerClick}
28
+ ></doc-version-picker>
29
+ </div>
16
30
  <img
17
31
  if:true={showChildrenLoading}
18
32
  class="tree-children-loading"
@@ -2,9 +2,24 @@ import { LightningElement, api } from "lwc";
2
2
  import { InternalTreeNode } from "typings/custom";
3
3
  import { track } from "dxUtils/analytics";
4
4
  import { isInViewport } from "dxUtils/browser";
5
+ import type { TopicVersionOption } from "dxUtils/topicVersion";
6
+ import {
7
+ firstContentNode,
8
+ getTopicVersionKey,
9
+ getVersionPickerOptions,
10
+ isParentOnlyTopic,
11
+ normalizePath,
12
+ persistTopicVersion,
13
+ resolveSelectedVersion,
14
+ shouldShowTopicVersionPicker
15
+ } from "dxUtils/topicVersion";
5
16
 
6
17
  const DEFAULT_TARGET = "_self";
7
18
 
19
+ type VersionedInternalTreeNode = InternalTreeNode & {
20
+ versions?: TopicVersionOption[];
21
+ };
22
+
8
23
  export default class TreeItem extends LightningElement {
9
24
  @api selectedKey?: string;
10
25
  @api isRoot: boolean = false;
@@ -20,7 +35,7 @@ export default class TreeItem extends LightningElement {
20
35
  this.isChildrenLoading = (node && node.isChildrenLoading) || false;
21
36
  }
22
37
 
23
- private _treeNode!: InternalTreeNode;
38
+ private _treeNode!: VersionedInternalTreeNode;
24
39
  private isExpanded: boolean = false;
25
40
  private isChildrenLoading: boolean = false;
26
41
  private rootParentLabel: string = "";
@@ -30,9 +45,22 @@ export default class TreeItem extends LightningElement {
30
45
  }
31
46
 
32
47
  private get isSelected(): boolean {
48
+ if (isParentOnlyTopic(this._treeNode)) {
49
+ return false;
50
+ }
33
51
  return this._treeNode.key === this.selectedKey && !this.isExternalLink;
34
52
  }
35
53
 
54
+ private get rowClass(): string {
55
+ return this.isSelected && this.showVersionPicker
56
+ ? "tree-item-row tree-item-row-selected"
57
+ : "tree-item-row";
58
+ }
59
+
60
+ private get isContainedSelection(): boolean {
61
+ return this.isSelected && this.showVersionPicker;
62
+ }
63
+
36
64
  private get hasChildren(): boolean {
37
65
  return !!(
38
66
  this.treeNode &&
@@ -79,8 +107,44 @@ export default class TreeItem extends LightningElement {
79
107
  return `${parentLabels.reverse().join(":")}:${this.treeNode.label}`;
80
108
  }
81
109
 
110
+ private get versions(): TopicVersionOption[] | undefined {
111
+ return this._treeNode?.versions;
112
+ }
113
+
114
+ private get versionPickerOptions(): TopicVersionOption[] | undefined {
115
+ return getVersionPickerOptions(this._treeNode);
116
+ }
117
+
118
+ private get showVersionPicker(): boolean {
119
+ return shouldShowTopicVersionPicker(
120
+ this.versions,
121
+ this._treeNode?.showForwardArrow
122
+ );
123
+ }
124
+
125
+ private get selectedVersion(): TopicVersionOption | undefined {
126
+ return resolveSelectedVersion(this._treeNode);
127
+ }
128
+
129
+ private get isLatestVersion(): boolean {
130
+ return (
131
+ !!this.selectedVersion &&
132
+ this.selectedVersion.id === this.versions?.[0]?.id
133
+ );
134
+ }
135
+
82
136
  private get href(): string | undefined {
137
+ if (isParentOnlyTopic(this._treeNode)) {
138
+ const contentNode = firstContentNode(this._treeNode);
139
+ return (
140
+ contentNode?.link?.href ||
141
+ contentNode?.name ||
142
+ this.selectedVersion?.link?.href ||
143
+ ""
144
+ );
145
+ }
83
146
  return (
147
+ this.selectedVersion?.link?.href ||
84
148
  (this._treeNode.link && this._treeNode.link.href) ||
85
149
  this._treeNode.name ||
86
150
  ""
@@ -100,6 +164,23 @@ export default class TreeItem extends LightningElement {
100
164
  setExpanded: this.setExpanded.bind(this)
101
165
  });
102
166
  }
167
+
168
+ // Remember this topic's resolved version (own URL, descendant URL, or
169
+ // session) so sibling pickers stay independent for the tab.
170
+ const selected = resolveSelectedVersion(this._treeNode);
171
+ const topicKey = getTopicVersionKey(this._treeNode);
172
+ if (this.versions?.length && selected && topicKey) {
173
+ persistTopicVersion(topicKey, selected.id);
174
+ }
175
+ }
176
+
177
+ private onVersionChange(event: CustomEvent): void {
178
+ event.stopPropagation();
179
+ persistTopicVersion(getTopicVersionKey(this._treeNode), event.detail);
180
+ }
181
+
182
+ private onVersionPickerClick(event: Event): void {
183
+ event.stopPropagation();
103
184
  }
104
185
 
105
186
  renderedCallback(): void {
@@ -130,8 +211,8 @@ export default class TreeItem extends LightningElement {
130
211
  private preventDefaultLinkBehavior(event: Event) {
131
212
  // prevent page refresh if href isn't present or link is already active
132
213
  if (
133
- !this._treeNode.link?.href ||
134
- this._treeNode.link?.href === window.location.pathname
214
+ !this.href ||
215
+ normalizePath(this.href) === normalizePath(window.location.pathname)
135
216
  ) {
136
217
  event.preventDefault();
137
218
  }
@@ -146,8 +227,12 @@ export default class TreeItem extends LightningElement {
146
227
  }
147
228
 
148
229
  this.sendGtm(event);
230
+ const contentNode = isParentOnlyTopic(this._treeNode)
231
+ ? firstContentNode(this._treeNode)
232
+ : undefined;
149
233
  this.fireEvent("tileselected", {
150
- name: this._treeNode.name,
234
+ name: contentNode?.name || this._treeNode.name,
235
+ ...(contentNode?.key ? { key: contentNode.key } : {}),
151
236
  isExternalLink: this.isExternalLink
152
237
  });
153
238
  }
@@ -165,8 +250,8 @@ export default class TreeItem extends LightningElement {
165
250
  this.dispatchEvent(
166
251
  new CustomEvent(type, {
167
252
  detail: {
168
- ...detail,
169
- key: this._treeNode.key
253
+ key: this._treeNode.key,
254
+ ...detail
170
255
  },
171
256
  bubbles: true,
172
257
  composed: true
@@ -66,6 +66,10 @@
66
66
  white-space: nowrap;
67
67
  }
68
68
 
69
+ .tile-with-version-picker {
70
+ padding-right: var(--dx-g-spacing-xs);
71
+ }
72
+
69
73
  .tile-with-children .tile-label {
70
74
  order: 2;
71
75
  }
@@ -28,10 +28,12 @@ export default class TreeTile extends LightningElement {
28
28
  @api isExpanded = false;
29
29
  @api hasChildren = false;
30
30
  @api isSelected = false;
31
+ @api isContainedSelection = false;
31
32
  @api href!: string;
32
33
  @api showArrow = false;
33
34
  @api target!: string;
34
35
  @api isExternalLink = false;
36
+ @api hasVersionPicker = false;
35
37
 
36
38
  get tileAriaLevel() {
37
39
  return this.treeNode.level + 2;
@@ -79,7 +81,11 @@ export default class TreeTile extends LightningElement {
79
81
  "tile-root": this.isRoot,
80
82
  "tile-with-children": !this.isRoot && this.hasChildren,
81
83
  "tile-root-with-child": this.isRoot && this.showArrow,
82
- "sidebar-item-selected": this.isSelected
84
+ "sidebar-item-selected":
85
+ this.isSelected && !this.isContainedSelection,
86
+ "sidebar-item-selected-text":
87
+ this.isSelected && this.isContainedSelection,
88
+ "tile-with-version-picker": this.hasVersionPicker
83
89
  });
84
90
  }
85
91
 
@@ -7,6 +7,7 @@ import {
7
7
  IconSprite,
8
8
  IconData,
9
9
  IconSymbol,
10
+ StatusBadgeVariant,
10
11
  TypeBadgeSize
11
12
  } from "typings/custom";
12
13
  import { colorToDxColors, buildStyleColorVariables } from "dxUtils/css";
@@ -20,9 +21,16 @@ import {
20
21
  getContentTypeColorVariables
21
22
  } from "dxUtils/contentTypes";
22
23
 
24
+ // Opt-in solid status variants (status-success/-warning/-error).
25
+ export const STATUS_BADGE_VARIANTS: StatusBadgeVariant[] = [
26
+ "status-success",
27
+ "status-warning",
28
+ "status-error"
29
+ ];
30
+
23
31
  export default class TypeBadge extends LightningElement {
24
32
  @api size: TypeBadgeSize = "default";
25
- @api variant: Brand | ContentType = "default";
33
+ @api variant: Brand | ContentType | StatusBadgeVariant = "default";
26
34
  @api color?: Color;
27
35
  @api dark?: false;
28
36
 
@@ -87,11 +95,24 @@ export default class TypeBadge extends LightningElement {
87
95
  return isContentType(this.variant);
88
96
  }
89
97
 
98
+ private get isStatus(): boolean {
99
+ return STATUS_BADGE_VARIANTS.includes(
100
+ this.variant as StatusBadgeVariant
101
+ );
102
+ }
103
+
90
104
  private get className(): string {
91
- return cx("dx-badge", `size-${this.size}`);
105
+ return cx(
106
+ "dx-badge",
107
+ `size-${this.size}`,
108
+ this.isStatus && this.variant
109
+ );
92
110
  }
93
111
 
94
112
  private get style(): string {
113
+ if (this.isStatus) {
114
+ return "";
115
+ }
95
116
  if (this.dark) {
96
117
  return buildStyleColorVariables({
97
118
  background: "--dx-g-purple-vibrant-40",
@@ -13,6 +13,10 @@
13
13
  color: var(--dx-g-blue-vibrant-40) !important;
14
14
  }
15
15
 
16
+ .sidebar-item-selected-text {
17
+ color: var(--dx-g-blue-vibrant-40) !important;
18
+ }
19
+
16
20
  .sidebar-item {
17
21
  padding: 6px var(--dx-g-spacing-lg) 6px
18
22
  var(--dx-g-global-header-padding-horizontal);
@@ -26,6 +26,28 @@
26
26
  --height: var(--dx-g-spacing-mlg);
27
27
  }
28
28
 
29
+ .dx-badge.status-success,
30
+ .dx-badge.status-warning,
31
+ .dx-badge.status-error {
32
+ /* No spacing token maps to 10px; status badges need this exact padding. */
33
+ --horizontal-spacing: 10px;
34
+ }
35
+
36
+ .dx-badge.status-success {
37
+ --color: var(--sds-g-gray-1);
38
+ --background: var(--dx-g-green-vibrant-50);
39
+ }
40
+
41
+ .dx-badge.status-warning {
42
+ --color: var(--dx-g-gray-15);
43
+ --background: var(--dx-g-yellow-vibrant-80);
44
+ }
45
+
46
+ .dx-badge.status-error {
47
+ --color: var(--sds-g-gray-1);
48
+ --background: var(--dx-g-red-vibrant-40);
49
+ }
50
+
29
51
  svg,
30
52
  dx-icon {
31
53
  margin-right: var(--dx-g-spacing-sm);
@@ -0,0 +1,467 @@
1
+ export const TOPIC_VERSION_SESSION_KEY = "sfdocs-topic-version-selections";
2
+
3
+ export type TopicVersionOption = {
4
+ id: string;
5
+ label: string;
6
+ deprecated?: boolean;
7
+ selected?: boolean;
8
+ link?: {
9
+ href?: string;
10
+ target?: string;
11
+ };
12
+ };
13
+
14
+ export type VersionedNode = {
15
+ name?: string;
16
+ label?: string;
17
+ key?: string;
18
+ link?: {
19
+ href?: string;
20
+ target?: string;
21
+ };
22
+ children?: VersionedNode[];
23
+ versions?: TopicVersionOption[];
24
+ /** Set on Redoc spec tiles after the docs layout decorates the TOC. */
25
+ showForwardArrow?: boolean;
26
+ /** Docs TOC topic type (`markdown` | `spec`). May be stripped after decoration. */
27
+ topicType?: string;
28
+ };
29
+
30
+ type VersionPathDelta = {
31
+ prefix: string;
32
+ inserted: string;
33
+ };
34
+
35
+ function isBrowser(): boolean {
36
+ return typeof window !== "undefined";
37
+ }
38
+
39
+ export function normalizePath(href?: string): string {
40
+ if (!href) {
41
+ return "";
42
+ }
43
+
44
+ try {
45
+ const url = href.startsWith("http")
46
+ ? new URL(href)
47
+ : new URL(
48
+ href,
49
+ isBrowser() ? window.location.origin : "http://localhost"
50
+ );
51
+ return url.pathname.replace(/\/$/, "") || "/";
52
+ } catch {
53
+ return href.replace(/\/$/, "");
54
+ }
55
+ }
56
+
57
+ export function hrefMatchesCurrentPage(href?: string): boolean {
58
+ if (!isBrowser()) {
59
+ return false;
60
+ }
61
+ return normalizePath(href) === normalizePath(window.location.pathname);
62
+ }
63
+
64
+ export function readTopicVersionSelections(): Record<string, string> {
65
+ if (!isBrowser() || !window.sessionStorage) {
66
+ return {};
67
+ }
68
+
69
+ try {
70
+ const raw = window.sessionStorage.getItem(TOPIC_VERSION_SESSION_KEY);
71
+ return raw ? JSON.parse(raw) : {};
72
+ } catch {
73
+ return {};
74
+ }
75
+ }
76
+
77
+ export function persistTopicVersion(
78
+ topicName: string,
79
+ versionId: string
80
+ ): void {
81
+ if (!topicName || !versionId || !isBrowser() || !window.sessionStorage) {
82
+ return;
83
+ }
84
+
85
+ const stored = readTopicVersionSelections();
86
+ stored[topicName] = versionId;
87
+ window.sessionStorage.setItem(
88
+ TOPIC_VERSION_SESSION_KEY,
89
+ JSON.stringify(stored)
90
+ );
91
+ }
92
+
93
+ /**
94
+ * Stable session key for a versioned topic. Parent-only sections have an empty
95
+ * `name`/`href`, so fall back to the latest switching URL (then the label).
96
+ */
97
+ export function getTopicVersionKey(node: VersionedNode | undefined): string {
98
+ if (!node) {
99
+ return "";
100
+ }
101
+ if (node.name) {
102
+ return node.name;
103
+ }
104
+ const latestHref = node.versions?.[0]?.link?.href;
105
+ if (latestHref) {
106
+ return latestHref;
107
+ }
108
+ return node.label || "";
109
+ }
110
+
111
+ /** Section header with children but no page of its own. */
112
+ export function isParentOnlyTopic(node: VersionedNode | undefined): boolean {
113
+ if (!node) {
114
+ return false;
115
+ }
116
+ const ownHref = node.link?.href || node.name;
117
+ return !ownHref && !!node.children?.length;
118
+ }
119
+
120
+ function isSpecNode(node: VersionedNode): boolean {
121
+ return node.showForwardArrow === true || node.topicType === "spec";
122
+ }
123
+
124
+ /**
125
+ * First descendant that has a markdown page (own href/name). Specs (Redoc)
126
+ * and external links are skipped so parent-only sections land on the first
127
+ * markdown topic even when a spec is authored first in the TOC.
128
+ */
129
+ export function firstContentNode(
130
+ node: VersionedNode | undefined
131
+ ): VersionedNode | undefined {
132
+ if (!node || node.link?.target === "_blank" || isSpecNode(node)) {
133
+ return undefined;
134
+ }
135
+ if (node.link?.href || node.name) {
136
+ return node;
137
+ }
138
+ for (const child of node.children || []) {
139
+ const found = firstContentNode(child);
140
+ if (found) {
141
+ return found;
142
+ }
143
+ }
144
+ return undefined;
145
+ }
146
+
147
+ export function shouldShowTopicVersionPicker(
148
+ versions?: TopicVersionOption[],
149
+ showForwardArrow?: boolean
150
+ ): boolean {
151
+ return !showForwardArrow && Array.isArray(versions) && versions.length > 1;
152
+ }
153
+
154
+ /**
155
+ * Difference between a picker's latest switching URL and an older one.
156
+ *
157
+ * latest: /docs/project/errors.html
158
+ * v1: /docs/project/v1/errors.html
159
+ * → prefix `/docs/project/`, inserted `v1/`
160
+ */
161
+ function getVersionPathDelta(
162
+ latestPath: string,
163
+ selectedPath: string
164
+ ): VersionPathDelta | undefined {
165
+ const latestDirEnd = latestPath.lastIndexOf("/") + 1;
166
+ if (latestDirEnd <= 0) {
167
+ return undefined;
168
+ }
169
+
170
+ const prefix = latestPath.slice(0, latestDirEnd);
171
+ const latestSlug = latestPath.slice(latestDirEnd);
172
+ if (!selectedPath.startsWith(prefix) || !latestSlug) {
173
+ return undefined;
174
+ }
175
+
176
+ const selectedAfterPrefix = selectedPath.slice(prefix.length);
177
+ if (!selectedAfterPrefix.endsWith(latestSlug)) {
178
+ return undefined;
179
+ }
180
+
181
+ const inserted = selectedAfterPrefix.slice(
182
+ 0,
183
+ selectedAfterPrefix.length - latestSlug.length
184
+ );
185
+ if (!inserted) {
186
+ return undefined;
187
+ }
188
+
189
+ return { prefix, inserted };
190
+ }
191
+
192
+ function applyVersionPathDelta(
193
+ hrefPath: string,
194
+ delta: VersionPathDelta
195
+ ): string | undefined {
196
+ if (!hrefPath.startsWith(delta.prefix)) {
197
+ return undefined;
198
+ }
199
+
200
+ const rest = hrefPath.slice(delta.prefix.length);
201
+ if (rest.startsWith(delta.inserted)) {
202
+ return hrefPath;
203
+ }
204
+
205
+ return delta.prefix + delta.inserted + rest;
206
+ }
207
+
208
+ function stripVersionPathDelta(
209
+ hrefPath: string,
210
+ delta: VersionPathDelta
211
+ ): string | undefined {
212
+ if (!hrefPath.startsWith(delta.prefix)) {
213
+ return undefined;
214
+ }
215
+
216
+ const rest = hrefPath.slice(delta.prefix.length);
217
+ if (!rest.startsWith(delta.inserted)) {
218
+ return undefined;
219
+ }
220
+
221
+ return delta.prefix + rest.slice(delta.inserted.length);
222
+ }
223
+
224
+ /**
225
+ * Maps a canonical (latest) href onto another version using the picker's
226
+ * latest vs selected switching URLs as the template.
227
+ *
228
+ * latest parent: /docs/project/errors.html
229
+ * v1 parent: /docs/project/v1/errors.html
230
+ * latest child: /docs/project/extra-child-a.html
231
+ * → v1 child: /docs/project/v1/extra-child-a.html
232
+ */
233
+ export function applyVersionToHref(
234
+ canonicalHref: string | undefined,
235
+ selectedVersion: TopicVersionOption | undefined,
236
+ versions?: TopicVersionOption[]
237
+ ): string | undefined {
238
+ if (!canonicalHref) {
239
+ return canonicalHref;
240
+ }
241
+ if (!selectedVersion || !versions?.length) {
242
+ return canonicalHref;
243
+ }
244
+
245
+ const latest = versions[0];
246
+ if (!latest || selectedVersion.id === latest.id) {
247
+ return canonicalHref;
248
+ }
249
+
250
+ const latestPath = normalizePath(latest.link?.href);
251
+ const selectedPath = normalizePath(selectedVersion.link?.href);
252
+ const hrefPath = normalizePath(canonicalHref);
253
+
254
+ if (!latestPath || !selectedPath || latestPath === selectedPath) {
255
+ return insertVersionBeforeSlug(canonicalHref, selectedVersion.id);
256
+ }
257
+
258
+ const delta = getVersionPathDelta(latestPath, selectedPath);
259
+ return (
260
+ (delta && applyVersionPathDelta(hrefPath, delta)) ||
261
+ insertVersionBeforeSlug(canonicalHref, selectedVersion.id)
262
+ );
263
+ }
264
+
265
+ function insertVersionBeforeSlug(href: string, versionId: string): string {
266
+ const path = normalizePath(href);
267
+ const lastSlash = path.lastIndexOf("/");
268
+ if (lastSlash <= 0 || !versionId) {
269
+ return href;
270
+ }
271
+ const dir = path.slice(0, lastSlash);
272
+ const slug = path.slice(lastSlash);
273
+ if (dir.endsWith(`/${versionId}`)) {
274
+ return path;
275
+ }
276
+ return `${dir}/${versionId}${slug}`;
277
+ }
278
+
279
+ export function toCanonicalHref(
280
+ href: string | undefined,
281
+ versions?: TopicVersionOption[]
282
+ ): string {
283
+ if (!href) {
284
+ return "";
285
+ }
286
+ if (!versions?.length) {
287
+ return normalizePath(href);
288
+ }
289
+
290
+ const latestPath = normalizePath(versions[0].link?.href);
291
+ const hrefPath = normalizePath(href);
292
+ if (!latestPath) {
293
+ return hrefPath;
294
+ }
295
+
296
+ for (const version of versions) {
297
+ if (version.id === versions[0].id) {
298
+ continue;
299
+ }
300
+ const delta = getVersionPathDelta(
301
+ latestPath,
302
+ normalizePath(version.link?.href)
303
+ );
304
+ const stripped = delta && stripVersionPathDelta(hrefPath, delta);
305
+ if (stripped) {
306
+ return stripped;
307
+ }
308
+ }
309
+
310
+ return hrefPath;
311
+ }
312
+
313
+ function hrefMatchesVersion(
314
+ href: string | undefined,
315
+ version: TopicVersionOption,
316
+ versions: TopicVersionOption[]
317
+ ): boolean {
318
+ return hrefMatchesCurrentPage(applyVersionToHref(href, version, versions));
319
+ }
320
+
321
+ function collectSubtreeCanonicalHrefs(
322
+ node: VersionedNode,
323
+ includeSelf = true
324
+ ): string[] {
325
+ const hrefs: string[] = [];
326
+ const own = node.link?.href || node.name;
327
+ if (includeSelf && own) {
328
+ hrefs.push(own);
329
+ }
330
+ for (const child of node.children || []) {
331
+ if (child.versions?.length || child.link?.target === "_blank") {
332
+ continue;
333
+ }
334
+ hrefs.push(...collectSubtreeCanonicalHrefs(child));
335
+ }
336
+ return hrefs;
337
+ }
338
+
339
+ export function isCurrentPageInSubtree(
340
+ node: VersionedNode,
341
+ versions?: TopicVersionOption[]
342
+ ): boolean {
343
+ if (!isBrowser() || !versions?.length) {
344
+ return false;
345
+ }
346
+
347
+ return collectSubtreeCanonicalHrefs(node).some((href) =>
348
+ versions.some((version) => hrefMatchesVersion(href, version, versions))
349
+ );
350
+ }
351
+
352
+ /**
353
+ * Picker option hrefs. When the current page is this topic or a descendant,
354
+ * each option points at that same topic in the chosen version so the child
355
+ * stays selected. Otherwise keep the backend switching URL (parent page, or
356
+ * first content child for parent-only sections).
357
+ */
358
+ export function getVersionPickerOptions(
359
+ node: VersionedNode | undefined
360
+ ): TopicVersionOption[] | undefined {
361
+ const versions = node?.versions;
362
+ if (!node || !versions?.length) {
363
+ return versions;
364
+ }
365
+ if (!isCurrentPageInSubtree(node, versions)) {
366
+ return versions;
367
+ }
368
+
369
+ const canonical = toCanonicalHref(window.location.pathname, versions);
370
+ return versions.map((version) => ({
371
+ ...version,
372
+ link: {
373
+ ...version.link,
374
+ href:
375
+ applyVersionToHref(canonical, version, versions) ||
376
+ version.link?.href
377
+ }
378
+ }));
379
+ }
380
+
381
+ /**
382
+ * Version whose page (or a descendant of it) is the current URL.
383
+ * Ignores the session-stored picker selection, which can remember an older
384
+ * version while the reader is still on the latest page.
385
+ */
386
+ export function versionForCurrentPage(
387
+ node: VersionedNode | undefined
388
+ ): TopicVersionOption | undefined {
389
+ const versions = node?.versions;
390
+ if (!node || !versions?.length || !isBrowser()) {
391
+ return undefined;
392
+ }
393
+
394
+ const hrefs = collectSubtreeCanonicalHrefs(node);
395
+ const matches = (version: TopicVersionOption) =>
396
+ hrefMatchesCurrentPage(version.link?.href) ||
397
+ hrefs.some((href) => hrefMatchesVersion(href, version, versions));
398
+
399
+ // Older versions first so a rewritten child URL is not mistaken for latest.
400
+ const older = versions.find(
401
+ (version) => version.id !== versions[0].id && matches(version)
402
+ );
403
+ if (older) {
404
+ return older;
405
+ }
406
+
407
+ return matches(versions[0]) ? versions[0] : undefined;
408
+ }
409
+
410
+ /**
411
+ * Deepest versioned topic that contains the current page, with the version
412
+ * the URL selects. Nested versioned topics own their pages.
413
+ */
414
+ export function findTopicVersionForCurrentPage(
415
+ nodes: VersionedNode[] | undefined | null
416
+ ): { node: VersionedNode; version: TopicVersionOption } | undefined {
417
+ let match: { node: VersionedNode; version: TopicVersionOption } | undefined;
418
+ const walk = (list?: VersionedNode[] | null) => {
419
+ for (const node of list || []) {
420
+ const version = versionForCurrentPage(node);
421
+ if (version) {
422
+ match = { node, version };
423
+ }
424
+ walk(node.children);
425
+ }
426
+ };
427
+ walk(nodes);
428
+ return match;
429
+ }
430
+
431
+ /**
432
+ * Current page rewritten onto the latest topic version. Falls back to the
433
+ * latest switching URL when the path delta cannot be applied.
434
+ */
435
+ export function latestHrefForCurrentPage(
436
+ versions: TopicVersionOption[] | undefined
437
+ ): string | undefined {
438
+ const latest = versions?.[0];
439
+ if (!latest) {
440
+ return undefined;
441
+ }
442
+ if (!isBrowser()) {
443
+ return latest.link?.href;
444
+ }
445
+
446
+ const canonical = toCanonicalHref(window.location.pathname, versions);
447
+ return applyVersionToHref(canonical, latest, versions) || latest.link?.href;
448
+ }
449
+
450
+ export function resolveSelectedVersion(
451
+ node: VersionedNode | undefined
452
+ ): TopicVersionOption | undefined {
453
+ const versions = node?.versions;
454
+ if (!node || !versions?.length) {
455
+ return undefined;
456
+ }
457
+
458
+ // The open page wins, including a canonical child opened from "View Latest".
459
+ // Session is only for a picker whose topic is not the current page.
460
+ const fromUrl = versionForCurrentPage(node);
461
+ if (fromUrl) {
462
+ return fromUrl;
463
+ }
464
+
465
+ const storedId = readTopicVersionSelections()[getTopicVersionKey(node)];
466
+ return versions.find((version) => version.id === storedId) || versions[0];
467
+ }