@salesforcedevs/arch-components 1.28.1-banner-2 → 1.28.1

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
@@ -15,7 +15,6 @@
15
15
  "arch/cardGridD",
16
16
  "arch/cardNew",
17
17
  "arch/children",
18
- "arch/content",
19
18
  "arch/contentIcon",
20
19
  "arch/context",
21
20
  "arch/contextAdapter",
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@salesforcedevs/arch-components",
3
- "version": "1.28.1-banner-2",
3
+ "version": "1.28.1",
4
4
  "description": "Architect Lightning web components",
5
5
  "license": "MIT",
6
6
  "engines": {
7
- "node": "20.x"
7
+ "node": "22.x"
8
8
  },
9
9
  "publishConfig": {
10
10
  "access": "public"
@@ -44,5 +44,5 @@
44
44
  "eventsourcemock": "2.0.0",
45
45
  "luxon": "3.4.4"
46
46
  },
47
- "gitHead": "4f8c415f6a58d0747a9e746a4b1717d85de32122"
47
+ "gitHead": "e0db9e8eabf2d398d8b599163aeba4261866cb29"
48
48
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @file Resolves a browser's preferred language (BCP-47 tags from
3
+ * navigator.languages) to one of the site's supported locale ids
4
+ * (e.g. "en-us", "ja-jp", "zh-tw"). Used to pick a sensible default language
5
+ * for search when the page itself carries no locale.
6
+ */
7
+
8
+ const DEFAULT_LOCALE = "en-us";
9
+
10
+ /**
11
+ * Region/script-aware overrides for cases a plain language-only match can't
12
+ * resolve. Keys are normalized lowercase BCP-47 tags (or language-only codes);
13
+ * values are site locale ids. Order of checks: exact id -> these rules ->
14
+ * language-only first match -> default.
15
+ *
16
+ * Product decisions baked in (see search locale design):
17
+ * - bare "es" -> es-mx (languages.yml lists es-mx as the generic Spanish)
18
+ * - any pt-* -> pt-br (only Brazilian Portuguese is published)
19
+ * - no/nb/nn -> nb-no (only Norwegian id)
20
+ * - Chinese is matched on script/region, never collapsed to language-only.
21
+ */
22
+ const TAG_OVERRIDES: Record<string, string> = {
23
+ // Chinese — Traditional
24
+ "zh-tw": "zh-tw",
25
+ "zh-hant": "zh-tw",
26
+ "zh-hk": "zh-tw",
27
+ "zh-mo": "zh-tw",
28
+ // Chinese — Simplified (and bare "zh")
29
+ zh: "zh-cn",
30
+ "zh-cn": "zh-cn",
31
+ "zh-hans": "zh-cn",
32
+ "zh-sg": "zh-cn",
33
+ // Spanish
34
+ es: "es-mx",
35
+ "es-es": "es-es",
36
+ // Portuguese (only pt-br exists)
37
+ pt: "pt-br",
38
+ "pt-pt": "pt-br",
39
+ // Norwegian (only nb-no exists)
40
+ no: "nb-no",
41
+ nb: "nb-no",
42
+ nn: "nb-no"
43
+ };
44
+
45
+ /**
46
+ * Map a single language-only code (e.g. "ja", "de") to its site locale id by
47
+ * finding the first supported id whose language subtag matches.
48
+ */
49
+ function languageOnlyMatch(
50
+ language: string,
51
+ supportedIds: string[]
52
+ ): string | undefined {
53
+ return supportedIds.find((id) => id.split("-")[0] === language);
54
+ }
55
+
56
+ /**
57
+ * Resolve the best site locale id for the given browser languages.
58
+ * The first browser language that maps to a supported id wins; earlier entries
59
+ * take precedence over later ones (matching navigator.languages ordering).
60
+ * @param navigatorLanguages Ordered list of BCP-47 tags (navigator.languages).
61
+ * @param supportedIds Site locale ids actually offered (e.g. from the dropdown).
62
+ * @returns A supported locale id, or "en-us" if nothing matches.
63
+ */
64
+ export function resolveBrowserLocale(
65
+ navigatorLanguages: readonly string[] | undefined,
66
+ supportedIds: string[]
67
+ ): string {
68
+ if (!navigatorLanguages || navigatorLanguages.length === 0) {
69
+ return DEFAULT_LOCALE;
70
+ }
71
+
72
+ const supported = new Set(supportedIds);
73
+
74
+ for (const raw of navigatorLanguages) {
75
+ if (!raw) {
76
+ continue;
77
+ }
78
+ const tag = raw.toLowerCase();
79
+
80
+ // 1. Exact id match (e.g. "ja-jp" -> ja-jp).
81
+ if (supported.has(tag)) {
82
+ return tag;
83
+ }
84
+
85
+ // 2. Region/script override (e.g. "zh-tw" -> zh-tw, "es" -> es-mx).
86
+ const overridden = TAG_OVERRIDES[tag];
87
+ if (overridden && supported.has(overridden)) {
88
+ return overridden;
89
+ }
90
+
91
+ // 3. Language-only fall-through (e.g. "de-ch" -> de -> de-de).
92
+ const language = tag.split("-")[0];
93
+ const overriddenLang = TAG_OVERRIDES[language];
94
+ if (overriddenLang && supported.has(overriddenLang)) {
95
+ return overriddenLang;
96
+ }
97
+ const matched = languageOnlyMatch(language, supportedIds);
98
+ if (matched) {
99
+ return matched;
100
+ }
101
+ }
102
+
103
+ return DEFAULT_LOCALE;
104
+ }
@@ -29,7 +29,7 @@ export default class extends LightningElement {
29
29
  private context: ContextAdapterValue = null!;
30
30
 
31
31
  private get className() {
32
- return `size--${this.size}`;
32
+ return `size-${this.size}`;
33
33
  }
34
34
 
35
35
  private get href() {
@@ -0,0 +1,72 @@
1
+ /**
2
+ * @file Per-device storage for the user's chosen search language, gated behind
3
+ * OneTrust functional-cookie consent.
4
+ *
5
+ * There is no shared consent util in the codebase; this mirrors the consent
6
+ * read used on the admin site (page-events-calendar.php's one_trust_notice),
7
+ * which parses the OptanonConsent cookie's `groups` field and inspects the
8
+ * functional category. OneTrust's standard functional group id is C0003;
9
+ * "C0003:1" means granted, "C0003:0" means denied.
10
+ */
11
+
12
+ const STORAGE_KEY = "arch-search-language";
13
+ const FUNCTIONAL_GROUP_ID = "C0003";
14
+
15
+ /**
16
+ * Read the OptanonConsent cookie and return whether the functional group is
17
+ * granted. Defensive: if OneTrust hasn't loaded or the cookie is absent
18
+ * (tests, pre-consent, non-prod), returns false so we never persist without
19
+ * an explicit grant.
20
+ */
21
+ function hasFunctionalConsent(): boolean {
22
+ try {
23
+ const cookie = document.cookie
24
+ .split("; ")
25
+ .find((row) => row.startsWith("OptanonConsent="));
26
+ if (!cookie) {
27
+ return false;
28
+ }
29
+ // The cookie value is URL-encoded querystring-style, e.g.
30
+ // "...&groups=C0001:1,C0003:1,C0002:0&...".
31
+ const value = decodeURIComponent(cookie.split("=").slice(1).join("="));
32
+ const groups = new URLSearchParams(value).get("groups");
33
+ if (!groups) {
34
+ return false;
35
+ }
36
+ return groups
37
+ .split(",")
38
+ .some((entry) => entry.trim() === `${FUNCTIONAL_GROUP_ID}:1`);
39
+ } catch (e) {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Get the stored language preference, or null if none / unavailable.
46
+ * Reading an already-stored preference is low-risk, so it is not consent-gated.
47
+ */
48
+ export function getStoredLanguage(): string | null {
49
+ try {
50
+ return window.localStorage.getItem(STORAGE_KEY);
51
+ } catch (e) {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Persist the chosen language, but only if the user has granted functional
58
+ * consent. Returns true if the value was written, false if it was skipped
59
+ * (no consent) or failed. When skipped, the choice still applies for the
60
+ * current page/session via in-memory state and the ?lang= URL param.
61
+ */
62
+ export function setStoredLanguage(language: string): boolean {
63
+ if (!language || !hasFunctionalConsent()) {
64
+ return false;
65
+ }
66
+ try {
67
+ window.localStorage.setItem(STORAGE_KEY, language);
68
+ return true;
69
+ } catch (e) {
70
+ return false;
71
+ }
72
+ }
@@ -13,6 +13,12 @@
13
13
  position: relative;
14
14
  }
15
15
 
16
+ .search-language-selector {
17
+ display: flex;
18
+ justify-content: flex-end;
19
+ margin-bottom: var(--arch-spacing-4);
20
+ }
21
+
16
22
  .search-input-label {
17
23
  position: absolute !important;
18
24
  height: 1px;
@@ -1,8 +1,23 @@
1
1
  <template>
2
2
  <arch-section-a background="white" title={titleSearchResults}>
3
- <template if:true={results}>
3
+ <template lwc:if={showLanguageSelector}>
4
+ <div class="search-language-selector">
5
+ <arch-select
6
+ assistive-text="Search language"
7
+ value={language}
8
+ onchange={handleLanguageChange}
9
+ >
10
+ <template for:each={localeOptions} for:item="locale">
11
+ <option key={locale.id} value={locale.id}>
12
+ {locale.displayText}
13
+ </option>
14
+ </template>
15
+ </arch-select>
16
+ </div>
17
+ </template>
18
+ <template lwc:if={results}>
4
19
  <template for:each={results} for:item="result">
5
- <div key={result.title}>
20
+ <div key={result.url}>
6
21
  <arch-summary
7
22
  description={result.description}
8
23
  compact
@@ -19,7 +34,7 @@
19
34
  </div>
20
35
  </template>
21
36
  </template>
22
- <template if:true={showSearchSuggestions}>
37
+ <template lwc:if={showSearchSuggestions}>
23
38
  <div style="display: block">
24
39
  <center>
25
40
  <img
@@ -1,5 +1,15 @@
1
- import { LightningElement, api, track } from "lwc";
1
+ import { LightningElement, api } from "lwc";
2
2
  import { track as trackEvent } from "arch/instrumentation";
3
+ import { resolveBrowserLocale } from "arch/browserLocale";
4
+ import { getStoredLanguage, setStoredLanguage } from "arch/localePreference";
5
+
6
+ const DEFAULT_LANGUAGE = "en-us";
7
+
8
+ interface LocaleOption {
9
+ id: string;
10
+ displayText?: string;
11
+ label?: string;
12
+ }
3
13
 
4
14
  function decodeHtmlEntities(value: unknown): string {
5
15
  if (typeof value !== "string" || value.length === 0) {
@@ -23,52 +33,189 @@ export default class SearchList extends LightningElement {
23
33
  @api algoliaAppId: string = "";
24
34
  @api algoliaApiKey: string = "";
25
35
 
26
- @track results: any;
36
+ // The catalog of selectable locales, injected from njk as a JSON string
37
+ // (the doc-framework ALL_LANGUAGES global, sourced from languages.yml).
38
+ @api supportedLocales: string = "";
39
+ // The page's server-side locale, used as a fallback default.
40
+ @api defaultLanguage: string = DEFAULT_LANGUAGE;
41
+
42
+ // The effective search language. Resolved at runtime; not an @api so the
43
+ // dropdown can change it and re-query. These are reassigned wholesale (not
44
+ // mutated in place), so plain reactive fields suffice — no @track needed.
45
+ language: string = DEFAULT_LANGUAGE;
46
+ localeOptions: LocaleOption[] = [];
47
+ results: any;
48
+
49
+ private keywords: string = "";
50
+ private abortController?: AbortController;
27
51
 
28
52
  connectedCallback() {
53
+ this.localeOptions = this.parseSupportedLocales();
54
+ this.language = this.resolveInitialLanguage();
55
+
29
56
  const params = new URLSearchParams(window.location.search);
30
- if (params && params.get("keywords")) {
31
- const keywords = params.get("keywords") || "";
32
- fetch(
33
- `https://${this.algoliaAppId}-dsn.algolia.net/1/indexes/${this.algoliaIndex}?query=${keywords}&hitsPerPage=20`,
34
- {
35
- headers: {
36
- "X-Algolia-Application-Id": this.algoliaAppId,
37
- "X-Algolia-API-Key": this.algoliaApiKey
38
- }
39
- }
40
- )
41
- .then((result) => {
42
- return result.json();
43
- })
44
- .then((json) => {
45
- trackEvent(this.template.host, "search_executed", {
46
- searchTerm: keywords,
47
- resultCount: json.hits?.length || 0
48
- });
49
- if (json.hits && json.hits.length) {
50
- this.titleSearchResults = `Search results for "${decodeURIComponent(
51
- keywords
52
- )}"`;
53
- this.results = json.hits.map((hit: any) => ({
54
- ...hit,
55
- description: decodeHtmlEntities(hit.description)
56
- }));
57
- this.showSearchSuggestions = false;
58
- } else {
59
- this.titleSearchResults = `No results for "${decodeURIComponent(
60
- keywords
61
- )}"`;
62
- this.showSearchSuggestions = true;
63
- }
64
- })
65
- .catch(() => {
66
- this.titleSearchResults = `An error occured`;
67
- this.showSearchSuggestions = false;
68
- });
57
+ if (params.get("keywords")) {
58
+ this.keywords = params.get("keywords") || "";
59
+ this.runSearch();
69
60
  } else {
70
61
  this.titleSearchResults = "No keywords provided";
71
62
  this.showSearchSuggestions = false;
72
63
  }
73
64
  }
65
+
66
+ /**
67
+ * Parse the injected locale catalog, restricting to entries with an id.
68
+ * Falls back to an empty list (which hides the dropdown) on bad input.
69
+ */
70
+ private parseSupportedLocales(): LocaleOption[] {
71
+ if (!this.supportedLocales) {
72
+ return [];
73
+ }
74
+ try {
75
+ const parsed = JSON.parse(this.supportedLocales);
76
+ if (!Array.isArray(parsed)) {
77
+ return [];
78
+ }
79
+ return parsed.filter(
80
+ (entry): entry is LocaleOption =>
81
+ !!entry && typeof entry.id === "string"
82
+ );
83
+ } catch (e) {
84
+ return [];
85
+ }
86
+ }
87
+
88
+ private get supportedIds(): string[] {
89
+ return this.localeOptions.map((option) => option.id);
90
+ }
91
+
92
+ /**
93
+ * Resolve the language to search in, most-explicit-wins:
94
+ * 1. ?lang= URL param (shared/bookmarked links)
95
+ * 2. stored preference (consented, from a prior visit)
96
+ * 3. browser preferred language mapped to a supported id
97
+ * 4. the page's server locale, else en-us
98
+ * Any candidate must be in the supported catalog (when one was provided).
99
+ */
100
+ private resolveInitialLanguage(): string {
101
+ const ids = this.supportedIds;
102
+ const isSupported = (value: string | null | undefined): boolean =>
103
+ !!value && (ids.length === 0 || ids.includes(value));
104
+
105
+ const fromUrl = new URLSearchParams(window.location.search).get("lang");
106
+ if (isSupported(fromUrl)) {
107
+ return fromUrl as string;
108
+ }
109
+
110
+ const stored = getStoredLanguage();
111
+ if (isSupported(stored)) {
112
+ return stored as string;
113
+ }
114
+
115
+ if (ids.length > 0) {
116
+ const detected = resolveBrowserLocale(
117
+ navigator.languages,
118
+ ids
119
+ );
120
+ if (isSupported(detected)) {
121
+ return detected;
122
+ }
123
+ }
124
+
125
+ return isSupported(this.defaultLanguage)
126
+ ? this.defaultLanguage
127
+ : DEFAULT_LANGUAGE;
128
+ }
129
+
130
+ /**
131
+ * Run the Algolia query for the current keywords + language. Aborts any
132
+ * in-flight request so a fast language switch can't render stale results.
133
+ */
134
+ private runSearch() {
135
+ this.abortController?.abort();
136
+ const controller = new AbortController();
137
+ this.abortController = controller;
138
+
139
+ const keywords = this.keywords;
140
+ // Filter to the chosen language so results are isolated by locale
141
+ // (records are tagged with `language` by the indexer; `language` is
142
+ // registered as a filterOnly facet).
143
+ const languageFilter = encodeURIComponent(`language:${this.language}`);
144
+ fetch(
145
+ `https://${this.algoliaAppId}-dsn.algolia.net/1/indexes/${
146
+ this.algoliaIndex
147
+ }?query=${encodeURIComponent(
148
+ keywords
149
+ )}&filters=${languageFilter}&hitsPerPage=20`,
150
+ {
151
+ headers: {
152
+ "X-Algolia-Application-Id": this.algoliaAppId,
153
+ "X-Algolia-API-Key": this.algoliaApiKey
154
+ },
155
+ signal: controller.signal
156
+ }
157
+ )
158
+ .then((result) => result.json())
159
+ .then((json) => {
160
+ trackEvent(this.template.host, "search_executed", {
161
+ searchTerm: keywords,
162
+ language: this.language,
163
+ resultCount: json.hits?.length || 0
164
+ });
165
+ if (json.hits && json.hits.length) {
166
+ // keywords is already decoded by URLSearchParams.get.
167
+ this.titleSearchResults = `Search results for "${keywords}"`;
168
+ this.results = json.hits.map((hit: any) => ({
169
+ ...hit,
170
+ description: decodeHtmlEntities(hit.description)
171
+ }));
172
+ this.showSearchSuggestions = false;
173
+ } else {
174
+ this.titleSearchResults = `No results for "${keywords}"`;
175
+ this.showSearchSuggestions = true;
176
+ }
177
+ })
178
+ .catch((error) => {
179
+ // Ignore aborts from a superseding language switch.
180
+ if (error?.name === "AbortError") {
181
+ return;
182
+ }
183
+ // A 400 here usually means the index has not been reindexed
184
+ // with the filterOnly(language) facet yet (see deploy order).
185
+ console.error("Algolia search request failed:", error);
186
+ this.titleSearchResults = `An error occurred`;
187
+ this.showSearchSuggestions = false;
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Dropdown change: switch language, persist (if consented), reflect in the
193
+ * URL for shareability, and re-run the same search.
194
+ */
195
+ handleLanguageChange(event: CustomEvent) {
196
+ const selected = event.detail;
197
+ if (!selected || selected === this.language) {
198
+ return;
199
+ }
200
+ this.language = selected;
201
+ setStoredLanguage(selected);
202
+
203
+ const params = new URLSearchParams(window.location.search);
204
+ params.set("lang", selected);
205
+ window.history.replaceState(
206
+ {},
207
+ "",
208
+ `${window.location.pathname}?${params.toString()}${
209
+ window.location.hash
210
+ }`
211
+ );
212
+
213
+ if (this.keywords) {
214
+ this.runSearch();
215
+ }
216
+ }
217
+
218
+ get showLanguageSelector(): boolean {
219
+ return this.localeOptions.length > 1;
220
+ }
74
221
  }
@@ -2,7 +2,7 @@
2
2
  <label class={labelClassName} for="select">{labelText}</label>
3
3
  <div class={containerClassName}>
4
4
  <arch-icon
5
- class="select-icon select-icon--left"
5
+ class="select-icon select-icon-left"
6
6
  if:true={iconSymbol}
7
7
  size="medium"
8
8
  sprite={iconSprite}
@@ -10,13 +10,14 @@
10
10
  ></arch-icon>
11
11
  <select
12
12
  name="arch-select"
13
- id={labelText}
13
+ id="select"
14
+ aria-label={labelText}
14
15
  lwc:dom="manual"
15
16
  onchange={handleChange}
16
17
  value={value}
17
18
  ></select>
18
19
  <arch-icon
19
- class="select-icon select-icon--right"
20
+ class="select-icon select-icon-right"
20
21
  size="small"
21
22
  symbol="chevrondown"
22
23
  ></arch-icon>
@@ -13,7 +13,7 @@ export default class extends ReflectedElement {
13
13
 
14
14
  private get containerClassName() {
15
15
  return classnames("select-container", {
16
- "select-container--icon": this.iconSymbol
16
+ "select-container-icon": this.iconSymbol
17
17
  });
18
18
  }
19
19
 
@@ -38,7 +38,7 @@ export default class extends ReflectedElement {
38
38
  );
39
39
  }
40
40
 
41
- private handleChange(event) {
41
+ private handleChange(event: Event) {
42
42
  this.dispatchEvent(
43
43
  new CustomEvent("change", {
44
44
  detail: this.contentElement.value