@salesforcedevs/dx-components 1.39.0-alpha1 → 1.40.0-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 +1 -0
- package/package.json +2 -3
- package/src/modules/dx/button/button.css +2 -2
- package/src/modules/dx/dropdown/dropdown.css +14 -0
- package/src/modules/dx/dropdown/dropdown.html +2 -2
- package/src/modules/dx/dropdown/dropdown.ts +24 -2
- package/src/modules/dx/dropdownOption/dropdownOption.css +20 -3
- package/src/modules/dx/footer/footer.ts +1 -3
- package/src/modules/dx/icon/icon.ts +1 -4
- package/src/modules/dx/searchResults/resultsTemplate.js +2 -5
- package/src/modules/dx/searchResults/searchResults.ts +0 -2
- package/src/modules/dx/sidebarOld/sidebarOld.html +0 -1
- package/src/modules/dx/sidebarSearch/sidebarSearch.ts +1 -4
- package/src/modules/dx/tree/treeHandler.ts +78 -10
- package/src/modules/dx/treeItem/treeItem.css +27 -0
- package/src/modules/dx/treeItem/treeItem.html +16 -2
- package/src/modules/dx/treeItem/treeItem.ts +91 -6
- package/src/modules/dx/treeTile/treeTile.css +4 -0
- package/src/modules/dx/treeTile/treeTile.ts +7 -1
- package/src/modules/dx/typeBadge/typeBadge.ts +23 -2
- package/src/modules/dxHelpers/commonTreeItem/commonTreeItem.css +4 -0
- package/src/modules/dxHelpers/typeBadge/typeBadge.css +22 -0
- package/src/modules/dxUtils/topicVersion/topicVersion.ts +410 -0
package/lwc.config.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforcedevs/dx-components",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.40.0-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.39.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={
|
|
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={
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
@@ -103,9 +103,7 @@ class Footer extends LightningElement {
|
|
|
103
103
|
// This encapsulates the old non-MFE footer functionality that should be removed once the footer MFE supports rendering in non-full-width contexts.
|
|
104
104
|
// It's encapsulated like this solely for ease of deletion later.
|
|
105
105
|
function augmentWithNonMFEFooterFunctionality(FooterClass: typeof Footer) {
|
|
106
|
-
|
|
107
|
-
const assetBasePath = (globalThis as any)?.LWR?.env?.assetBasePath || "";
|
|
108
|
-
const baseSocialIconUrl = `${assetBasePath}/assets/icons/brand-sprite/svg/symbols.svg`;
|
|
106
|
+
const baseSocialIconUrl = `/assets/icons/brand-sprite/svg/symbols.svg`;
|
|
109
107
|
|
|
110
108
|
return class NonMFEFooter extends FooterClass {
|
|
111
109
|
private didRequestFooterConfig = false;
|
|
@@ -40,10 +40,7 @@ export default class Icon extends LightningElement {
|
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
private get assetUrl() {
|
|
43
|
-
|
|
44
|
-
// when the site is served under a sub-path. Empty in local dev, so no-op there.
|
|
45
|
-
const assetBasePath = (globalThis as any)?.LWR?.env?.assetBasePath || "";
|
|
46
|
-
return `${assetBasePath}/assets/icons/${this.sprite}-sprite/svg/symbols.svg`;
|
|
43
|
+
return `/assets/icons/${this.sprite}-sprite/svg/symbols.svg`;
|
|
47
44
|
}
|
|
48
45
|
|
|
49
46
|
private get href() {
|
|
@@ -5,9 +5,6 @@
|
|
|
5
5
|
*/
|
|
6
6
|
const html = String.raw;
|
|
7
7
|
|
|
8
|
-
// Prefix hardcoded asset URLs with the deploy asset base path (e.g. "/PR-123"); empty in local dev.
|
|
9
|
-
const assetBasePath = (globalThis && globalThis.LWR && globalThis.LWR.env && globalThis.LWR.env.assetBasePath) || "";
|
|
10
|
-
|
|
11
8
|
export default html`
|
|
12
9
|
<div class="coveo-show-if-no-results">
|
|
13
10
|
<div class="no-results">
|
|
@@ -73,7 +70,7 @@ export default html`
|
|
|
73
70
|
>
|
|
74
71
|
<%- title %>
|
|
75
72
|
<% if (!raw.uri.includes('developer.salesforce.com') && !raw.uri.includes('developer-website-s.herokuapp.com')) { %>
|
|
76
|
-
<svg xmlns="http://www.w3.org/2000/svg" style="display: inline; vertical-align: baseline;" fill="var(--dx-g-blue-vibrant-20)" width="20" height="20" part="svg" aria-hidden="true"><use xlink:href="
|
|
73
|
+
<svg xmlns="http://www.w3.org/2000/svg" style="display: inline; vertical-align: baseline;" fill="var(--dx-g-blue-vibrant-20)" width="20" height="20" part="svg" aria-hidden="true"><use xlink:href="/assets/icons/utility-sprite/svg/symbols.svg#new_window"></use></svg>
|
|
77
74
|
<% } %>
|
|
78
75
|
</a>
|
|
79
76
|
<span class="CoveoFieldValue" data-field="@sflastmodifieddate" data-helper="postedDate" data-html-value="true"></span>
|
|
@@ -124,7 +121,7 @@ export default html`
|
|
|
124
121
|
<%- uri %>
|
|
125
122
|
<% } %>
|
|
126
123
|
<% if (!raw.uri.includes('developer.salesforce.com') && !raw.uri.includes('developer-website-s.herokuapp.com')) { %>
|
|
127
|
-
<svg xmlns="http://www.w3.org/2000/svg" style="display: inline; vertical-align: baseline;" fill="var(--dx-g-blue-vibrant-20)" width="20" height="20" part="svg" aria-hidden="true"><use xlink:href="
|
|
124
|
+
<svg xmlns="http://www.w3.org/2000/svg" style="display: inline; vertical-align: baseline;" fill="var(--dx-g-blue-vibrant-20)" width="20" height="20" part="svg" aria-hidden="true"><use xlink:href="/assets/icons/utility-sprite/svg/symbols.svg#new_window"></use></svg>
|
|
128
125
|
<% } %>
|
|
129
126
|
</a>
|
|
130
127
|
<span class="CoveoFieldValue" data-field="@sflastmodifieddate" data-helper="postedDate" data-html-value="true"></span>
|
|
@@ -54,8 +54,6 @@ const buildTemplateHelperBadge = (value: keyof typeof CONTENT_TYPE_LABELS) => {
|
|
|
54
54
|
const style = getContentTypeColorVariables(value);
|
|
55
55
|
const label = CONTENT_TYPE_LABELS[value];
|
|
56
56
|
const { iconSprite, iconSymbol } = CONTENT_TYPE_ICONS[value];
|
|
57
|
-
// Prefix with the deploy asset base path (e.g. "/PR-123"); empty in local dev.
|
|
58
|
-
const assetBasePath = (globalThis as any)?.LWR?.env?.assetBasePath || "";
|
|
59
57
|
return `
|
|
60
58
|
<div style="${style}" class="dx-badge">
|
|
61
59
|
<svg
|
|
@@ -317,12 +317,9 @@ export default class SidebarSearch extends LightningElement {
|
|
|
317
317
|
|
|
318
318
|
// discussion about this normalization here: https://salesforce-internal.slack.com/archives/C020S6784JX/p1639506238376600
|
|
319
319
|
|
|
320
|
-
// Deploy base path (e.g. "/PR-123"); empty in local dev. window.location.pathname
|
|
321
|
-
// is served under this prefix, so route hrefs and comparisons must include it.
|
|
322
|
-
const basePath = (globalThis as any)?.LWR?.env?.basePath || "";
|
|
323
320
|
let pathname, queryParam;
|
|
324
321
|
if (sfhtml_url__c) {
|
|
325
|
-
pathname =
|
|
322
|
+
pathname = `/docs/${sfhtml_url__c}`;
|
|
326
323
|
} else {
|
|
327
324
|
const isNestedGuide = clickUri.includes("/guide/");
|
|
328
325
|
const url = new URL(clickUri);
|
|
@@ -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:
|
|
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
|
-
|
|
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:
|
|
66
|
+
node: VersionedTreeNode,
|
|
52
67
|
level: number = 0,
|
|
53
68
|
parent?: MapItem,
|
|
54
|
-
position?: number
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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.
|
|
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
|
-
<
|
|
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
|
-
|
|
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!:
|
|
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.
|
|
134
|
-
this.
|
|
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
|
-
|
|
169
|
-
|
|
253
|
+
key: this._treeNode.key,
|
|
254
|
+
...detail
|
|
170
255
|
},
|
|
171
256
|
bubbles: true,
|
|
172
257
|
composed: true
|
|
@@ -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":
|
|
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(
|
|
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,410 @@
|
|
|
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
|
+
export function resolveSelectedVersion(
|
|
382
|
+
node: VersionedNode | undefined
|
|
383
|
+
): TopicVersionOption | undefined {
|
|
384
|
+
const versions = node?.versions;
|
|
385
|
+
if (!node || !versions?.length) {
|
|
386
|
+
return undefined;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const fromOwnUrl = versions.find((version) =>
|
|
390
|
+
hrefMatchesCurrentPage(version.link?.href)
|
|
391
|
+
);
|
|
392
|
+
if (fromOwnUrl) {
|
|
393
|
+
return fromOwnUrl;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const descendantHrefs = collectSubtreeCanonicalHrefs(node, false);
|
|
397
|
+
const fromDescendant = versions.find(
|
|
398
|
+
(version) =>
|
|
399
|
+
version.id !== versions[0].id &&
|
|
400
|
+
descendantHrefs.some((href) =>
|
|
401
|
+
hrefMatchesVersion(href, version, versions)
|
|
402
|
+
)
|
|
403
|
+
);
|
|
404
|
+
if (fromDescendant) {
|
|
405
|
+
return fromDescendant;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const storedId = readTopicVersionSelections()[getTopicVersionKey(node)];
|
|
409
|
+
return versions.find((version) => version.id === storedId) || versions[0];
|
|
410
|
+
}
|