@salesforcedevs/docs-components 1.33.2 → 1.34.0-alpha1

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
@@ -18,6 +18,7 @@
18
18
  "doc/contentMedia",
19
19
  "doc/docXmlContent",
20
20
  "doc/lwcContentLayout",
21
+ "doc/unifiedContentLayout",
21
22
  "doc/header",
22
23
  "doc/heading",
23
24
  "doc/headingAnchor",
package/package.json CHANGED
@@ -1,29 +1,29 @@
1
1
  {
2
- "name": "@salesforcedevs/docs-components",
3
- "version": "1.33.2",
4
- "description": "Docs Lightning web components for DSC",
5
- "license": "MIT",
6
- "main": "index.js",
7
- "engines": {
8
- "node": "22.x"
9
- },
10
- "publishConfig": {
11
- "access": "public"
12
- },
13
- "dependencies": {
14
- "@api-components/amf-helper-mixin": "4.5.29",
15
- "classnames": "2.5.1",
16
- "dompurify": "3.2.4",
17
- "kagekiri": "1.4.2",
18
- "lodash.orderby": "4.6.0",
19
- "lodash.uniqby": "4.7.0",
20
- "query-string": "7.1.3",
21
- "sentence-case": "3.0.4"
22
- },
23
- "devDependencies": {
24
- "@types/classnames": "2.3.1",
25
- "@types/lodash.orderby": "4.6.9",
26
- "@types/lodash.uniqby": "4.7.9"
27
- },
28
- "gitHead": "8a1fa72aa921307f372be0268d5b8b539322dbbb"
29
- }
2
+ "name": "@salesforcedevs/docs-components",
3
+ "version": "1.34.0-alpha1",
4
+ "description": "Docs Lightning web components for DSC",
5
+ "license": "MIT",
6
+ "main": "index.js",
7
+ "engines": {
8
+ "node": "22.x"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "dependencies": {
14
+ "@api-components/amf-helper-mixin": "4.5.29",
15
+ "classnames": "2.5.1",
16
+ "dompurify": "3.2.4",
17
+ "kagekiri": "1.4.2",
18
+ "lodash.orderby": "4.6.0",
19
+ "lodash.uniqby": "4.7.0",
20
+ "query-string": "7.1.3",
21
+ "sentence-case": "3.0.4"
22
+ },
23
+ "devDependencies": {
24
+ "@types/classnames": "2.3.1",
25
+ "@types/lodash.orderby": "4.6.9",
26
+ "@types/lodash.uniqby": "4.7.9"
27
+ },
28
+ "gitHead": "4629fdd9ca18a13480044ad43515b91945d16aad"
29
+ }
@@ -7,6 +7,7 @@
7
7
  .toolbar {
8
8
  display: flex;
9
9
  align-items: center;
10
+ flex-wrap: wrap;
10
11
  gap: var(--dx-g-spacing-smd);
11
12
  margin-bottom: var(--dx-g-spacing-lg);
12
13
  }
@@ -1,7 +1,7 @@
1
1
  :host {
2
2
  --dx-footer-margin-top: 142px;
3
3
  --doc-c-redoc-sidebar-top: calc(
4
- var(--dx-g-global-header-height) + var(--dx-g-doc-header-height) +
5
- var(--dx-g-spacing-xl)
4
+ var(--dx-g-global-header-height) + var(--dx-g-doc-header-height)
6
5
  );
6
+ --doc-c-redoc-sidebar-bg: white;
7
7
  }
@@ -3,9 +3,13 @@ import { createElement, LightningElement, api } from "lwc";
3
3
  import DocPhase from "doc/phase";
4
4
  import DxFooter from "dx/footer";
5
5
  import DxIcon from "dx/icon";
6
+ import SidebarFooterNav from "dx/sidebarFooterNav";
7
+ import VersionPicker from "doc/versionPicker";
6
8
  import SprigSurvey from "doc/sprigSurvey";
7
9
  import { throttle } from "throttle-debounce";
8
10
  import { pollUntil } from "dxUtils/async";
11
+ import { toJson } from "dxUtils/normalizers";
12
+ import type { OptionWithLink } from "typings/custom";
9
13
 
