@salesforcedevs/docs-components 1.29.0-newct-alpha2 → 1.29.0-toolbar-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
@@ -5,6 +5,7 @@
5
5
  { "npm": "@salesforcedevs/dw-components" }
6
6
  ],
7
7
  "expose": [
8
+ "doc/contentActionToolbar",
8
9
  "doc/amfReference",
9
10
  "doc/banner",
10
11
  "doc/localeBanner",
@@ -17,7 +18,6 @@
17
18
  "doc/contentMedia",
18
19
  "doc/docXmlContent",
19
20
  "doc/lwcContentLayout",
20
- "doc/unifiedContentLayout",
21
21
  "doc/header",
22
22
  "doc/heading",
23
23
  "doc/headingAnchor",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@salesforcedevs/docs-components",
3
- "version": "1.29.0-newct-alpha2",
3
+ "version": "1.29.0-toolbar-alpha1",
4
4
  "description": "Docs Lightning web components for DSC",
5
5
  "license": "MIT",
6
6
  "main": "index.js",
@@ -17,6 +17,7 @@
17
17
  languages={languages}
18
18
  language={language}
19
19
  show-footer={enableFooter}
20
+ show-content-action-toolbar={showContentActionToolbar}
20
21
  empty-state-message={emptyStateMessage}
21
22
  origin={origin}
22
23
  >
@@ -73,6 +73,17 @@ export default class AmfReference extends LightningElement {
73
73
  return this.isSpecBasedReference(this._currentReferenceId);
74
74
  }
75
75
 
76
+ /**
77
+ * Content action toolbar is only enabled for markdown-based references in the en-us locale.
78
+ */
79
+ get showContentActionToolbar(): boolean {
80
+ if (this.showSpecBasedReference) {
81
+ return false;
82
+ }
83
+ const locale = this.language?.toLowerCase() ?? "en-us";
84
+ return locale === "en-us";
85
+ }
86
+
76
87
  @api
77
88
  get referenceSetConfig(): ReferenceSetConfig {
78
89
  return this._referenceSetConfig;
@@ -0,0 +1,31 @@
1
+ @import "dxHelpers/reset";
2
+
3
+ :host {
4
+ display: block;
5
+ }
6
+
7
+ .toolbar {
8
+ display: flex;
9
+ align-items: center;
10
+ gap: var(--dx-g-spacing-smd);
11
+ margin-bottom: var(--dx-g-spacing-lg);
12
+ }
13
+
14
+ .toolbar-button {
15
+ --dx-c-button-font-weight: var(--dx-g-font-demi);
16
+ --dx-c-button-line-height: var(--dx-g-spacing-mlg);
17
+ --dx-c-button-letter-spacing: 0.005em;
18
+ }
19
+
20
+ .divider {
21
+ width: 1px;
22
+ height: var(--dx-g-spacing-md);
23
+ background-color: var(--dx-g-gray-70);
24
+ }
25
+
26
+ @media screen and (max-width: 480px) {
27
+ .toolbar-button_copy-url,
28
+ .divider_copy-url {
29
+ display: none;
30
+ }
31
+ }
@@ -0,0 +1,53 @@
1
+ <template>
2
+ <div class="toolbar">
3
+ <dx-tooltip placement="top-right" label={copyMarkdownLabel}>
4
+ <dx-button
5
+ class="toolbar-button"
6
+ variant="inline"
7
+ size="small"
8
+ icon-sprite="utility"
9
+ icon-symbol="copy"
10
+ icon-size="medium"
11
+ icon-position="right"
12
+ aria-label={copyMarkdownButtonText}
13
+ onclick={handleCopyMarkdown}
14
+ >
15
+ {copyMarkdownButtonText}
16
+ </dx-button>
17
+ </dx-tooltip>
18
+
19
+ <div class="divider"></div>
20
+
21
+ <dx-button
22
+ class="toolbar-button"
23
+ variant="inline"
24
+ size="small"
25
+ icon-sprite="utility"
26
+ icon-symbol="new_window"
27
+ icon-size="medium"
28
+ icon-position="right"
29
+ aria-label={viewMarkdownButtonText}
30
+ onclick={handleViewMarkdown}
31
+ >
32
+ {viewMarkdownButtonText}
33
+ </dx-button>
34
+
35
+ <div class="divider divider_copy-url"></div>
36
+
37
+ <dx-tooltip placement="top-right" label={copyUrlLabel}>
38
+ <dx-button
39
+ class="toolbar-button toolbar-button_copy-url"
40
+ variant="inline"
41
+ size="small"
42
+ icon-sprite="utility"
43
+ icon-symbol="link"
44
+ icon-size="medium"
45
+ icon-position="right"
46
+ aria-label={copyUrlButtonText}
47
+ onclick={handleCopyUrl}
48
+ >
49
+ {copyUrlButtonText}
50
+ </dx-button>
51
+ </dx-tooltip>
52
+ </div>
53
+ </template>
@@ -0,0 +1,138 @@
1
+ import { LightningElement } from "lwc";
2
+ import { track } from "dxUtils/analytics";
3
+
4
+ const DEFAULT_COPY_TOOLTIP_LABEL = "Click to copy";
5
+ const COPIED_TOOLTIP_LABEL = "Copied!";
6
+ const COPIED_TOOLTIP_RESET_MS = 2000;
7
+
8
+ const ANALYTICS_CONTENT_CATEGORY = "content action toolbar";
9
+ const COPY_MARKDOWN_LABEL = "Copy as Markdown";
10
+ const VIEW_MARKDOWN_LABEL = "View as Markdown";
11
+ const COPY_URL_LABEL = "Copy URL to Markdown";
12
+
13
+ export default class ContentActionToolbar extends LightningElement {
14
+ copyMarkdownLabel: string = DEFAULT_COPY_TOOLTIP_LABEL;
15
+ copyUrlLabel: string = DEFAULT_COPY_TOOLTIP_LABEL;
16
+
17
+ private copyTooltipResetTimeout: number | null = null;
18
+
19
+ get copyMarkdownButtonText(): string {
20
+ return COPY_MARKDOWN_LABEL;
21
+ }
22
+
23
+ get viewMarkdownButtonText(): string {
24
+ return VIEW_MARKDOWN_LABEL;
25
+ }
26
+
27
+ get copyUrlButtonText(): string {
28
+ return COPY_URL_LABEL;
29
+ }
30
+
31
+ async handleCopyMarkdown(event: Event) {
32
+ const markdownUrl = this.getMarkdownUrl();
33
+ if (!markdownUrl) {
34
+ return;
35
+ }
36
+
37
+ this.trackToolbarEvent(
38
+ event,
39
+ "custEv_linkClick",
40
+ COPY_MARKDOWN_LABEL,
41
+ markdownUrl
42
+ );
43
+
44
+ try {
45
+ const response = await fetch(markdownUrl);
46
+ if (!response.ok) {
47
+ return;
48
+ }
49
+ const markdown = await response.text();
50
+ await navigator.clipboard.writeText(markdown);
51
+ this.flashCopied("copyMarkdownLabel");
52
+ } catch (error) {
53
+ console.error(error);
54
+ }
55
+ }
56
+
57
+ handleViewMarkdown(event: Event) {
58
+ const markdownUrl = this.getMarkdownUrl();
59
+ if (!markdownUrl) {
60
+ return;
61
+ }
62
+
63
+ this.trackToolbarEvent(
64
+ event,
65
+ "custEv_linkClick",
66
+ VIEW_MARKDOWN_LABEL,
67
+ markdownUrl
68
+ );
69
+
70
+ window.open(markdownUrl, "_blank", "noopener,noreferrer");
71
+ }
72
+
73
+ async handleCopyUrl(event: Event) {
74
+ const markdownUrl = this.getMarkdownUrl();
75
+ if (!markdownUrl) {
76
+ return;
77
+ }
78
+
79
+ this.trackToolbarEvent(
80
+ event,
81
+ "custEv_linkClick",
82
+ COPY_URL_LABEL,
83
+ markdownUrl
84
+ );
85
+
86
+ try {
87
+ await navigator.clipboard.writeText(markdownUrl);
88
+ this.flashCopied("copyUrlLabel");
89
+ } catch (error) {
90
+ console.error(error);
91
+ }
92
+ }
93
+
94
+ private trackToolbarEvent(
95
+ event: Event,
96
+ eventName: "custEv_linkClick",
97
+ label: string,
98
+ url: string
99
+ ): void {
100
+ track(event.currentTarget!, eventName, {
101
+ click_text: label,
102
+ click_url: url,
103
+ element_type: "button_link",
104
+ element_title: label,
105
+ content_category: ANALYTICS_CONTENT_CATEGORY
106
+ });
107
+ }
108
+
109
+ /**
110
+ * Returns the `.md` equivalent of the current page URL with any hash and
111
+ * query string stripped, or `null` when the current page does not end
112
+ * with `.html`.
113
+ */
114
+ private getMarkdownUrl(): string | null {
115
+ const url = new URL(window.location.href);
116
+ url.hash = "";
117
+ url.search = "";
118
+
119
+ if (!url.pathname.endsWith(".html")) {
120
+ return null;
121
+ }
122
+
123
+ url.pathname = url.pathname.replace(/\.html$/, ".md");
124
+ return url.toString();
125
+ }
126
+
127
+ private flashCopied(labelKey: "copyMarkdownLabel" | "copyUrlLabel") {
128
+ if (this.copyTooltipResetTimeout !== null) {
129
+ window.clearTimeout(this.copyTooltipResetTimeout);
130
+ }
131
+ this[labelKey] = COPIED_TOOLTIP_LABEL;
132
+ this.copyTooltipResetTimeout = window.setTimeout(() => {
133
+ this.copyMarkdownLabel = DEFAULT_COPY_TOOLTIP_LABEL;
134
+ this.copyUrlLabel = DEFAULT_COPY_TOOLTIP_LABEL;
135
+ this.copyTooltipResetTimeout = null;
136
+ }, COPIED_TOOLTIP_RESET_MS);
137
+ }
138
+ }
@@ -0,0 +1,48 @@
1
+ import { http, HttpResponse } from "msw";
2
+
3
+ /**
4
+ * Mocks for the content action toolbar so stories never hit the real
5
+ * docs backend. Any story rendering `doc-content-action-toolbar` (directly or
6
+ * via `doc-content-layout`) must register `contentActionToolbarMswHandlers`
7
+ * and call `interceptWindowOpenForContentActionToolbar()`.
8
+ */
9
+ export const DUMMY_MARKDOWN_CONTENT = `# Dummy Markdown
10
+
11
+ Storybook serves this placeholder content in place of the real markdown
12
+ that the docs backend would return for the current page. It exists so
13
+ the "Copied" tooltip, the "View as Markdown" new tab, and the
14
+ "Copy URL to Markdown" clipboard behavior are all exercisable in
15
+ storybook without hitting the live backend.
16
+
17
+ - Item 1
18
+ - Item 2
19
+ - Item 3
20
+ `;
21
+
22
+ export const DUMMY_MARKDOWN_DATA_URL = `data:text/markdown;charset=utf-8,${encodeURIComponent(
23
+ DUMMY_MARKDOWN_CONTENT
24
+ )}`;
25
+
26
+ /** Intercepts any `.md` GET request and returns the dummy markdown. */
27
+ export const contentActionToolbarMswHandlers = [
28
+ http.get(/\.md(\?.*)?$/, () => HttpResponse.text(DUMMY_MARKDOWN_CONTENT))
29
+ ];
30
+
31
+ let windowOpenIntercepted = false;
32
+
33
+ /** Redirects `window.open` calls for `.md` URLs to the dummy markdown data URL (MSW does not cover new tabs). */
34
+ export function interceptWindowOpenForContentActionToolbar() {
35
+ if (windowOpenIntercepted) {
36
+ return;
37
+ }
38
+ windowOpenIntercepted = true;
39
+
40
+ const originalOpen = window.open.bind(window);
41
+ window.open = ((url?: string | URL, target?: string, features?: string) => {
42
+ const stringUrl = url?.toString() ?? "";
43
+ if (stringUrl.endsWith(".md")) {
44
+ return originalOpen(DUMMY_MARKDOWN_DATA_URL, target, features);
45
+ }
46
+ return originalOpen(url ?? "", target, features);
47
+ }) as typeof window.open;
48
+ }
@@ -1,11 +1,15 @@
1
1
  /* eslint-disable @lwc/lwc/no-document-query */
2
- import { LightningElement, api, track } from "lwc";
3
- import { closest } from "kagekiri";
2
+ import { LightningElement, api, createElement, track } from "lwc";
3
+ import { closest, querySelector } from "kagekiri";
4
4
  import { toJson, normalizeBoolean } from "dxUtils/normalizers";
5
5
  import { highlightTerms } from "dxUtils/highlight";
6
6
  import { SearchSyncer } from "docUtils/searchSyncer";
7
7
  import type { OptionWithLink } from "typings/custom";
8
8
  import { buildDocLinkClickHandler } from "dxUtils/analytics";
9
+ import ContentActionToolbar from "doc/contentActionToolbar";
10
+
11
+ const CONTENT_ACTION_TOOLBAR_TAG = "doc-content-action-toolbar";
12
+ const PAGE_HEADING_SELECTOR = "h1";
9
13
 
10
14
  type AnchorMap = { [key: string]: { intersect: boolean; id: string } };
11
15
 
@@ -95,6 +99,17 @@ export default class ContentLayout extends LightningElement {
95
99
  /** Optional origin URL for the footer MFE (e.g. wp-json endpoint). Passed through to dx-footer. */
96
100
  @api origin: string | null = null;
97
101
 
102
+ /** Controls whether the content action toolbar is displayed. */
103
+ @api
104
+ get showContentActionToolbar() {
105
+ return this._showContentActionToolbar;
106
+ }
107
+ set showContentActionToolbar(value) {
108
+ this._showContentActionToolbar = normalizeBoolean(value);
109
+ this.updateContentActionToolbar();
110
+ }
111
+ private _showContentActionToolbar = false;
112
+
98
113
  @api
99
114
  get breadcrumbs() {
100
115
  return this._breadcrumbs;
@@ -152,6 +167,7 @@ export default class ContentLayout extends LightningElement {
152
167
  protected hasRendered: boolean = false;
153
168
  protected contentLoaded: boolean = false;
154
169
  protected sidebarOpen: boolean = false;
170
+ protected contentActionToolbarElement: HTMLElement | null = null;
155
171
 
156
172
  get shouldDisplayFeedback() {
157
173
  return this.contentLoaded && typeof Sprig !== "undefined";
@@ -257,6 +273,8 @@ export default class ContentLayout extends LightningElement {
257
273
  window.addEventListener("scroll", this.adjustNavPosition);
258
274
  window.addEventListener("resize", this.adjustNavPosition);
259
275
 
276
+ this.updateContentActionToolbar();
277
+
260
278
  if (!this.hasRendered) {
261
279
  this.hasRendered = true;
262
280
  this.restoreScroll();
@@ -266,6 +284,47 @@ export default class ContentLayout extends LightningElement {
266
284
  }
267
285
  }
268
286
 
287
+ /**
288
+ * Inserts the content action toolbar into the slotted content immediately
289
+ * after the first H1 found inside this layout.
290
+ */
291
+ protected updateContentActionToolbar(): void {
292
+ if (!this.showContentActionToolbar) {
293
+ this.removeContentActionToolbar();
294
+ return;
295
+ }
296
+
297
+ const heading = querySelector(
298
+ PAGE_HEADING_SELECTOR,
299
+ this.template.host
300
+ ) as HTMLElement | null;
301
+
302
+ if (!heading) {
303
+ return;
304
+ }
305
+
306
+ if (
307
+ this.contentActionToolbarElement?.previousElementSibling === heading
308
+ ) {
309
+ return;
310
+ }
311
+
312
+ this.removeContentActionToolbar();
313
+
314
+ const toolbar = createElement(CONTENT_ACTION_TOOLBAR_TAG, {
315
+ is: ContentActionToolbar
316
+ }) as unknown as HTMLElement;
317
+ heading.parentNode?.insertBefore(toolbar, heading.nextSibling);
318
+ this.contentActionToolbarElement = toolbar;
319
+ }
320
+
321
+ protected removeContentActionToolbar(): void {
322
+ if (this.contentActionToolbarElement) {
323
+ this.contentActionToolbarElement.remove();
324
+ this.contentActionToolbarElement = null;
325
+ }
326
+ }
327
+
269
328
  disconnectedCallback(): void {
270
329
  this.disconnectObserver();
271
330
  window.removeEventListener(
@@ -281,6 +340,8 @@ export default class ContentLayout extends LightningElement {
281
340
 
282
341
  // Remove link click handler
283
342
  this.template.removeEventListener("click", this.handleLinkClick);
343
+
344
+ this.removeContentActionToolbar();
284
345
  }
285
346
 
286
347
  restoreScroll() {
@@ -523,6 +584,7 @@ export default class ContentLayout extends LightningElement {
523
584
 
524
585
  onSlotChange(): void {
525
586
  this.updateRNB();
587
+ this.updateContentActionToolbar();
526
588
  this.contentLoaded = true;
527
589
  }
528
590
 
@@ -1,3 +0,0 @@
1
- :host {
2
- display: block;
3
- }
@@ -1,34 +0,0 @@
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
- origin={origin}
18
- >
19
- <doc-phase
20
- slot="doc-phase"
21
- lwc:if={docPhaseInfo}
22
- doc-phase-info={docPhaseInfo}
23
- ></doc-phase>
24
-
25
- <template lwc:if={isMarkdownTopic}>
26
- <slot></slot>
27
- </template>
28
- <!--
29
- TODO(W-22340752): render OAS specs via doc-redoc-reference when
30
- topicType === "spec". The docs content-type parser currently 404s
31
- spec topics until that path lands.
32
- -->
33
- </doc-content-layout>
34
- </template>
@@ -1,94 +0,0 @@
1
- import { LightningElement, api } from "lwc";
2
- import { toJson, normalizeBoolean } from "dxUtils/normalizers";
3
- import type { OptionWithLink } from "typings/custom";
4
-
5
- /**
6
- * Topic types emitted by the docs content-type parser
7
- * (see @salesforcedevs/sfdocs-doc-framework: `TopicTypeEnum`).
8
- */
9
- const TOPIC_TYPE_MARKDOWN = "markdown";
10
- const TOPIC_TYPE_SPEC = "spec";
11
-
12
- /**
13
- * Wrapper around `doc-content-layout` for the "docs" content type emitted by
14
- * the `DocsContentTypeParser`.
15
- *
16
- * Mirrors the role that `doc-amf-reference` plays for the "reference" content
17
- * type: it owns content-type-aware concerns (markdown body vs. OAS spec via
18
- * Redoc) and forwards the chrome (sidebar, breadcrumbs, TOC, footer) to
19
- * `doc-content-layout`.
20
- *
21
- * Only the markdown topic type is wired up today. OAS spec rendering via Redoc
22
- * is tracked separately (W-22340752) and currently 404s upstream in the parser.
23
- */
24
- export default class UnifiedContentLayout extends LightningElement {
25
- @api breadcrumbs: string | null = null;
26
- @api sidebarHeader?: string;
27
- @api sidebarValue?: string;
28
- @api tocTitle?: string;
29
- @api tocOptions?: string;
30
- @api tocAriaLevel?: string;
31
- @api languages?: OptionWithLink[];
32
- @api language?: string;
33
-
34
- /** Optional origin URL for the footer MFE (e.g. wp-json endpoint). */
35
- @api origin: string | null = null;
36
-
37
- /** Article name from breadcrumbs, used as share title (e.g. for social share). */
38
- @api shareTitle: string | null = null;
39
-
40
- /** Optional Twitter "via" handle (e.g. SalesforceDevs) for social share. */
41
- @api twitterVia: string | null = null;
42
-
43
- @api hideFooter = false;
44
-
45
- /**
46
- * Topic type forwarded from the layout template. The docs parser supports
47
- * `markdown` today and `spec` (OAS via Redoc) in a follow-up.
48
- */
49
- @api topicType: string = TOPIC_TYPE_MARKDOWN;
50
-
51
- private _docPhaseInfo: string | null = null;
52
- private _sidebarContent: unknown = null;
53
-
54
- @api
55
- get docPhaseInfo(): string | null {
56
- return this._docPhaseInfo;
57
- }
58
-
59
- set docPhaseInfo(value: string | null) {
60
- this._docPhaseInfo = value || null;
61
- }
62
-
63
- @api
64
- get sidebarContent(): unknown {
65
- return this._sidebarContent;
66
- }
67
-
68
- set sidebarContent(value: string | { topics?: unknown } | null) {
69
- this._sidebarContent = value ? toJson(value)?.topics ?? null : null;
70
- }
71
-
72
- private _expandChildren = false;
73
-
74
- @api
75
- get expandChildren(): boolean {
76
- return this._expandChildren;
77
- }
78
-
79
- set expandChildren(value: boolean | string) {
80
- this._expandChildren = normalizeBoolean(value);
81
- }
82
-
83
- get isMarkdownTopic(): boolean {
84
- return this.topicType === TOPIC_TYPE_MARKDOWN;
85
- }
86
-
87
- get isSpecTopic(): boolean {
88
- return this.topicType === TOPIC_TYPE_SPEC;
89
- }
90
-
91
- private get enableFooter(): boolean {
92
- return !this.hideFooter;
93
- }
94
- }