@salesforcedevs/dx-components 1.40.0-docs-feedback-4 → 1.40.0-integration-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-docs-feedback-4",
3
+ "version": "1.40.0-integration-alpha",
4
4
  "description": "DX Lightning web components",
5
5
  "license": "MIT",
6
6
  "engines": {
@@ -43,6 +43,5 @@
43
43
  "eventsourcemock": "2.0.0",
44
44
  "luxon": "3.4.4",
45
45
  "msw": "^2.12.4"
46
- },
47
- "stableVersion": "1.40.0"
46
+ }
48
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,6 +14,20 @@
14
14
  display: none;
15
15
  }
16
16
 
17
+ /* Opt-in fit-content menu: grows to content (capped at the popover's viewport
18
+ max-width); long labels truncate instead of wrapping. */
19
+ .menu-fit-content::part(content) {
20
+ width: max-content;
21
+ }
22
+
23
+ .menu-fit-content {
24
+ --dx-c-dropdown-option-min-width: 0;
25
+ --dx-c-dropdown-option-label-display: block;
26
+ --dx-c-dropdown-option-white-space: nowrap;
27
+ --dx-c-dropdown-option-overflow: hidden;
28
+ --dx-c-dropdown-option-text-overflow: ellipsis;
29
+ }
30
+
17
31
  @media screen and (max-width: 1000px) {
18
32
  .menu-nested .menu_option:not(:first-child) {
19
33
  border-top: var(--border);
@@ -2,7 +2,7 @@
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}
@@ -10,7 +10,7 @@
10
10
  page-padding={pagePadding}
11
11
  placement={placement}
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,32 @@ export default class Dropdown extends LightningElement {
80
82
  return this.areOptionsEmpty ? null : "menu";
81
83
  }
82
84
 
85
+ private get isFitContent() {
86
+ return this.widthMode === "fit-content";
87
+ }
88
+
89
+ // fit-content takes precedence; the misuse is surfaced in className() below.
90
+ private get effectiveWidth() {
91
+ return this.isFitContent ? null : this.width;
92
+ }
93
+
94
+ private get effectiveFullWidth() {
95
+ return this.isFitContent ? false : this.fullWidth;
96
+ }
97
+
83
98
  private get className() {
99
+ const fitContent = this.isFitContent;
100
+ if (fitContent && (this.width || this.fullWidth)) {
101
+ console.error(
102
+ '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.'
103
+ );
104
+ }
84
105
  return cx(
85
106
  this.isNested && "menu-nested",
86
107
  this.isNested && this.options.length >= 2 && "menu-nested-2-col",
87
108
  this.offset && `menu-offset-${this.offset}`,
88
- this.areOptionsEmpty && "menu-hidden"
109
+ this.areOptionsEmpty && "menu-hidden",
110
+ fitContent && "menu-fit-content"
89
111
  );
90
112
  }
91
113
 
@@ -25,9 +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);
28
+
29
+ /* Opt-in hooks; defaults preserve today's look. */
30
+ padding: var(--dx-c-dropdown-option-padding, var(--dx-g-spacing-sm));
29
31
  font-size: var(--dx-g-text-sm);
30
- border-radius: var(--dx-g-spacing-xs);
32
+ border-radius: var(
33
+ --dx-c-dropdown-option-border-radius,
34
+ var(--dx-g-spacing-xs)
35
+ );
31
36
  transition: var(--dx-g-transition-hue-1x);
32
37
  align-items: center;
33
38
  outline: none !important;
@@ -58,6 +63,9 @@
58
63
  font-family: var(--dx-g-font-sans);
59
64
  font-size: var(--dx-g-text-sm);
60
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);
61
69
  }
62
70
 