10
14
  declare global {
11
15
  interface Window {
@@ -30,14 +34,28 @@ type ReferenceItem = {
30
34
  topic?: ReferenceTopic;
31
35
  };
32
36
 
37
+ /** A selectable spec version. Mirrors `ReferenceVersion` in `doc/amfReference`. */
38
+ type ReferenceVersion = {
39
+ id: string;
40
+ label: string;
41
+ deprecated?: boolean;
42
+ selected?: boolean;
43
+ link: { href: string };
44
+ };
45
+
33
46
  type ReferenceConfig = {
34
47
  refList: ReferenceItem[];
48
+ versions?: ReferenceVersion[];
35
49
  };
36
50
 
37
51
  const SCROLL_THROTTLE_DELAY = 50;
38
52
  const ELEMENT_TIMEOUT = 10000;
39
53
  const ELEMENT_CHECK_INTERVAL = 100;
40
- const REFERENCES_SEGMENT = "/references/";
54
+ const DEFAULT_PROJECT_TITLE = "All Reference";
55
+ const BACK_TARGET_STORAGE_KEY = "redoc-back-target";
56
+ const SELECTED_VIEW_STORAGE_KEY = "redoc-selected-view";
57
+ const DOCS_PATH_SEGMENT = "docs";
58
+ const DEFAULT_LOCALE = "en-us";
41
59
 
42
60
  export default class RedocReference extends LightningElement {
43
61
  private _referenceConfig: ReferenceConfig = { refList: [] };
@@ -47,6 +65,7 @@ export default class RedocReference extends LightningElement {
47
65
  private docHeaderElement: Element | null = null;
48
66
  private docPhaseWrapperElement: Element | null = null;
49
67
  private lastSidebarTop = 0;
68
+ private _languages: OptionWithLink[] = [];
50
69
 
51
70
  /**
52
71
  * History length captured at mount (pre-Redoc), used by `onBackClick` to
@@ -91,19 +110,112 @@ export default class RedocReference extends LightningElement {
91
110
  * Project title (same value passed to `<doc-header>` as `subtitle`). Used
92
111
  * inside the Redoc-rendered UI to label the parent project.
93
112
  */
94
- @api projectTitle: string | null = "All Reference";
113
+ @api
114
+ get projectTitle(): string | null {
115
+ return this.isDocContentType
116
+ ? this._projectTitle
117
+ : DEFAULT_PROJECT_TITLE;
118
+ }
119
+ set projectTitle(value: string | null) {
120
+ this._projectTitle = value;
121
+ }
122
+ private _projectTitle: string | null = null;
123
+
124
+ /**
125
+ * Href to navigate to when the back link is clicked AND there is no
126
+ * usable referrer (e.g. the user opened the page directly in a fresh
127
+ * tab).
128
+ */
129
+ @api headerHref: string | null = null;
130
+
131
+ @api
132
+ get contentType(): string {
133
+ return this._contentType;
134
+ }
135
+ set contentType(value: string) {
136
+ this._contentType = value;
137
+ }
138
+ private _contentType: string = "";
139
+
140
+ @api
141
+ get languages(): OptionWithLink[] {
142
+ return this._languages;
143
+ }
144
+ set languages(value: string | OptionWithLink[]) {
145
+ this._languages = toJson(value) || [];
146
+ }
147
+
148
+ @api language: string | null = null;
95
149
 
96
150
  get specTitle(): string | null {
97
151
  return this.getSelectedReference()?.title ?? null;
98
152
  }
99
153
 
154
+ get isDocContentType(): boolean {
155
+ return this.contentType === "docs";
156
+ }
157
+
158
+ get isReferenceContentType(): boolean {
159
+ return this.contentType === "references";
160
+ }
161
+
162
+ get isMultiSpecSet(): boolean {
163
+ const refCount = this.referenceConfig?.refList?.length ?? 0;
164
+ return refCount > 1;
165
+ }
166
+
167
+ get versions(): ReferenceVersion[] {
168
+ return this._referenceConfig?.versions ?? [];
169
+ }
170
+
171
+ /** The version flagged `selected`, else the first (the latest/GA version). */
172
+ get selectedVersion(): ReferenceVersion | null {
173
+ const versions = this.versions;
174
+ return versions.find((v) => v.selected) ?? versions[0] ?? null;
175
+ }
176
+
177
+ /** True when the selected version is the latest/GA one (index 0). */
178
+ get latestVersion(): boolean {
179
+ return this.versions.length
180
+ ? this.selectedVersion?.id === this.versions[0].id
181
+ : false;
182
+ }
183
+
184
+ get hasVersionPicker(): boolean {
185
+ return this.versions.length > 0;
186
+ }
187
+
188
+ /** A single version has nothing to choose, so it renders read-only. */
189
+ get isVersionReadOnly(): boolean {
190
+ return this.versions.length === 1;
191
+ }
192
+
193
+ // Reads stored back target
194
+ private getBackTargetFromSession(): string | null {
195
+ return sessionStorage.getItem(BACK_TARGET_STORAGE_KEY);
196
+ }
197
+
198
+ private setBackTargetInSession(href: string): void {
199
+ sessionStorage.setItem(BACK_TARGET_STORAGE_KEY, href);
200
+ }
201
+
100
202
  /**
101
- * Whether to show the project header (only for multi-spec reference sets).
203
+ * when more than one localized spec is available
204
+ */
205
+ get hasLocalePicker(): boolean {
206
+ return this.languages.length > 1;
207
+ }
208
+
209
+ /**
210
+ * Whether to show the project header. Shown for multi-spec reference
211
+ * sets, and for any docs-content spec topic so it always has a back
212
+ * link.
102
213
  */
103
214
  get showRedocHeader(): boolean {
104
- const refCount = this._referenceConfig?.refList?.length ?? 0;
105
- const isMultiSpecSet = refCount > 1;
106
- return isMultiSpecSet && !!(this.projectTitle || this.specTitle);
215
+ return (
216
+ (this.isReferenceContentType && this.isMultiSpecSet) ||
217
+ this.isDocContentType
218
+ );
107
219
  }
108
220
 
109
221
  /**
@@ -111,16 +223,73 @@ export default class RedocReference extends LightningElement {
111
223
  */
112
224
  private onBackClick = (event: Event): void => {
113
225
  event.preventDefault();
226
+
227
+ const target = this.getTranslatedBackTarget() ?? this.headerHref;
228
+
229
+ if (target) {
230
+ window.location.assign(target);
231
+ }
232
+ };
233
+
234
+ private getTranslatedBackTarget(): string | null {
235
+ const stored = this.getBackTargetFromSession();
236
+ if (stored) {
237
+ return this.language
238
+ ? this.translateToCurrentLocale(stored)
239
+ : stored;
240
+ }
241
+
242
+ const referrer = this.getSameOriginReferrerHref();
243
+ return referrer &&
244
+ !this.isLocaleHref(new URL(referrer).pathname) &&
245
+ !this.isVersionHref(new URL(referrer).pathname)
246
+ ? referrer
247
+ : null;
248
+ }
249
+
250
+ /* The locale segment sits after "docs" for docs pages,
251
+ * and after the product segment for references.
252
+ */
253
+ private translateToCurrentLocale(storedHref: string): string {
254
+ const url = new URL(storedHref, window.location.origin);
255
+ const localeIds = this.languages.map((loc) => loc.id);
256
+ const segments = url.pathname.split("/").filter(Boolean);
257
+
258
+ // docs -> right after "docs"; references -> after the product segment.
259
+ const localeIndex = this.isDocContentType ? 1 : 2;
260
+
261
+ if (segments[0] === DOCS_PATH_SEGMENT) {
262
+ // Drop the existing locale segment, if any
263
+ if (localeIds.includes(segments[localeIndex])) {
264
+ segments.splice(localeIndex, 1);
265
+ }
266
+ // Add the current locale
267
+ if (this.language && this.language !== DEFAULT_LOCALE) {
268
+ segments.splice(localeIndex, 0, this.language);
269
+ }
270
+ }
271
+
272
+ url.pathname = "/" + segments.join("/");
273
+ return url.href;
274
+ }
275
+
276
+ // Preserves target across locale switches, updates on normal navigation
277
+ private trackBackTarget(): void {
114
278
  const referrerHref = this.getSameOriginReferrerHref();
115
- if (referrerHref) {
116
- window.location.href = referrerHref;
279
+ if (!referrerHref) {
117
280
  return;
118
281
  }
119
- const fallbackHref = this.getReferencesRootHref();
120
- if (fallbackHref) {
121
- window.location.href = fallbackHref;
282
+
283
+ const referrerUrl = new URL(referrerHref);
284
+ if (
285
+ this.isLocaleHref(referrerUrl.pathname) ||
286
+ this.isVersionHref(referrerUrl.pathname)
287
+ ) {
288
+ return;
122
289
  }
123
- };
290
+
291
+ this.setBackTargetInSession(referrerHref);
292
+ }
124
293
 
125
294
  /**
126
295
  * Returns the referrer URL when the page was reached via in-tab navigation
@@ -144,16 +313,29 @@ export default class RedocReference extends LightningElement {
144
313
  }
145
314
 
146
315
  /**
147
- * Derives the project's `.../references` root from the current URL by
148
- * trimming any trailing reference id (and deeper segments). Returns null
149
- * when the URL doesn't contain a `/references` segment.
316
+ * switching locale on this same page.
150
317
  */
151
- private getReferencesRootHref(): string | null {
152
- const { pathname } = window.location;
153
- const idx = pathname.lastIndexOf(REFERENCES_SEGMENT);
154
- return idx === -1
155
- ? null
156
- : pathname.slice(0, idx + REFERENCES_SEGMENT.length);
318
+ private isLocaleHref(pathname: string): boolean {
319
+ return this.languages.some((locale) => {
320
+ const href = locale?.link?.href;
321
+ return (
322
+ !!href &&
323
+ new URL(href, window.location.origin).pathname === pathname
324
+ );
325
+ });
326
+ }
327
+
328
+ /**
329
+ * switching version on this same page.
330
+ */
331
+ private isVersionHref(pathname: string): boolean {
332
+ return this.versions.some((version) => {
333
+ const href = version?.link?.href;
334
+ return (
335
+ !!href &&
336
+ new URL(href, window.location.origin).pathname === pathname
337
+ );
338
+ });
157
339
  }
158
340
 
159
341
  /** When origin is provided, pass it to the footer; otherwise use dx-footer's default. */
@@ -168,6 +350,9 @@ export default class RedocReference extends LightningElement {
168
350
  // so it reflects real in-tab navigation rather than Redoc's churn.
169
351
  this.initialHistoryLength = window.history.length;
170
352
 
353
+ // Track the back target, preserving it across locale switches
354
+ this.trackBackTarget();
355
+
171
356
  window.addEventListener("scroll", this.handleScrollAndResize);
172
357
  window.addEventListener("resize", this.handleScrollAndResize);
173
358
  }
@@ -225,6 +410,14 @@ export default class RedocReference extends LightningElement {
225
410
  return parseInt(value, 10) || 0;
226
411
  }
227
412
 
413
+ /** Redoc's LNB background color, themeable via `--doc-c-redoc-sidebar-bg`. */
414
+ private get sidebarBackgroundColor(): string {
415
+ const value = getComputedStyle(this.template.host).getPropertyValue(
416
+ "--doc-c-redoc-sidebar-bg"
417
+ );
418
+ return value.trim() || "white";
419
+ }
420
+
228
421
  /*
229
422
  ** Since we could not use --dx-g-global-header-height as getPropertyValue returns a calc expression,
230
423
  ** we are using the respective CSS variables to calculate the height.
@@ -340,7 +533,14 @@ export default class RedocReference extends LightningElement {
340
533
  specUrl,
341
534
  {
342
535
  // Dynamic scroll offset to account for headers
343
- scrollYOffset: this.calculateScrollYOffset
536
+ scrollYOffset: this.calculateScrollYOffset,
537
+ // Redoc's own styled background outranks injected
538
+ // CSS, so the LNB background must be set via theme.
539
+ theme: {
540
+ sidebar: {
541
+ backgroundColor: this.sidebarBackgroundColor
542
+ }
543
+ }
344
544
  },
345
545
  redocContainer,
346
546
  (error: any) => {
@@ -394,14 +594,26 @@ export default class RedocReference extends LightningElement {
394
594
  this.appendFooterItems(apiContentDiv);
395
595
 
396
596
  // Inject the multi-spec project header into Redoc's left menu only.
397
- this.insertProjectHeaderInMenu(redocContainer);
597
+ this.insertSidebarNav(redocContainer);
398
598
 
399
599
  // Wait for footer to be rendered before updating styles
400
600
  requestAnimationFrame(() => {
401
- this.updateRedocThirdColumnStyle(redocContainer);
402
-
403
- // Fix initial hash scroll after doc phase insertion
404
- this.handleInitialHashScrollFix();
601
+ try {
602
+ this.updateRedocThirdColumnStyle(redocContainer);
603
+
604
+ // Restore the view selected before a version switch only
605
+ // after all layout mutations (footer, header) are
606
+ // complete, so the scroll lands on the correct element.
607
+ this.restoreSelectedView();
608
+
609
+ // Fix initial hash scroll after doc phase insertion
610
+ this.handleInitialHashScrollFix();
611
+ } catch (error) {
612
+ this.showErrorUI(
613
+ "Failed to integrate custom components:",
614
+ error
615
+ );
616
+ }
405
617
  });
406
618
  } catch (error) {
407
619
  this.showErrorUI("Failed to integrate custom components:", error);
@@ -409,31 +621,107 @@ export default class RedocReference extends LightningElement {
409
621
  }
410
622
 
411
623
  /**
412
- * Inserts the project header into Redoc for multi-spec reference sets.
624
+ * Inserts the project header and LNB footer into Redoc.
625
+ */
626
+ private insertSidebarNav(redocContainer: HTMLElement): void {
627
+ // Select the LNB and content area of Redoc and insert the requried header.
628
+ if (this.showRedocHeader) {
629
+ redocContainer
630
+ .querySelectorAll<HTMLElement>(".menu-content, .api-content")
631
+ .forEach((target) => {
632
+ target.insertBefore(
633
+ this.buildProjectHeaderDom(),
634
+ target.firstChild
635
+ );
636
+ });
637
+ }
638
+
639
+ // Locale picker
640
+ if (this.hasLocalePicker) {
641
+ const menuContent = redocContainer.querySelector(".menu-content");
642
+ menuContent?.appendChild(this.buildLocalePickerDom());
643
+ }
644
+ }
645
+
646
+ /** Builds the version picker DOM by reusing `doc-version-picker`. */
647
+ private buildVersionPickerDom(): HTMLElement {
648
+ const wrapper = document.createElement("div");
649
+ wrapper.className = "redoc-version-picker";
650
+
651
+ const picker = createElement("doc-version-picker", {
652
+ is: VersionPicker
653
+ });
654
+
655
+ Object.assign(picker, {
656
+ versions: this.versions,
657
+ selectedVersion: this.selectedVersion,
658
+ latestVersion: this.latestVersion,
659
+ readOnly: this.isVersionReadOnly
660
+ });
661
+ picker.addEventListener("change", this.onVersionChange);
662
+ wrapper.appendChild(picker);
663
+
664
+ return wrapper;
665
+ }
666
+
667
+ /** Stashes the current view (URL hash) before the version link navigates. */
668
+ private onVersionChange = (): void => {
669
+ sessionStorage.setItem(
670
+ SELECTED_VIEW_STORAGE_KEY,
671
+ window.location.hash || ""
672
+ );
673
+ };
674
+
675
+ /**
676
+ * Re-applies the view stashed before a version switch, if its anchor exists
677
+ * in the new spec; otherwise a no-op (lands on the spec root).
413
678
  */
414
- private insertProjectHeaderInMenu(redocContainer: HTMLElement): void {
415
- if (!this.showRedocHeader) {
679
+ private restoreSelectedView(): void {
680
+ const storedHash = sessionStorage.getItem(SELECTED_VIEW_STORAGE_KEY);
681
+ sessionStorage.removeItem(SELECTED_VIEW_STORAGE_KEY);
682
+
683
+ if (!storedHash || window.location.hash) {
416
684
  return;
417
685
  }
418
686
 
419
- // Select the LNB and content area of Redoc and insert the requried header.
420
- redocContainer
421
- .querySelectorAll<HTMLElement>(".menu-content, .api-content")
422
- .forEach((target) => {
423
- target.insertBefore(
424
- this.buildProjectHeaderDom(),
425
- target.firstChild
426
- );
427
- });
687
+ const targetId = storedHash.replace(/^#/, "");
688
+ if (targetId && document.getElementById(targetId)) {
689
+ window.location.hash = storedHash;
690
+ }
428
691
  }
429
692
 
430
693
  /**
431
- * Builds a fresh project-title/spec-title header DOM node.
694
+ * Builds the locale picker DOM by reusing `dx-sidebar-footer-nav`
695
+ */
696
+ private buildLocalePickerDom(): HTMLElement {
697
+ const wrapper = document.createElement("div");
698
+ wrapper.className = "redoc-footer-nav";
699
+
700
+ const picker = createElement("dx-sidebar-footer-nav", {
701
+ is: SidebarFooterNav
702
+ });
703
+
704
+ Object.assign(picker, {
705
+ languages: this.languages,
706
+ language: this.language
707
+ });
708
+ wrapper.appendChild(picker);
709
+
710
+ return wrapper;
711
+ }
712
+
713
+ /**
714
+ * Builds the doc header: a title group (back link + spec title) and, when
715
+ * versions are available, the version picker as a sibling row. Layout/gaps
716
+ * are styled by the developer-website's redoc CSS.
432
717
  */
433
718
  private buildProjectHeaderDom(): HTMLElement {
434
719
  const wrapper = document.createElement("div");
435
720
  wrapper.className = "redoc-project-header";
436
721
 
722
+ const main = document.createElement("div");
723
+ main.className = "redoc-project-header-main";
724
+
437
725
  if (this.projectTitle) {
438
726
  const backLink = document.createElement("a");
439
727
  backLink.className = "redoc-project-back";
@@ -454,14 +742,20 @@ export default class RedocReference extends LightningElement {
454
742
 
455
743
  backLink.appendChild(icon);
456
744
  backLink.appendChild(label);
457
- wrapper.appendChild(backLink);
745
+ main.appendChild(backLink);
458
746
  }
459
747
 
460
748
  if (this.specTitle) {
461
749
  const specEl = document.createElement("h2");
462
750
  specEl.className = "redoc-spec-title dx-text-display-7";
463
751
  specEl.textContent = this.specTitle;
464
- wrapper.appendChild(specEl);
752
+ main.appendChild(specEl);
753
+ }
754
+
755
+ wrapper.appendChild(main);
756
+
757
+ if (this.hasVersionPicker) {
758
+ wrapper.appendChild(this.buildVersionPickerDom());
465
759
  }
466
760
 
467
761
  return wrapper;
@@ -0,0 +1,19 @@
1
+ :host {
2
+ display: block;
3
+ }
4
+
5
+ .content-type-docs doc-phase {
6
+ --doc-c-phase-top: calc(
7
+ var(--dx-g-global-header-height) + var(--dx-g-doc-header-height) +
8
+ var(--dx-g-spacing-xl)
9
+ );
10
+ }
11
+
12
+ @media screen and (max-width: 768px) {
13
+ .content-type-docs doc-phase {
14
+ --doc-c-phase-top: calc(
15
+ var(--dx-g-global-header-height) + var(--dx-g-doc-header-height) +
16
+ 40px
17
+ );
18
+ }
19
+ }
@@ -0,0 +1,29 @@
1
+ <template>
2
+ <doc-content-layout
3
+ class="content-type content-type-markdown content-type-docs"
4
+ breadcrumbs={breadcrumbs}
5
+ share-title={shareTitle}
6
+ share-twitter-via={twitterVia}
7
+ sidebar-header={sidebarHeader}
8
+ sidebar-value={sidebarValue}
9
+ sidebar-content={sidebarContent}
10
+ toc-title={tocTitle}
11
+ toc-options={tocOptions}
12
+ toc-aria-level={tocAriaLevel}
13
+ enable-slot-change="true"
14
+ languages={languages}
15
+ language={language}
16
+ show-footer={enableFooter}
17
+ show-content-action-toolbar={showContentActionToolbar}
18
+ origin={origin}
19
+ dev-center={devCenter}
20
+ brand={brand}
21
+ >
22
+ <doc-phase
23
+ slot="doc-phase"
24
+ lwc:if={docPhaseInfo}
25
+ doc-phase-info={docPhaseInfo}
26
+ ></doc-phase>
27
+ <slot></slot>
28
+ </doc-content-layout>
29
+ </template>
@@ -0,0 +1,94 @@
1
+ import { LightningElement, api } from "lwc";
2
+ import { toJson } from "dxUtils/normalizers";
3
+ import type { OptionWithLink, TreeNode } from "typings/custom";
4
+
5
+ /**
6
+ * Per-topic type emitted by the docs content-type parser
7
+ * (see @salesforcedevs/sfdocs-doc-framework: `TopicTypeEnum`). Only `spec`
8
+ * is meaningful inside this component; everything else renders as a plain
9
+ * markdown-style tile.
10
+ */
11
+ const TOPIC_TYPE_SPEC = "spec";
12
+
13
+ /**
14
+ * Translates per-topic `topicType` into the generic `showForwardArrow` flag
15
+ * that `dx-tree-tile` reads (spec topics get the forward arrow icon for Redoc).
16
+ */
17
+ function decorateTopicsWithForwardArrow(
18
+ topics:
19
+ | Array<TreeNode & { topicType?: string; versions?: unknown }>
20
+ | undefined
21
+ ): TreeNode[] | undefined {
22
+ return topics?.map((topic) => {
23
+ const { topicType, versions, ...decorated } = topic;
24
+ if (topicType === TOPIC_TYPE_SPEC) {
25
+ decorated.showForwardArrow = true;
26
+ // Nested and standalone specs never get a sidebar picker; only the
27
+ // parent MD topic (when versioned) shows one.
28
+ } else if (versions) {
29
+ decorated.versions = versions;
30
+ }
31
+ if (decorated.children) {
32
+ decorated.children = decorateTopicsWithForwardArrow(
33
+ decorated.children
34
+ );
35
+ }
36
+ return decorated;
37
+ });
38
+ }
39
+
40
+ /**
41
+ * Wrapper around `doc-content-layout` for the "docs" content type emitted by
42
+ * the `DocsContentTypeParser`.
43
+ */
44
+ export default class UnifiedContentLayout extends LightningElement {
45
+ @api breadcrumbs: string | null = null;
46
+ @api sidebarHeader?: string;
47
+ @api sidebarValue?: string;
48
+ @api tocTitle?: string;
49
+ @api tocOptions?: string;
50
+ @api tocAriaLevel?: string;
51
+ @api languages?: OptionWithLink[];
52
+ @api language?: string;
53
+ @api devCenter: any = null;
54
+ @api brand: any = null;
55
+ @api showContentActionToolbar = false;
56
+
57
+ /** Optional origin URL for the footer MFE (e.g. wp-json endpoint). */
58
+ @api origin: string | null = null;
59
+
60
+ /** Article name from breadcrumbs, used as share title (e.g. for social share). */
61
+ @api shareTitle: string | null = null;
62
+
63
+ /** Optional Twitter "via" handle (e.g. SalesforceDevs) for social share. */
64
+ @api twitterVia: string | null = null;
65
+
66
+ @api hideFooter = false;
67
+
68
+ private _docPhaseInfo: string | null = null;
69
+ private _sidebarContent: unknown = null;
70
+
71
+ @api
72
+ get docPhaseInfo(): string | null {
73
+ return this._docPhaseInfo;
74
+ }
75
+
76
+ set docPhaseInfo(value: string | null) {
77
+ this._docPhaseInfo = value || null;
78
+ }
79
+
80
+ @api
81
+ get sidebarContent(): unknown {
82
+ return this._sidebarContent;
83
+ }
84
+
85
+ set sidebarContent(value: string) {
86
+ this._sidebarContent = decorateTopicsWithForwardArrow(
87
+ toJson(value)?.topics
88
+ );
89
+ }
90
+
91
+ private get enableFooter(): boolean {
92
+ return !this.hideFooter;
93
+ }
94
+ }
@@ -4,26 +4,59 @@
4
4
  :host {
5
5
  --dx-c-dropdown-option-font-weight: normal;
6
6
  --dx-c-dropdown-option-label-color: var(--dx-g-gray-10);
7
+ --dx-c-dropdown-option-padding: var(--dx-g-spacing-sm)
8
+ var(--dx-g-spacing-lg);
9
+ --dx-c-popover-padding: var(--dx-g-spacing-sm) 0;
7
10
  --popover-container-open-transform: translateY(4px);
8
11
  }
9
12
 
13
+ /* Open menus must stack above later sibling pickers in the sidebar. */
14
+ :host(:has([aria-expanded="true"])) {
15
+ position: relative;
16
+ z-index: 2;
17
+ }
18
+
10
19
  .version-picker-container {
11
- padding: 8px var(--dx-g-spacing-lg) 8px
12
- var(--dx-g-global-header-padding-horizontal);
13
- border-top: 1px solid var(--dx-g-gray-90);
14
- border-bottom: 1px solid var(--dx-g-gray-90);
20
+ /* Override --doc-version-picker-padding to change the inset (e.g. when the
21
+ picker sits inside an already-padded container). */
22
+ padding: var(
23
+ --doc-version-picker-padding,
24
+ var(--dx-g-spacing-sm) var(--dx-g-spacing-lg) var(--dx-g-spacing-sm)
25
+ var(--dx-g-global-header-padding-horizontal)
26
+ );
27
+
28
+ /* Override --doc-version-picker-divider (e.g. `none`) to restyle dividers. */
29
+ border-top: var(
30
+ --doc-version-picker-divider,
31
+ 1px solid var(--dx-g-gray-90)
32
+ );
33
+ border-bottom: var(
34
+ --doc-version-picker-divider,
35
+ 1px solid var(--dx-g-gray-90)
36
+ );
37
+ }
38
+
39
+ /* Inline sidebar variant: drop the header-slot chrome so the picker sits
40
+ beside a tree node without extra padding or divider lines. */
41
+ .version-picker-container-small {
42
+ display: flex;
43
+ align-items: center;
44
+ padding: 0;
45
+ border: none;
15
46
  }
16
47
 
17
48
  .version-picker-button {
18
49
  display: flex;
19
50
  width: var(--doc-version-picker-width, 296px);
51
+
52
+ --dx-c-button-horizontal-spacing: var(--dx-g-spacing-sm);
53
+ --dx-g-button-icon-color: var(--dx-g-gray-50);
20
54
  }
21
55
 
22
56
  .version-picker-button:hover,
23
- .version-picker-button:active,
24
- .version-picker-button:focus {
25
- --dx-c-button-secondary-color-hover: var(--dx-g-cloud-blue-vibrant-95);
26
- --dx-c-button-primary-color: var(--dx-g-blue-vibrant-40);
57
+ .version-picker-button:focus-within,
58
+ .version-picker-button[aria-expanded="true"] {
59
+ --dx-g-button-icon-color: var(--dx-g-blue-vibrant-20);
27
60
  }
28
61
 
29
62
  /**
@@ -35,6 +68,27 @@ dx-button::part(content) {
35
68
  overflow: hidden;
36
69
  }
37
70
 
71
+ /* The border is an inset box-shadow so thickening it on hover/focus doesn't
72
+ resize the small variant's fit-content box. */
73
+ .version-picker-button::part(container) {
74
+ background: white;
75
+ box-shadow: inset 0 0 0 1px var(--dx-g-gray-50);
76
+ color: var(--dx-g-gray-10);
77
+ }
78
+
79
+ .version-picker-button::part(container):hover {
80
+ background: var(--dx-g-blue-vibrant-95);
81
+ box-shadow: inset 0 0 0 2px var(--dx-g-blue-vibrant-20);
82
+ }
83
+
84
+ /* aria-expanded keeps the focus ring while the menu is open. */
85
+ .version-picker-button:focus-within::part(container),
86
+ .version-picker-button[aria-expanded="true"]::part(container) {
87
+ background: var(--dx-g-blue-vibrant-95);
88
+ box-shadow: inset 0 0 0 2px var(--dx-g-blue-vibrant-20), 0 0 0 2px white,
89
+ 0 0 0 4px var(--dx-g-blue-vibrant-60);
90
+ }
91
+
38
92
  .selected-version {
39
93
  display: flex;
40
94
  flex-direction: row;
@@ -49,16 +103,100 @@ dx-button::part(content) {
49
103
  white-space: nowrap;
50
104
  }
51
105
 
52
- dx-type-badge.latest-badge {
53
- --dx-c-type-badge-color: var(--dx-g-green-vibrant-40);
54
- --dx-c-type-badge-background: var(--dx-g-green-vibrant-95);
55
-
106
+ dx-type-badge.latest-badge,
107
+ dx-type-badge.not-latest-badge {
56
108
  margin-left: var(--dx-g-spacing-sm);
57
109
  }
58
110
 
59
- dx-type-badge.not-latest-badge {
60
- --dx-c-type-badge-color: var(--dx-g-red-vibrant-40);
61
- --dx-c-type-badge-background: var(--dx-g-red-vibrant-95);
111
+ /* ------------------------------------------------------------------ *
112
+ * Small "dot" variant (opt-in via `small`). Additive
113
+ * ------------------------------------------------------------------ */
62
114
 
63
- margin-left: var(--dx-g-spacing-sm);
115
+ /* Option padding is sm both axes: the small menu tracks the ~104px trigger, so
116
+ the default 24px horizontal would crowd the labels. */
117
+ .version-picker-dropdown-small {
118
+ --dx-c-dropdown-option-font-size: var(--dx-g-text-xs);
119
+ --dx-c-dropdown-option-padding: var(--dx-g-spacing-sm)
120
+ var(--dx-g-spacing-sm);
121
+ --dx-c-dropdown-option-border-radius: 0;
122
+ --dx-c-popover-border: none;
123
+ }
124
+
125
+ .version-picker-button-small {
126
+ width: fit-content;
127
+ max-width: 104px;
128
+
129
+ --dx-c-button-font-size: var(--dx-g-text-xs);
130
+ --dx-c-button-font-weight: var(--dx-g-font-normal);
131
+ --dx-c-button-line-height: var(--dx-g-spacing-lg);
132
+ --dx-c-button-icon-gap: var(--dx-g-spacing-2xs);
133
+ }
134
+
135
+ /* min-width: 0 on each flex ancestor lets the label truncate: a flex item's
136
+ default min-width: auto refuses to shrink below its content and overrides the
137
+ host's max-width, so without this the trigger overflows instead. */
138
+ .version-picker-button-small::part(content) {
139
+ display: flex;
140
+ flex: 1;
141
+ min-width: 0;
142
+ width: auto;
143
+ overflow: hidden;
144
+ }
145
+
146
+ /* width:100% re-ties to the 104px-capped host (container is width:inherit,
147
+ which copies width but not max-width). Border/bg/states are shared above. */
148
+ .version-picker-button-small::part(container) {
149
+ width: 100%;
150
+ height: var(--dx-g-spacing-lg);
151
+ padding: 0 var(--dx-g-spacing-xs);
152
+ }
153
+
154
+ .selected-version-small {
155
+ flex: 1;
156
+ min-width: 0;
157
+ }
158
+
159
+ .version-picker-dot {
160
+ flex: 0 0 auto;
161
+ width: var(--dx-g-spacing-sm);
162
+ height: var(--dx-g-spacing-sm);
163
+ margin-right: var(--dx-g-spacing-xs);
164
+ border-radius: 50%;
165
+ }
166
+
167
+ .version-picker-dot-latest {
168
+ background: var(--dx-g-green-vibrant-60);
169
+ }
170
+
171
+ .version-picker-dot-not-latest {
172
+ background: var(--dx-g-yellow-vibrant-80);
173
+ }
174
+
175
+ /* inline-block (not flex) so the label can truncate; line-height centers it. */
176
+ .version-picker-readonly {
177
+ box-sizing: border-box;
178
+ display: inline-block;
179
+ width: var(--doc-version-picker-width, 296px);
180
+ height: var(--dx-g-spacing-xl);
181
+ margin: 0;
182
+ padding: 0 var(--dx-g-spacing-sm);
183
+ border: 1px solid var(--dx-g-gray-80);
184
+ border-radius: var(--dx-g-spacing-xs);
185
+ overflow: hidden;
186
+ color: var(--dx-g-gray-10);
187
+ text-overflow: ellipsis;
188
+ white-space: nowrap;
189
+ font: var(--dx-g-font-normal) var(--dx-g-text-sm) / var(--dx-g-spacing-xl)
190
+ var(--dx-g-font-sans),
191
+ sans-serif;
192
+ }
193
+
194
+ .version-picker-readonly-small {
195
+ width: fit-content;
196
+ max-width: 104px;
197
+ height: var(--dx-g-spacing-lg);
198
+ padding: 0 var(--dx-g-spacing-xs);
199
+ font: var(--dx-g-font-normal) var(--dx-g-text-xs) / var(--dx-g-spacing-lg)
200
+ var(--dx-g-font-sans),
201
+ sans-serif;
64
202
  }
@@ -1,37 +1,70 @@
1
1
  <template>
2
- <div lwc:if={showVersionPicker} class="version-picker-container">
2
+ <div lwc:if={showVersionPicker} class={containerClass}>
3
+ <!-- Small read-only: plain value, no dropdown -->
4
+ <p
5
+ lwc:if={readOnly}
6
+ class={readOnlyClass}
7
+ title={selectedVersion.label}
8
+ >
9
+ {selectedVersion.label}
10
+ </p>
11
+
3
12
  <dx-dropdown
13
+ lwc:else
14
+ class={dropdownClass}
4
15
  options={versions}
5
16
  analytics-event="custEv_docVersionSelect"
6
17
  analytics-payload={analyticsPayload}
7
18
  value={selectedVersion.id}
8
- width="var(--doc-version-picker-width)"
19
+ full-width="true"
9
20
  onchange={onVersionChange}
10
21
  >
11
22
  <dx-button
12
- class="version-picker-button"
23
+ class={triggerClass}
13
24
  variant="tertiary"
14
25
  size="small"
26
+ font="sans"
15
27
  icon-symbol="chevrondown"
16
- icon-size="medium"
28
+ icon-size={triggerIconSize}
29
+ aria-label={triggerAriaLabel}
17
30
  >
18
- <div class="selected-version">
19
- <p class="selected-version-label">
20
- {selectedVersion.label}
21
- </p>
22
- <template lwc:if={showLatestTag}>
23
- <dx-type-badge
24
- class="latest-badge"
25
- lwc:if={latestVersion}
26
- value="Latest"
27
- size="small"
28
- ></dx-type-badge>
29
- <dx-type-badge
30
- class="not-latest-badge"
31
- lwc:else
32
- value="Not Latest"
33
- size="small"
34
- ></dx-type-badge>
31
+ <div class={selectedVersionClass}>
32
+ <!-- Small: leading colored dot -->
33
+ <template lwc:if={small}>
34
+ <template lwc:if={showLatestTag}>
35
+ <span class={dotClass} aria-hidden="true"></span>
36
+ </template>
37
+ <p
38
+ class="selected-version-label"
39
+ title={selectedVersion.label}
40
+ >
41
+ {selectedVersion.label}
42
+ </p>
43
+ </template>
44
+ <!-- Default: trailing Latest/Not-Latest badge -->
45
+ <template lwc:else>
46
+ <p
47
+ class="selected-version-label"
48
+ title={selectedVersion.label}
49
+ >
50
+ {selectedVersion.label}
51
+ </p>
52
+ <template lwc:if={showLatestTag}>
53
+ <dx-type-badge
54
+ class="latest-badge"
55
+ lwc:if={latestVersion}
56
+ variant="status-success"
57
+ value="Latest"
58
+ size="small"
59
+ ></dx-type-badge>
60
+ <dx-type-badge
61
+ class="not-latest-badge"
62
+ lwc:else
63
+ variant="status-warning"
64
+ value="Not Latest"
65
+ size="small"
66
+ ></dx-type-badge>
67
+ </template>
35
68
  </template>
36
69
  </div>
37
70
  </dx-button>
@@ -1,4 +1,5 @@
1
1
  import { LightningElement, api, track } from "lwc";
2
+ import cx from "classnames";
2
3
 
3
4
  import { AnalyticsPayload, OptionWithNested } from "typings/custom";
4
5
 
@@ -12,6 +13,8 @@ export default class VersionPicker extends LightningElement {
12
13
  private _selectedVersion?: OptionWithNested;
13
14
  private _latestVersion: boolean = false;
14
15
  private _hideBadge: boolean = false;
16
+ private _small: boolean = false;
17
+ private _readOnly: boolean = false;
15
18
 
16
19
  @api
17
20
  get versions() {
@@ -51,14 +54,97 @@ export default class VersionPicker extends LightningElement {
51
54
  this._hideBadge = normalizeBoolean(value);
52
55
  }
53
56
 
57
+ // Opt-in compact "dot" variant (24px, abbreviated label). Off by default so
58
+ // existing consumers render the default trigger unchanged.
59
+ @api
60
+ get small() {
61
+ return this._small;
62
+ }
63
+
64
+ set small(value) {
65
+ this._small = normalizeBoolean(value);
66
+ }
67
+
68
+ // Opt-in read-only state: plain value, no dropdown menu.
69
+ @api
70
+ get readOnly() {
71
+ return this._readOnly;
72
+ }
73
+
74
+ set readOnly(value) {
75
+ this._readOnly = normalizeBoolean(value);
76
+ }
77
+
54
78
  private get showVersionPicker() {
55
79
  return this._versions && this._versions.length !== 0;
56
80
  }
57
81
 
82
+ private get containerClass(): string {
83
+ return this.small
84
+ ? "version-picker-container-small"
85
+ : "version-picker-container";
86
+ }
87
+
58
88
  private get showLatestTag(): boolean {
59
89
  return !this.hideBadge;
60
90
  }
61
91
 
92
+ private get readOnlyClass(): string {
93
+ return cx(
94
+ "version-picker-readonly",
95
+ this.small && "version-picker-readonly-small"
96
+ );
97
+ }
98
+
99
+ private get dotClass(): string {
100
+ return `version-picker-dot ${
101
+ this.latestVersion
102
+ ? "version-picker-dot-latest"
103
+ : "version-picker-dot-not-latest"
104
+ }`;
105
+ }
106
+
107
+ // Accessible name for the small trigger: includes the latest state so it
108
+ // isn't conveyed by the dot's color alone (WCAG 1.4.1). Omits the state
109
+ // when the indicator is hidden.
110
+ private get smallTriggerAriaLabel(): string {
111
+ const label = this.selectedVersion?.label ?? "";
112
+ if (!this.showLatestTag) {
113
+ return label;
114
+ }
115
+ return `${label}, ${this.latestVersion ? "Latest" : "Not Latest"}`;
116
+ }
117
+
118
+ // --- Per-variant values for the shared dropdown/button markup below. The
119
+ // small variant opts into extra classes and a compact chevron.
120
+
121
+ // Not cx(): the default must have NO class attribute (cx returns "", which
122
+ // would render class="" and break the frozen consumer snapshots).
123
+ private get dropdownClass(): string | undefined {
124
+ return this.small ? "version-picker-dropdown-small" : undefined;
125
+ }
126
+
127
+ private get triggerClass(): string {
128
+ return cx(
129
+ "version-picker-button",
130
+ this.small && "version-picker-button-small"
131
+ );
132
+ }
133
+
134
+ private get triggerIconSize(): string {
135
+ return this.small ? "xsmall" : "medium";
136
+ }
137
+
138
+ // Default returns "" (dx-button's own default) so the trigger's rendered
139
+ // aria-label is unchanged for the default variant.
140
+ private get triggerAriaLabel(): string {
141
+ return this.small ? this.smallTriggerAriaLabel : "";
142
+ }
143
+
144
+ private get selectedVersionClass(): string {
145
+ return cx("selected-version", this.small && "selected-version-small");
146
+ }
147
+
62
148
  private onVersionChange(e: CustomEvent) {
63
149
  this.dispatchEvent(new CustomEvent("change", { detail: e.detail }));
64
150
  }
package/LICENSE DELETED
@@ -1,12 +0,0 @@
1
- Copyright (c) 2020, Salesforce.com, Inc.
2
- All rights reserved.
3
-
4
- Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
5
-
6
- * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
7
-
8
- * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
9
-
10
- * Neither the name of Salesforce.com nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
11
-
12
- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.