@salesforcedevs/arch-components 1.28.1 → 1.29.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
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@salesforcedevs/arch-components",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.29.0",
|
|
4
4
|
"description": "Architect Lightning web components",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"engines": {
|
|
@@ -44,5 +44,5 @@
|
|
|
44
44
|
"eventsourcemock": "2.0.0",
|
|
45
45
|
"luxon": "3.4.4"
|
|
46
46
|
},
|
|
47
|
-
"gitHead": "
|
|
47
|
+
"gitHead": "5d072e2fa81d7eccadcbfb4134ee007ce2b0c7c4"
|
|
48
48
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
@import "arch/reset";
|
|
2
|
+
|
|
3
|
+
:host {
|
|
4
|
+
font-size: var(--arch-font-size-md);
|
|
5
|
+
position: relative;
|
|
6
|
+
width: 100%;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
.search-toolbar {
|
|
10
|
+
display: flex;
|
|
11
|
+
gap: var(--arch-spacing-4);
|
|
12
|
+
justify-content: flex-end;
|
|
13
|
+
margin-bottom: var(--arch-spacing-4);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.search-language-selector,
|
|
17
|
+
.search-sort-selector {
|
|
18
|
+
display: flex;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.search-facets {
|
|
22
|
+
display: flex;
|
|
23
|
+
flex-wrap: wrap;
|
|
24
|
+
gap: var(--arch-spacing-2);
|
|
25
|
+
margin-bottom: var(--arch-spacing-4);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.search-facet-chip {
|
|
29
|
+
align-items: center;
|
|
30
|
+
background-color: white;
|
|
31
|
+
border: 1px solid var(--arch-color-fog);
|
|
32
|
+
border-radius: var(--arch-radius-lg);
|
|
33
|
+
color: var(--arch-color-black);
|
|
34
|
+
cursor: pointer;
|
|
35
|
+
display: inline-flex;
|
|
36
|
+
font-family: inherit;
|
|
37
|
+
font-size: var(--arch-font-size-sm);
|
|
38
|
+
gap: var(--arch-spacing-2);
|
|
39
|
+
padding: var(--arch-spacing-1) var(--arch-spacing-3);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.search-facet-chip[aria-pressed="true"] {
|
|
43
|
+
background-color: var(--arch-color-meteorite);
|
|
44
|
+
border-color: var(--arch-color-meteorite);
|
|
45
|
+
color: white;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.search-facet-count {
|
|
49
|
+
font-size: var(--arch-font-size-xs);
|
|
50
|
+
opacity: 0.8;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.search-summary {
|
|
54
|
+
color: var(--arch-color-granite);
|
|
55
|
+
font-size: var(--arch-font-size-sm);
|
|
56
|
+
margin-bottom: var(--arch-spacing-4);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/* Semantic results list (WCAG 1.3.1) — reset the list chrome so the visual
|
|
60
|
+
is identical to the prior div-based markup. */
|
|
61
|
+
.search-result-list {
|
|
62
|
+
list-style: none;
|
|
63
|
+
margin: 0;
|
|
64
|
+
padding: 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.search-result-type {
|
|
68
|
+
color: var(--arch-color-meteorite);
|
|
69
|
+
font-size: var(--arch-font-size-sm);
|
|
70
|
+
line-height: var(--arch-line-height-snug);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.search-state {
|
|
74
|
+
padding: var(--arch-spacing-6) 0;
|
|
75
|
+
text-align: center;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.search-state-error {
|
|
79
|
+
color: var(--arch-color-error, #c23934);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.search-pagination {
|
|
83
|
+
align-items: center;
|
|
84
|
+
display: flex;
|
|
85
|
+
flex-wrap: wrap;
|
|
86
|
+
gap: var(--arch-spacing-2);
|
|
87
|
+
justify-content: center;
|
|
88
|
+
margin-top: var(--arch-spacing-6);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.search-pagination button {
|
|
92
|
+
background-color: white;
|
|
93
|
+
border: 1px solid var(--arch-color-fog);
|
|
94
|
+
border-radius: var(--arch-radius-md);
|
|
95
|
+
color: var(--arch-color-black);
|
|
96
|
+
cursor: pointer;
|
|
97
|
+
font-family: inherit;
|
|
98
|
+
font-size: var(--arch-font-size-sm);
|
|
99
|
+
min-width: 2.5rem;
|
|
100
|
+
padding: var(--arch-spacing-1) var(--arch-spacing-3);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.search-pagination button[disabled] {
|
|
104
|
+
cursor: default;
|
|
105
|
+
opacity: 0.5;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.search-pagination button[aria-current="page"] {
|
|
109
|
+
background-color: var(--arch-color-meteorite);
|
|
110
|
+
border-color: var(--arch-color-meteorite);
|
|
111
|
+
color: white;
|
|
112
|
+
opacity: 1;
|
|
113
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<arch-section-a background="white" title="Search">
|
|
3
|
+
<div class="search-toolbar">
|
|
4
|
+
<template lwc:if={showLanguageSelector}>
|
|
5
|
+
<div class="search-language-selector">
|
|
6
|
+
<arch-select
|
|
7
|
+
assistive-text="Search language"
|
|
8
|
+
value={language}
|
|
9
|
+
onchange={handleLanguageChange}
|
|
10
|
+
>
|
|
11
|
+
<template for:each={localeOptions} for:item="locale">
|
|
12
|
+
<option key={locale.id} value={locale.id}>
|
|
13
|
+
{locale.displayText}
|
|
14
|
+
</option>
|
|
15
|
+
</template>
|
|
16
|
+
</arch-select>
|
|
17
|
+
</div>
|
|
18
|
+
</template>
|
|
19
|
+
<div class="search-sort-selector">
|
|
20
|
+
<arch-select
|
|
21
|
+
assistive-text="Sort results"
|
|
22
|
+
value={sort}
|
|
23
|
+
onchange={handleSortChange}
|
|
24
|
+
>
|
|
25
|
+
<template for:each={sortChoices} for:item="choice">
|
|
26
|
+
<option key={choice.id} value={choice.id}>
|
|
27
|
+
{choice.displayText}
|
|
28
|
+
</option>
|
|
29
|
+
</template>
|
|
30
|
+
</arch-select>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<template lwc:if={showFacets}>
|
|
35
|
+
<div class="search-facets" role="group" aria-label="Filter by type">
|
|
36
|
+
<template for:each={contentTypeFacets} for:item="facet">
|
|
37
|
+
<button
|
|
38
|
+
key={facet.value}
|
|
39
|
+
type="button"
|
|
40
|
+
class="search-facet-chip"
|
|
41
|
+
data-value={facet.value}
|
|
42
|
+
aria-pressed={facet.active}
|
|
43
|
+
onclick={handleFacetClick}
|
|
44
|
+
>
|
|
45
|
+
{facet.label}
|
|
46
|
+
<span class="search-facet-count">{facet.count}</span>
|
|
47
|
+
</button>
|
|
48
|
+
</template>
|
|
49
|
+
</div>
|
|
50
|
+
</template>
|
|
51
|
+
|
|
52
|
+
<template lwc:if={isLoading}>
|
|
53
|
+
<div class="search-state search-state-loading">
|
|
54
|
+
<arch-spinner size="medium"></arch-spinner>
|
|
55
|
+
</div>
|
|
56
|
+
</template>
|
|
57
|
+
|
|
58
|
+
<template lwc:if={isIdle}>
|
|
59
|
+
<div class="search-state search-state-idle">
|
|
60
|
+
<p>Enter a search to get started.</p>
|
|
61
|
+
</div>
|
|
62
|
+
</template>
|
|
63
|
+
|
|
64
|
+
<template lwc:if={isError}>
|
|
65
|
+
<div class="search-state search-state-error" role="alert">
|
|
66
|
+
<p>{errorMessage}</p>
|
|
67
|
+
</div>
|
|
68
|
+
</template>
|
|
69
|
+
|
|
70
|
+
<template lwc:if={isEmpty}>
|
|
71
|
+
<div class="search-state search-state-empty">
|
|
72
|
+
<center>
|
|
73
|
+
<img
|
|
74
|
+
src="https://developer.salesforce.com/ns-assets/ruth_search.png"
|
|
75
|
+
alt="Search tips illustration"
|
|
76
|
+
/>
|
|
77
|
+
<h4 style="padding-top: 8px">Here are some search tips</h4>
|
|
78
|
+
<span style="display: block">
|
|
79
|
+
Check the spelling of your keywords.
|
|
80
|
+
</span>
|
|
81
|
+
<span style="display: block">
|
|
82
|
+
Try using fewer, different, or more general words.
|
|
83
|
+
</span>
|
|
84
|
+
</center>
|
|
85
|
+
</div>
|
|
86
|
+
</template>
|
|
87
|
+
|
|
88
|
+
<template lwc:if={hasResults}>
|
|
89
|
+
<p class="search-summary">{summaryText}</p>
|
|
90
|
+
<ul class="search-result-list">
|
|
91
|
+
<template for:each={results} for:item="result">
|
|
92
|
+
<li class="search-result-item" key={result.key}>
|
|
93
|
+
<arch-summary
|
|
94
|
+
compact
|
|
95
|
+
display-mode="brick-tile"
|
|
96
|
+
path={result.url}
|
|
97
|
+
title={result.decodedTitle}
|
|
98
|
+
>
|
|
99
|
+
<div slot="pretitle">
|
|
100
|
+
<template lwc:if={result.hasContentType}>
|
|
101
|
+
<span class="search-result-type">
|
|
102
|
+
{result.contentType}
|
|
103
|
+
</span>
|
|
104
|
+
</template>
|
|
105
|
+
</div>
|
|
106
|
+
<span slot="meta-right">
|
|
107
|
+
<template lwc:if={result.hasDate}>
|
|
108
|
+
<time>{result.displayDate}</time>
|
|
109
|
+
</template>
|
|
110
|
+
</span>
|
|
111
|
+
<span slot="meta-left">
|
|
112
|
+
<template lwc:if={result.hasDescription}>
|
|
113
|
+
{result.decodedDescription}
|
|
114
|
+
</template>
|
|
115
|
+
</span>
|
|
116
|
+
</arch-summary>
|
|
117
|
+
<!-- prettier-ignore -->
|
|
118
|
+
<hr>
|
|
119
|
+
</li>
|
|
120
|
+
</template>
|
|
121
|
+
</ul>
|
|
122
|
+
|
|
123
|
+
<template lwc:if={showPagination}>
|
|
124
|
+
<nav
|
|
125
|
+
class="search-pagination"
|
|
126
|
+
aria-label="Search results pages"
|
|
127
|
+
>
|
|
128
|
+
<button
|
|
129
|
+
type="button"
|
|
130
|
+
class="search-page-prev"
|
|
131
|
+
aria-label="Previous page"
|
|
132
|
+
disabled={isFirstPage}
|
|
133
|
+
onclick={handlePrevPage}
|
|
134
|
+
>
|
|
135
|
+
Previous
|
|
136
|
+
</button>
|
|
137
|
+
<template for:each={pageButtons} for:item="pageButton">
|
|
138
|
+
<button
|
|
139
|
+
key={pageButton.page}
|
|
140
|
+
type="button"
|
|
141
|
+
class="search-page-number"
|
|
142
|
+
data-page={pageButton.page}
|
|
143
|
+
aria-label={pageButton.label}
|
|
144
|
+
aria-current={pageButton.ariaCurrent}
|
|
145
|
+
disabled={pageButton.isCurrent}
|
|
146
|
+
onclick={handlePageClick}
|
|
147
|
+
>
|
|
148
|
+
{pageButton.label}
|
|
149
|
+
</button>
|
|
150
|
+
</template>
|
|
151
|
+
<button
|
|
152
|
+
type="button"
|
|
153
|
+
class="search-page-next"
|
|
154
|
+
aria-label="Next page"
|
|
155
|
+
disabled={isLastPage}
|
|
156
|
+
onclick={handleNextPage}
|
|
157
|
+
>
|
|
158
|
+
Next
|
|
159
|
+
</button>
|
|
160
|
+
</nav>
|
|
161
|
+
</template>
|
|
162
|
+
</template>
|
|
163
|
+
</arch-section-a>
|
|
164
|
+
</template>
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { LightningElement, api, track } from "lwc";
|
|
2
|
+
import { track as trackEvent } from "arch/instrumentation";
|
|
3
|
+
import { resolveBrowserLocale } from "arch/browserLocale";
|
|
4
|
+
import { getStoredLanguage, setStoredLanguage } from "arch/localePreference";
|
|
5
|
+
import { decodeHtmlEntities, isSortOption } from "docUtils/searchClient";
|
|
6
|
+
import { SearchController } from "docUtils/searchController";
|
|
7
|
+
import type { SearchViewModel, SortOption } from "docUtils/searchController";
|
|
8
|
+
import type { SearchResult, FacetBucket } from "docUtils/searchClient";
|
|
9
|
+
import { parseSupportedLocales } from "docUtils/searchLocale";
|
|
10
|
+
import type { LocaleDeps, LocaleOption } from "docUtils/searchLocale";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_LANGUAGE = "en-us";
|
|
13
|
+
// The architect site value the search backend filters on. arch/searchList
|
|
14
|
+
// pinned no explicit site to the backend (it filtered by language only against
|
|
15
|
+
// Algolia), so we pin the canonical brand id "architect" here (see decisions).
|
|
16
|
+
const ARCHITECT_SITE = "architect";
|
|
17
|
+
|
|
18
|
+
// View-model shape used by the template — augments the controller's results
|
|
19
|
+
// with already-decoded title/description so the template stays logic-free.
|
|
20
|
+
interface RenderResult extends SearchResult {
|
|
21
|
+
// Iteration key for the template for:each. URL alone is NOT unique — two
|
|
22
|
+
// fused results can share a canonical URL, and a duplicate LWC key throws
|
|
23
|
+
// and fails to render the WHOLE list, so we disambiguate with the index.
|
|
24
|
+
key: string;
|
|
25
|
+
decodedTitle: string;
|
|
26
|
+
decodedDescription: string;
|
|
27
|
+
hasDescription: boolean;
|
|
28
|
+
hasContentType: boolean;
|
|
29
|
+
hasDate: boolean;
|
|
30
|
+
displayDate: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface RenderFacet extends FacetBucket {
|
|
34
|
+
active: boolean;
|
|
35
|
+
label: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface SortChoice {
|
|
39
|
+
id: SortOption;
|
|
40
|
+
displayText: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface PageButton {
|
|
44
|
+
page: number;
|
|
45
|
+
label: string;
|
|
46
|
+
isCurrent: boolean;
|
|
47
|
+
ariaCurrent: "page" | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const SORT_CHOICES: SortChoice[] = [
|
|
51
|
+
{ id: "relevance", displayText: "Relevance" },
|
|
52
|
+
{ id: "date", displayText: "Newest" }
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
export default class SearchHybrid extends LightningElement {
|
|
56
|
+
// The catalog of selectable locales, injected from njk as a JSON string
|
|
57
|
+
// (the doc-framework ALL_LANGUAGES global, sourced from languages.yml).
|
|
58
|
+
@api supportedLocales: string = "";
|
|
59
|
+
// The page's server-side locale, used as a fallback default.
|
|
60
|
+
@api defaultLanguage: string = DEFAULT_LANGUAGE;
|
|
61
|
+
|
|
62
|
+
@track private vm: SearchViewModel | null = null;
|
|
63
|
+
|
|
64
|
+
private controller?: SearchController;
|
|
65
|
+
private unsubscribe?: () => void;
|
|
66
|
+
private localeCatalog: LocaleOption[] = [];
|
|
67
|
+
|
|
68
|
+
connectedCallback() {
|
|
69
|
+
this.localeCatalog = parseSupportedLocales(this.supportedLocales);
|
|
70
|
+
const supportedIds = this.localeCatalog.map((option) => option.id);
|
|
71
|
+
|
|
72
|
+
const deps: LocaleDeps = {
|
|
73
|
+
getStored: () => getStoredLanguage(),
|
|
74
|
+
setStored: (value: string) => setStoredLanguage(value),
|
|
75
|
+
resolveBrowser: (langs, ids) => resolveBrowserLocale(langs, ids)
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const host = this.template.host;
|
|
79
|
+
this.controller = new SearchController({
|
|
80
|
+
catalog: this.localeCatalog,
|
|
81
|
+
defaultLanguage: this.defaultLanguage || DEFAULT_LANGUAGE,
|
|
82
|
+
supportedIds,
|
|
83
|
+
site: ARCHITECT_SITE,
|
|
84
|
+
deps,
|
|
85
|
+
instrument: (event, payload) => {
|
|
86
|
+
trackEvent(host, event, payload);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
this.unsubscribe = this.controller.subscribe((vm) => {
|
|
91
|
+
this.vm = vm;
|
|
92
|
+
});
|
|
93
|
+
this.vm = this.controller.viewModel;
|
|
94
|
+
this.controller.init();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
disconnectedCallback() {
|
|
98
|
+
this.unsubscribe?.();
|
|
99
|
+
this.controller?.dispose();
|
|
100
|
+
this.controller = undefined;
|
|
101
|
+
this.unsubscribe = undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- view-model derived getters (template-facing) -------------------
|
|
105
|
+
|
|
106
|
+
get localeOptions(): LocaleOption[] {
|
|
107
|
+
return this.localeCatalog;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
get showLanguageSelector(): boolean {
|
|
111
|
+
return this.localeCatalog.length > 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
get language(): string {
|
|
115
|
+
return this.vm?.locale ?? DEFAULT_LANGUAGE;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
get sortChoices(): SortChoice[] {
|
|
119
|
+
return SORT_CHOICES;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
get sort(): SortOption {
|
|
123
|
+
return this.vm?.sort ?? "relevance";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
get isLoading(): boolean {
|
|
127
|
+
return this.vm?.status === "loading";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
get isIdle(): boolean {
|
|
131
|
+
return this.vm?.status === "idle";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
get isEmpty(): boolean {
|
|
135
|
+
return this.vm?.status === "empty";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
get isError(): boolean {
|
|
139
|
+
return this.vm?.status === "error";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
get hasResults(): boolean {
|
|
143
|
+
return this.vm?.status === "results" && this.results.length > 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
get errorMessage(): string {
|
|
147
|
+
return this.vm?.errorMessage ?? "An error occurred.";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
get summaryText(): string {
|
|
151
|
+
const vm = this.vm;
|
|
152
|
+
if (!vm) {
|
|
153
|
+
return "";
|
|
154
|
+
}
|
|
155
|
+
const query = vm.query ? ` for "${decodeHtmlEntities(vm.query)}"` : "";
|
|
156
|
+
if (vm.total === 1) {
|
|
157
|
+
return `1 result${query}`;
|
|
158
|
+
}
|
|
159
|
+
return `${vm.total} results${query}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
get results(): RenderResult[] {
|
|
163
|
+
const list = this.vm?.results ?? [];
|
|
164
|
+
return list.map((result, index) => {
|
|
165
|
+
const description = result.description;
|
|
166
|
+
// Derive hasDate from the FORMATTED output, not the raw value, so a
|
|
167
|
+
// non-null but unparseable lastmod never yields an empty <time>.
|
|
168
|
+
const displayDate = this.formatDate(result.lastmod);
|
|
169
|
+
return {
|
|
170
|
+
...result,
|
|
171
|
+
// Index-disambiguated so duplicate URLs can't collide (see key).
|
|
172
|
+
key: `${result.url}-${index}`,
|
|
173
|
+
decodedTitle: decodeHtmlEntities(result.title),
|
|
174
|
+
decodedDescription: decodeHtmlEntities(description),
|
|
175
|
+
hasDescription:
|
|
176
|
+
typeof description === "string" && description.length > 0,
|
|
177
|
+
hasContentType:
|
|
178
|
+
typeof result.contentType === "string" &&
|
|
179
|
+
result.contentType.length > 0,
|
|
180
|
+
hasDate: displayDate.length > 0,
|
|
181
|
+
displayDate
|
|
182
|
+
};
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
get contentTypeFacets(): RenderFacet[] {
|
|
187
|
+
const facets = this.vm?.contentTypeFacets ?? [];
|
|
188
|
+
const selected = this.vm?.selectedType ?? null;
|
|
189
|
+
return facets.map((bucket) => ({
|
|
190
|
+
...bucket,
|
|
191
|
+
active: selected === bucket.value,
|
|
192
|
+
// arch has no dxConstants label map; show the raw facet value.
|
|
193
|
+
label: bucket.value
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
get showFacets(): boolean {
|
|
198
|
+
return this.contentTypeFacets.length > 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---- pagination (hand-rolled, null-safe) ----------------------------
|
|
202
|
+
|
|
203
|
+
get totalPages(): number {
|
|
204
|
+
return this.vm?.totalPages ?? 1;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
get currentPage(): number {
|
|
208
|
+
return this.vm?.page ?? 1;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
get showPagination(): boolean {
|
|
212
|
+
return this.hasResults && this.totalPages > 1;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
get isFirstPage(): boolean {
|
|
216
|
+
return this.currentPage <= 1;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
get isLastPage(): boolean {
|
|
220
|
+
return this.currentPage >= this.totalPages;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
get pageButtons(): PageButton[] {
|
|
224
|
+
const total = this.totalPages;
|
|
225
|
+
const current = this.currentPage;
|
|
226
|
+
const buttons: PageButton[] = [];
|
|
227
|
+
for (let page = 1; page <= total; page += 1) {
|
|
228
|
+
buttons.push({
|
|
229
|
+
page,
|
|
230
|
+
label: String(page),
|
|
231
|
+
isCurrent: page === current,
|
|
232
|
+
ariaCurrent: page === current ? "page" : null
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
return buttons;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---- event handlers --------------------------------------------------
|
|
239
|
+
|
|
240
|
+
handleLanguageChange(event: CustomEvent) {
|
|
241
|
+
const selected = event.detail;
|
|
242
|
+
if (typeof selected !== "string" || selected === this.language) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
this.controller?.setLanguage(selected);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
handleSortChange(event: CustomEvent) {
|
|
249
|
+
const selected = event.detail;
|
|
250
|
+
if (typeof selected === "string" && isSortOption(selected)) {
|
|
251
|
+
this.controller?.setSort(selected);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
handleFacetClick(event: Event) {
|
|
256
|
+
const value = (event.currentTarget as HTMLElement)?.dataset.value;
|
|
257
|
+
if (typeof value === "string") {
|
|
258
|
+
// Disjunctive toggle — the controller clears it if re-selected.
|
|
259
|
+
this.controller?.setType(value);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
handlePrevPage() {
|
|
264
|
+
if (!this.isFirstPage) {
|
|
265
|
+
this.controller?.setPage(this.currentPage - 1);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
handleNextPage() {
|
|
270
|
+
if (!this.isLastPage) {
|
|
271
|
+
this.controller?.setPage(this.currentPage + 1);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
handlePageClick(event: Event) {
|
|
276
|
+
const raw = (event.currentTarget as HTMLElement)?.dataset.page;
|
|
277
|
+
const page = Number(raw);
|
|
278
|
+
if (Number.isFinite(page) && page >= 1) {
|
|
279
|
+
this.controller?.setPage(page);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---- helpers ---------------------------------------------------------
|
|
284
|
+
|
|
285
|
+
private formatDate(lastmod: string | null): string {
|
|
286
|
+
if (typeof lastmod !== "string" || lastmod.length === 0) {
|
|
287
|
+
return "";
|
|
288
|
+
}
|
|
289
|
+
const date = new Date(lastmod);
|
|
290
|
+
if (Number.isNaN(date.getTime())) {
|
|
291
|
+
return "";
|
|
292
|
+
}
|
|
293
|
+
return date.toLocaleDateString(undefined, {
|
|
294
|
+
year: "numeric",
|
|
295
|
+
month: "short",
|
|
296
|
+
day: "numeric"
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
}
|