63
71
  .option-indented .option_details {
@@ -67,10 +75,19 @@
67
75
 
68
76
  .option_label {
69
77
  color: var(--dx-c-dropdown-option-label-color, var(--dx-g-blue-vibrant-50));
70
- 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);
71
81
  align-items: center;
72
82
  font-weight: var(--dx-c-dropdown-option-font-weight, var(--dx-g-font-bold));
73
83
  font-size: var(--dx-c-dropdown-option-font-size, var(--dx-g-text-base));
84
+
85
+ /* Opt-in truncation: consumers set --dx-c-dropdown-option-white-space:
86
+ nowrap to truncate labels with an ellipsis instead of wrapping.
87
+ Defaults preserve today's wrapping behavior. */
88
+ white-space: var(--dx-c-dropdown-option-white-space, normal);
89
+ overflow: var(--dx-c-dropdown-option-overflow, visible);
90
+ text-overflow: var(--dx-c-dropdown-option-text-overflow, clip);
74
91
  }
75
92
 
76
93
  .option-indented .option_label {
@@ -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,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
112
  showForwardArrow: node.showForwardArrow,
69
- redirectToChild: node.redirectToChild
113
+ versions: node.versions
70
114
  };
71
115
 
72
116
  const mapItem = { node: convertedNode, parent };
73
117
  if (node.children && node.children.length > 0) {
74
118
  convertedNode.children = node.children.map((child, index) =>
75
- this.convert(child, level + 1, mapItem, index)
119
+ this.convert(child, level + 1, mapItem, index, versionContext)
76
120
  );
77
121
  }
78
122
 
79
123
  this._nodeMap[convertedNode.key] = mapItem;
80
- 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
+ }
81
137
  return convertedNode;
82
138
  }
