@salesforcedevs/dx-components 1.33.1 → 1.35.0
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 -2
- package/src/modules/dx/agentMiawUi/agentMiawUi.ts +68 -6
- package/src/modules/dx/searchHybrid/searchHybrid.css +165 -0
- package/src/modules/dx/searchHybrid/searchHybrid.html +136 -0
- package/src/modules/dx/searchHybrid/searchHybrid.ts +306 -0
- package/src/modules/dxUtils/searchLocalePrefs/searchLocalePrefs.ts +193 -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.35.0",
|
|
4
4
|
"description": "DX Lightning web components",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"engines": {
|
|
@@ -44,5 +44,5 @@
|
|
|
44
44
|
"luxon": "3.4.4",
|
|
45
45
|
"msw": "^2.12.4"
|
|
46
46
|
},
|
|
47
|
-
"gitHead": "
|
|
47
|
+
"gitHead": "7f00db154db1ca528e2bed2c33226a3ff674cd17"
|
|
48
48
|
}
|
|
@@ -57,6 +57,24 @@ function loadMiawUiScript(): Promise<void> {
|
|
|
57
57
|
return scriptLoaded;
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
function dropCookie(cookieDomain: string) {
|
|
61
|
+
const { hostname, protocol } = window.location;
|
|
62
|
+
const onCookieDomain =
|
|
63
|
+
!!cookieDomain &&
|
|
64
|
+
(hostname === cookieDomain || hostname.endsWith(`.${cookieDomain}`));
|
|
65
|
+
|
|
66
|
+
document.cookie = [
|
|
67
|
+
"show_agent=true",
|
|
68
|
+
"Path=/",
|
|
69
|
+
"Max-Age=2592000",
|
|
70
|
+
"SameSite=Lax",
|
|
71
|
+
protocol === "https:" ? "Secure" : null,
|
|
72
|
+
onCookieDomain ? `Domain=${cookieDomain}` : null
|
|
73
|
+
]
|
|
74
|
+
.filter(Boolean)
|
|
75
|
+
.join("; ");
|
|
76
|
+
}
|
|
77
|
+
|
|
60
78
|
export default class AgentMiawUi extends LightningElement {
|
|
61
79
|
private static readonly SIDEBAR_OPEN_ATTR = "data-xsf-agent-sidebar-open";
|
|
62
80
|
private static readonly AGENT_ROOT_SELECTOR = '[data-testid="agent-root"]';
|
|
@@ -78,6 +96,16 @@ export default class AgentMiawUi extends LightningElement {
|
|
|
78
96
|
/** When set, forwarded to the embed as `welcome-text` (greeting / headline copy). */
|
|
79
97
|
@api welcomeText?: string;
|
|
80
98
|
@api agentforceEnv?: string;
|
|
99
|
+
/**
|
|
100
|
+
* When true, mounting of the agent is gated behind the `showAgent=true` query param
|
|
101
|
+
* or `show_agent=true` cookie opt-in; otherwise (and by default), gating is off.
|
|
102
|
+
*/
|
|
103
|
+
@api hideAgentConditionally: boolean = false;
|
|
104
|
+
/**
|
|
105
|
+
* Apex domain that the opt-in cookie targets (covers subdomains); applied only when the
|
|
106
|
+
* current host matches it, so local dev / preview hosts fall back to a host-only cookie.
|
|
107
|
+
*/
|
|
108
|
+
@api cookieDomain?: string;
|
|
81
109
|
|
|
82
110
|
/** After first mount attempt is scheduled, we do not run it again for this instance. */
|
|
83
111
|
private hasRendered = false;
|
|
@@ -87,7 +115,9 @@ export default class AgentMiawUi extends LightningElement {
|
|
|
87
115
|
renderedCallback(): void {
|
|
88
116
|
if (!this.hasRendered) {
|
|
89
117
|
this.hasRendered = true;
|
|
90
|
-
this.
|
|
118
|
+
if (this.shouldMount()) {
|
|
119
|
+
this.mountMiawHost();
|
|
120
|
+
}
|
|
91
121
|
}
|
|
92
122
|
}
|
|
93
123
|
|
|
@@ -96,6 +126,39 @@ export default class AgentMiawUi extends LightningElement {
|
|
|
96
126
|
this.sidebarStateObserver = null;
|
|
97
127
|
}
|
|
98
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Decide whether the agent should mount. If conditional gating is off, always mount.
|
|
131
|
+
* If conditional gating of the agent is on, return `true` only when the user has opted in
|
|
132
|
+
* via the `showAgent=true` query param or the `show_agent=true` cookie. (When only the
|
|
133
|
+
* param is present, persists a sticky opt-in cookie (~30 days) as a side effect so the
|
|
134
|
+
* choice carries across pages and subdomains.)
|
|
135
|
+
*
|
|
136
|
+
* CROSS-SITE CONTRACT: the param key/value, cookie token, Max-Age, SameSite, Secure and
|
|
137
|
+
* Domain-scoping logic are MIRRORED in architect-sf, since both serve pages for Architect.
|
|
138
|
+
* Changing these values requires a coordinated change in all repos.
|
|
139
|
+
*/
|
|
140
|
+
private shouldMount(): boolean {
|
|
141
|
+
if (!this.hideAgentConditionally) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// When the agent should be conditionally hidden, we check for special query param or
|
|
146
|
+
// cookie required for actually mounting/rendering it.
|
|
147
|
+
const hasParam =
|
|
148
|
+
new URLSearchParams(window.location.search).get("showAgent") ===
|
|
149
|
+
"true";
|
|
150
|
+
const hasCookie = /(?:^|;\s*)show_agent=true(?:;|$)/.test(
|
|
151
|
+
document.cookie
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// If the user doesn't have the cookie yet but does have the param, set the cookie
|
|
155
|
+
if (hasParam && !hasCookie) {
|
|
156
|
+
dropCookie(this.cookieDomain || "");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return hasParam || hasCookie;
|
|
160
|
+
}
|
|
161
|
+
|
|
99
162
|
private async mountMiawHost(): Promise<void> {
|
|
100
163
|
try {
|
|
101
164
|
await loadMiawUiScript();
|
|
@@ -114,10 +177,7 @@ export default class AgentMiawUi extends LightningElement {
|
|
|
114
177
|
this.richComponentVersion
|
|
115
178
|
);
|
|
116
179
|
if (this.routingAttributes) {
|
|
117
|
-
el.setAttribute(
|
|
118
|
-
"routing-attributes",
|
|
119
|
-
this.routingAttributes
|
|
120
|
-
);
|
|
180
|
+
el.setAttribute("routing-attributes", this.routingAttributes);
|
|
121
181
|
}
|
|
122
182
|
if (this.prompts) {
|
|
123
183
|
el.setAttribute("prompts", this.prompts);
|
|
@@ -134,7 +194,9 @@ export default class AgentMiawUi extends LightningElement {
|
|
|
134
194
|
}
|
|
135
195
|
|
|
136
196
|
private setSidebarOpenState(isOpen: boolean): void {
|
|
137
|
-
const targets = document.querySelectorAll(
|
|
197
|
+
const targets = document.querySelectorAll(
|
|
198
|
+
this.sidebarStateTargetSelector
|
|
199
|
+
);
|
|
138
200
|
targets.forEach((target) => {
|
|
139
201
|
target.setAttribute(
|
|
140
202
|
AgentMiawUi.SIDEBAR_OPEN_ATTR,
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
:host {
|
|
2
|
+
display: block;
|
|
3
|
+
font-family: var(--dx-g-font-sans);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
.dx-search-hybrid {
|
|
7
|
+
background-color: #fafaf9;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/* Header --------------------------------------------------------------- */
|
|
11
|
+
.dx-search-header {
|
|
12
|
+
padding: var(--dx-g-spacing-xl);
|
|
13
|
+
background: linear-gradient(
|
|
14
|
+
77deg,
|
|
15
|
+
var(--dx-g-indigo-vibrant-30) 40.1%,
|
|
16
|
+
var(--dx-g-indigo-vibrant-40) 99.07%
|
|
17
|
+
);
|
|
18
|
+
color: white;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.dx-search-header-container {
|
|
22
|
+
margin: 0 auto;
|
|
23
|
+
max-width: 1200px;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.dx-search-header-title {
|
|
27
|
+
font-size: var(--dx-g-text-lg);
|
|
28
|
+
line-height: var(--dx-g-text-lg);
|
|
29
|
+
font-weight: var(--dx-g-font-normal);
|
|
30
|
+
margin-bottom: var(--dx-g-spacing-md);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.dx-search-header-title-results {
|
|
34
|
+
font-weight: var(--dx-g-font-bold);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.dx-search-header-query {
|
|
38
|
+
font-family: var(--dx-g-font-display);
|
|
39
|
+
font-size: var(--dx-g-text-2xl);
|
|
40
|
+
line-height: var(--dx-g-text-2xl);
|
|
41
|
+
font-weight: var(--dx-g-font-demi);
|
|
42
|
+
margin-top: var(--dx-g-spacing-md);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* Body + toolbar ------------------------------------------------------- */
|
|
46
|
+
.dx-search-body {
|
|
47
|
+
margin: 0 auto;
|
|
48
|
+
max-width: 1200px;
|
|
49
|
+
padding: var(--dx-g-spacing-xl);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
.dx-search-toolbar {
|
|
53
|
+
display: flex;
|
|
54
|
+
flex-wrap: wrap;
|
|
55
|
+
align-items: flex-end;
|
|
56
|
+
justify-content: space-between;
|
|
57
|
+
gap: var(--dx-g-spacing-md);
|
|
58
|
+
margin-bottom: var(--dx-g-spacing-lg);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.dx-facet-row {
|
|
62
|
+
display: flex;
|
|
63
|
+
flex-wrap: wrap;
|
|
64
|
+
gap: var(--dx-g-spacing-sm);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.dx-facet-chip {
|
|
68
|
+
background-color: white;
|
|
69
|
+
color: var(--dx-g-blue-vibrant-20);
|
|
70
|
+
border: 1px solid var(--dx-g-blue-vibrant-50);
|
|
71
|
+
border-radius: 500px;
|
|
72
|
+
padding: 6px 14px;
|
|
73
|
+
font-size: var(--dx-g-text-sm);
|
|
74
|
+
cursor: pointer;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.dx-facet-chip:hover {
|
|
78
|
+
background-color: var(--dx-g-blue-vibrant-95);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.dx-facet-chip_active {
|
|
82
|
+
background-color: var(--dx-g-blue-vibrant-50);
|
|
83
|
+
color: white;
|
|
84
|
+
font-weight: var(--dx-g-font-demi);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.dx-facet-chip-count {
|
|
88
|
+
margin-left: 6px;
|
|
89
|
+
opacity: 0.75;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.dx-search-controls {
|
|
93
|
+
display: flex;
|
|
94
|
+
gap: var(--dx-g-spacing-md);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* Status states -------------------------------------------------------- */
|
|
98
|
+
.dx-search-status {
|
|
99
|
+
display: flex;
|
|
100
|
+
justify-content: center;
|
|
101
|
+
padding: var(--dx-g-spacing-2xl) 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
.dx-search-error {
|
|
105
|
+
color: var(--dx-g-red-vibrant-40, #ba0517);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* Result cards (ported from dx/searchResults visual style) ------------- */
|
|
109
|
+
.dx-result-list {
|
|
110
|
+
list-style: none;
|
|
111
|
+
margin: 0;
|
|
112
|
+
padding: 0;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.dx-result {
|
|
116
|
+
background: white;
|
|
117
|
+
border-radius: 16px;
|
|
118
|
+
border: 1px solid rgb(24 24 24 / 4%);
|
|
119
|
+
box-shadow: 0 0 2px 0 rgb(24 24 24 / 8%), 0 2px 4px 1px rgb(24 24 24 / 16%);
|
|
120
|
+
padding: 20px;
|
|
121
|
+
margin-bottom: var(--dx-g-spacing-lg);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.dx-result-info {
|
|
125
|
+
display: flex;
|
|
126
|
+
gap: 12px;
|
|
127
|
+
margin-bottom: var(--dx-g-spacing-smd);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.dx-badge {
|
|
131
|
+
display: inline-flex;
|
|
132
|
+
align-items: center;
|
|
133
|
+
border-radius: 4px;
|
|
134
|
+
padding: 2px 8px;
|
|
135
|
+
font-size: var(--dx-g-text-xs);
|
|
136
|
+
background-color: var(--dx-g-blue-vibrant-95);
|
|
137
|
+
color: var(--dx-g-blue-vibrant-20);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.dx-result-title {
|
|
141
|
+
color: var(--dx-g-blue-vibrant-20);
|
|
142
|
+
display: block;
|
|
143
|
+
font-family: var(--dx-g-font-display);
|
|
144
|
+
font-size: var(--dx-g-text-lg);
|
|
145
|
+
margin: 0 0 var(--dx-g-spacing-sm);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.dx-result-date {
|
|
149
|
+
color: #555;
|
|
150
|
+
font-size: var(--dx-g-text-xs);
|
|
151
|
+
margin: 0 0 var(--dx-g-spacing-xs);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
.dx-result-excerpt {
|
|
155
|
+
color: var(--dx-g-gray-10);
|
|
156
|
+
font-size: 14px;
|
|
157
|
+
line-height: 20px;
|
|
158
|
+
margin: 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* Pagination ----------------------------------------------------------- */
|
|
162
|
+
.dx-pagination-container {
|
|
163
|
+
margin: 0 auto;
|
|
164
|
+
width: fit-content;
|
|
165
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="dx-search-hybrid">
|
|
3
|
+
<!-- Header: result count + query (?q-driven; no input box per D15.3) -->
|
|
4
|
+
<div if:true={hasQuery} class="dx-search-header">
|
|
5
|
+
<div class="dx-search-header-container">
|
|
6
|
+
<p class="dx-search-header-title">
|
|
7
|
+
<span class="dx-search-header-title-results">{total}</span>
|
|
8
|
+
results for
|
|
9
|
+
</p>
|
|
10
|
+
<p class="dx-search-header-query">“{query}”</p>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<div class="dx-search-body">
|
|
15
|
+
<!-- Toolbar: content-type facets + sort + locale selector -->
|
|
16
|
+
<div class="dx-search-toolbar">
|
|
17
|
+
<div
|
|
18
|
+
if:true={hasFacets}
|
|
19
|
+
class="dx-facet-row"
|
|
20
|
+
role="group"
|
|
21
|
+
aria-label="Filter by content type"
|
|
22
|
+
>
|
|
23
|
+
<template for:each={facetChips} for:item="chip">
|
|
24
|
+
<button
|
|
25
|
+
key={chip.value}
|
|
26
|
+
type="button"
|
|
27
|
+
class={chip.cssClass}
|
|
28
|
+
data-value={chip.value}
|
|
29
|
+
aria-pressed={chip.active}
|
|
30
|
+
onclick={handleFacetClick}
|
|
31
|
+
>
|
|
32
|
+
{chip.label}
|
|
33
|
+
<span class="dx-facet-chip-count">
|
|
34
|
+
{chip.count}
|
|
35
|
+
</span>
|
|
36
|
+
</button>
|
|
37
|
+
</template>
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<div class="dx-search-controls">
|
|
41
|
+
<div if:true={showLocaleSelector} class="dx-search-locale">
|
|
42
|
+
<dx-select
|
|
43
|
+
label="Language"
|
|
44
|
+
options={localeSelectOptions}
|
|
45
|
+
value={localeValue}
|
|
46
|
+
onchange={handleLocaleChange}
|
|
47
|
+
></dx-select>
|
|
48
|
+
</div>
|
|
49
|
+
<div class="dx-search-sort">
|
|
50
|
+
<dx-select
|
|
51
|
+
label="Sort by"
|
|
52
|
+
options={sortOptions}
|
|
53
|
+
value={sortValue}
|
|
54
|
+
onchange={handleSortChange}
|
|
55
|
+
></dx-select>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<!-- Loading -->
|
|
61
|
+
<template if:true={isLoading}>
|
|
62
|
+
<div class="dx-search-status" aria-live="polite">
|
|
63
|
+
<dx-spinner size="medium" variant="base"></dx-spinner>
|
|
64
|
+
</div>
|
|
65
|
+
</template>
|
|
66
|
+
|
|
67
|
+
<!-- Idle prompt (no query yet) -->
|
|
68
|
+
<template if:true={isIdle}>
|
|
69
|
+
<div class="dx-search-status" aria-live="polite">
|
|
70
|
+
<dx-empty-state
|
|
71
|
+
header="Search the developer site"
|
|
72
|
+
subtitle="Enter a search term to get started."
|
|
73
|
+
variant="alt"
|
|
74
|
+
></dx-empty-state>
|
|
75
|
+
</div>
|
|
76
|
+
</template>
|
|
77
|
+
|
|
78
|
+
<!-- Empty (query ran, no results) -->
|
|
79
|
+
<template if:true={isEmpty}>
|
|
80
|
+
<div class="dx-search-status" aria-live="polite">
|
|
81
|
+
<dx-empty-state
|
|
82
|
+
header="No results found"
|
|
83
|
+
subtitle="Try different keywords or check your spelling."
|
|
84
|
+
variant="alt"
|
|
85
|
+
></dx-empty-state>
|
|
86
|
+
</div>
|
|
87
|
+
</template>
|
|
88
|
+
|
|
89
|
+
<!-- Error -->
|
|
90
|
+
<template if:true={isError}>
|
|
91
|
+
<div class="dx-search-status dx-search-error" role="alert">
|
|
92
|
+
<p>{errorMessage}</p>
|
|
93
|
+
</div>
|
|
94
|
+
</template>
|
|
95
|
+
|
|
96
|
+
<!-- Results -->
|
|
97
|
+
<template if:true={hasResults}>
|
|
98
|
+
<ul class="dx-result-list">
|
|
99
|
+
<template for:each={displayResults} for:item="result">
|
|
100
|
+
<li key={result.key} class="dx-result">
|
|
101
|
+
<div
|
|
102
|
+
if:true={result.hasBadge}
|
|
103
|
+
class="dx-result-info"
|
|
104
|
+
>
|
|
105
|
+
<span class="dx-badge">
|
|
106
|
+
{result.contentTypeLabel}
|
|
107
|
+
</span>
|
|
108
|
+
</div>
|
|
109
|
+
<a href={result.url} class="dx-result-title">
|
|
110
|
+
{result.title}
|
|
111
|
+
</a>
|
|
112
|
+
<p if:true={result.hasDate} class="dx-result-date">
|
|
113
|
+
{result.displayDate}
|
|
114
|
+
</p>
|
|
115
|
+
<p
|
|
116
|
+
if:true={result.hasDescription}
|
|
117
|
+
class="dx-result-excerpt"
|
|
118
|
+
>
|
|
119
|
+
{result.description}
|
|
120
|
+
</p>
|
|
121
|
+
</li>
|
|
122
|
+
</template>
|
|
123
|
+
</ul>
|
|
124
|
+
|
|
125
|
+
<div if:true={showPagination} class="dx-pagination-container">
|
|
126
|
+
<dx-pagination
|
|
127
|
+
current-page={currentPage}
|
|
128
|
+
total-pages={totalPages}
|
|
129
|
+
pages-to-show="5"
|
|
130
|
+
onpagechange={handlePageChange}
|
|
131
|
+
></dx-pagination>
|
|
132
|
+
</div>
|
|
133
|
+
</template>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</template>
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { LightningElement, api, track } from "lwc";
|
|
2
|
+
import {
|
|
3
|
+
SearchController,
|
|
4
|
+
type SearchViewModel
|
|
5
|
+
} from "docUtils/searchController";
|
|
6
|
+
import type { LocaleOption } from "docUtils/searchLocale";
|
|
7
|
+
import {
|
|
8
|
+
decodeHtmlEntities,
|
|
9
|
+
isSortOption,
|
|
10
|
+
SORT_OPTIONS,
|
|
11
|
+
type SearchResult,
|
|
12
|
+
type SortOption
|
|
13
|
+
} from "docUtils/searchClient";
|
|
14
|
+
import { searchLocalePrefs } from "dxUtils/searchLocalePrefs";
|
|
15
|
+
import { track as trackGTM } from "dxUtils/analytics";
|
|
16
|
+
import { CONTENT_TYPE_LABELS } from "dxConstants/contentTypes";
|
|
17
|
+
import { parseSupportedLocales } from "docUtils/searchLocale";
|
|
18
|
+
|
|
19
|
+
// The developer site pins itself as the "developer" site in the unified
|
|
20
|
+
// search corpus (matches the dx search call; the SSG/WordPress developer docs
|
|
21
|
+
// are ingested under this site value).
|
|
22
|
+
const DX_SITE = "developer";
|
|
23
|
+
|
|
24
|
+
const SORT_LABELS: Record<SortOption, string> = {
|
|
25
|
+
relevance: "Relevance",
|
|
26
|
+
date: "Most recent"
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
interface DisplayResult {
|
|
30
|
+
key: string;
|
|
31
|
+
url: string;
|
|
32
|
+
title: string;
|
|
33
|
+
description: string | null;
|
|
34
|
+
hasDescription: boolean;
|
|
35
|
+
contentTypeLabel: string | null;
|
|
36
|
+
hasBadge: boolean;
|
|
37
|
+
displayDate: string;
|
|
38
|
+
hasDate: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface FacetChip {
|
|
42
|
+
value: string;
|
|
43
|
+
label: string;
|
|
44
|
+
count: number;
|
|
45
|
+
active: boolean;
|
|
46
|
+
cssClass: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface SortSelectOption {
|
|
50
|
+
value: string;
|
|
51
|
+
label: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default class SearchHybrid extends LightningElement {
|
|
55
|
+
/**
|
|
56
|
+
* JSON string of the supported locale catalog (LocaleOption[]). Injected by
|
|
57
|
+
* the host page. Drives both the controller catalog and the locale selector.
|
|
58
|
+
*/
|
|
59
|
+
@api locales?: string;
|
|
60
|
+
|
|
61
|
+
/** The page's server locale, used as the default search language. */
|
|
62
|
+
@api defaultLanguage: string = "en-us";
|
|
63
|
+
|
|
64
|
+
/** Results per page. */
|
|
65
|
+
@api limit?: number;
|
|
66
|
+
|
|
67
|
+
/** Override the /api/search base path (mostly for tests / storybook). */
|
|
68
|
+
@api basePath?: string;
|
|
69
|
+
|
|
70
|
+
@track private vm: SearchViewModel | null = null;
|
|
71
|
+
|
|
72
|
+
private controller: SearchController | null = null;
|
|
73
|
+
private unsubscribe: (() => void) | null = null;
|
|
74
|
+
|
|
75
|
+
connectedCallback(): void {
|
|
76
|
+
const catalog = parseSupportedLocales(this.locales ?? "");
|
|
77
|
+
const supportedIds = catalog.map((l: LocaleOption) => l.id);
|
|
78
|
+
|
|
79
|
+
this.controller = new SearchController({
|
|
80
|
+
catalog,
|
|
81
|
+
defaultLanguage: this.defaultLanguage,
|
|
82
|
+
supportedIds,
|
|
83
|
+
site: DX_SITE,
|
|
84
|
+
limit: this.limit,
|
|
85
|
+
basePath: this.basePath,
|
|
86
|
+
deps: searchLocalePrefs,
|
|
87
|
+
instrument: (event, payload) => {
|
|
88
|
+
// dxUtils/analytics.track requires an EventTarget; route through
|
|
89
|
+
// the host element so the instrumentation listener can capture it.
|
|
90
|
+
trackGTM(this as unknown as EventTarget, event, payload);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
this.unsubscribe = this.controller.subscribe((vm) => {
|
|
95
|
+
this.vm = vm;
|
|
96
|
+
});
|
|
97
|
+
this.vm = this.controller.viewModel;
|
|
98
|
+
this.controller.init();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
disconnectedCallback(): void {
|
|
102
|
+
this.unsubscribe?.();
|
|
103
|
+
this.unsubscribe = null;
|
|
104
|
+
this.controller?.dispose();
|
|
105
|
+
this.controller = null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- status flags -----------------------------------------------------
|
|
109
|
+
|
|
110
|
+
get isIdle(): boolean {
|
|
111
|
+
return this.vm?.status === "idle";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
get isLoading(): boolean {
|
|
115
|
+
return this.vm?.status === "loading";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
get isError(): boolean {
|
|
119
|
+
return this.vm?.status === "error";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
get isEmpty(): boolean {
|
|
123
|
+
return this.vm?.status === "empty";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
get hasResults(): boolean {
|
|
127
|
+
return this.vm?.status === "results";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
get errorMessage(): string {
|
|
131
|
+
return this.vm?.errorMessage || "Something went wrong. Try again.";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get query(): string {
|
|
135
|
+
return this.vm?.query ?? "";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get hasQuery(): boolean {
|
|
139
|
+
return this.query !== "";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
get total(): number {
|
|
143
|
+
return this.vm?.total ?? 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ---- results ----------------------------------------------------------
|
|
147
|
+
|
|
148
|
+
get displayResults(): DisplayResult[] {
|
|
149
|
+
const results = this.vm?.results ?? [];
|
|
150
|
+
return results.map((r: SearchResult, i: number) => {
|
|
151
|
+
const description =
|
|
152
|
+
r.description != null && r.description !== ""
|
|
153
|
+
? decodeHtmlEntities(r.description)
|
|
154
|
+
: null;
|
|
155
|
+
const label = this.contentTypeLabel(r.contentType);
|
|
156
|
+
const displayDate = this.formatDate(r.lastmod);
|
|
157
|
+
return {
|
|
158
|
+
key: `${r.url}-${i}`,
|
|
159
|
+
url: r.url,
|
|
160
|
+
title: decodeHtmlEntities(r.title),
|
|
161
|
+
description,
|
|
162
|
+
hasDescription: description !== null,
|
|
163
|
+
contentTypeLabel: label,
|
|
164
|
+
hasBadge: label !== null,
|
|
165
|
+
displayDate,
|
|
166
|
+
// Tie hasDate to the FORMATTED output so a non-null but
|
|
167
|
+
// unparseable lastmod renders no date element (no raw ISO).
|
|
168
|
+
hasDate: displayDate.length > 0
|
|
169
|
+
};
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Null/NaN-safe date formatter (mirrors arch/searchHybrid). lastmod is null
|
|
175
|
+
* for the entire SSG corpus (the common case → ""); the WordPress portion
|
|
176
|
+
* of the developer corpus emits an ISO timestamp, which we render as a
|
|
177
|
+
* human date rather than the raw machine string.
|
|
178
|
+
*/
|
|
179
|
+
private formatDate(lastmod: string | null): string {
|
|
180
|
+
if (typeof lastmod !== "string" || lastmod.length === 0) {
|
|
181
|
+
return "";
|
|
182
|
+
}
|
|
183
|
+
const date = new Date(lastmod);
|
|
184
|
+
if (Number.isNaN(date.getTime())) {
|
|
185
|
+
return "";
|
|
186
|
+
}
|
|
187
|
+
return date.toLocaleDateString(undefined, {
|
|
188
|
+
year: "numeric",
|
|
189
|
+
month: "short",
|
|
190
|
+
day: "numeric"
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Null-safe content-type label lookup. contentType is null for the entire
|
|
196
|
+
* SSG corpus and is the common case; unknown values fall back to the raw
|
|
197
|
+
* value so we never render an empty badge or crash.
|
|
198
|
+
*/
|
|
199
|
+
private contentTypeLabel(contentType: string | null): string | null {
|
|
200
|
+
if (contentType == null || contentType === "") {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
const labels = CONTENT_TYPE_LABELS as Record<string, string>;
|
|
204
|
+
return labels[contentType] ?? contentType;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ---- content-type facet chips (disjunctive) ---------------------------
|
|
208
|
+
|
|
209
|
+
get facetChips(): FacetChip[] {
|
|
210
|
+
const facets = this.vm?.contentTypeFacets ?? [];
|
|
211
|
+
const selected = this.vm?.selectedType ?? null;
|
|
212
|
+
return facets
|
|
213
|
+
.filter((b) => b.value != null && b.value !== "")
|
|
214
|
+
.map((b) => {
|
|
215
|
+
const active = selected === b.value;
|
|
216
|
+
return {
|
|
217
|
+
value: b.value,
|
|
218
|
+
label: this.contentTypeLabel(b.value) ?? b.value,
|
|
219
|
+
count: b.count,
|
|
220
|
+
active,
|
|
221
|
+
cssClass: active
|
|
222
|
+
? "dx-facet-chip dx-facet-chip_active"
|
|
223
|
+
: "dx-facet-chip"
|
|
224
|
+
};
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
get hasFacets(): boolean {
|
|
229
|
+
return this.facetChips.length > 0;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ---- sort + locale select options -------------------------------------
|
|
233
|
+
|
|
234
|
+
get sortOptions(): SortSelectOption[] {
|
|
235
|
+
return SORT_OPTIONS.map((value) => ({
|
|
236
|
+
value,
|
|
237
|
+
label: SORT_LABELS[value]
|
|
238
|
+
}));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
get sortValue(): string {
|
|
242
|
+
return this.vm?.sort ?? "relevance";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
get showLocaleSelector(): boolean {
|
|
246
|
+
return this.controller?.showLocaleSelector ?? false;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
get localeSelectOptions(): SortSelectOption[] {
|
|
250
|
+
const options = this.controller?.localeOptions ?? [];
|
|
251
|
+
return options.map((l: LocaleOption) => ({
|
|
252
|
+
value: l.id,
|
|
253
|
+
label: l.displayText ?? l.label ?? l.id
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
get localeValue(): string {
|
|
258
|
+
return this.vm?.locale ?? this.defaultLanguage;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// ---- pagination -------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
get currentPage(): string {
|
|
264
|
+
return String(this.vm?.page ?? 1);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
get totalPages(): string {
|
|
268
|
+
return String(this.vm?.totalPages ?? 1);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
get showPagination(): boolean {
|
|
272
|
+
return (this.vm?.totalPages ?? 1) > 1;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ---- event handlers ---------------------------------------------------
|
|
276
|
+
|
|
277
|
+
handleSortChange(event: CustomEvent<{ value: string }>): void {
|
|
278
|
+
const value = event.detail?.value;
|
|
279
|
+
if (isSortOption(value)) {
|
|
280
|
+
this.controller?.setSort(value);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
handleLocaleChange(event: CustomEvent<{ value: string }>): void {
|
|
285
|
+
const value = event.detail?.value;
|
|
286
|
+
if (value) {
|
|
287
|
+
this.controller?.setLanguage(value);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
handleFacetClick(event: Event): void {
|
|
292
|
+
const value = (event.currentTarget as HTMLElement)?.dataset?.value;
|
|
293
|
+
if (value) {
|
|
294
|
+
// Disjunctive toggle: the controller clears it if it is already
|
|
295
|
+
// active.
|
|
296
|
+
this.controller?.setType(value);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
handlePageChange(event: CustomEvent<number>): void {
|
|
301
|
+
const page = event.detail;
|
|
302
|
+
if (typeof page === "number" && page >= 1) {
|
|
303
|
+
this.controller?.setPage(page);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Developer-site implementation of docUtils `LocaleDeps` for the unified
|
|
3
|
+
* hybrid search UI (P4.1). Supplies per-device storage of the user's chosen
|
|
4
|
+
* search language (consent-gated behind OneTrust functional-cookie consent) and
|
|
5
|
+
* a browser-language -> supported-locale resolver.
|
|
6
|
+
*
|
|
7
|
+
* dx has no equivalent of arch/localePreference or arch/browserLocale and no
|
|
8
|
+
* shared consent util, so this mirrors the consent read used elsewhere in the
|
|
9
|
+
* org (the OptanonConsent cookie's `groups` field, functional category C0003).
|
|
10
|
+
* OneTrust's standard functional group id is C0003; "C0003:1" means granted,
|
|
11
|
+
* "C0003:0" means denied. We never persist a preference without an explicit
|
|
12
|
+
* grant.
|
|
13
|
+
*
|
|
14
|
+
* Exposes `searchLocalePrefs`, an object implementing the docUtils LocaleDeps
|
|
15
|
+
* shape ({ getStored, setStored, resolveBrowser }), plus a `buildSearchLocalePrefs`
|
|
16
|
+
* factory for tests / dependency injection.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { LocaleDeps } from "docUtils/searchLocale";
|
|
20
|
+
|
|
21
|
+
export const STORAGE_KEY = "dx-search-language";
|
|
22
|
+
export const FUNCTIONAL_GROUP_ID = "C0003";
|
|
23
|
+
export const DEFAULT_LOCALE = "en-us";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Region/script-aware overrides for cases a plain language-only match can't
|
|
27
|
+
* resolve. Keys are normalized lowercase BCP-47 tags (or language-only codes);
|
|
28
|
+
* values are site locale ids. Order of checks: exact id -> these rules ->
|
|
29
|
+
* language-only first match -> default.
|
|
30
|
+
*
|
|
31
|
+
* Mirrors the product decisions baked into arch/browserLocale:
|
|
32
|
+
* - bare "es" -> es-mx (es-mx is the generic Spanish)
|
|
33
|
+
* - any pt-* -> pt-br (only Brazilian Portuguese is published)
|
|
34
|
+
* - no/nb/nn -> nb-no (only Norwegian id)
|
|
35
|
+
* - Chinese is matched on script/region, never collapsed to language-only.
|
|
36
|
+
*/
|
|
37
|
+
const TAG_OVERRIDES: Record<string, string> = {
|
|
38
|
+
// Chinese — Traditional
|
|
39
|
+
"zh-tw": "zh-tw",
|
|
40
|
+
"zh-hant": "zh-tw",
|
|
41
|
+
"zh-hk": "zh-tw",
|
|
42
|
+
"zh-mo": "zh-tw",
|
|
43
|
+
// Chinese — Simplified (and bare "zh")
|
|
44
|
+
zh: "zh-cn",
|
|
45
|
+
"zh-cn": "zh-cn",
|
|
46
|
+
"zh-hans": "zh-cn",
|
|
47
|
+
"zh-sg": "zh-cn",
|
|
48
|
+
// Spanish
|
|
49
|
+
es: "es-mx",
|
|
50
|
+
"es-es": "es-es",
|
|
51
|
+
// Portuguese (only pt-br exists)
|
|
52
|
+
pt: "pt-br",
|
|
53
|
+
"pt-pt": "pt-br",
|
|
54
|
+
// Norwegian (only nb-no exists)
|
|
55
|
+
no: "nb-no",
|
|
56
|
+
nb: "nb-no",
|
|
57
|
+
nn: "nb-no"
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read the OptanonConsent cookie and return whether the functional group is
|
|
62
|
+
* granted. Defensive: if OneTrust hasn't loaded or the cookie is absent
|
|
63
|
+
* (tests, pre-consent, non-prod), returns false so we never persist without an
|
|
64
|
+
* explicit grant.
|
|
65
|
+
*/
|
|
66
|
+
export function hasFunctionalConsent(): boolean {
|
|
67
|
+
try {
|
|
68
|
+
const cookie = document.cookie
|
|
69
|
+
.split("; ")
|
|
70
|
+
.find((row) => row.startsWith("OptanonConsent="));
|
|
71
|
+
if (!cookie) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
// The cookie value is URL-encoded querystring-style, e.g.
|
|
75
|
+
// "...&groups=C0001:1,C0003:1,C0002:0&...".
|
|
76
|
+
const value = decodeURIComponent(cookie.split("=").slice(1).join("="));
|
|
77
|
+
const groups = new URLSearchParams(value).get("groups");
|
|
78
|
+
if (!groups) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
return groups
|
|
82
|
+
.split(",")
|
|
83
|
+
.some((entry) => entry.trim() === `${FUNCTIONAL_GROUP_ID}:1`);
|
|
84
|
+
} catch (e) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Map a single language-only code (e.g. "ja", "de") to its site locale id by
|
|
91
|
+
* finding the first supported id whose language subtag matches.
|
|
92
|
+
*/
|
|
93
|
+
function languageOnlyMatch(
|
|
94
|
+
language: string,
|
|
95
|
+
supportedIds: string[]
|
|
96
|
+
): string | undefined {
|
|
97
|
+
return supportedIds.find((id) => id.split("-")[0] === language);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get the stored language preference, or null if none / unavailable. Reading an
|
|
102
|
+
* already-stored preference is low-risk, so it is NOT consent-gated.
|
|
103
|
+
*/
|
|
104
|
+
export function getStoredLanguage(): string | null {
|
|
105
|
+
try {
|
|
106
|
+
return window.localStorage.getItem(STORAGE_KEY);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Persist the chosen language, but only if the user has granted functional
|
|
114
|
+
* consent. Returns true if the value was written, false if it was skipped (no
|
|
115
|
+
* consent) or failed. When skipped, the choice still applies for the current
|
|
116
|
+
* page/session via in-memory state and the ?lang= URL param.
|
|
117
|
+
*/
|
|
118
|
+
export function setStoredLanguage(language: string): boolean {
|
|
119
|
+
if (!language || !hasFunctionalConsent()) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
window.localStorage.setItem(STORAGE_KEY, language);
|
|
124
|
+
return true;
|
|
125
|
+
} catch (e) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Resolve the best site locale id for the given browser languages. The first
|
|
132
|
+
* browser language that maps to a supported id wins; earlier entries take
|
|
133
|
+
* precedence over later ones (matching navigator.languages ordering).
|
|
134
|
+
* Returns "en-us" if nothing matches.
|
|
135
|
+
*/
|
|
136
|
+
export function resolveBrowserLocale(
|
|
137
|
+
navigatorLanguages: readonly string[] | undefined,
|
|
138
|
+
supportedIds: string[]
|
|
139
|
+
): string {
|
|
140
|
+
if (!navigatorLanguages || navigatorLanguages.length === 0) {
|
|
141
|
+
return DEFAULT_LOCALE;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const supported = new Set(supportedIds);
|
|
145
|
+
|
|
146
|
+
for (const raw of navigatorLanguages) {
|
|
147
|
+
if (!raw) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const tag = raw.toLowerCase();
|
|
151
|
+
|
|
152
|
+
// 1. Exact id match (e.g. "ja-jp" -> ja-jp).
|
|
153
|
+
if (supported.has(tag)) {
|
|
154
|
+
return tag;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 2. Region/script override (e.g. "zh-tw" -> zh-tw, "es" -> es-mx).
|
|
158
|
+
const overridden = TAG_OVERRIDES[tag];
|
|
159
|
+
if (overridden && supported.has(overridden)) {
|
|
160
|
+
return overridden;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 3. Language-only fall-through (e.g. "de-ch" -> de -> de-de).
|
|
164
|
+
const language = tag.split("-")[0];
|
|
165
|
+
const overriddenLang = TAG_OVERRIDES[language];
|
|
166
|
+
if (overriddenLang && supported.has(overriddenLang)) {
|
|
167
|
+
return overriddenLang;
|
|
168
|
+
}
|
|
169
|
+
const matched = languageOnlyMatch(language, supportedIds);
|
|
170
|
+
if (matched) {
|
|
171
|
+
return matched;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return DEFAULT_LOCALE;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Build a docUtils LocaleDeps bundle for the developer site. Exposed as a
|
|
180
|
+
* factory so tests / callers can inject overrides; the default-exported
|
|
181
|
+
* `searchLocalePrefs` is the production singleton.
|
|
182
|
+
*/
|
|
183
|
+
export function buildSearchLocalePrefs(
|
|
184
|
+
overrides: Partial<LocaleDeps> = {}
|
|
185
|
+
): LocaleDeps {
|
|
186
|
+
return {
|
|
187
|
+
getStored: overrides.getStored ?? getStoredLanguage,
|
|
188
|
+
setStored: overrides.setStored ?? setStoredLanguage,
|
|
189
|
+
resolveBrowser: overrides.resolveBrowser ?? resolveBrowserLocale
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export const searchLocalePrefs: LocaleDeps = buildSearchLocalePrefs();
|