@honua/app-platform 0.1.4-beta.0 → 0.1.9-beta.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/app/index.d.ts.map +1 -1
- package/app/index.js +4 -0
- package/contract/types.d.ts +1 -1
- package/contract/types.d.ts.map +1 -1
- package/contract/types.js +4 -0
- package/control-plane/admin-client.d.ts +87 -0
- package/control-plane/admin-client.d.ts.map +1 -0
- package/control-plane/admin-client.js +169 -0
- package/control-plane/client.d.ts.map +1 -1
- package/control-plane/client.js +21 -3
- package/control-plane/commands/catalog.d.ts +133 -0
- package/control-plane/commands/catalog.d.ts.map +1 -0
- package/control-plane/commands/catalog.js +353 -0
- package/control-plane/commands/errors.d.ts +96 -0
- package/control-plane/commands/errors.d.ts.map +1 -0
- package/control-plane/commands/errors.js +130 -0
- package/control-plane/commands/identity.d.ts +71 -0
- package/control-plane/commands/identity.d.ts.map +1 -0
- package/control-plane/commands/identity.js +0 -0
- package/control-plane/commands/index.d.ts +39 -0
- package/control-plane/commands/index.d.ts.map +1 -0
- package/control-plane/commands/index.js +34 -0
- package/control-plane/commands/runtime.d.ts +146 -0
- package/control-plane/commands/runtime.d.ts.map +1 -0
- package/control-plane/commands/runtime.js +318 -0
- package/control-plane/commands/types.d.ts +226 -0
- package/control-plane/commands/types.d.ts.map +1 -0
- package/control-plane/commands/types.js +30 -0
- package/control-plane/commands/validate.d.ts +27 -0
- package/control-plane/commands/validate.d.ts.map +1 -0
- package/control-plane/commands/validate.js +132 -0
- package/control-plane/generated/admin-api.d.ts +30263 -0
- package/control-plane/generated/admin-api.d.ts.map +1 -0
- package/control-plane/generated/admin-api.js +5 -0
- package/control-plane/generated/admin-operations.d.ts +4053 -0
- package/control-plane/generated/admin-operations.d.ts.map +1 -0
- package/control-plane/generated/admin-operations.js +820 -0
- package/control-plane/index.d.ts +11 -0
- package/control-plane/index.d.ts.map +1 -1
- package/control-plane/index.js +8 -0
- package/control-plane/types.d.ts +14 -0
- package/control-plane/types.d.ts.map +1 -1
- package/controls/catalog.d.ts.map +1 -1
- package/controls/catalog.js +12 -1
- package/controls/qualification.d.ts.map +1 -1
- package/controls/qualification.js +61 -2
- package/core/client.d.ts +6 -0
- package/core/client.d.ts.map +1 -1
- package/core/client.js +12 -10
- package/core/credential-redaction.d.ts +163 -0
- package/core/credential-redaction.d.ts.map +1 -0
- package/core/credential-redaction.js +389 -0
- package/core/error-classifications.d.ts +32 -0
- package/core/error-classifications.d.ts.map +1 -1
- package/core/error-classifications.js +32 -0
- package/core/error-code-registry.d.ts +33 -1
- package/core/error-code-registry.d.ts.map +1 -1
- package/core/error-code-registry.js +32 -0
- package/core/errors.d.ts +1 -1
- package/core/errors.d.ts.map +1 -1
- package/core/geoparquet-sql.d.ts +1 -1
- package/core/geoparquet-sql.js +2 -2
- package/core/grpc-adapter.d.ts +2 -2
- package/core/grpc-adapter.d.ts.map +1 -1
- package/core/grpc-adapter.js +30 -6
- package/core/job-run-errors.d.ts +10 -0
- package/core/job-run-errors.d.ts.map +1 -0
- package/core/job-run-errors.js +14 -0
- package/core/job-run-lifecycle.d.ts +34 -0
- package/core/job-run-lifecycle.d.ts.map +1 -0
- package/core/job-run-lifecycle.js +193 -0
- package/core/ogc-processes.d.ts +38 -31
- package/core/ogc-processes.d.ts.map +1 -1
- package/core/ogc-processes.js +123 -173
- package/core/process-runner.js +15 -100
- package/core/stac.d.ts +2 -1
- package/core/stac.d.ts.map +1 -1
- package/core/stac.js +29 -11
- package/core/surfaces.d.ts +2 -8
- package/core/surfaces.d.ts.map +1 -1
- package/core/surfaces.js +21 -112
- package/core/types.d.ts +22 -8
- package/core/types.d.ts.map +1 -1
- package/gen/geospatial/v1/common_pb.d.ts +514 -0
- package/gen/geospatial/v1/common_pb.d.ts.map +1 -0
- package/gen/geospatial/v1/common_pb.js +335 -0
- package/gen/geospatial/v1/execution_types_pb.d.ts +1186 -0
- package/gen/geospatial/v1/execution_types_pb.d.ts.map +1 -0
- package/gen/geospatial/v1/execution_types_pb.js +396 -0
- package/gen/geospatial/v1/feature_service_pb.d.ts +403 -0
- package/gen/geospatial/v1/feature_service_pb.d.ts.map +1 -0
- package/gen/geospatial/v1/feature_service_pb.js +47 -0
- package/gen/geospatial/v1/index.d.ts +5 -0
- package/gen/geospatial/v1/index.d.ts.map +1 -0
- package/gen/geospatial/v1/index.js +9 -0
- package/gen/geospatial/v1/spatial_types_pb.d.ts +285 -0
- package/gen/geospatial/v1/spatial_types_pb.d.ts.map +1 -0
- package/gen/geospatial/v1/spatial_types_pb.js +58 -0
- package/gen/geospatial/v1/workspace_artifact_types_pb.d.ts +379 -0
- package/gen/geospatial/v1/workspace_artifact_types_pb.d.ts.map +1 -0
- package/gen/geospatial/v1/workspace_artifact_types_pb.js +151 -0
- package/generated-app/projection.d.ts.map +1 -1
- package/generated-app/projection.js +10 -1
- package/interactions/declarative-contract.d.ts +149 -0
- package/interactions/declarative-contract.d.ts.map +1 -0
- package/interactions/declarative-contract.js +293 -0
- package/interactions/declarative.d.ts +198 -0
- package/interactions/declarative.d.ts.map +1 -0
- package/interactions/declarative.js +470 -0
- package/interactions/feature-state.d.ts +7 -0
- package/interactions/feature-state.d.ts.map +1 -1
- package/interactions/feature-state.js +10 -2
- package/map/maplibre-target.js +1 -1
- package/operate/client.d.ts +11 -1
- package/operate/client.d.ts.map +1 -1
- package/operate/client.js +39 -2
- package/operate/index.d.ts +1 -1
- package/operate/index.d.ts.map +1 -1
- package/operate/types.d.ts +40 -6
- package/operate/types.d.ts.map +1 -1
- package/package.json +2 -2
- package/query-planner/grpc.d.ts +4 -4
- package/query-planner/grpc.d.ts.map +1 -1
- package/query-planner/grpc.js +11 -11
- package/query-planner/types.d.ts +4 -4
- package/query-planner/types.d.ts.map +1 -1
- package/realtime/sse.d.ts.map +1 -1
- package/realtime/sse.js +7 -1
- package/runtime/errors.d.ts +1 -1
- package/runtime/errors.d.ts.map +1 -1
- package/runtime/errors.js +2 -0
- package/runtime/generated/map-package-schema-meta.d.ts +11 -0
- package/runtime/generated/map-package-schema-meta.d.ts.map +1 -0
- package/runtime/generated/map-package-schema-meta.js +12 -0
- package/runtime/generated/map-package-schema-validator.d.ts +16 -0
- package/runtime/generated/map-package-schema-validator.d.ts.map +1 -0
- package/runtime/generated/map-package-schema-validator.js +1422 -0
- package/runtime/index.d.ts +5 -1
- package/runtime/index.d.ts.map +1 -1
- package/runtime/index.js +6 -0
- package/runtime/map-package-export.d.ts +151 -0
- package/runtime/map-package-export.d.ts.map +1 -0
- package/runtime/map-package-export.js +636 -0
- package/runtime/map-package-validation.d.ts +21 -1
- package/runtime/map-package-validation.d.ts.map +1 -1
- package/runtime/map-package-validation.js +132 -95
- package/runtime/map-package.d.ts +80 -0
- package/runtime/map-package.d.ts.map +1 -1
- package/runtime/style-compose.d.ts.map +1 -1
- package/runtime/style-compose.js +78 -9
- package/scene-workspace/cesium-adapter.d.ts +8 -8
- package/scene-workspace/cesium-adapter.d.ts.map +1 -1
- package/scene-workspace/cesium-adapter.js +31 -18
- package/scene-workspace/primitives.d.ts.map +1 -1
- package/scene-workspace/primitives.js +9 -6
- package/scene-workspace/source-to-cesium.js +1 -1
- package/studio/chart-spec.d.ts +6 -6
- package/studio/chart-spec.d.ts.map +1 -1
- package/studio/chart-spec.js +6 -6
- package/studio/index.d.ts +17 -7
- package/studio/index.d.ts.map +1 -1
- package/studio/index.js +15 -5
- package/studio/lifecycle-client.d.ts +262 -22
- package/studio/lifecycle-client.d.ts.map +1 -1
- package/studio/lifecycle-client.js +524 -24
- package/studio/lifecycle-errors.d.ts +8 -2
- package/studio/lifecycle-errors.d.ts.map +1 -1
- package/studio/lifecycle-errors.js +6 -1
- package/studio/lifecycle-types.d.ts +228 -8
- package/studio/lifecycle-types.d.ts.map +1 -1
- package/studio/publication-status.d.ts +55 -0
- package/studio/publication-status.d.ts.map +1 -0
- package/studio/publication-status.js +103 -0
- package/web-components/application-context.d.ts +186 -0
- package/web-components/application-context.d.ts.map +1 -0
- package/web-components/application-context.js +429 -0
- package/web-components/elements.d.ts +10 -1
- package/web-components/elements.d.ts.map +1 -1
- package/web-components/elements.js +187 -7
- package/web-components/export-redaction.d.ts +11 -102
- package/web-components/export-redaction.d.ts.map +1 -1
- package/web-components/export-redaction.js +7 -260
- package/web-components/feature-editor-offline.d.ts +32 -0
- package/web-components/feature-editor-offline.d.ts.map +1 -0
- package/web-components/feature-editor-offline.js +115 -0
- package/web-components/feature-editor-workflow.d.ts +85 -22
- package/web-components/feature-editor-workflow.d.ts.map +1 -1
- package/web-components/feature-editor-workflow.js +240 -0
- package/web-components/feature-editor.d.ts +20 -6
- package/web-components/feature-editor.d.ts.map +1 -1
- package/web-components/feature-editor.js +159 -7
- package/web-components/feature-inspection.d.ts +287 -0
- package/web-components/feature-inspection.d.ts.map +1 -0
- package/web-components/feature-inspection.js +1464 -0
- package/web-components/feature-mutation.d.ts +77 -0
- package/web-components/feature-mutation.d.ts.map +1 -0
- package/web-components/feature-mutation.js +137 -0
- package/web-components/feature-table-engine.d.ts +94 -4
- package/web-components/feature-table-engine.d.ts.map +1 -1
- package/web-components/feature-table-engine.js +365 -18
- package/web-components/feature-table-view.d.ts +35 -1
- package/web-components/feature-table-view.d.ts.map +1 -1
- package/web-components/feature-table-view.js +128 -12
- package/web-components/index.d.ts +14 -6
- package/web-components/index.d.ts.map +1 -1
- package/web-components/index.js +8 -2
- package/gen/honua/v1/feature_service_pb.d.ts +0 -918
- package/gen/honua/v1/feature_service_pb.d.ts.map +0 -1
- package/gen/honua/v1/feature_service_pb.js +0 -375
|
@@ -0,0 +1,1464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability-aware feature inspection workflow.
|
|
3
|
+
*
|
|
4
|
+
* The controller in this module is the supported join between map hits,
|
|
5
|
+
* server search, table rows, source-qualified selection, popup/details
|
|
6
|
+
* presentation, attachments, relationships, and realtime freshness. It never
|
|
7
|
+
* evaluates Arcade or inserts source HTML into the document.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
import { queryFilter } from "../contract/query-filter.js";
|
|
12
|
+
import { HonuaCapabilityNotSupportedError } from "../core/errors.js";
|
|
13
|
+
import { featureSelectionKey, sourceFeatureSelectionTarget } from "../exploration/selection.js";
|
|
14
|
+
import { renderCspSafeShadowHtml } from "./csp-styles.js";
|
|
15
|
+
const DEFAULT_PAGE_SIZE = 10;
|
|
16
|
+
const DEFAULT_MAX_PAGES = 5;
|
|
17
|
+
const DEFAULT_MAX_OVERLAPPING = 25;
|
|
18
|
+
const DEFAULT_MAX_FIELDS = 24;
|
|
19
|
+
const DEFAULT_MAX_RELATIONSHIPS = 8;
|
|
20
|
+
const DEFAULT_DETAILS_TTL_MS = 30_000;
|
|
21
|
+
const DEFAULT_SEARCH_TTL_MS = 5_000;
|
|
22
|
+
const DEFAULT_CACHE_ENTRIES = 100;
|
|
23
|
+
class BoundedTtlCache {
|
|
24
|
+
maxEntries;
|
|
25
|
+
now;
|
|
26
|
+
#entries = new Map();
|
|
27
|
+
constructor(maxEntries, now) {
|
|
28
|
+
this.maxEntries = maxEntries;
|
|
29
|
+
this.now = now;
|
|
30
|
+
}
|
|
31
|
+
get(key) {
|
|
32
|
+
const entry = this.#entries.get(key);
|
|
33
|
+
if (!entry)
|
|
34
|
+
return undefined;
|
|
35
|
+
if (entry.expiresAt <= this.now()) {
|
|
36
|
+
this.#entries.delete(key);
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
this.#entries.delete(key);
|
|
40
|
+
this.#entries.set(key, entry);
|
|
41
|
+
return entry.value;
|
|
42
|
+
}
|
|
43
|
+
set(key, value, ttlMs) {
|
|
44
|
+
this.#entries.delete(key);
|
|
45
|
+
this.#entries.set(key, { value, expiresAt: this.now() + Math.max(0, ttlMs) });
|
|
46
|
+
while (this.#entries.size > this.maxEntries) {
|
|
47
|
+
const oldest = this.#entries.keys().next().value;
|
|
48
|
+
if (oldest === undefined)
|
|
49
|
+
break;
|
|
50
|
+
this.#entries.delete(oldest);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
clear() {
|
|
54
|
+
this.#entries.clear();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Create a bounded, cancellable inspection workflow. */
|
|
58
|
+
export function createHonuaFeatureInspection(options) {
|
|
59
|
+
return new FeatureInspectionController(options);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Bind inspection to the supported application shell without introducing a
|
|
63
|
+
* second selection identity or authorization cache scope.
|
|
64
|
+
*/
|
|
65
|
+
export function createHonuaFeatureInspectionFromApplicationContext(context, options = {}) {
|
|
66
|
+
const boundSource = () => context.snapshot.binding.source;
|
|
67
|
+
const sourceIdentity = () => context.snapshot.binding.sourceIdentity ?? context.snapshot.binding.source?.descriptor.id;
|
|
68
|
+
return createHonuaFeatureInspection({
|
|
69
|
+
...options,
|
|
70
|
+
resolveSource: (sourceId) => {
|
|
71
|
+
const source = boundSource();
|
|
72
|
+
if (source && (sourceIdentity() === sourceId || source.descriptor.id === sourceId))
|
|
73
|
+
return source;
|
|
74
|
+
return options.resolveSource?.(sourceId);
|
|
75
|
+
},
|
|
76
|
+
sourceIds: options.sourceIds ?? (sourceIdentity() ? [sourceIdentity()] : []),
|
|
77
|
+
selection: {
|
|
78
|
+
setSelection(targets) {
|
|
79
|
+
context.update({ selection: targets });
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
authScope: () => {
|
|
83
|
+
const authorization = context.snapshot.authorization;
|
|
84
|
+
return [authorization.status, authorization.principalId ?? "", ...authorization.scopes].join(":");
|
|
85
|
+
},
|
|
86
|
+
version: () => [
|
|
87
|
+
context.snapshot.binding.sourceIdentity ?? "",
|
|
88
|
+
context.snapshot.binding.planIdentity ?? "",
|
|
89
|
+
context.snapshot.invalidationGeneration,
|
|
90
|
+
context.snapshot.freshness.generation,
|
|
91
|
+
].join(":"),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
class FeatureInspectionController {
|
|
95
|
+
options;
|
|
96
|
+
#listeners = new Set();
|
|
97
|
+
#now;
|
|
98
|
+
#detailsCache;
|
|
99
|
+
#searchCache;
|
|
100
|
+
#pageSize;
|
|
101
|
+
#maxStoredItems;
|
|
102
|
+
#maxOverlapping;
|
|
103
|
+
#maxFields;
|
|
104
|
+
#maxRelationships;
|
|
105
|
+
#maxSearchSources;
|
|
106
|
+
#detailsTtlMs;
|
|
107
|
+
#searchTtlMs;
|
|
108
|
+
#detailsAbort;
|
|
109
|
+
#searchAbort;
|
|
110
|
+
#generation = 0;
|
|
111
|
+
#disposed = false;
|
|
112
|
+
#cachedDetails;
|
|
113
|
+
#attachmentPage = 0;
|
|
114
|
+
#relationshipPages = new Map();
|
|
115
|
+
#state = {
|
|
116
|
+
status: "idle",
|
|
117
|
+
candidates: [],
|
|
118
|
+
activeIndex: 0,
|
|
119
|
+
relationships: [],
|
|
120
|
+
diagnostics: [],
|
|
121
|
+
search: { status: "idle", query: "", results: [], diagnostics: [] },
|
|
122
|
+
};
|
|
123
|
+
constructor(options) {
|
|
124
|
+
this.options = options;
|
|
125
|
+
this.#now = options.now ?? Date.now;
|
|
126
|
+
const budgets = options.budgets ?? {};
|
|
127
|
+
this.#pageSize = positiveInteger(budgets.pageSize, DEFAULT_PAGE_SIZE);
|
|
128
|
+
const maxPages = positiveInteger(budgets.maxPages, DEFAULT_MAX_PAGES);
|
|
129
|
+
this.#maxStoredItems = this.#pageSize * maxPages;
|
|
130
|
+
this.#maxOverlapping = positiveInteger(budgets.maxOverlappingResults, DEFAULT_MAX_OVERLAPPING);
|
|
131
|
+
this.#maxFields = positiveInteger(budgets.maxFields, DEFAULT_MAX_FIELDS);
|
|
132
|
+
this.#maxRelationships = positiveInteger(budgets.maxRelationships, DEFAULT_MAX_RELATIONSHIPS);
|
|
133
|
+
this.#maxSearchSources = positiveInteger(budgets.maxSearchSources, 10);
|
|
134
|
+
const cacheEntries = positiveInteger(budgets.cacheEntries, DEFAULT_CACHE_ENTRIES);
|
|
135
|
+
this.#detailsCache = new BoundedTtlCache(cacheEntries, this.#now);
|
|
136
|
+
this.#searchCache = new BoundedTtlCache(cacheEntries, this.#now);
|
|
137
|
+
this.#detailsTtlMs = nonNegativeNumber(options.detailsTtlMs, DEFAULT_DETAILS_TTL_MS);
|
|
138
|
+
this.#searchTtlMs = nonNegativeNumber(options.searchTtlMs, DEFAULT_SEARCH_TTL_MS);
|
|
139
|
+
}
|
|
140
|
+
snapshot() {
|
|
141
|
+
return this.#state;
|
|
142
|
+
}
|
|
143
|
+
subscribe(listener) {
|
|
144
|
+
this.#assertLive();
|
|
145
|
+
this.#listeners.add(listener);
|
|
146
|
+
listener(this.#state);
|
|
147
|
+
return { remove: () => this.#listeners.delete(listener) };
|
|
148
|
+
}
|
|
149
|
+
openFromMapClick(candidates) {
|
|
150
|
+
return this.open(candidates, { origin: "map" });
|
|
151
|
+
}
|
|
152
|
+
openFromTableRow(candidate) {
|
|
153
|
+
return this.open(candidate, { origin: "table" });
|
|
154
|
+
}
|
|
155
|
+
openSearchResult(index) {
|
|
156
|
+
const result = this.#state.search.results[index];
|
|
157
|
+
if (!result)
|
|
158
|
+
return Promise.resolve(this.#state);
|
|
159
|
+
return this.open({ target: result.target, feature: result.feature, authoritative: true }, { origin: "search" });
|
|
160
|
+
}
|
|
161
|
+
async open(input, options = {}) {
|
|
162
|
+
this.#assertLive();
|
|
163
|
+
const supplied = (Array.isArray(input) ? input : [input]);
|
|
164
|
+
const deduped = dedupeCandidates(supplied).slice(0, this.#maxOverlapping);
|
|
165
|
+
if (deduped.length === 0) {
|
|
166
|
+
this.close();
|
|
167
|
+
return this.#state;
|
|
168
|
+
}
|
|
169
|
+
const diagnostics = [];
|
|
170
|
+
if (supplied.length > deduped.length) {
|
|
171
|
+
diagnostics.push({
|
|
172
|
+
code: "result-limit",
|
|
173
|
+
message: `Inspection is limited to ${this.#maxOverlapping} overlapping results. Refine the map hit or search to inspect more.`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
const activeIndex = clamp(options.activeIndex ?? 0, 0, deduped.length - 1);
|
|
177
|
+
const candidate = deduped[activeIndex];
|
|
178
|
+
if (!candidate)
|
|
179
|
+
return this.#state;
|
|
180
|
+
const origin = options.origin ?? "programmatic";
|
|
181
|
+
this.#detailsAbort?.abort();
|
|
182
|
+
this.#detailsAbort = new AbortController();
|
|
183
|
+
this.#generation += 1;
|
|
184
|
+
const generation = this.#generation;
|
|
185
|
+
this.#cachedDetails = undefined;
|
|
186
|
+
this.#attachmentPage = 0;
|
|
187
|
+
this.#relationshipPages.clear();
|
|
188
|
+
this.#state = {
|
|
189
|
+
...this.#state,
|
|
190
|
+
status: "loading",
|
|
191
|
+
origin,
|
|
192
|
+
candidates: deduped,
|
|
193
|
+
activeIndex,
|
|
194
|
+
feature: undefined,
|
|
195
|
+
attachments: undefined,
|
|
196
|
+
relationships: [],
|
|
197
|
+
diagnostics,
|
|
198
|
+
staleReason: undefined,
|
|
199
|
+
};
|
|
200
|
+
this.options.selection?.setSelection([candidate.target], { origin });
|
|
201
|
+
this.#notify();
|
|
202
|
+
const source = this.options.resolveSource(candidate.target.sourceId);
|
|
203
|
+
if (!source || !source.capabilities.has("query")) {
|
|
204
|
+
if (generation !== this.#generation)
|
|
205
|
+
return this.#state;
|
|
206
|
+
const diagnostic = capabilityDiagnostic("query", candidate.target.sourceId, source?.descriptor.protocol);
|
|
207
|
+
this.#state = { ...this.#state, status: "unsupported", diagnostics: [...diagnostics, diagnostic] };
|
|
208
|
+
this.#notify();
|
|
209
|
+
return this.#state;
|
|
210
|
+
}
|
|
211
|
+
const presentation = this.#presentation(source);
|
|
212
|
+
const fields = requiredFields(source, presentation, this.#maxFields);
|
|
213
|
+
const key = this.#detailsKey(source, candidate.target, fields);
|
|
214
|
+
const cached = options.force ? undefined : this.#detailsCache.get(key);
|
|
215
|
+
try {
|
|
216
|
+
const details = cached ??
|
|
217
|
+
(await this.#loadDetails(source, candidate, presentation, fields, this.#detailsAbort.signal, diagnostics));
|
|
218
|
+
if (generation !== this.#generation || this.#detailsAbort.signal.aborted)
|
|
219
|
+
return this.#state;
|
|
220
|
+
if (!cached)
|
|
221
|
+
this.#detailsCache.set(key, details, this.#detailsTtlMs);
|
|
222
|
+
this.#cachedDetails = details;
|
|
223
|
+
this.#state = this.#projectDetails("ready", details);
|
|
224
|
+
this.#notify();
|
|
225
|
+
return this.#state;
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
if (generation !== this.#generation || isAbortError(error))
|
|
229
|
+
return this.#state;
|
|
230
|
+
const diagnostic = diagnosticForError(error, "query-failed", candidate.target.sourceId);
|
|
231
|
+
this.#state = {
|
|
232
|
+
...this.#state,
|
|
233
|
+
status: error instanceof HonuaCapabilityNotSupportedError ? "unsupported" : "error",
|
|
234
|
+
diagnostics: [...diagnostics, diagnostic],
|
|
235
|
+
};
|
|
236
|
+
this.#notify();
|
|
237
|
+
return this.#state;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async search(query, options = {}) {
|
|
241
|
+
this.#assertLive();
|
|
242
|
+
const normalized = query.trim();
|
|
243
|
+
this.#searchAbort?.abort();
|
|
244
|
+
if (!normalized) {
|
|
245
|
+
this.#setSearch({ status: "idle", query: "", results: [], diagnostics: [] });
|
|
246
|
+
return this.#state.search;
|
|
247
|
+
}
|
|
248
|
+
const controller = new AbortController();
|
|
249
|
+
this.#searchAbort = controller;
|
|
250
|
+
const sourceIds = [...new Set(options.sourceIds ?? this.options.sourceIds ?? [])].slice(0, this.#maxSearchSources);
|
|
251
|
+
this.#setSearch({ status: "loading", query: normalized, results: [], diagnostics: [] });
|
|
252
|
+
const diagnostics = [];
|
|
253
|
+
const results = [];
|
|
254
|
+
await Promise.all(sourceIds.map(async (sourceId) => {
|
|
255
|
+
const source = this.options.resolveSource(sourceId);
|
|
256
|
+
if (!source || !source.capabilities.has("query")) {
|
|
257
|
+
diagnostics.push({
|
|
258
|
+
code: "search-unsupported",
|
|
259
|
+
capability: "query",
|
|
260
|
+
sourceId,
|
|
261
|
+
message: `Server search is unavailable for source "${sourceId}" because it does not advertise query.`,
|
|
262
|
+
});
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const fields = this.#searchFields(source);
|
|
266
|
+
if (fields.length === 0) {
|
|
267
|
+
diagnostics.push({
|
|
268
|
+
code: "search-unsupported",
|
|
269
|
+
capability: "query",
|
|
270
|
+
sourceId,
|
|
271
|
+
message: `Server search is unavailable for source "${sourceId}" because it has no searchable fields.`,
|
|
272
|
+
});
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const cacheKey = this.#searchKey(source, normalized, fields);
|
|
276
|
+
const cached = this.#searchCache.get(cacheKey);
|
|
277
|
+
if (cached) {
|
|
278
|
+
results.push(...cached);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
const pk = primaryKey(source);
|
|
283
|
+
const outFields = uniqueStrings([pk, ...fields]).slice(0, this.#maxFields);
|
|
284
|
+
const result = await source.query({
|
|
285
|
+
filter: queryFilter.or(...fields.map((field) => queryFilter.like(field, `%${normalized}%`))),
|
|
286
|
+
outFields,
|
|
287
|
+
returnGeometry: false,
|
|
288
|
+
pagination: { limit: this.#maxStoredItems },
|
|
289
|
+
signal: controller.signal,
|
|
290
|
+
});
|
|
291
|
+
const sourceResults = result.features.slice(0, this.#maxStoredItems).map((feature, index) => {
|
|
292
|
+
const id = featureId(feature.attributes, pk, index);
|
|
293
|
+
const target = sourceFeatureSelectionTarget(sourceId, id);
|
|
294
|
+
const presentation = this.#presentation(source);
|
|
295
|
+
return {
|
|
296
|
+
id: featureSelectionKey(target),
|
|
297
|
+
target,
|
|
298
|
+
title: featureTitle(feature.attributes, presentation.titleField, id),
|
|
299
|
+
subtitle: source.descriptor.id,
|
|
300
|
+
feature,
|
|
301
|
+
};
|
|
302
|
+
});
|
|
303
|
+
this.#searchCache.set(cacheKey, sourceResults, this.#searchTtlMs);
|
|
304
|
+
results.push(...sourceResults);
|
|
305
|
+
if (result.exceededTransferLimit || result.features.length > this.#maxStoredItems) {
|
|
306
|
+
diagnostics.push({
|
|
307
|
+
code: "result-limit",
|
|
308
|
+
sourceId,
|
|
309
|
+
message: `Search results for source "${sourceId}" were limited to ${this.#maxStoredItems}. Refine the search text for more specific results.`,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
if (isAbortError(error))
|
|
315
|
+
return;
|
|
316
|
+
diagnostics.push(diagnosticForError(error, "search-failed", sourceId));
|
|
317
|
+
}
|
|
318
|
+
}));
|
|
319
|
+
if (controller.signal.aborted || this.#searchAbort !== controller)
|
|
320
|
+
return this.#state.search;
|
|
321
|
+
const bounded = dedupeSearchResults(results).slice(0, this.#maxStoredItems);
|
|
322
|
+
const status = bounded.length > 0 || diagnostics.length === 0
|
|
323
|
+
? "ready"
|
|
324
|
+
: diagnostics.every(isUnsupportedDiagnostic)
|
|
325
|
+
? "unsupported"
|
|
326
|
+
: "error";
|
|
327
|
+
this.#setSearch({ status, query: normalized, results: bounded, diagnostics });
|
|
328
|
+
return this.#state.search;
|
|
329
|
+
}
|
|
330
|
+
navigate(index) {
|
|
331
|
+
if (this.#state.candidates.length === 0)
|
|
332
|
+
return Promise.resolve(this.#state);
|
|
333
|
+
return this.open(this.#state.candidates, {
|
|
334
|
+
origin: this.#state.origin ?? "programmatic",
|
|
335
|
+
activeIndex: clamp(index, 0, this.#state.candidates.length - 1),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
next() {
|
|
339
|
+
return this.navigate(this.#state.activeIndex + 1);
|
|
340
|
+
}
|
|
341
|
+
previous() {
|
|
342
|
+
return this.navigate(this.#state.activeIndex - 1);
|
|
343
|
+
}
|
|
344
|
+
setAttachmentPage(page) {
|
|
345
|
+
if (!this.#cachedDetails)
|
|
346
|
+
return this.#state;
|
|
347
|
+
this.#attachmentPage = boundedPage(page, this.#cachedDetails.attachmentTotal, this.#pageSize);
|
|
348
|
+
this.#state = this.#projectDetails(this.#state.status, this.#cachedDetails);
|
|
349
|
+
this.#notify();
|
|
350
|
+
return this.#state;
|
|
351
|
+
}
|
|
352
|
+
setRelationshipPage(relationshipId, page) {
|
|
353
|
+
if (!this.#cachedDetails)
|
|
354
|
+
return this.#state;
|
|
355
|
+
const relationship = this.#cachedDetails.relationships.find((candidate) => candidate.id === relationshipId);
|
|
356
|
+
if (!relationship)
|
|
357
|
+
return this.#state;
|
|
358
|
+
this.#relationshipPages.set(relationshipId, boundedPage(page, relationship.total, this.#pageSize));
|
|
359
|
+
this.#state = this.#projectDetails(this.#state.status, this.#cachedDetails);
|
|
360
|
+
this.#notify();
|
|
361
|
+
return this.#state;
|
|
362
|
+
}
|
|
363
|
+
refresh() {
|
|
364
|
+
if (this.#state.candidates.length === 0)
|
|
365
|
+
return Promise.resolve(this.#state);
|
|
366
|
+
return this.open(this.#state.candidates, {
|
|
367
|
+
activeIndex: this.#state.activeIndex,
|
|
368
|
+
origin: "refresh",
|
|
369
|
+
force: true,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
applyRealtime(update) {
|
|
373
|
+
const current = this.#state.feature;
|
|
374
|
+
if (!current || featureSelectionKey(current.target) !== featureSelectionKey(update.target))
|
|
375
|
+
return this.#state;
|
|
376
|
+
if (update.kind === "delete") {
|
|
377
|
+
this.#detailsAbort?.abort();
|
|
378
|
+
this.#cachedDetails = undefined;
|
|
379
|
+
this.#state = {
|
|
380
|
+
...this.#state,
|
|
381
|
+
status: "deleted",
|
|
382
|
+
feature: undefined,
|
|
383
|
+
attachments: undefined,
|
|
384
|
+
relationships: [],
|
|
385
|
+
staleReason: "The selected feature was deleted by a realtime update.",
|
|
386
|
+
};
|
|
387
|
+
this.#notify();
|
|
388
|
+
return this.#state;
|
|
389
|
+
}
|
|
390
|
+
const changedFields = update.changedFields ?? [];
|
|
391
|
+
const loadedFields = new Set(current.fields.map((field) => field.name));
|
|
392
|
+
const safePatch = update.completeness === "full" ||
|
|
393
|
+
(update.completeness === "patch" &&
|
|
394
|
+
changedFields.length > 0 &&
|
|
395
|
+
changedFields.every((field) => loadedFields.has(field)));
|
|
396
|
+
if (!safePatch || !update.attributes) {
|
|
397
|
+
this.#state = {
|
|
398
|
+
...this.#state,
|
|
399
|
+
status: "stale",
|
|
400
|
+
staleReason: "A realtime update changed fields that were not safely patchable. Refresh to load the authoritative feature.",
|
|
401
|
+
};
|
|
402
|
+
this.#notify();
|
|
403
|
+
return this.#state;
|
|
404
|
+
}
|
|
405
|
+
const attributes = update.completeness === "full"
|
|
406
|
+
? update.attributes
|
|
407
|
+
: { ...current.attributes, ...update.attributes };
|
|
408
|
+
const source = this.options.resolveSource(current.target.sourceId);
|
|
409
|
+
if (!source)
|
|
410
|
+
return this.#state;
|
|
411
|
+
const feature = projectFeature(current.target, { attributes, ...(update.geometry !== undefined ? { geometry: update.geometry } : {}) }, source, this.#presentation(source), this.options, [], current.fields.map((field) => field.name));
|
|
412
|
+
this.#state = { ...this.#state, status: "ready", feature, staleReason: undefined };
|
|
413
|
+
this.#notify();
|
|
414
|
+
return this.#state;
|
|
415
|
+
}
|
|
416
|
+
close() {
|
|
417
|
+
if (this.#disposed)
|
|
418
|
+
return;
|
|
419
|
+
this.#detailsAbort?.abort();
|
|
420
|
+
this.#detailsAbort = undefined;
|
|
421
|
+
this.#generation += 1;
|
|
422
|
+
this.#cachedDetails = undefined;
|
|
423
|
+
this.#state = {
|
|
424
|
+
...this.#state,
|
|
425
|
+
status: "idle",
|
|
426
|
+
origin: undefined,
|
|
427
|
+
candidates: [],
|
|
428
|
+
activeIndex: 0,
|
|
429
|
+
feature: undefined,
|
|
430
|
+
attachments: undefined,
|
|
431
|
+
relationships: [],
|
|
432
|
+
diagnostics: [],
|
|
433
|
+
staleReason: undefined,
|
|
434
|
+
};
|
|
435
|
+
this.options.selection?.setSelection([], { origin: "programmatic" });
|
|
436
|
+
this.#notify();
|
|
437
|
+
}
|
|
438
|
+
dispose() {
|
|
439
|
+
if (this.#disposed)
|
|
440
|
+
return;
|
|
441
|
+
this.#disposed = true;
|
|
442
|
+
this.#detailsAbort?.abort();
|
|
443
|
+
this.#searchAbort?.abort();
|
|
444
|
+
this.#detailsCache.clear();
|
|
445
|
+
this.#searchCache.clear();
|
|
446
|
+
this.#listeners.clear();
|
|
447
|
+
}
|
|
448
|
+
async #loadDetails(source, candidate, presentation, fields, signal, initialDiagnostics) {
|
|
449
|
+
const pk = primaryKey(source);
|
|
450
|
+
const feature = candidate.authoritative && candidate.feature
|
|
451
|
+
? candidate.feature
|
|
452
|
+
: (await source.query({
|
|
453
|
+
filter: queryFilter.eq(pk, scalarFeatureId(candidate.target.id)),
|
|
454
|
+
outFields: fields,
|
|
455
|
+
returnGeometry: false,
|
|
456
|
+
pagination: { limit: 1 },
|
|
457
|
+
signal,
|
|
458
|
+
})).features[0];
|
|
459
|
+
if (!feature) {
|
|
460
|
+
throw new FeatureNotFoundError(candidate.target.sourceId, candidate.target.id);
|
|
461
|
+
}
|
|
462
|
+
const diagnostics = [...initialDiagnostics];
|
|
463
|
+
if ((presentation.arcadeExpressions?.length ?? 0) > 0) {
|
|
464
|
+
diagnostics.push({
|
|
465
|
+
code: "popup-arcade-unsupported",
|
|
466
|
+
sourceId: candidate.target.sourceId,
|
|
467
|
+
message: "Arcade popup expressions are not executed. Replace them with server-computed fields or application-owned presentation logic.",
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
const projected = projectFeature(candidate.target, feature, source, presentation, this.options, diagnostics, fields);
|
|
471
|
+
const [attachmentResult, relationshipResult] = await Promise.all([
|
|
472
|
+
this.#loadAttachments(source, candidate.target, signal),
|
|
473
|
+
this.#loadRelationships(source, candidate.target, signal),
|
|
474
|
+
]);
|
|
475
|
+
diagnostics.push(...attachmentResult.diagnostics, ...relationshipResult.diagnostics);
|
|
476
|
+
return {
|
|
477
|
+
feature: projected,
|
|
478
|
+
attachments: attachmentResult.items,
|
|
479
|
+
attachmentTotal: attachmentResult.total,
|
|
480
|
+
attachmentTruncated: attachmentResult.truncated,
|
|
481
|
+
relationships: relationshipResult.items,
|
|
482
|
+
diagnostics,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
async #loadAttachments(source, target, signal) {
|
|
486
|
+
if (!source.capabilities.has("attachments")) {
|
|
487
|
+
return {
|
|
488
|
+
items: [],
|
|
489
|
+
total: 0,
|
|
490
|
+
truncated: false,
|
|
491
|
+
diagnostics: [capabilityDiagnostic("attachments", target.sourceId, source.descriptor.protocol)],
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
if (!this.options.loadAttachmentPage) {
|
|
495
|
+
return {
|
|
496
|
+
items: [],
|
|
497
|
+
total: 0,
|
|
498
|
+
truncated: false,
|
|
499
|
+
diagnostics: [
|
|
500
|
+
{
|
|
501
|
+
code: "attachments-unbounded",
|
|
502
|
+
capability: "attachments",
|
|
503
|
+
sourceId: target.sourceId,
|
|
504
|
+
message: "Attachments were not requested because the canonical source API cannot bound the response. Configure loadAttachmentPage with a protocol-aware offset/limit implementation.",
|
|
505
|
+
},
|
|
506
|
+
],
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
try {
|
|
510
|
+
const result = await this.options.loadAttachmentPage({
|
|
511
|
+
source,
|
|
512
|
+
target,
|
|
513
|
+
offset: 0,
|
|
514
|
+
limit: this.#maxStoredItems,
|
|
515
|
+
signal,
|
|
516
|
+
});
|
|
517
|
+
signal.throwIfAborted();
|
|
518
|
+
const bounded = result.items.slice(0, this.#maxStoredItems);
|
|
519
|
+
const items = bounded.map((attachment) => {
|
|
520
|
+
const rawHref = this.options.attachmentHref?.(attachment, target);
|
|
521
|
+
const href = rawHref
|
|
522
|
+
? sanitizeHonuaInspectionHref(rawHref, {
|
|
523
|
+
baseHref: this.options.baseHref,
|
|
524
|
+
allowedOrigins: this.options.allowedLinkOrigins,
|
|
525
|
+
})
|
|
526
|
+
: undefined;
|
|
527
|
+
return { ...attachment, ...(href ? { href } : {}) };
|
|
528
|
+
});
|
|
529
|
+
return {
|
|
530
|
+
items,
|
|
531
|
+
total: Math.max(items.length, result.total),
|
|
532
|
+
truncated: result.truncated === true || result.total > items.length || result.items.length > items.length,
|
|
533
|
+
diagnostics: result.items.length > this.#maxStoredItems
|
|
534
|
+
? [
|
|
535
|
+
{
|
|
536
|
+
code: "result-limit",
|
|
537
|
+
sourceId: target.sourceId,
|
|
538
|
+
message: `The attachment loader returned more than its ${this.#maxStoredItems}-item limit. Extra entries were discarded.`,
|
|
539
|
+
},
|
|
540
|
+
]
|
|
541
|
+
: [],
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
catch (error) {
|
|
545
|
+
if (isAbortError(error))
|
|
546
|
+
throw error;
|
|
547
|
+
return {
|
|
548
|
+
items: [],
|
|
549
|
+
total: 0,
|
|
550
|
+
truncated: false,
|
|
551
|
+
diagnostics: [diagnosticForError(error, "attachments-failed", target.sourceId)],
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
async #loadRelationships(source, target, signal) {
|
|
556
|
+
const definitions = this.#relationships(source).slice(0, this.#maxRelationships);
|
|
557
|
+
if (definitions.length === 0)
|
|
558
|
+
return { items: [], diagnostics: [] };
|
|
559
|
+
if (!source.capabilities.has("queryRelated")) {
|
|
560
|
+
return {
|
|
561
|
+
items: [],
|
|
562
|
+
diagnostics: [capabilityDiagnostic("queryRelated", target.sourceId, source.descriptor.protocol)],
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
if (!this.options.loadRelationshipPage) {
|
|
566
|
+
return {
|
|
567
|
+
items: [],
|
|
568
|
+
diagnostics: [
|
|
569
|
+
{
|
|
570
|
+
code: "relationships-unbounded",
|
|
571
|
+
capability: "queryRelated",
|
|
572
|
+
sourceId: target.sourceId,
|
|
573
|
+
message: "Related records were not requested because the canonical source API cannot bound the response. Configure loadRelationshipPage with a protocol-aware offset/limit implementation.",
|
|
574
|
+
},
|
|
575
|
+
],
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
const loadRelationshipPage = this.options.loadRelationshipPage;
|
|
579
|
+
const diagnostics = [];
|
|
580
|
+
const items = await Promise.all(definitions.map(async (definition) => {
|
|
581
|
+
try {
|
|
582
|
+
const result = await loadRelationshipPage({
|
|
583
|
+
source,
|
|
584
|
+
target,
|
|
585
|
+
relationship: {
|
|
586
|
+
...definition,
|
|
587
|
+
...(definition.outFields ? { outFields: definition.outFields.slice(0, this.#maxFields) } : {}),
|
|
588
|
+
},
|
|
589
|
+
offset: 0,
|
|
590
|
+
limit: this.#maxStoredItems,
|
|
591
|
+
signal,
|
|
592
|
+
});
|
|
593
|
+
signal.throwIfAborted();
|
|
594
|
+
const features = result.items.slice(0, this.#maxStoredItems);
|
|
595
|
+
const total = Math.max(features.length, result.total);
|
|
596
|
+
if (result.items.length > this.#maxStoredItems) {
|
|
597
|
+
diagnostics.push({
|
|
598
|
+
code: "result-limit",
|
|
599
|
+
sourceId: target.sourceId,
|
|
600
|
+
relationshipId: definition.id,
|
|
601
|
+
message: `The related-record loader returned more than its ${this.#maxStoredItems}-item limit. Extra entries were discarded.`,
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
id: definition.id,
|
|
606
|
+
label: definition.label ?? `Related records ${definition.id}`,
|
|
607
|
+
fields: result.fields ?? [],
|
|
608
|
+
features,
|
|
609
|
+
total,
|
|
610
|
+
truncated: result.truncated === true || total > features.length || result.items.length > features.length,
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
if (isAbortError(error))
|
|
615
|
+
throw error;
|
|
616
|
+
diagnostics.push({
|
|
617
|
+
...diagnosticForError(error, "relationships-failed", target.sourceId),
|
|
618
|
+
relationshipId: definition.id,
|
|
619
|
+
});
|
|
620
|
+
return undefined;
|
|
621
|
+
}
|
|
622
|
+
}));
|
|
623
|
+
return { items: items.filter((item) => item !== undefined), diagnostics };
|
|
624
|
+
}
|
|
625
|
+
#projectDetails(status, details) {
|
|
626
|
+
return {
|
|
627
|
+
...this.#state,
|
|
628
|
+
status,
|
|
629
|
+
feature: details.feature,
|
|
630
|
+
attachments: pageOf(details.attachments, details.attachmentTotal, this.#attachmentPage, this.#pageSize, details.attachmentTruncated),
|
|
631
|
+
relationships: details.relationships.map((relationship) => ({
|
|
632
|
+
id: relationship.id,
|
|
633
|
+
label: relationship.label,
|
|
634
|
+
fields: relationship.fields,
|
|
635
|
+
page: pageOf(relationship.features, relationship.total, this.#relationshipPages.get(relationship.id) ?? 0, this.#pageSize, relationship.truncated),
|
|
636
|
+
})),
|
|
637
|
+
diagnostics: details.diagnostics,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
#presentation(source) {
|
|
641
|
+
const configured = this.options.presentation;
|
|
642
|
+
return (typeof configured === "function" ? configured(source) : configured) ?? {};
|
|
643
|
+
}
|
|
644
|
+
#relationships(source) {
|
|
645
|
+
const configured = this.options.relationships;
|
|
646
|
+
return (typeof configured === "function" ? configured(source) : configured) ?? [];
|
|
647
|
+
}
|
|
648
|
+
#searchFields(source) {
|
|
649
|
+
const configured = this.options.searchFields;
|
|
650
|
+
const fields = typeof configured === "function" ? configured(source) : configured;
|
|
651
|
+
if (fields)
|
|
652
|
+
return uniqueStrings(fields).slice(0, this.#maxFields);
|
|
653
|
+
return searchableFields(source.descriptor.schema?.fields ?? []).slice(0, this.#maxFields);
|
|
654
|
+
}
|
|
655
|
+
#detailsKey(source, target, fields) {
|
|
656
|
+
const relationships = this.#relationships(source)
|
|
657
|
+
.slice(0, this.#maxRelationships)
|
|
658
|
+
.map((relationship) => `${relationship.id}:${relationship.outFields?.join(",") ?? "*"}`)
|
|
659
|
+
.join("|");
|
|
660
|
+
return [
|
|
661
|
+
featureSelectionKey(target),
|
|
662
|
+
fields.join(","),
|
|
663
|
+
relationships,
|
|
664
|
+
this.options.authScope?.() ?? "",
|
|
665
|
+
this.options.version?.(source) ?? "",
|
|
666
|
+
].join("\u0001");
|
|
667
|
+
}
|
|
668
|
+
#searchKey(source, query, fields) {
|
|
669
|
+
return [
|
|
670
|
+
source.descriptor.id,
|
|
671
|
+
query.toLocaleLowerCase(),
|
|
672
|
+
fields.join(","),
|
|
673
|
+
this.options.authScope?.() ?? "",
|
|
674
|
+
this.options.version?.(source) ?? "",
|
|
675
|
+
].join("\u0001");
|
|
676
|
+
}
|
|
677
|
+
#setSearch(search) {
|
|
678
|
+
this.#state = { ...this.#state, search };
|
|
679
|
+
this.#notify();
|
|
680
|
+
}
|
|
681
|
+
#notify() {
|
|
682
|
+
for (const listener of this.#listeners)
|
|
683
|
+
listener(this.#state);
|
|
684
|
+
}
|
|
685
|
+
#assertLive() {
|
|
686
|
+
if (this.#disposed)
|
|
687
|
+
throw new Error("HonuaFeatureInspectionController has been disposed");
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
class FeatureNotFoundError extends Error {
|
|
691
|
+
sourceId;
|
|
692
|
+
featureId;
|
|
693
|
+
constructor(sourceId, featureId) {
|
|
694
|
+
super(`Feature ${String(featureId)} was not found in source ${sourceId}`);
|
|
695
|
+
this.sourceId = sourceId;
|
|
696
|
+
this.featureId = featureId;
|
|
697
|
+
this.name = "FeatureNotFoundError";
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
function projectFeature(target, feature, source, presentation, options, diagnostics, requestedFields = requiredFields(source, presentation, DEFAULT_MAX_FIELDS)) {
|
|
701
|
+
const attributes = feature.attributes;
|
|
702
|
+
const schemaFields = new Map((source.descriptor.schema?.fields ?? []).map((field) => [field.name, field]));
|
|
703
|
+
const names = requestedFields.filter((name) => name in attributes);
|
|
704
|
+
const fields = names.map((name) => ({
|
|
705
|
+
name,
|
|
706
|
+
label: schemaFields.get(name)?.alias ?? name,
|
|
707
|
+
value: attributes[name],
|
|
708
|
+
text: formatHonuaInspectionValue(attributes[name]),
|
|
709
|
+
}));
|
|
710
|
+
const links = [];
|
|
711
|
+
for (const link of presentation.links ?? []) {
|
|
712
|
+
const raw = attributes[link.hrefField];
|
|
713
|
+
if (typeof raw !== "string" || !raw.trim())
|
|
714
|
+
continue;
|
|
715
|
+
const href = sanitizeHonuaInspectionHref(raw, {
|
|
716
|
+
baseHref: options.baseHref,
|
|
717
|
+
allowedOrigins: options.allowedLinkOrigins,
|
|
718
|
+
});
|
|
719
|
+
if (!href) {
|
|
720
|
+
diagnostics.push({
|
|
721
|
+
code: "unsafe-link",
|
|
722
|
+
sourceId: target.sourceId,
|
|
723
|
+
message: `The external link in field "${link.hrefField}" was withheld because its URL is unsafe or outside the configured origins.`,
|
|
724
|
+
});
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
const baseOrigin = safeOrigin(options.baseHref);
|
|
728
|
+
links.push({ label: link.label, href, external: safeOrigin(href) !== baseOrigin });
|
|
729
|
+
}
|
|
730
|
+
const description = presentation.description
|
|
731
|
+
? sanitizeHonuaInspectionRichText(interpolateInspectionTemplate(presentation.description, attributes))
|
|
732
|
+
: undefined;
|
|
733
|
+
return {
|
|
734
|
+
target,
|
|
735
|
+
title: featureTitle(feature.attributes, presentation.titleField, target.id),
|
|
736
|
+
...(description ? { description } : {}),
|
|
737
|
+
fields,
|
|
738
|
+
links,
|
|
739
|
+
attributes: feature.attributes,
|
|
740
|
+
...(feature.geometry !== undefined ? { geometry: feature.geometry } : {}),
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
function requiredFields(source, presentation, maxFields) {
|
|
744
|
+
const pk = primaryKey(source);
|
|
745
|
+
const requested = presentation.fields ?? source.descriptor.schema?.fields?.map((field) => field.name) ?? [pk];
|
|
746
|
+
const linkFields = presentation.links?.map((link) => link.hrefField) ?? [];
|
|
747
|
+
return uniqueStrings([
|
|
748
|
+
pk,
|
|
749
|
+
...(presentation.titleField ? [presentation.titleField] : []),
|
|
750
|
+
...requested,
|
|
751
|
+
...linkFields,
|
|
752
|
+
]).slice(0, maxFields);
|
|
753
|
+
}
|
|
754
|
+
function primaryKey(source) {
|
|
755
|
+
return (source.descriptor.schema?.primaryKey ??
|
|
756
|
+
source.descriptor.schema?.fields?.find((field) => /^(objectid|fid|id)$/i.test(field.name))?.name ??
|
|
757
|
+
"OBJECTID");
|
|
758
|
+
}
|
|
759
|
+
function searchableFields(fields) {
|
|
760
|
+
const strings = fields.filter((field) => /string|guid/i.test(field.type)).map((field) => field.name);
|
|
761
|
+
return strings.length > 0 ? strings : fields.map((field) => field.name);
|
|
762
|
+
}
|
|
763
|
+
function scalarFeatureId(id) {
|
|
764
|
+
return id;
|
|
765
|
+
}
|
|
766
|
+
function featureId(attributes, pk, fallbackIndex) {
|
|
767
|
+
const record = asRecord(attributes);
|
|
768
|
+
const value = record[pk];
|
|
769
|
+
if (typeof value === "string" || typeof value === "number")
|
|
770
|
+
return value;
|
|
771
|
+
for (const name of ["OBJECTID", "objectid", "id", "ID", "fid", "FID"]) {
|
|
772
|
+
const candidate = record[name];
|
|
773
|
+
if (typeof candidate === "string" || typeof candidate === "number")
|
|
774
|
+
return candidate;
|
|
775
|
+
}
|
|
776
|
+
return `result:${fallbackIndex}`;
|
|
777
|
+
}
|
|
778
|
+
function featureTitle(attributes, titleField, id) {
|
|
779
|
+
const record = asRecord(attributes);
|
|
780
|
+
const named = titleField ? record[titleField] : undefined;
|
|
781
|
+
if (typeof named === "string" && named.trim())
|
|
782
|
+
return named;
|
|
783
|
+
for (const key of ["name", "Name", "NAME", "title", "Title", "TITLE", "label", "Label", "LABEL"]) {
|
|
784
|
+
const value = record[key];
|
|
785
|
+
if (typeof value === "string" && value.trim())
|
|
786
|
+
return value;
|
|
787
|
+
}
|
|
788
|
+
return String(id);
|
|
789
|
+
}
|
|
790
|
+
function asRecord(value) {
|
|
791
|
+
return value !== null && typeof value === "object" ? value : {};
|
|
792
|
+
}
|
|
793
|
+
function dedupeCandidates(candidates) {
|
|
794
|
+
const seen = new Set();
|
|
795
|
+
return candidates.filter((candidate) => {
|
|
796
|
+
const key = featureSelectionKey(candidate.target);
|
|
797
|
+
if (seen.has(key))
|
|
798
|
+
return false;
|
|
799
|
+
seen.add(key);
|
|
800
|
+
return true;
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
function dedupeSearchResults(results) {
|
|
804
|
+
const seen = new Set();
|
|
805
|
+
return results.filter((result) => {
|
|
806
|
+
if (seen.has(result.id))
|
|
807
|
+
return false;
|
|
808
|
+
seen.add(result.id);
|
|
809
|
+
return true;
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
function pageOf(values, total, page, limit, truncated) {
|
|
813
|
+
const resolvedPage = boundedPage(page, total, limit);
|
|
814
|
+
const offset = resolvedPage * limit;
|
|
815
|
+
return {
|
|
816
|
+
items: values.slice(offset, offset + limit),
|
|
817
|
+
offset,
|
|
818
|
+
limit,
|
|
819
|
+
total,
|
|
820
|
+
hasPrevious: resolvedPage > 0,
|
|
821
|
+
hasNext: offset + limit < total && offset + limit < values.length,
|
|
822
|
+
truncated,
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
function boundedPage(page, total, limit) {
|
|
826
|
+
return clamp(Math.trunc(page), 0, Math.max(0, Math.ceil(total / limit) - 1));
|
|
827
|
+
}
|
|
828
|
+
function capabilityDiagnostic(capability, sourceId, protocol = "unknown") {
|
|
829
|
+
const error = new HonuaCapabilityNotSupportedError(capability, protocol, sourceId);
|
|
830
|
+
const labels = { query: "Feature details", attachments: "Attachments", queryRelated: "Related records" };
|
|
831
|
+
const codes = {
|
|
832
|
+
query: "query-unsupported",
|
|
833
|
+
attachments: "attachments-unsupported",
|
|
834
|
+
queryRelated: "relationships-unsupported",
|
|
835
|
+
};
|
|
836
|
+
return {
|
|
837
|
+
code: codes[capability],
|
|
838
|
+
capability,
|
|
839
|
+
sourceId,
|
|
840
|
+
message: `${labels[capability]} are unavailable: ${error.message}. Choose a source that advertises "${capability}" or hide this presentation.`,
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
function diagnosticForError(error, fallback, sourceId) {
|
|
844
|
+
if (error instanceof FeatureNotFoundError) {
|
|
845
|
+
return {
|
|
846
|
+
code: "feature-not-found",
|
|
847
|
+
sourceId,
|
|
848
|
+
message: `Feature "${String(error.featureId)}" is no longer available in source "${sourceId}". Clear the selection or refresh its origin.`,
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
if (error instanceof HonuaCapabilityNotSupportedError) {
|
|
852
|
+
const capability = error.capability === "attachments" || error.capability === "queryRelated" ? error.capability : "query";
|
|
853
|
+
return capabilityDiagnostic(capability, sourceId, error.protocol);
|
|
854
|
+
}
|
|
855
|
+
const labels = {
|
|
856
|
+
"query-failed": "Feature details could not be loaded. Retry or verify access to the source.",
|
|
857
|
+
"search-failed": "Server search failed. Refine the query, retry, or verify access to the source.",
|
|
858
|
+
"attachments-failed": "Attachments could not be loaded. Retry or verify attachment access.",
|
|
859
|
+
"relationships-failed": "Related records could not be loaded. Retry or verify relationship access.",
|
|
860
|
+
};
|
|
861
|
+
return { code: fallback, sourceId, message: labels[fallback] };
|
|
862
|
+
}
|
|
863
|
+
function isUnsupportedDiagnostic(diagnostic) {
|
|
864
|
+
return diagnostic.code.endsWith("-unsupported");
|
|
865
|
+
}
|
|
866
|
+
function isAbortError(error) {
|
|
867
|
+
return ((typeof DOMException === "function" && error instanceof DOMException && error.name === "AbortError") ||
|
|
868
|
+
(error instanceof Error && error.name === "AbortError"));
|
|
869
|
+
}
|
|
870
|
+
function positiveInteger(value, fallback) {
|
|
871
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
|
|
872
|
+
}
|
|
873
|
+
function nonNegativeNumber(value, fallback) {
|
|
874
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : fallback;
|
|
875
|
+
}
|
|
876
|
+
function clamp(value, min, max) {
|
|
877
|
+
return Math.min(max, Math.max(min, value));
|
|
878
|
+
}
|
|
879
|
+
function uniqueStrings(values) {
|
|
880
|
+
return [...new Set(values.filter((value) => value.trim().length > 0))];
|
|
881
|
+
}
|
|
882
|
+
function safeOrigin(value) {
|
|
883
|
+
if (!value)
|
|
884
|
+
return undefined;
|
|
885
|
+
try {
|
|
886
|
+
return new URL(value).origin;
|
|
887
|
+
}
|
|
888
|
+
catch {
|
|
889
|
+
return undefined;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Convert source-owned popup text to plain text. Tags, script/style blocks,
|
|
894
|
+
* event handlers, and control characters never reach a DOM interpretation.
|
|
895
|
+
*/
|
|
896
|
+
export function sanitizeHonuaInspectionRichText(value) {
|
|
897
|
+
const plainText = decodeHonuaInspectionEntities(stripHonuaInspectionMarkup(value));
|
|
898
|
+
return [...plainText]
|
|
899
|
+
.filter((character) => {
|
|
900
|
+
const code = character.charCodeAt(0);
|
|
901
|
+
return code === 9 || code === 10 || code === 13 || (code >= 32 && code !== 127);
|
|
902
|
+
})
|
|
903
|
+
.join("")
|
|
904
|
+
.replace(/[ \t]+/g, " ")
|
|
905
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
906
|
+
.trim();
|
|
907
|
+
}
|
|
908
|
+
const WITHHELD_INSPECTION_ELEMENTS = new Set(["script", "style", "iframe", "object", "embed"]);
|
|
909
|
+
function stripHonuaInspectionMarkup(value) {
|
|
910
|
+
let result = "";
|
|
911
|
+
let offset = 0;
|
|
912
|
+
let withheldElement;
|
|
913
|
+
while (offset < value.length) {
|
|
914
|
+
const open = value.indexOf("<", offset);
|
|
915
|
+
if (open < 0) {
|
|
916
|
+
if (!withheldElement)
|
|
917
|
+
result += value.slice(offset);
|
|
918
|
+
break;
|
|
919
|
+
}
|
|
920
|
+
if (!withheldElement)
|
|
921
|
+
result += value.slice(offset, open);
|
|
922
|
+
const close = value.indexOf(">", open + 1);
|
|
923
|
+
if (close < 0) {
|
|
924
|
+
if (!withheldElement)
|
|
925
|
+
result += value.slice(open);
|
|
926
|
+
break;
|
|
927
|
+
}
|
|
928
|
+
const tag = value.slice(open + 1, close).trim();
|
|
929
|
+
const closing = tag.startsWith("/");
|
|
930
|
+
const name = (closing ? tag.slice(1) : tag).split(/[\s/]/, 1)[0]?.toLocaleLowerCase() ?? "";
|
|
931
|
+
if (withheldElement) {
|
|
932
|
+
if (closing && name === withheldElement)
|
|
933
|
+
withheldElement = undefined;
|
|
934
|
+
}
|
|
935
|
+
else if (!closing && WITHHELD_INSPECTION_ELEMENTS.has(name)) {
|
|
936
|
+
withheldElement = name;
|
|
937
|
+
result += " ";
|
|
938
|
+
}
|
|
939
|
+
else if (name === "br" || (closing && name === "p")) {
|
|
940
|
+
result += "\n";
|
|
941
|
+
}
|
|
942
|
+
else {
|
|
943
|
+
result += " ";
|
|
944
|
+
}
|
|
945
|
+
offset = close + 1;
|
|
946
|
+
}
|
|
947
|
+
return result;
|
|
948
|
+
}
|
|
949
|
+
function decodeHonuaInspectionEntities(value) {
|
|
950
|
+
const entities = {
|
|
951
|
+
nbsp: " ",
|
|
952
|
+
lt: "<",
|
|
953
|
+
gt: ">",
|
|
954
|
+
amp: "&",
|
|
955
|
+
quot: '"',
|
|
956
|
+
"#39": "'",
|
|
957
|
+
"#x27": "'",
|
|
958
|
+
};
|
|
959
|
+
return value.replace(/&(nbsp|lt|gt|amp|quot|#39|#x27);/gi, (entity, name) => entities[name.toLocaleLowerCase()] ?? entity);
|
|
960
|
+
}
|
|
961
|
+
/** Resolve a display link without carrying credentials or unsafe schemes. */
|
|
962
|
+
export function sanitizeHonuaInspectionHref(value, options = {}) {
|
|
963
|
+
try {
|
|
964
|
+
const url = options.baseHref ? new URL(value, options.baseHref) : new URL(value);
|
|
965
|
+
if (url.protocol !== "https:" && url.protocol !== "http:")
|
|
966
|
+
return undefined;
|
|
967
|
+
if (url.username || url.password)
|
|
968
|
+
return undefined;
|
|
969
|
+
const allowed = options.allowedOrigins;
|
|
970
|
+
if (allowed && allowed.length > 0 && !allowed.includes(url.origin))
|
|
971
|
+
return undefined;
|
|
972
|
+
url.hash = "";
|
|
973
|
+
return url.href;
|
|
974
|
+
}
|
|
975
|
+
catch {
|
|
976
|
+
return undefined;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
/** Format a scalar popup/detail value without interpreting it as markup. */
|
|
980
|
+
export function formatHonuaInspectionValue(value) {
|
|
981
|
+
if (value === null || value === undefined)
|
|
982
|
+
return "";
|
|
983
|
+
if (value instanceof Date)
|
|
984
|
+
return value.toISOString();
|
|
985
|
+
if (typeof value === "object") {
|
|
986
|
+
try {
|
|
987
|
+
return JSON.stringify(value);
|
|
988
|
+
}
|
|
989
|
+
catch {
|
|
990
|
+
return "[unavailable]";
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
return String(value);
|
|
994
|
+
}
|
|
995
|
+
function interpolateInspectionTemplate(template, attributes) {
|
|
996
|
+
return template.replace(/\{([A-Za-z_][A-Za-z0-9_.:]*)\}/g, (_match, field) => formatHonuaInspectionValue(attributes[field]));
|
|
997
|
+
}
|
|
998
|
+
const globalDom = globalThis;
|
|
999
|
+
const HTMLElementBase = globalDom.HTMLElement ?? class {
|
|
1000
|
+
};
|
|
1001
|
+
/** Accessible search + popup/details presentation for a feature inspection controller. */
|
|
1002
|
+
export class HonuaFeatureInspectionElement extends HTMLElementBase {
|
|
1003
|
+
#controller;
|
|
1004
|
+
#subscription;
|
|
1005
|
+
#applicationContext;
|
|
1006
|
+
#ownsController = false;
|
|
1007
|
+
#state;
|
|
1008
|
+
#restoreFocus;
|
|
1009
|
+
#messages = {};
|
|
1010
|
+
/** Caller-injected labels, status text, range formatters, and diagnostics. */
|
|
1011
|
+
get messages() {
|
|
1012
|
+
return this.#messages;
|
|
1013
|
+
}
|
|
1014
|
+
set messages(messages) {
|
|
1015
|
+
this.#messages = messages ?? {};
|
|
1016
|
+
this.#render();
|
|
1017
|
+
}
|
|
1018
|
+
get inspection() {
|
|
1019
|
+
return this.#controller;
|
|
1020
|
+
}
|
|
1021
|
+
set inspection(controller) {
|
|
1022
|
+
this.#bindInspection(controller, false);
|
|
1023
|
+
}
|
|
1024
|
+
/** Application shell context assigned by `mountHonuaApplication()`. */
|
|
1025
|
+
get applicationContext() {
|
|
1026
|
+
return this.#applicationContext;
|
|
1027
|
+
}
|
|
1028
|
+
set applicationContext(context) {
|
|
1029
|
+
if (context === this.#applicationContext)
|
|
1030
|
+
return;
|
|
1031
|
+
this.#applicationContext = context;
|
|
1032
|
+
if (!context) {
|
|
1033
|
+
if (this.#ownsController)
|
|
1034
|
+
this.#bindInspection(undefined, false);
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
this.#bindApplicationContext(context);
|
|
1038
|
+
}
|
|
1039
|
+
honuaApplicationContextConnected(context) {
|
|
1040
|
+
this.applicationContext = context;
|
|
1041
|
+
}
|
|
1042
|
+
honuaApplicationContextChanged(event) {
|
|
1043
|
+
if (event.current !== this.#applicationContext?.snapshot)
|
|
1044
|
+
return;
|
|
1045
|
+
if (event.changed.includes("binding") || event.changed.includes("authorization")) {
|
|
1046
|
+
this.#bindApplicationContext(this.#applicationContext);
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
if (event.changed.includes("selection"))
|
|
1050
|
+
this.#applyContextSelection(event.current.selection);
|
|
1051
|
+
this.#applyContextPresentation();
|
|
1052
|
+
}
|
|
1053
|
+
honuaApplicationContextDisconnected(context) {
|
|
1054
|
+
if (this.#applicationContext === context)
|
|
1055
|
+
this.applicationContext = undefined;
|
|
1056
|
+
}
|
|
1057
|
+
#bindInspection(controller, owned) {
|
|
1058
|
+
if (controller === this.#controller)
|
|
1059
|
+
return;
|
|
1060
|
+
this.#subscription?.remove();
|
|
1061
|
+
if (this.#ownsController)
|
|
1062
|
+
this.#controller?.dispose();
|
|
1063
|
+
this.#controller = controller;
|
|
1064
|
+
this.#ownsController = owned;
|
|
1065
|
+
this.#state = controller?.snapshot();
|
|
1066
|
+
if (controller)
|
|
1067
|
+
this.#subscribeInspection(controller);
|
|
1068
|
+
this.#render();
|
|
1069
|
+
}
|
|
1070
|
+
#subscribeInspection(controller) {
|
|
1071
|
+
this.#subscription = controller.subscribe((state) => {
|
|
1072
|
+
const wasOpen = this.#state?.status !== "idle" && this.#state !== undefined;
|
|
1073
|
+
const isOpen = state.status !== "idle";
|
|
1074
|
+
if (!wasOpen && isOpen)
|
|
1075
|
+
this.#captureFocus();
|
|
1076
|
+
this.#state = state;
|
|
1077
|
+
this.#render();
|
|
1078
|
+
if (wasOpen && !isOpen)
|
|
1079
|
+
this.#restoreFocus?.focus();
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
#bindApplicationContext(context) {
|
|
1083
|
+
if (!context.snapshot.binding.source) {
|
|
1084
|
+
if (this.#ownsController)
|
|
1085
|
+
this.#bindInspection(undefined, false);
|
|
1086
|
+
this.#applyContextPresentation();
|
|
1087
|
+
return;
|
|
1088
|
+
}
|
|
1089
|
+
this.#bindInspection(createHonuaFeatureInspectionFromApplicationContext(context), true);
|
|
1090
|
+
this.#applyContextSelection(context.snapshot.selection);
|
|
1091
|
+
this.#applyContextPresentation();
|
|
1092
|
+
}
|
|
1093
|
+
#applyContextSelection(selection) {
|
|
1094
|
+
if (!this.#controller)
|
|
1095
|
+
return;
|
|
1096
|
+
const target = selection.find(isSourceQualifiedTarget);
|
|
1097
|
+
const current = this.#controller.snapshot().candidates[this.#controller.snapshot().activeIndex]?.target;
|
|
1098
|
+
if (!target) {
|
|
1099
|
+
if (current)
|
|
1100
|
+
this.#controller.close();
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
if (!current || featureSelectionKey(current) !== featureSelectionKey(target)) {
|
|
1104
|
+
void this.#controller.open({ target }, { origin: "programmatic" });
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
#applyContextPresentation() {
|
|
1108
|
+
const snapshot = this.#applicationContext?.snapshot;
|
|
1109
|
+
if (!snapshot)
|
|
1110
|
+
return;
|
|
1111
|
+
this.setAttribute("lang", snapshot.locale.locale);
|
|
1112
|
+
this.setAttribute("dir", snapshot.locale.direction ?? "auto");
|
|
1113
|
+
this.style.colorScheme = snapshot.theme.colorScheme ?? "";
|
|
1114
|
+
}
|
|
1115
|
+
connectedCallback() {
|
|
1116
|
+
if (!this.shadowRoot && typeof this.attachShadow === "function")
|
|
1117
|
+
this.attachShadow({ mode: "open" });
|
|
1118
|
+
this.shadowRoot?.addEventListener("click", this.#onClick);
|
|
1119
|
+
this.shadowRoot?.addEventListener("keydown", this.#onKeyDown);
|
|
1120
|
+
this.shadowRoot?.addEventListener("submit", this.#onSubmit);
|
|
1121
|
+
if (this.#controller && !this.#subscription)
|
|
1122
|
+
this.#subscribeInspection(this.#controller);
|
|
1123
|
+
if (this.#applicationContext && !this.#controller)
|
|
1124
|
+
this.#bindApplicationContext(this.#applicationContext);
|
|
1125
|
+
this.#render();
|
|
1126
|
+
}
|
|
1127
|
+
disconnectedCallback() {
|
|
1128
|
+
this.shadowRoot?.removeEventListener("click", this.#onClick);
|
|
1129
|
+
this.shadowRoot?.removeEventListener("keydown", this.#onKeyDown);
|
|
1130
|
+
this.shadowRoot?.removeEventListener("submit", this.#onSubmit);
|
|
1131
|
+
this.#subscription?.remove();
|
|
1132
|
+
this.#subscription = undefined;
|
|
1133
|
+
if (this.#ownsController)
|
|
1134
|
+
this.#bindInspection(undefined, false);
|
|
1135
|
+
}
|
|
1136
|
+
#captureFocus() {
|
|
1137
|
+
const active = this.ownerDocument?.activeElement;
|
|
1138
|
+
this.#restoreFocus = active instanceof HTMLElement ? active : undefined;
|
|
1139
|
+
}
|
|
1140
|
+
#render() {
|
|
1141
|
+
const root = this.shadowRoot;
|
|
1142
|
+
if (!root)
|
|
1143
|
+
return;
|
|
1144
|
+
const focus = captureInspectionFocus(root);
|
|
1145
|
+
const state = this.#state;
|
|
1146
|
+
const messages = resolveInspectionMessages(this.#messages, this.#applicationContext);
|
|
1147
|
+
const label = this.getAttribute("label") ?? messages.panelLabel;
|
|
1148
|
+
const search = state?.search ?? { status: "idle", query: "", results: [], diagnostics: [] };
|
|
1149
|
+
const feature = state?.feature;
|
|
1150
|
+
const role = this.getAttribute("presentation") === "popup" ? "dialog" : "region";
|
|
1151
|
+
const details = feature
|
|
1152
|
+
? `<article class="details" part="details" role="${role}"${role === "dialog" ? ' aria-modal="false"' : ""} aria-labelledby="honua-inspection-title">
|
|
1153
|
+
<header><h2 id="honua-inspection-title" tabindex="-1">${escapeHtml(feature.title)}</h2>
|
|
1154
|
+
<button type="button" data-focus-id="close" data-action="close" aria-label="${escapeAttribute(messages.closeDetailsLabel)}">×</button></header>
|
|
1155
|
+
${feature.description ? `<p>${escapeHtml(feature.description)}</p>` : ""}
|
|
1156
|
+
${renderNavigation(state, messages)}
|
|
1157
|
+
<dl>${feature.fields
|
|
1158
|
+
.map((field) => `<div><dt>${escapeHtml(field.label)}</dt><dd>${escapeHtml(field.text)}</dd></div>`)
|
|
1159
|
+
.join("")}</dl>
|
|
1160
|
+
${renderLinks(feature.links, messages)}
|
|
1161
|
+
${renderAttachments(state?.attachments, messages)}
|
|
1162
|
+
${renderRelationships(state?.relationships ?? [], messages)}
|
|
1163
|
+
</article>`
|
|
1164
|
+
: "";
|
|
1165
|
+
renderCspSafeShadowHtml(root, `<style>${inspectionStyles()}</style>
|
|
1166
|
+
<section class="inspection" part="panel" aria-label="${escapeAttribute(label)}" aria-busy="${String(state?.status === "loading" || search.status === "loading")}">
|
|
1167
|
+
<form role="search"><label for="honua-inspection-search">${escapeHtml(messages.searchLabel)}</label>
|
|
1168
|
+
<div class="search-row"><input id="honua-inspection-search" name="q" type="search" value="${escapeAttribute(search.query)}" data-focus-id="search" autocomplete="off" role="combobox" aria-autocomplete="list" aria-expanded="${String(search.results.length > 0)}" aria-controls="honua-inspection-results" />
|
|
1169
|
+
<button type="submit" data-focus-id="search-submit">${escapeHtml(messages.searchButtonLabel)}</button></div></form>
|
|
1170
|
+
<ol id="honua-inspection-results" class="results" role="listbox" aria-label="${escapeAttribute(messages.searchResultsLabel)}">${search.results
|
|
1171
|
+
.map((result, index) => `<li role="option" aria-selected="${String(featureSelectionKey(result.target) ===
|
|
1172
|
+
(state?.feature ? featureSelectionKey(state.feature.target) : undefined))}"><button type="button" data-focus-id="search-${index}" data-search-index="${index}">${escapeHtml(result.title)}<small>${escapeHtml(result.subtitle ?? result.target.sourceId)}</small></button></li>`)
|
|
1173
|
+
.join("")}</ol>
|
|
1174
|
+
<p class="status" role="status" aria-live="polite">${escapeHtml(statusText(state, messages))}</p>
|
|
1175
|
+
${renderDiagnostics(state?.diagnostics ?? [], search.diagnostics, messages)}
|
|
1176
|
+
${details}
|
|
1177
|
+
</section>`);
|
|
1178
|
+
restoreInspectionFocus(root, focus);
|
|
1179
|
+
}
|
|
1180
|
+
#onClick = (event) => {
|
|
1181
|
+
const button = event.composedPath().find((entry) => entry instanceof HTMLButtonElement);
|
|
1182
|
+
if (button)
|
|
1183
|
+
this.#activate(button);
|
|
1184
|
+
};
|
|
1185
|
+
#onSubmit = (event) => {
|
|
1186
|
+
event.preventDefault();
|
|
1187
|
+
this.#search();
|
|
1188
|
+
};
|
|
1189
|
+
#onKeyDown = (event) => {
|
|
1190
|
+
const keyboardEvent = event;
|
|
1191
|
+
const target = keyboardEvent.composedPath()[0];
|
|
1192
|
+
if (target instanceof HTMLButtonElement && (keyboardEvent.key === "Enter" || keyboardEvent.key === " ")) {
|
|
1193
|
+
keyboardEvent.preventDefault();
|
|
1194
|
+
this.#activate(target);
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
if (target instanceof HTMLInputElement && keyboardEvent.key === "Enter") {
|
|
1198
|
+
keyboardEvent.preventDefault();
|
|
1199
|
+
this.#search();
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
if (keyboardEvent.key === "/" && !(target instanceof HTMLInputElement)) {
|
|
1203
|
+
keyboardEvent.preventDefault();
|
|
1204
|
+
this.shadowRoot?.querySelector("input[name='q']")?.focus();
|
|
1205
|
+
return;
|
|
1206
|
+
}
|
|
1207
|
+
if (keyboardEvent.key === "Escape" && this.#state?.status !== "idle") {
|
|
1208
|
+
event.preventDefault();
|
|
1209
|
+
this.#controller?.close();
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
if (target instanceof HTMLInputElement)
|
|
1213
|
+
return;
|
|
1214
|
+
if (keyboardEvent.key === "ArrowLeft") {
|
|
1215
|
+
keyboardEvent.preventDefault();
|
|
1216
|
+
void this.#controller?.previous();
|
|
1217
|
+
}
|
|
1218
|
+
else if (keyboardEvent.key === "ArrowRight") {
|
|
1219
|
+
keyboardEvent.preventDefault();
|
|
1220
|
+
void this.#controller?.next();
|
|
1221
|
+
}
|
|
1222
|
+
else if (keyboardEvent.key === "Home") {
|
|
1223
|
+
keyboardEvent.preventDefault();
|
|
1224
|
+
void this.#controller?.navigate(0);
|
|
1225
|
+
}
|
|
1226
|
+
else if (keyboardEvent.key === "End") {
|
|
1227
|
+
keyboardEvent.preventDefault();
|
|
1228
|
+
void this.#controller?.navigate(Number.MAX_SAFE_INTEGER);
|
|
1229
|
+
}
|
|
1230
|
+
};
|
|
1231
|
+
#search() {
|
|
1232
|
+
const value = this.shadowRoot?.querySelector("input[name='q']")?.value ?? "";
|
|
1233
|
+
void this.#controller?.search(value);
|
|
1234
|
+
}
|
|
1235
|
+
#activate(button) {
|
|
1236
|
+
if (button.disabled)
|
|
1237
|
+
return;
|
|
1238
|
+
if (button.dataset.searchIndex !== undefined) {
|
|
1239
|
+
void this.#controller?.openSearchResult(Number(button.dataset.searchIndex));
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
switch (button.dataset.action) {
|
|
1243
|
+
case "close":
|
|
1244
|
+
this.#controller?.close();
|
|
1245
|
+
return;
|
|
1246
|
+
case "previous":
|
|
1247
|
+
void this.#controller?.previous();
|
|
1248
|
+
return;
|
|
1249
|
+
case "next":
|
|
1250
|
+
void this.#controller?.next();
|
|
1251
|
+
return;
|
|
1252
|
+
case "refresh":
|
|
1253
|
+
void this.#controller?.refresh();
|
|
1254
|
+
return;
|
|
1255
|
+
case "attachments-previous":
|
|
1256
|
+
this.#moveAttachmentPage(-1);
|
|
1257
|
+
return;
|
|
1258
|
+
case "attachments-next":
|
|
1259
|
+
this.#moveAttachmentPage(1);
|
|
1260
|
+
return;
|
|
1261
|
+
}
|
|
1262
|
+
if (button.dataset.relationshipId === undefined)
|
|
1263
|
+
return;
|
|
1264
|
+
const relationshipId = Number(button.dataset.relationshipId);
|
|
1265
|
+
const relationship = this.#state?.relationships.find((candidate) => candidate.id === relationshipId);
|
|
1266
|
+
if (!relationship)
|
|
1267
|
+
return;
|
|
1268
|
+
const direction = button.dataset.relationshipPage === "previous" ? -1 : 1;
|
|
1269
|
+
this.#controller?.setRelationshipPage(relationshipId, relationship.page.offset / relationship.page.limit + direction);
|
|
1270
|
+
}
|
|
1271
|
+
#moveAttachmentPage(direction) {
|
|
1272
|
+
const page = this.#state?.attachments;
|
|
1273
|
+
if (page)
|
|
1274
|
+
this.#controller?.setAttachmentPage(page.offset / page.limit + direction);
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
/** Define `<honua-feature-inspection>` in a registry, if it is not already defined. */
|
|
1278
|
+
export function defineHonuaFeatureInspection(registry = globalDom.customElements) {
|
|
1279
|
+
if (!registry?.get("honua-feature-inspection"))
|
|
1280
|
+
registry?.define("honua-feature-inspection", HonuaFeatureInspectionElement);
|
|
1281
|
+
}
|
|
1282
|
+
const DEFAULT_INSPECTION_MESSAGES = Object.freeze({
|
|
1283
|
+
panelLabel: "Feature inspection",
|
|
1284
|
+
searchLabel: "Search features",
|
|
1285
|
+
searchButtonLabel: "Search",
|
|
1286
|
+
searchResultsLabel: "Feature search results",
|
|
1287
|
+
closeDetailsLabel: "Close feature details",
|
|
1288
|
+
overlappingResultsLabel: "Overlapping feature results",
|
|
1289
|
+
previousResultLabel: "Previous",
|
|
1290
|
+
nextResultLabel: "Next",
|
|
1291
|
+
resultPosition: (index, total) => `${index} of ${total}`,
|
|
1292
|
+
refreshLabel: "Refresh",
|
|
1293
|
+
featureLinksLabel: "Feature links",
|
|
1294
|
+
attachmentsLabel: "Attachments",
|
|
1295
|
+
attachmentLabel: (id) => `Attachment ${String(id)}`,
|
|
1296
|
+
attachmentSize: (bytes) => `${bytes} bytes`,
|
|
1297
|
+
attachmentPagesLabel: "Attachment pages",
|
|
1298
|
+
previousAttachmentsLabel: "Previous attachments",
|
|
1299
|
+
nextAttachmentsLabel: "Next attachments",
|
|
1300
|
+
relationshipPagesLabel: (relationship) => `${relationship} pages`,
|
|
1301
|
+
previousRelatedLabel: "Previous related records",
|
|
1302
|
+
nextRelatedLabel: "Next related records",
|
|
1303
|
+
diagnosticsLabel: "Inspection diagnostics",
|
|
1304
|
+
diagnostic: (diagnostic) => diagnostic.message,
|
|
1305
|
+
range: (offset, count, total) => `${offset + Math.min(1, count)}-${offset + count} of ${total}`,
|
|
1306
|
+
disconnectedStatus: "Connect an inspection controller to search and inspect features.",
|
|
1307
|
+
searchingStatus: "Searching features.",
|
|
1308
|
+
loadingStatus: "Loading feature details.",
|
|
1309
|
+
deletedStatus: "The selected feature was deleted.",
|
|
1310
|
+
staleStatus: "Feature details are stale. Refresh to continue.",
|
|
1311
|
+
showingStatus: (title) => `Showing ${title}.`,
|
|
1312
|
+
searchResultsStatus: (count) => `${count} feature search result(s).`,
|
|
1313
|
+
emptyStatus: "No feature selected.",
|
|
1314
|
+
});
|
|
1315
|
+
function resolveInspectionMessages(messages, context) {
|
|
1316
|
+
const statuses = context?.snapshot.locale.status;
|
|
1317
|
+
return {
|
|
1318
|
+
...DEFAULT_INSPECTION_MESSAGES,
|
|
1319
|
+
...(statuses?.loading ? { loadingStatus: statuses.loading, searchingStatus: statuses.loading } : {}),
|
|
1320
|
+
...(statuses?.stale ? { staleStatus: statuses.stale } : {}),
|
|
1321
|
+
...(statuses?.empty ? { emptyStatus: statuses.empty } : {}),
|
|
1322
|
+
...(statuses?.unsupported ? { disconnectedStatus: statuses.unsupported } : {}),
|
|
1323
|
+
...messages,
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
function renderNavigation(state, messages) {
|
|
1327
|
+
if (state.candidates.length <= 1)
|
|
1328
|
+
return state.status === "stale" ? refreshButton(messages) : "";
|
|
1329
|
+
return `<nav aria-label="${escapeAttribute(messages.overlappingResultsLabel)}">
|
|
1330
|
+
<button type="button" data-focus-id="result-previous" data-action="previous"${state.activeIndex === 0 ? " disabled" : ""}>${escapeHtml(messages.previousResultLabel)}</button>
|
|
1331
|
+
<span>${escapeHtml(messages.resultPosition(state.activeIndex + 1, state.candidates.length))}</span>
|
|
1332
|
+
<button type="button" data-focus-id="result-next" data-action="next"${state.activeIndex + 1 >= state.candidates.length ? " disabled" : ""}>${escapeHtml(messages.nextResultLabel)}</button>
|
|
1333
|
+
${state.status === "stale" ? refreshButton(messages) : ""}
|
|
1334
|
+
</nav>`;
|
|
1335
|
+
}
|
|
1336
|
+
function refreshButton(messages) {
|
|
1337
|
+
return `<button type="button" data-focus-id="refresh" data-action="refresh">${escapeHtml(messages.refreshLabel)}</button>`;
|
|
1338
|
+
}
|
|
1339
|
+
function renderLinks(links, messages) {
|
|
1340
|
+
if (links.length === 0)
|
|
1341
|
+
return "";
|
|
1342
|
+
return `<ul class="links" aria-label="${escapeAttribute(messages.featureLinksLabel)}">${links
|
|
1343
|
+
.map((link) => `<li><a href="${escapeAttribute(link.href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(link.label)}</a></li>`)
|
|
1344
|
+
.join("")}</ul>`;
|
|
1345
|
+
}
|
|
1346
|
+
function renderAttachments(page, messages) {
|
|
1347
|
+
if (!page || page.total === 0)
|
|
1348
|
+
return "";
|
|
1349
|
+
return `<section aria-labelledby="honua-inspection-attachments"><h3 id="honua-inspection-attachments">${escapeHtml(messages.attachmentsLabel)}</h3>
|
|
1350
|
+
<ul>${page.items
|
|
1351
|
+
.map((attachment) => {
|
|
1352
|
+
const label = attachment.name ?? messages.attachmentLabel(attachment.id);
|
|
1353
|
+
return `<li>${attachment.href
|
|
1354
|
+
? `<a href="${escapeAttribute(attachment.href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(label)}</a>`
|
|
1355
|
+
: escapeHtml(label)}${attachment.size === undefined ? "" : ` (${escapeHtml(messages.attachmentSize(attachment.size))})`}</li>`;
|
|
1356
|
+
})
|
|
1357
|
+
.join("")}</ul><p>${escapeHtml(messages.range(page.offset, page.items.length, page.total))}</p>
|
|
1358
|
+
<nav aria-label="${escapeAttribute(messages.attachmentPagesLabel)}"><button type="button" data-focus-id="attachments-previous" data-action="attachments-previous"${page.hasPrevious ? "" : " disabled"}>${escapeHtml(messages.previousAttachmentsLabel)}</button><button type="button" data-focus-id="attachments-next" data-action="attachments-next"${page.hasNext ? "" : " disabled"}>${escapeHtml(messages.nextAttachmentsLabel)}</button></nav></section>`;
|
|
1359
|
+
}
|
|
1360
|
+
function renderRelationships(relationships, messages) {
|
|
1361
|
+
return relationships
|
|
1362
|
+
.map((relationship) => `<section aria-labelledby="honua-relationship-${relationship.id}">
|
|
1363
|
+
<h3 id="honua-relationship-${relationship.id}">${escapeHtml(relationship.label)}</h3>
|
|
1364
|
+
<ol>${relationship.page.items
|
|
1365
|
+
.map((feature) => `<li>${escapeHtml(featureTitle(feature.attributes, undefined, "related"))}</li>`)
|
|
1366
|
+
.join("")}</ol>
|
|
1367
|
+
<p>${escapeHtml(messages.range(relationship.page.offset, relationship.page.items.length, relationship.page.total))}</p>
|
|
1368
|
+
<nav aria-label="${escapeAttribute(messages.relationshipPagesLabel(relationship.label))}">
|
|
1369
|
+
<button type="button" data-focus-id="relationship-${relationship.id}-previous" data-relationship-id="${relationship.id}" data-relationship-page="previous"${relationship.page.hasPrevious ? "" : " disabled"}>${escapeHtml(messages.previousRelatedLabel)}</button>
|
|
1370
|
+
<button type="button" data-focus-id="relationship-${relationship.id}-next" data-relationship-id="${relationship.id}" data-relationship-page="next"${relationship.page.hasNext ? "" : " disabled"}>${escapeHtml(messages.nextRelatedLabel)}</button>
|
|
1371
|
+
</nav>
|
|
1372
|
+
</section>`)
|
|
1373
|
+
.join("");
|
|
1374
|
+
}
|
|
1375
|
+
function renderDiagnostics(details, search, messages) {
|
|
1376
|
+
const diagnostics = [...details, ...search];
|
|
1377
|
+
if (diagnostics.length === 0)
|
|
1378
|
+
return "";
|
|
1379
|
+
return `<ul class="diagnostics" role="alert" aria-label="${escapeAttribute(messages.diagnosticsLabel)}">${diagnostics
|
|
1380
|
+
.map((diagnostic) => `<li>${escapeHtml(messages.diagnostic(diagnostic))}</li>`)
|
|
1381
|
+
.join("")}</ul>`;
|
|
1382
|
+
}
|
|
1383
|
+
function statusText(state, messages) {
|
|
1384
|
+
if (!state)
|
|
1385
|
+
return messages.disconnectedStatus;
|
|
1386
|
+
if (state.search.status === "loading")
|
|
1387
|
+
return messages.searchingStatus;
|
|
1388
|
+
if (state.status === "loading")
|
|
1389
|
+
return messages.loadingStatus;
|
|
1390
|
+
if (state.status === "deleted")
|
|
1391
|
+
return state.staleReason ?? messages.deletedStatus;
|
|
1392
|
+
if (state.status === "stale")
|
|
1393
|
+
return state.staleReason ?? messages.staleStatus;
|
|
1394
|
+
if (state.feature)
|
|
1395
|
+
return messages.showingStatus(state.feature.title);
|
|
1396
|
+
if (state.search.status === "ready")
|
|
1397
|
+
return messages.searchResultsStatus(state.search.results.length);
|
|
1398
|
+
return messages.emptyStatus;
|
|
1399
|
+
}
|
|
1400
|
+
function escapeHtml(value) {
|
|
1401
|
+
return String(value)
|
|
1402
|
+
.replaceAll("&", "&")
|
|
1403
|
+
.replaceAll("<", "<")
|
|
1404
|
+
.replaceAll(">", ">")
|
|
1405
|
+
.replaceAll('"', """)
|
|
1406
|
+
.replaceAll("'", "'");
|
|
1407
|
+
}
|
|
1408
|
+
function isSourceQualifiedTarget(value) {
|
|
1409
|
+
if (!value || typeof value !== "object")
|
|
1410
|
+
return false;
|
|
1411
|
+
const target = value;
|
|
1412
|
+
return (typeof target.sourceId === "string" &&
|
|
1413
|
+
(typeof target.id === "string" || (typeof target.id === "number" && Number.isFinite(target.id))));
|
|
1414
|
+
}
|
|
1415
|
+
function escapeAttribute(value) {
|
|
1416
|
+
return escapeHtml(value).replaceAll("`", "`");
|
|
1417
|
+
}
|
|
1418
|
+
function captureInspectionFocus(root) {
|
|
1419
|
+
const active = root.activeElement;
|
|
1420
|
+
if (!(active instanceof HTMLElement))
|
|
1421
|
+
return undefined;
|
|
1422
|
+
const id = active.dataset.focusId;
|
|
1423
|
+
if (!id)
|
|
1424
|
+
return undefined;
|
|
1425
|
+
if (!(active instanceof HTMLInputElement))
|
|
1426
|
+
return { id };
|
|
1427
|
+
return {
|
|
1428
|
+
id,
|
|
1429
|
+
value: active.value,
|
|
1430
|
+
...(active.selectionStart !== null ? { selectionStart: active.selectionStart } : {}),
|
|
1431
|
+
...(active.selectionEnd !== null ? { selectionEnd: active.selectionEnd } : {}),
|
|
1432
|
+
...(active.selectionDirection ? { selectionDirection: active.selectionDirection } : {}),
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
function restoreInspectionFocus(root, snapshot) {
|
|
1436
|
+
if (!snapshot)
|
|
1437
|
+
return;
|
|
1438
|
+
const target = [...root.querySelectorAll("[data-focus-id]")].find((candidate) => candidate.dataset.focusId === snapshot.id);
|
|
1439
|
+
if (!target)
|
|
1440
|
+
return;
|
|
1441
|
+
if (target instanceof HTMLInputElement && snapshot.value !== undefined) {
|
|
1442
|
+
target.value = snapshot.value;
|
|
1443
|
+
}
|
|
1444
|
+
target.focus();
|
|
1445
|
+
if (target instanceof HTMLInputElement &&
|
|
1446
|
+
snapshot.selectionStart !== undefined &&
|
|
1447
|
+
snapshot.selectionEnd !== undefined) {
|
|
1448
|
+
target.setSelectionRange(snapshot.selectionStart, snapshot.selectionEnd, snapshot.selectionDirection);
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
function inspectionStyles() {
|
|
1452
|
+
return `:host{display:block;min-inline-size:0;max-inline-size:100%;container-type:inline-size;color:var(--honua-color-text,#17202a);font:var(--honua-font,normal 400 1rem/1.5 system-ui,sans-serif)}
|
|
1453
|
+
*{box-sizing:border-box}.inspection{display:grid;min-inline-size:0;gap:.75rem}.search-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.5rem}
|
|
1454
|
+
label,h2,h3{font-weight:600}input,button,a{font:inherit}input{min-inline-size:0;max-inline-size:100%;padding:.5rem;border:1px solid var(--honua-color-border,#777);border-radius:.25rem}
|
|
1455
|
+
button{max-inline-size:100%;padding:.45rem .7rem;overflow-wrap:anywhere}.results,.links,.diagnostics{margin:0;padding-inline-start:1.25rem}.results:empty{display:none}.results button{inline-size:100%;text-align:start}.results small{display:block}
|
|
1456
|
+
.details{min-inline-size:0;border:1px solid var(--honua-color-border,#aaa);border-radius:.375rem;padding:1rem;overflow-wrap:anywhere}.details header{display:flex;justify-content:space-between;gap:1rem}.details h2{margin:0}
|
|
1457
|
+
dl{display:grid;gap:.25rem}dl div{display:grid;grid-template-columns:minmax(8rem,1fr) 2fr;gap:.5rem}dt{font-weight:600}dd{margin:0;overflow-wrap:anywhere}
|
|
1458
|
+
nav{display:flex;align-items:center;gap:.5rem;flex-wrap:wrap}.status{min-height:1.5em}.diagnostics{color:var(--honua-color-danger,#8b1a1a)}
|
|
1459
|
+
:focus-visible{outline:3px solid var(--honua-color-focus,#1565c0);outline-offset:2px}
|
|
1460
|
+
@container(max-width:320px){.search-row,dl div{grid-template-columns:minmax(0,1fr)}.search-row button,nav button{inline-size:100%}.details header{align-items:stretch;flex-direction:column}}
|
|
1461
|
+
@media(max-width:320px){.search-row,dl div{grid-template-columns:minmax(0,1fr)}.search-row button,nav button{inline-size:100%}}
|
|
1462
|
+
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important;animation:none!important}}
|
|
1463
|
+
@media(forced-colors:active),(prefers-contrast:more){:host{color:CanvasText;background:Canvas}.inspection,.details,input{color:CanvasText;background:Canvas;border-color:CanvasText}button{color:ButtonText;background:ButtonFace;border:1px solid ButtonText}a{color:LinkText}.diagnostics{color:CanvasText;border-inline-start:3px solid MarkText}:focus-visible{outline-color:Highlight}}`;
|
|
1464
|
+
}
|