@salesforcedevs/dx-components 1.33.0 → 1.34.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 +2 -0
- 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.34.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": "5d072e2fa81d7eccadcbfb4134ee007ce2b0c7c4"
|
|
48
48
|
}
|
|
@@ -77,6 +77,7 @@ export default class AgentMiawUi extends LightningElement {
|
|
|
77
77
|
@api prompts?: string;
|
|
78
78
|
/** When set, forwarded to the embed as `welcome-text` (greeting / headline copy). */
|
|
79
79
|
@api welcomeText?: string;
|
|
80
|
+
@api agentforceEnv?: string;
|
|
80
81
|
|
|
81
82
|
/** After first mount attempt is scheduled, we do not run it again for this instance. */
|
|
82
83
|
private hasRendered = false;
|
|
@@ -124,6 +125,7 @@ export default class AgentMiawUi extends LightningElement {
|
|
|
124
125
|
if (this.welcomeText) {
|
|
125
126
|
el.setAttribute("welcome-text", this.welcomeText);
|
|
126
127
|
}
|
|
128
|
+
el.setAttribute("env", this.agentforceEnv || "dev");
|
|
127
129
|
container.appendChild(el);
|
|
128
130
|
this.bridgeSidebarState(el);
|
|
129
131
|
} catch (e) {
|
|
@@ -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();
|