83
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
+
84
151
  private calculateKey(parentKey: string, position?: number): string {
85
152
  if (position === undefined) {
86
153
  return parentKey;
@@ -4,6 +4,28 @@
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
+
7
29
  button {
8
30
  width: 100%;
9
31
  text-align: left;
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <a href={href} target={target} onclick={onLinkClick} tabindex="-1">
2
+ <div class="tree-item-row">
3
3
  <dx-tree-tile
4
4
  href={href}
5
5
  is-root={isRoot}
@@ -11,8 +11,21 @@
11
11
  show-arrow={isParent}
12
12
  target={target}
13
13
  is-external-link={isExternalLink}
14
+ has-version-picker={showVersionPicker}
15
+ onclick={onLinkClick}
14
16
  ></dx-tree-tile>
15
- </a>
17
+ <doc-version-picker
18
+ if:true={showVersionPicker}
19
+ class="tree-item-version-picker"
20
+ small="true"
21
+ versions={versionPickerOptions}
22
+ selected-version={selectedVersion}
23
+ latest-version={isLatestVersion}
24
+ onchange={onVersionChange}
25
+ onclick={onVersionPickerClick}
26
+ onmousedown={onVersionPickerClick}
27
+ ></doc-version-picker>
28
+ </div>
16
29
  <img
17
30
  if:true={showChildrenLoading}
18
31
  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,6 +45,9 @@ 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
 
@@ -49,10 +67,6 @@ export default class TreeItem extends LightningElement {
49
67
  return this.hasChildren || this.isChildrenLoading;
50
68
  }
51
69
 
52
- private get isContentlessParent(): boolean {
53
- return this.isParent && !!this._treeNode?.redirectToChild;
54
- }
55
-
56
70
  private get showChildren(): boolean {
57
71
  return this.isExpanded && this.hasChildren;
58
72
  }
@@ -83,8 +97,44 @@ export default class TreeItem extends LightningElement {
83
97
  return `${parentLabels.reverse().join(":")}:${this.treeNode.label}`;
84
98
  }
85
99
 
100
+ private get versions(): TopicVersionOption[] | undefined {
101
+ return this._treeNode?.versions;
102
+ }
103
+
104
+ private get versionPickerOptions(): TopicVersionOption[] | undefined {
105
+ return getVersionPickerOptions(this._treeNode);
106
+ }
107
+
108
+ private get showVersionPicker(): boolean {
109
+ return shouldShowTopicVersionPicker(
110
+ this.versions,
111
+ this._treeNode?.showForwardArrow
112
+ );
113
+ }
114
+
115
+ private get selectedVersion(): TopicVersionOption | undefined {
116
+ return resolveSelectedVersion(this._treeNode);
117
+ }
118
+
119
+ private get isLatestVersion(): boolean {
120
+ return (
121
+ !!this.selectedVersion &&
122
+ this.selectedVersion.id === this.versions?.[0]?.id
123
+ );
124
+ }
125
+
86
126
  private get href(): string | undefined {
127
+ if (isParentOnlyTopic(this._treeNode)) {
128
+ const contentNode = firstContentNode(this._treeNode);
129
+ return (
130
+ contentNode?.link?.href ||
131
+ contentNode?.name ||
132
+ this.selectedVersion?.link?.href ||
133
+ ""
134
+ );
135
+ }
87
136
  return (
137
+ this.selectedVersion?.link?.href ||
88
138
  (this._treeNode.link && this._treeNode.link.href) ||
89
139
  this._treeNode.name ||
90
140
  ""
@@ -104,6 +154,23 @@ export default class TreeItem extends LightningElement {
104
154
  setExpanded: this.setExpanded.bind(this)
105
155
  });
106
156
  }
157
+
158
+ // Remember this topic's resolved version (own URL, descendant URL, or
159
+ // session) so sibling pickers stay independent for the tab.
160
+ const selected = resolveSelectedVersion(this._treeNode);
161
+ const topicKey = getTopicVersionKey(this._treeNode);
162
+ if (this.versions?.length && selected && topicKey) {
163
+ persistTopicVersion(topicKey, selected.id);
164
+ }
165
+ }
166
+
167
+ private onVersionChange(event: CustomEvent): void {
168
+ event.stopPropagation();
169
+ persistTopicVersion(getTopicVersionKey(this._treeNode), event.detail);
170
+ }
171
+
172
+ private onVersionPickerClick(event: Event): void {
173
+ event.stopPropagation();
107
174
  }
108
175
 
109
176
  renderedCallback(): void {
@@ -134,8 +201,8 @@ export default class TreeItem extends LightningElement {
134
201
  private preventDefaultLinkBehavior(event: Event) {
135
202
  // prevent page refresh if href isn't present or link is already active
136
203
  if (
137
- !this._treeNode.link?.href ||
138
- this._treeNode.link?.href === window.location.pathname
204
+ !this.href ||
205
+ normalizePath(this.href) === normalizePath(window.location.pathname)
139
206
  ) {
140
207
  event.preventDefault();
141
208
  }
@@ -144,31 +211,18 @@ export default class TreeItem extends LightningElement {
144
211
  private onLinkClick(event: Event): void {
145
212
  this.preventDefaultLinkBehavior(event);
146
213
 
147
- /**
148
- * A content-less parent that redirects to a content child navigates
149
- * like a normal link (navigation handles selection and ancestor
150
- * expansion). A content-less parent whose chain has no content page to
151
- * redirect to only expands, it is never selected.
152
- */
153
- if (this.isContentlessParent) {
154
- if (this._treeNode.link?.href) {
155
- this.sendGtm(event);
156
- return;
157
- }
158
- const isSelectAction = false;
159
- this.doExpand(isSelectAction);
160
- this.sendGtm(event);
161
- return;
162
- }
163
-
164
214
  if (this.isParent) {
165
215
  const isSelectAction = true;
166
216
  this.doExpand(isSelectAction);
167
217
  }
168
218
 
169
219
  this.sendGtm(event);
220
+ const contentNode = isParentOnlyTopic(this._treeNode)
221
+ ? firstContentNode(this._treeNode)
222
+ : undefined;
170
223
  this.fireEvent("tileselected", {
171
- name: this._treeNode.name,
224
+ name: contentNode?.name || this._treeNode.name,
225
+ ...(contentNode?.key ? { key: contentNode.key } : {}),
172
226
  isExternalLink: this.isExternalLink
173
227
  });
174
228
  }
@@ -186,8 +240,8 @@ export default class TreeItem extends LightningElement {
186
240
  this.dispatchEvent(
187
241
  new CustomEvent(type, {
188
242
  detail: {
189
- ...detail,
190
- key: this._treeNode.key
243
+ key: this._treeNode.key,
244
+ ...detail
191
245
  },
192
246
  bubbles: true,
193
247
  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
  }
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <a href={href} class={tileClass} target={target} aria-label={linkAriaLabel}>
2
+ <a href={href} class={tileClass} target={target}>
3
3
  <div class="flex indentation" role="heading" aria-level={tileAriaLevel}>
4
4
  <div class="flex tile-expand-icon tile-icon" if:true={showArrow}>
5
5
  <dx-icon
@@ -32,33 +32,12 @@ export default class TreeTile extends LightningElement {
32
32
  @api showArrow = false;
33
33
  @api target!: string;
34
34
  @api isExternalLink = false;
35
+ @api hasVersionPicker = false;
35
36
 
36
37
  get tileAriaLevel() {
37
38
  return this.treeNode.level + 2;
38
39
  }
39
40
 
40
- /**
41
- * Accessible name for the link. If a parent redirects to its first child,
42
- * include both the parent and child labels so the destination is clear to
43
- * screen reader users (WCAG 2.4.4)
44
- */
45
- get linkAriaLabel(): string | null {
46
- if (!this.treeNode.redirectToChild || !this.treeNode.link?.href) {
47
- return null;
48
- }
49
- /**
50
- * The redirect can target a deep descendant, so walk the first-child
51
- * chain to the node that owns the destination link and name it.
52
- */
53
- let destination = this.treeNode.children?.[0];
54
- while (destination?.redirectToChild) {
55
- destination = destination.children?.[0];
56
- }
57
- return destination?.link?.href === this.treeNode.link.href
58
- ? `${this.treeNode.label}, opens ${destination.label}`
59
- : null;
60
- }
61
-
62
41
  private get iconSize() {
63
42
  return "xsmall";
64
43
  }
@@ -101,7 +80,8 @@ export default class TreeTile extends LightningElement {
101
80
  "tile-root": this.isRoot,
102
81
  "tile-with-children": !this.isRoot && this.hasChildren,
103
82
  "tile-root-with-child": this.isRoot && this.showArrow,
104
- "sidebar-item-selected": this.isSelected
83
+ "sidebar-item-selected": this.isSelected,
84
+ "tile-with-version-picker": this.hasVersionPicker
105
85
  });
106
86
  }
107
87
 
@@ -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",
@@ -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,401 @@
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
+ };
25
+
26
+ type VersionPathDelta = {
27
+ prefix: string;
28
+ inserted: string;
29
+ };
30
+
31
+ function isBrowser(): boolean {
32
+ return typeof window !== "undefined";
33
+ }
34
+
35
+ export function normalizePath(href?: string): string {
36
+ if (!href) {
37
+ return "";
38
+ }
39
+
40
+ try {
41
+ const url = href.startsWith("http")
42
+ ? new URL(href)
43
+ : new URL(
44
+ href,
45
+ isBrowser() ? window.location.origin : "http://localhost"
46
+ );
47
+ return url.pathname.replace(/\/$/, "") || "/";
48
+ } catch {
49
+ return href.replace(/\/$/, "");
50
+ }
51
+ }
52
+
53
+ export function hrefMatchesCurrentPage(href?: string): boolean {
54
+ if (!isBrowser()) {
55
+ return false;
56
+ }
57
+ return normalizePath(href) === normalizePath(window.location.pathname);
58
+ }
59
+
60
+ export function readTopicVersionSelections(): Record<string, string> {
61
+ if (!isBrowser() || !window.sessionStorage) {
62
+ return {};
63
+ }
64
+
65
+ try {
66
+ const raw = window.sessionStorage.getItem(TOPIC_VERSION_SESSION_KEY);
67
+ return raw ? JSON.parse(raw) : {};
68
+ } catch {
69
+ return {};
70
+ }
71
+ }
72
+
73
+ export function persistTopicVersion(
74
+ topicName: string,
75
+ versionId: string
76
+ ): void {
77
+ if (!topicName || !versionId || !isBrowser() || !window.sessionStorage) {
78
+ return;
79
+ }
80
+
81
+ const stored = readTopicVersionSelections();
82
+ stored[topicName] = versionId;
83
+ window.sessionStorage.setItem(
84
+ TOPIC_VERSION_SESSION_KEY,
85
+ JSON.stringify(stored)
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Stable session key for a versioned topic. Parent-only sections have an empty
91
+ * `name`/`href`, so fall back to the latest switching URL (then the label).
92
+ */
93
+ export function getTopicVersionKey(node: VersionedNode | undefined): string {
94
+ if (!node) {
95
+ return "";
96
+ }
97
+ if (node.name) {
98
+ return node.name;
99
+ }
100
+ const latestHref = node.versions?.[0]?.link?.href;
101
+ if (latestHref) {
102
+ return latestHref;
103
+ }
104
+ return node.label || "";
105
+ }
106
+
107
+ /** Section header with children but no page of its own. */
108
+ export function isParentOnlyTopic(node: VersionedNode | undefined): boolean {
109
+ if (!node) {
110
+ return false;
111
+ }
112
+ const ownHref = node.link?.href || node.name;
113
+ return !ownHref && !!node.children?.length;
114
+ }
115
+
116
+ /**
117
+ * First descendant that has a page (own href/name). Used so parent-only
118
+ * sections select and navigate to content instead of an empty header.
119
+ */
120
+ export function firstContentNode(
121
+ node: VersionedNode | undefined
122
+ ): VersionedNode | undefined {
123
+ if (!node || node.link?.target === "_blank") {
124
+ return undefined;
125
+ }
126
+ if (node.link?.href || node.name) {
127
+ return node;
128
+ }
129
+ for (const child of node.children || []) {
130
+ const found = firstContentNode(child);
131
+ if (found) {
132
+ return found;
133
+ }
134
+ }
135
+ return undefined;
136
+ }
137
+
138
+ export function shouldShowTopicVersionPicker(
139
+ versions?: TopicVersionOption[],
140
+ showForwardArrow?: boolean
141
+ ): boolean {
142
+ return !showForwardArrow && Array.isArray(versions) && versions.length > 1;
143
+ }
144
+
145
+ /**
146
+ * Difference between a picker's latest switching URL and an older one.
147
+ *
148
+ * latest: /docs/project/errors.html
149
+ * v1: /docs/project/v1/errors.html
150
+ * → prefix `/docs/project/`, inserted `v1/`
151
+ */
152
+ function getVersionPathDelta(
153
+ latestPath: string,
154
+ selectedPath: string
155
+ ): VersionPathDelta | undefined {
156
+ const latestDirEnd = latestPath.lastIndexOf("/") + 1;
157
+ if (latestDirEnd <= 0) {
158
+ return undefined;
159
+ }
160
+
161
+ const prefix = latestPath.slice(0, latestDirEnd);
162
+ const latestSlug = latestPath.slice(latestDirEnd);
163
+ if (!selectedPath.startsWith(prefix) || !latestSlug) {
164
+ return undefined;
165
+ }
166
+
167
+ const selectedAfterPrefix = selectedPath.slice(prefix.length);
168
+ if (!selectedAfterPrefix.endsWith(latestSlug)) {
169
+ return undefined;
170
+ }
171
+
172
+ const inserted = selectedAfterPrefix.slice(
173
+ 0,
174
+ selectedAfterPrefix.length - latestSlug.length
175
+ );
176
+ if (!inserted) {
177
+ return undefined;
178
+ }
179
+
180
+ return { prefix, inserted };
181
+ }
182
+
183
+ function applyVersionPathDelta(
184
+ hrefPath: string,
185
+ delta: VersionPathDelta
186
+ ): string | undefined {
187
+ if (!hrefPath.startsWith(delta.prefix)) {
188
+ return undefined;
189
+ }
190
+
191
+ const rest = hrefPath.slice(delta.prefix.length);
192
+ if (rest.startsWith(delta.inserted)) {
193
+ return hrefPath;
194
+ }
195
+
196
+ return delta.prefix + delta.inserted + rest;
197
+ }
198
+
199
+ function stripVersionPathDelta(
200
+ hrefPath: string,
201
+ delta: VersionPathDelta
202
+ ): string | undefined {
203
+ if (!hrefPath.startsWith(delta.prefix)) {
204
+ return undefined;
205
+ }
206
+
207
+ const rest = hrefPath.slice(delta.prefix.length);
208
+ if (!rest.startsWith(delta.inserted)) {
209
+ return undefined;
210
+ }
211
+
212
+ return delta.prefix + rest.slice(delta.inserted.length);
213
+ }
214
+
215
+ /**
216
+ * Maps a canonical (latest) href onto another version using the picker's
217
+ * latest vs selected switching URLs as the template.
218
+ *
219
+ * latest parent: /docs/project/errors.html
220
+ * v1 parent: /docs/project/v1/errors.html
221
+ * latest child: /docs/project/extra-child-a.html
222
+ * → v1 child: /docs/project/v1/extra-child-a.html
223
+ */
224
+ export function applyVersionToHref(
225
+ canonicalHref: string | undefined,
226
+ selectedVersion: TopicVersionOption | undefined,
227
+ versions?: TopicVersionOption[]
228
+ ): string | undefined {
229
+ if (!canonicalHref) {
230
+ return canonicalHref;
231
+ }
232
+ if (!selectedVersion || !versions?.length) {
233
+ return canonicalHref;
234
+ }
235
+
236
+ const latest = versions[0];
237
+ if (!latest || selectedVersion.id === latest.id) {
238
+ return canonicalHref;
239
+ }
240
+
241
+ const latestPath = normalizePath(latest.link?.href);
242
+ const selectedPath = normalizePath(selectedVersion.link?.href);
243
+ const hrefPath = normalizePath(canonicalHref);
244
+
245
+ if (!latestPath || !selectedPath || latestPath === selectedPath) {
246
+ return insertVersionBeforeSlug(canonicalHref, selectedVersion.id);
247
+ }
248
+
249
+ const delta = getVersionPathDelta(latestPath, selectedPath);
250
+ return (
251
+ (delta && applyVersionPathDelta(hrefPath, delta)) ||
252
+ insertVersionBeforeSlug(canonicalHref, selectedVersion.id)
253
+ );
254
+ }
255
+
256
+ function insertVersionBeforeSlug(href: string, versionId: string): string {
257
+ const path = normalizePath(href);
258
+ const lastSlash = path.lastIndexOf("/");
259
+ if (lastSlash <= 0 || !versionId) {
260
+ return href;
261
+ }
262
+ const dir = path.slice(0, lastSlash);
263
+ const slug = path.slice(lastSlash);
264
+ if (dir.endsWith(`/${versionId}`)) {
265
+ return path;
266
+ }
267
+ return `${dir}/${versionId}${slug}`;
268
+ }
269
+
270
+ export function toCanonicalHref(
271
+ href: string | undefined,
272
+ versions?: TopicVersionOption[]
273
+ ): string {
274
+ if (!href) {
275
+ return "";
276
+ }
277
+ if (!versions?.length) {
278
+ return normalizePath(href);
279
+ }
280
+
281
+ const latestPath = normalizePath(versions[0].link?.href);
282
+ const hrefPath = normalizePath(href);
283
+ if (!latestPath) {
284
+ return hrefPath;
285
+ }
286
+
287
+ for (const version of versions) {
288
+ if (version.id === versions[0].id) {
289
+ continue;
290
+ }
291
+ const delta = getVersionPathDelta(
292
+ latestPath,
293
+ normalizePath(version.link?.href)
294
+ );
295
+ const stripped = delta && stripVersionPathDelta(hrefPath, delta);
296
+ if (stripped) {
297
+ return stripped;
298
+ }
299
+ }
300
+
301
+ return hrefPath;
302
+ }
303
+
304
+ function hrefMatchesVersion(
305
+ href: string | undefined,
306
+ version: TopicVersionOption,
307
+ versions: TopicVersionOption[]
308
+ ): boolean {
309
+ return hrefMatchesCurrentPage(applyVersionToHref(href, version, versions));
310
+ }
311
+
312
+ function collectSubtreeCanonicalHrefs(
313
+ node: VersionedNode,
314
+ includeSelf = true
315
+ ): string[] {
316
+ const hrefs: string[] = [];
317
+ const own = node.link?.href || node.name;
318
+ if (includeSelf && own) {
319
+ hrefs.push(own);
320
+ }
321
+ for (const child of node.children || []) {
322
+ if (child.versions?.length || child.link?.target === "_blank") {
323
+ continue;
324
+ }
325
+ hrefs.push(...collectSubtreeCanonicalHrefs(child));
326
+ }
327
+ return hrefs;
328
+ }
329
+
330
+ export function isCurrentPageInSubtree(
331
+ node: VersionedNode,
332
+ versions?: TopicVersionOption[]
333
+ ): boolean {
334
+ if (!isBrowser() || !versions?.length) {
335
+ return false;
336
+ }
337
+
338
+ return collectSubtreeCanonicalHrefs(node).some((href) =>
339
+ versions.some((version) => hrefMatchesVersion(href, version, versions))
340
+ );
341
+ }
342
+
343
+ /**
344
+ * Picker option hrefs. When the current page is this topic or a descendant,
345
+ * each option points at that same topic in the chosen version so the child
346
+ * stays selected. Otherwise keep the backend switching URL (parent page, or
347
+ * first content child for parent-only sections).
348
+ */
349
+ export function getVersionPickerOptions(
350
+ node: VersionedNode | undefined
351
+ ): TopicVersionOption[] | undefined {
352
+ const versions = node?.versions;
353
+ if (!node || !versions?.length) {
354
+ return versions;
355
+ }
356
+ if (!isCurrentPageInSubtree(node, versions)) {
357
+ return versions;
358
+ }
359
+
360
+ const canonical = toCanonicalHref(window.location.pathname, versions);
361
+ return versions.map((version) => ({
362
+ ...version,
363
+ link: {
364
+ ...version.link,
365
+ href:
366
+ applyVersionToHref(canonical, version, versions) ||
367
+ version.link?.href
368
+ }
369
+ }));
370
+ }
371
+
372
+ export function resolveSelectedVersion(
373
+ node: VersionedNode | undefined
374
+ ): TopicVersionOption | undefined {
375
+ const versions = node?.versions;
376
+ if (!node || !versions?.length) {
377
+ return undefined;
378
+ }
379
+
380
+ const fromOwnUrl = versions.find((version) =>
381
+ hrefMatchesCurrentPage(version.link?.href)
382
+ );
383
+ if (fromOwnUrl) {
384
+ return fromOwnUrl;
385
+ }
386
+
387
+ const descendantHrefs = collectSubtreeCanonicalHrefs(node, false);
388
+ const fromDescendant = versions.find(
389
+ (version) =>
390
+ version.id !== versions[0].id &&
391
+ descendantHrefs.some((href) =>
392
+ hrefMatchesVersion(href, version, versions)
393
+ )
394
+ );
395
+ if (fromDescendant) {
396
+ return fromDescendant;
397
+ }
398
+
399
+ const storedId = readTopicVersionSelections()[getTopicVersionKey(node)];
400
+ return versions.find((version) => version.id === storedId) || versions[0];
401
+ }