@tocdk-oss/ng-spotlight 0.0.0-stage → 21.0.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.
@@ -0,0 +1,2085 @@
1
+ import { Router } from '@angular/router';
2
+ import { HttpClient } from '@angular/common/http';
3
+ import { map, isObservable, catchError, of, from, Observable, filter, withLatestFrom, merge, switchMap, timeout, timer, startWith, shareReplay, combineLatest, Subject, BehaviorSubject, firstValueFrom } from 'rxjs';
4
+ import * as i0 from '@angular/core';
5
+ import { InjectionToken, signal, isSignal, inject, Injector, computed, DestroyRef, Injectable, makeEnvironmentProviders, provideEnvironmentInitializer, EnvironmentInjector, input, ChangeDetectionStrategy, Component, ViewEncapsulation, TemplateRef, Directive, ViewContainerRef, viewChild, contentChildren, contentChild, effect, untracked, afterRenderEffect, afterNextRender } from '@angular/core';
6
+ import { DOCUMENT, NgComponentOutlet, NgTemplateOutlet } from '@angular/common';
7
+ import { toSignal } from '@angular/core/rxjs-interop';
8
+ import { FocusTrapFactory } from '@angular/cdk/a11y';
9
+ import { BreakpointObserver } from '@angular/cdk/layout';
10
+ import { Overlay } from '@angular/cdk/overlay';
11
+ import { TemplatePortal } from '@angular/cdk/portal';
12
+
13
+ const ALLOWED_SCHEMES = new Set(['http', 'https', 'mailto', 'tel']);
14
+ /**
15
+ * true for http(s), mailto, tel and path-relative URLs (`/x`, `./x`, `x`, `?q`, `#h`).
16
+ *
17
+ * - Browsers drop ASCII tab/newline and leading control characters before reading the scheme
18
+ * ("java\nscript:" runs script), so those are removed before the check.
19
+ * - Protocol-relative URLs (`//host`, and `\\host` or `/\host`, since browsers read `\` as `/`
20
+ * in http(s) URLs) point at another site, so they do not count as relative.
21
+ */
22
+ function isSafeUrl(url) {
23
+ // What the browser parses: leading C0 controls and spaces stripped, tab/LF/CR removed anywhere.
24
+ const parsed = url.replace(/^[\u0000- \u007f]+/, '').replace(/[\t\n\r]/g, '');
25
+ if (/^[/\\]{2}/.test(parsed))
26
+ return false;
27
+ const compact = url.replace(/[\u0000- \u007f]/g, '');
28
+ const match = /^([a-z][a-z0-9+.-]*):/i.exec(compact);
29
+ return match === null || ALLOWED_SCHEMES.has(match[1].toLowerCase());
30
+ }
31
+
32
+ const BADGE_TONES = new Set(['info', 'success', 'warn', 'danger']);
33
+ function isRecord(value) {
34
+ return typeof value === 'object' && value !== null;
35
+ }
36
+ function isPlainObject(value) {
37
+ if (typeof value !== 'object' || value === null)
38
+ return false;
39
+ const proto = Object.getPrototypeOf(value);
40
+ return proto === Object.prototype || proto === null;
41
+ }
42
+ /** JSON data that reads back exactly as written: no undefined, functions, NaN or class instances. */
43
+ function isJsonData(value, depth = 0) {
44
+ if (depth > 8)
45
+ return false;
46
+ if (value === null || typeof value === 'string' || typeof value === 'boolean')
47
+ return true;
48
+ if (typeof value === 'number')
49
+ return Number.isFinite(value);
50
+ if (Array.isArray(value))
51
+ return value.every((v) => isJsonData(v, depth + 1));
52
+ return isPlainObject(value) && Object.values(value).every((v) => isJsonData(v, depth + 1));
53
+ }
54
+ /**
55
+ * Route segments that survive storage: strings, finite numbers and plain JSON objects (matrix
56
+ * params such as `{ tab: 'orders' }`, or `{ outlets: … }`). The same rule decides what is saved
57
+ * and what is loaded back, so a saved route is never dropped on reload.
58
+ */
59
+ function isRouteSegments(value) {
60
+ return (Array.isArray(value) &&
61
+ value.every((segment) => typeof segment === 'string' ||
62
+ (typeof segment === 'number' && Number.isFinite(segment)) ||
63
+ (isPlainObject(segment) && isJsonData(segment))));
64
+ }
65
+ function isBadge(value) {
66
+ if (!isRecord(value) || typeof value['label'] !== 'string')
67
+ return false;
68
+ const tone = value['tone'];
69
+ return tone === undefined || (typeof tone === 'string' && BADGE_TONES.has(tone));
70
+ }
71
+ /** Exactly one of route/url, each valid: unsafe URLs are dropped rather than replayed. */
72
+ function isStoredAction(value) {
73
+ if (!isRecord(value))
74
+ return false;
75
+ const hasRoute = 'route' in value;
76
+ const hasUrl = 'url' in value;
77
+ if (hasRoute === hasUrl)
78
+ return false;
79
+ if (hasRoute)
80
+ return typeof value['route'] === 'string' || isRouteSegments(value['route']);
81
+ return typeof value['url'] === 'string' && isSafeUrl(value['url']);
82
+ }
83
+ function isRecentEntry(value) {
84
+ if (!isRecord(value))
85
+ return false;
86
+ if (typeof value['id'] !== 'string' ||
87
+ typeof value['title'] !== 'string' ||
88
+ typeof value['group'] !== 'string' ||
89
+ typeof value['sourceId'] !== 'string') {
90
+ return false;
91
+ }
92
+ if (value['subtitle'] !== undefined && typeof value['subtitle'] !== 'string')
93
+ return false;
94
+ if (value['icon'] !== undefined && typeof value['icon'] !== 'string')
95
+ return false;
96
+ if (value['badges'] !== undefined && !(Array.isArray(value['badges']) && value['badges'].every(isBadge)))
97
+ return false;
98
+ return value['action'] === undefined || isStoredAction(value['action']);
99
+ }
100
+ /**
101
+ * Recent items in localStorage under `ng-spotlight:recent:<key>`. Every access is wrapped
102
+ * in try/catch, so the palette still works when storage is blocked, full or corrupted.
103
+ */
104
+ function localStorageRecent(key) {
105
+ const storageKey = `ng-spotlight:recent:${key}`;
106
+ return {
107
+ load() {
108
+ try {
109
+ const raw = globalThis.localStorage?.getItem(storageKey);
110
+ if (!raw)
111
+ return Promise.resolve([]);
112
+ const parsed = JSON.parse(raw);
113
+ return Promise.resolve(Array.isArray(parsed) ? parsed.filter(isRecentEntry) : []);
114
+ }
115
+ catch {
116
+ return Promise.resolve([]);
117
+ }
118
+ },
119
+ save(items) {
120
+ try {
121
+ globalThis.localStorage?.setItem(storageKey, JSON.stringify(items));
122
+ }
123
+ catch {
124
+ // Blocked or full storage: Recent simply isn't persisted.
125
+ }
126
+ return Promise.resolve();
127
+ },
128
+ };
129
+ }
130
+ /**
131
+ * Keeps only the fields needed to re-render the item and re-run a route or url action. A route
132
+ * array that would not survive JSON (a Date, a function, `undefined`, NaN, …) is stored by id
133
+ * only, like a run() item, and resolved through its source on the next load.
134
+ */
135
+ function toRecentEntry(item, sourceId) {
136
+ const entry = { id: item.id, title: item.title, group: item.group, sourceId };
137
+ if (item.subtitle !== undefined)
138
+ entry.subtitle = item.subtitle;
139
+ if (item.icon !== undefined)
140
+ entry.icon = item.icon;
141
+ if (item.badges !== undefined)
142
+ entry.badges = item.badges;
143
+ const action = item.action;
144
+ if ('route' in action) {
145
+ if (typeof action.route === 'string' || isRouteSegments(action.route))
146
+ entry.action = { route: action.route };
147
+ }
148
+ else if ('url' in action)
149
+ entry.action = action.newTab ? { url: action.url, newTab: true } : { url: action.url };
150
+ return entry;
151
+ }
152
+ /** A stored route/url entry as an item; null for run() entries (resolve those through the source). */
153
+ function entryToItem(entry) {
154
+ if (entry.action === undefined)
155
+ return null;
156
+ const item = { id: entry.id, title: entry.title, group: entry.group, action: entry.action };
157
+ if (entry.subtitle !== undefined)
158
+ item.subtitle = entry.subtitle;
159
+ if (entry.icon !== undefined)
160
+ item.icon = entry.icon;
161
+ if (entry.badges !== undefined)
162
+ item.badges = entry.badges;
163
+ return item;
164
+ }
165
+ /** Most recent first, unique per source + id, at most `max` entries. */
166
+ function pushRecent(list, entry, max) {
167
+ const rest = list.filter((e) => !(e.id === entry.id && e.sourceId === entry.sourceId));
168
+ return [entry, ...rest].slice(0, Math.max(0, max));
169
+ }
170
+
171
+ /** Fixed commands. Results are local: the library ranks and filters them. */
172
+ function staticSource(options) {
173
+ const seen = new Set();
174
+ const items = [];
175
+ for (const input of options.items) {
176
+ const item = { ...input, group: input.group ?? options.group };
177
+ // Ids must be unique per group; a later duplicate is dropped (as normalizeResult does).
178
+ const key = `${item.group}::${item.id}`;
179
+ if (seen.has(key))
180
+ continue;
181
+ seen.add(key);
182
+ items.push(item);
183
+ }
184
+ return {
185
+ id: options.id ?? `static:${options.group}`,
186
+ group: options.group,
187
+ prefix: options.prefix,
188
+ priority: options.priority,
189
+ local: true,
190
+ search: () => items,
191
+ };
192
+ }
193
+
194
+ /** Depth-first walk of a Router config. Skips redirects, `**` and custom matchers. */
195
+ function walkRoutes(routes, includeLazy, parentSegments = [], parentHasParam = false) {
196
+ const out = [];
197
+ for (const route of routes) {
198
+ if (route.path === '**' || route.redirectTo !== undefined || route.matcher !== undefined)
199
+ continue;
200
+ const segments = [...parentSegments, ...(route.path ?? '').split('/').filter((s) => s !== '')];
201
+ const hasParam = parentHasParam || segments.some((s) => s.startsWith(':'));
202
+ const data = route.data ?? {};
203
+ const spotlight = data['spotlight'];
204
+ const routeData = typeof spotlight === 'object' && spotlight !== null ? spotlight : {};
205
+ const breadcrumb = data['breadcrumb'];
206
+ const rawLabel = typeof route.title === 'string'
207
+ ? route.title
208
+ : typeof routeData.label === 'string'
209
+ ? routeData.label
210
+ : typeof breadcrumb === 'string'
211
+ ? breadcrumb
212
+ : null;
213
+ const children = route.children ?? (includeLazy ? route._loadedRoutes : undefined);
214
+ const rendersComponent = route.component !== undefined || route.loadComponent !== undefined;
215
+ out.push({
216
+ route,
217
+ url: '/' + segments.join('/'),
218
+ label: rawLabel !== null && rawLabel.trim() !== '' ? rawLabel : null,
219
+ keywords: Array.isArray(routeData.keywords)
220
+ ? routeData.keywords.filter((k) => typeof k === 'string')
221
+ : [],
222
+ suggested: routeData.suggested === true,
223
+ optedOut: spotlight === false,
224
+ hasParam,
225
+ isPage: rendersComponent && route.children === undefined && route.loadChildren === undefined,
226
+ routable: rendersComponent ||
227
+ (children ? children.some((child) => (child.path ?? '') === '') : route.loadChildren !== undefined),
228
+ });
229
+ if (children)
230
+ out.push(...walkRoutes(children, includeLazy, segments, hasParam));
231
+ }
232
+ return out;
233
+ }
234
+
235
+ /**
236
+ * Every labelled static route that is a page, including lazy children once loaded. A labelled
237
+ * parent that renders nothing at its own URL (no component, no empty-path child) is skipped;
238
+ * its labelled children still appear. A deeper route with the same URL (an empty-path child)
239
+ * wins over its parent. Results are local.
240
+ */
241
+ function routeSource(options = {}) {
242
+ const group = options.group ?? 'Pages';
243
+ return {
244
+ id: options.id ?? 'routes',
245
+ group,
246
+ prefix: options.prefix,
247
+ priority: options.priority,
248
+ local: true,
249
+ search: (_query, ctx) => {
250
+ const router = ctx.injector.get(Router);
251
+ const byUrl = new Map();
252
+ for (const entry of walkRoutes(router.config, true)) {
253
+ if (entry.optedOut || entry.hasParam || entry.label === null || !entry.routable)
254
+ continue;
255
+ if (options.filter && !options.filter(entry.route, entry.url))
256
+ continue;
257
+ byUrl.set(entry.url, {
258
+ id: `route:${entry.url}`,
259
+ title: entry.label,
260
+ subtitle: entry.url,
261
+ group,
262
+ keywords: entry.keywords,
263
+ suggested: entry.suggested,
264
+ action: { route: entry.url },
265
+ });
266
+ }
267
+ return [...byUrl.values()];
268
+ },
269
+ };
270
+ }
271
+
272
+ /**
273
+ * Static pages that have no label (title, data.spotlight.label or data.breadcrumb) and are not
274
+ * opted out with data.spotlight: false. Synchronous: `loadChildren` is not followed, so pass
275
+ * lazy route arrays in directly. A page whose URL a labelled parent already covers passes.
276
+ *
277
+ * expect(spotlightRouteAudit(adminRoutes)).toEqual([]);
278
+ */
279
+ function spotlightRouteAudit(routes, options = {}) {
280
+ const walked = walkRoutes(routes, false);
281
+ const labelledUrls = new Set(walked.filter((w) => w.label !== null).map((w) => w.url));
282
+ return walked
283
+ .filter((w) => w.isPage && !w.hasParam && !w.optedOut && w.label === null && !labelledUrls.has(w.url))
284
+ .filter((w) => !(options.ignore?.(w.route, w.url) ?? false))
285
+ .map((w) => ({ path: w.url, reason: 'missing-label' }));
286
+ }
287
+
288
+ class SpotlightResultError extends Error {
289
+ constructor(message) {
290
+ super(message);
291
+ this.name = 'SpotlightResultError';
292
+ }
293
+ }
294
+ function isSpotlightResponse(value) {
295
+ return typeof value === 'object' && value !== null && Array.isArray(value.groups);
296
+ }
297
+ function isValidAction(value) {
298
+ if (typeof value !== 'object' || value === null)
299
+ return false;
300
+ const action = value;
301
+ return (typeof action['route'] === 'string' ||
302
+ Array.isArray(action['route']) ||
303
+ typeof action['url'] === 'string' ||
304
+ typeof action['run'] === 'function');
305
+ }
306
+ function isOptionalString(value) {
307
+ return value === undefined || typeof value === 'string';
308
+ }
309
+ function isValidBadge(value) {
310
+ if (typeof value !== 'object' || value === null)
311
+ return false;
312
+ const badge = value;
313
+ if (typeof badge['label'] !== 'string')
314
+ return false;
315
+ const tone = badge['tone'];
316
+ return tone === undefined || tone === 'info' || tone === 'success' || tone === 'warn' || tone === 'danger';
317
+ }
318
+ /**
319
+ * id and title are non-empty strings, the action is usable, optional fields have the right
320
+ * shape and the reserved `children` key is absent (even when its value is undefined).
321
+ */
322
+ function isValidItem(value) {
323
+ if (typeof value !== 'object' || value === null)
324
+ return false;
325
+ const item = value;
326
+ if (typeof item['id'] !== 'string' ||
327
+ item['id'] === '' ||
328
+ typeof item['title'] !== 'string' ||
329
+ !isValidAction(item['action']) ||
330
+ 'children' in item) {
331
+ return false;
332
+ }
333
+ if (!isOptionalString(item['subtitle']) || !isOptionalString(item['icon']))
334
+ return false;
335
+ const keywords = item['keywords'];
336
+ if (keywords !== undefined && !(Array.isArray(keywords) && keywords.every((k) => typeof k === 'string'))) {
337
+ return false;
338
+ }
339
+ const badges = item['badges'];
340
+ if (badges !== undefined && !(Array.isArray(badges) && badges.every(isValidBadge)))
341
+ return false;
342
+ const suggested = item['suggested'];
343
+ return suggested === undefined || typeof suggested === 'boolean';
344
+ }
345
+ /** Optional item fields that JSON backends commonly send as `null` to mean "absent". */
346
+ const NULLABLE_FIELDS = ['subtitle', 'icon', 'keywords', 'badges', 'suggested', 'data'];
347
+ /** Strips `null` optional fields, then validates. Returns null for an invalid item. */
348
+ function toItem(value) {
349
+ if (typeof value !== 'object' || value === null)
350
+ return null;
351
+ let candidate = value;
352
+ if (NULLABLE_FIELDS.some((key) => candidate[key] === null)) {
353
+ candidate = { ...candidate };
354
+ for (const key of NULLABLE_FIELDS)
355
+ if (candidate[key] === null)
356
+ delete candidate[key];
357
+ }
358
+ return isValidItem(candidate) ? candidate : null;
359
+ }
360
+ /**
361
+ * Turns whatever a source returned into validated groups. Invalid items and groups are
362
+ * dropped, `null` optional fields count as absent, and a later item whose `group::id` was
363
+ * already seen is dropped (ids must be unique per group). Grouped responses that repeat a
364
+ * group id merge into the first group with that id. A value that is not a result at all
365
+ * throws SpotlightResultError, which the engine turns into an error row for that source only.
366
+ */
367
+ function normalizeResult(result, fallbackGroup) {
368
+ const byGroup = new Map();
369
+ const seen = new Set();
370
+ const add = (group, raw) => {
371
+ const item = toItem(raw);
372
+ if (item === null)
373
+ return;
374
+ const key = `${group.id}::${item.id}`;
375
+ if (seen.has(key))
376
+ return;
377
+ seen.add(key);
378
+ group.items.push({ ...item, group: group.id });
379
+ };
380
+ if (Array.isArray(result)) {
381
+ for (const raw of result) {
382
+ const rawGroup = raw?.group;
383
+ const id = typeof rawGroup === 'string' && rawGroup !== '' ? rawGroup : fallbackGroup;
384
+ let group = byGroup.get(id);
385
+ if (!group) {
386
+ group = { id, label: id, items: [] };
387
+ byGroup.set(id, group);
388
+ }
389
+ add(group, raw);
390
+ }
391
+ return [...byGroup.values()].filter((g) => g.items.length > 0);
392
+ }
393
+ if (isSpotlightResponse(result)) {
394
+ for (const rawGroup of result.groups) {
395
+ if (typeof rawGroup !== 'object' || rawGroup === null)
396
+ continue;
397
+ const g = rawGroup;
398
+ const id = g['id'];
399
+ if (typeof id !== 'string' || id === '')
400
+ continue;
401
+ let group = byGroup.get(id);
402
+ if (!group) {
403
+ group = { id, label: typeof g['label'] === 'string' ? g['label'] : id, items: [] };
404
+ const total = g['total'];
405
+ if (typeof total === 'number' && Number.isInteger(total) && total >= 0)
406
+ group.total = total;
407
+ byGroup.set(id, group);
408
+ }
409
+ const rawItems = Array.isArray(g['items']) ? g['items'] : [];
410
+ for (const raw of rawItems)
411
+ add(group, raw);
412
+ }
413
+ return [...byGroup.values()];
414
+ }
415
+ throw new SpotlightResultError('[ng-spotlight] a source returned something that is neither SpotlightItem[] nor { groups: SpotlightGroup[] }');
416
+ }
417
+
418
+ /** Global request/map defaults for httpSource. Provided by provideSpotlight({ http }). */
419
+ const SPOTLIGHT_HTTP_DEFAULTS = new InjectionToken('SPOTLIGHT_HTTP_DEFAULTS');
420
+
421
+ function defaultRequest(query, ctx) {
422
+ return { method: 'GET', params: ctx.chip !== null ? { q: query, chip: ctx.chip } : { q: query } };
423
+ }
424
+ /**
425
+ * Remote results in the server's order. Default request: GET {url}?q={query}[&chip={groupId}].
426
+ * Default response: SpotlightResponse or SpotlightItem[]. Per-source request/map win over
427
+ * provideSpotlight({ http }), which wins over the defaults.
428
+ */
429
+ function httpSource(options) {
430
+ const { url, request, map: map$1, ...given } = options;
431
+ // An explicit `undefined` (e.g. `minLength: config.minLength`) must not override a default.
432
+ const rest = Object.fromEntries(Object.entries(given).filter(([, value]) => value !== undefined));
433
+ const defaultMap = (response) => {
434
+ if (Array.isArray(response) || isSpotlightResponse(response))
435
+ return response;
436
+ throw new Error(`[ng-spotlight] httpSource ${url}: response is neither SpotlightResponse nor SpotlightItem[]`);
437
+ };
438
+ return {
439
+ minLength: 2,
440
+ debounceMs: 150,
441
+ local: false,
442
+ ...rest,
443
+ id: rest.id ?? `http:${url}`,
444
+ search: (query, ctx) => {
445
+ const globals = ctx.injector.get(SPOTLIGHT_HTTP_DEFAULTS, {});
446
+ const req = (request ?? globals.request ?? defaultRequest)(query, ctx);
447
+ const toResult = map$1 ?? globals.map ?? defaultMap;
448
+ return ctx.injector
449
+ .get(HttpClient)
450
+ .request(req.method ?? 'GET', url, {
451
+ params: req.params,
452
+ headers: req.headers,
453
+ body: req.body,
454
+ responseType: 'json',
455
+ })
456
+ .pipe(map((response) => toResult(response, query)));
457
+ },
458
+ };
459
+ }
460
+
461
+ const WORD_BOUNDARY = /[\s\-_/.:,()[\]]/;
462
+ /** Score bonus for items that are in Recent. Smaller than the gap between match tiers. */
463
+ const RECENT_BOOST = 50;
464
+ /**
465
+ * 1000 exact, 800 start of text, 600 start of a word, 200–300 subsequence (tighter is higher),
466
+ * -1 no match, 0 for the empty query.
467
+ */
468
+ function scoreText(text, query) {
469
+ if (query === '')
470
+ return 0;
471
+ const t = text.toLowerCase();
472
+ const q = query.toLowerCase();
473
+ if (t === q)
474
+ return 1000;
475
+ if (t.startsWith(q))
476
+ return 800;
477
+ for (let i = t.indexOf(q, 1); i > 0; i = t.indexOf(q, i + 1)) {
478
+ if (WORD_BOUNDARY.test(t[i - 1]))
479
+ return 600;
480
+ }
481
+ let pos = -1;
482
+ let first = -1;
483
+ for (const ch of q) {
484
+ pos = t.indexOf(ch, pos + 1);
485
+ if (pos === -1)
486
+ return -1;
487
+ if (first === -1)
488
+ first = pos;
489
+ }
490
+ const gaps = pos - first + 1 - q.length;
491
+ return 200 + Math.max(0, 100 - gaps);
492
+ }
493
+ /** Title score, or half the best keyword score, plus the Recent boost. null = no match. */
494
+ function scoreItem(item, query, recentIds) {
495
+ let best = scoreText(item.title, query);
496
+ for (const keyword of item.keywords ?? []) {
497
+ const score = scoreText(keyword, query);
498
+ if (score >= 0)
499
+ best = Math.max(best, score / 2);
500
+ }
501
+ if (best < 0)
502
+ return null;
503
+ return best + (recentIds.has(item.id) ? RECENT_BOOST : 0);
504
+ }
505
+ /** Filters and sorts local results. Stable: equal scores keep the source's order. */
506
+ function rankItems(items, query, recentIds = new Set()) {
507
+ if (query === '')
508
+ return [...items];
509
+ return items
510
+ .map((item, index) => ({ item, index, score: scoreItem(item, query, recentIds) }))
511
+ .filter((entry) => entry.score !== null)
512
+ .sort((a, b) => b.score - a.score || a.index - b.index)
513
+ .map((entry) => entry.item);
514
+ }
515
+
516
+ const SPOTLIGHT_TEXT_KEYS = [
517
+ 'placeholder',
518
+ 'all',
519
+ 'recent',
520
+ 'noResults',
521
+ 'loading',
522
+ 'error',
523
+ 'retry',
524
+ 'hintNavigate',
525
+ 'hintOpen',
526
+ 'hintClose',
527
+ ];
528
+ const DEFAULT_SPOTLIGHT_TEXTS = {
529
+ placeholder: 'Search…',
530
+ all: 'All',
531
+ recent: 'Recent',
532
+ noResults: 'No results',
533
+ loading: 'Loading…',
534
+ error: 'Something went wrong',
535
+ retry: 'Retry',
536
+ hintNavigate: 'to navigate',
537
+ hintOpen: 'to open',
538
+ hintClose: 'to close',
539
+ resultsAnnouncement: (count) => (count === 0 ? 'No results' : count === 1 ? '1 result' : `${count} results`),
540
+ };
541
+ function toTextSignal(value, fallback, injector) {
542
+ if (value === undefined)
543
+ return signal(fallback).asReadonly();
544
+ if (isSignal(value))
545
+ return value;
546
+ // An erroring source keeps the fallback; without this, toSignal rethrows on every read.
547
+ if (isObservable(value)) {
548
+ return toSignal(value.pipe(catchError(() => of(fallback))), { initialValue: fallback, injector });
549
+ }
550
+ return signal(value).asReadonly();
551
+ }
552
+ /** Every text becomes a Signal<string>; Observables are subscribed for the injector's lifetime. */
553
+ function resolveTexts(texts, injector) {
554
+ const resolved = {};
555
+ for (const key of SPOTLIGHT_TEXT_KEYS) {
556
+ resolved[key] = toTextSignal(texts?.[key], DEFAULT_SPOTLIGHT_TEXTS[key], injector);
557
+ }
558
+ return {
559
+ ...resolved,
560
+ resultsAnnouncement: texts?.resultsAnnouncement ?? DEFAULT_SPOTLIGHT_TEXTS.resultsAnnouncement,
561
+ };
562
+ }
563
+
564
+ const DEFAULT_TIMEOUT_MS = 5000;
565
+ const IDLE = { groups: [], loading: false, error: null };
566
+ function planQuery(sources, raw) {
567
+ const trimmed = raw.trim();
568
+ const prefixed = sources
569
+ .filter((reg) => typeof reg.source.prefix === 'string' && reg.source.prefix !== '')
570
+ .sort((a, b) => b.source.prefix.length - a.source.prefix.length);
571
+ for (const reg of prefixed) {
572
+ const prefix = reg.source.prefix;
573
+ if (trimmed.startsWith(prefix))
574
+ return { query: trimmed.slice(prefix.length).trim(), only: reg.source };
575
+ }
576
+ return { query: trimmed, only: null };
577
+ }
578
+ function sortSources(sources) {
579
+ return [...sources].sort((a, b) => (a.source.priority ?? a.order) - (b.source.priority ?? b.order) || a.order - b.order);
580
+ }
581
+ function toObservableResult(result) {
582
+ if (isObservable(result))
583
+ return result;
584
+ if (typeof result.then === 'function')
585
+ return from(result);
586
+ return of(result);
587
+ }
588
+ function knownGroups(learned, source) {
589
+ let known = learned.get(source.id);
590
+ if (!known) {
591
+ known = new Map(source.group ? [[source.group, source.group]] : []);
592
+ learned.set(source.id, known);
593
+ }
594
+ return known;
595
+ }
596
+ /** Calls search() with a fresh AbortSignal that is aborted if the call is superseded. */
597
+ function callSource(reg, query, chip) {
598
+ return new Observable((subscriber) => {
599
+ const controller = new AbortController();
600
+ let finished = false;
601
+ let inner;
602
+ try {
603
+ inner = toObservableResult(reg.source.search(query, { signal: controller.signal, chip, injector: reg.injector })).subscribe({
604
+ next: (value) => subscriber.next(value),
605
+ error: (error) => {
606
+ finished = true;
607
+ subscriber.error(error);
608
+ },
609
+ complete: () => {
610
+ finished = true;
611
+ subscriber.complete();
612
+ },
613
+ });
614
+ }
615
+ catch (error) {
616
+ finished = true;
617
+ subscriber.error(error);
618
+ }
619
+ return () => {
620
+ if (!finished)
621
+ controller.abort();
622
+ inner?.unsubscribe();
623
+ };
624
+ });
625
+ }
626
+ function runSource(reg, all, input$, options, learned) {
627
+ const source = reg.source;
628
+ let last = [];
629
+ const retried$ = options.retry$.pipe(filter((id) => id === source.id), withLatestFrom(input$), map(([, state]) => state));
630
+ return merge(input$, retried$).pipe(switchMap((state) => {
631
+ const plan = planQuery(all, state.query);
632
+ const known = knownGroups(learned, source);
633
+ const eligible = (plan.only === null || plan.only === source) &&
634
+ (state.chip === null || known.has(state.chip)) &&
635
+ plan.query.length >= (source.minLength ?? 0);
636
+ if (!eligible) {
637
+ last = [];
638
+ return of(IDLE);
639
+ }
640
+ const browseAll = plan.only !== null;
641
+ const result$ = callSource(reg, plan.query, state.chip).pipe(timeout({ first: source.timeoutMs ?? DEFAULT_TIMEOUT_MS }), map((result) => {
642
+ let groups = normalizeResult(result, source.group ?? source.id);
643
+ for (const g of groups)
644
+ known.set(g.id, g.label);
645
+ if (state.chip !== null)
646
+ groups = groups.filter((g) => g.id === state.chip);
647
+ if (source.local === true && plan.query !== '') {
648
+ const recent = options.recentIds(source.id);
649
+ groups = groups.map((g) => ({ ...g, items: rankItems(g.items, plan.query, recent) }));
650
+ }
651
+ if (plan.query === '' && !browseAll) {
652
+ groups = groups.map((g) => ({ ...g, items: g.items.filter((i) => i.suggested === true) }));
653
+ }
654
+ groups = groups.filter((g) => g.items.length > 0);
655
+ last = groups;
656
+ return { groups, loading: false, error: null };
657
+ }), catchError((error) => {
658
+ last = [];
659
+ try {
660
+ options.onError(error, source);
661
+ }
662
+ catch {
663
+ // a throwing consumer callback must not take down other groups
664
+ }
665
+ return of({ groups: [], loading: false, error });
666
+ }));
667
+ const debounceMs = source.debounceMs ?? 0;
668
+ const delayed$ = debounceMs > 0 ? timer(debounceMs).pipe(switchMap(() => result$)) : result$;
669
+ return delayed$.pipe(startWith({ groups: last, loading: true, error: null }));
670
+ }));
671
+ }
672
+ function toGroupStates(reg, state, learned, placeholderLabel) {
673
+ const source = reg.source;
674
+ if (state.groups.length > 0) {
675
+ return state.groups.map((g) => ({
676
+ key: `${source.id}::${g.id}`,
677
+ id: g.id,
678
+ label: g.label,
679
+ ...(g.total !== undefined ? { total: g.total } : {}),
680
+ sourceId: source.id,
681
+ items: g.items,
682
+ loading: state.loading,
683
+ error: null,
684
+ }));
685
+ }
686
+ if (!state.loading && state.error === null)
687
+ return [];
688
+ // Placeholder header (spinner or error row) in the source's usual group. A group-less
689
+ // source that never answered gets a neutral label, never its internal id.
690
+ const known = learned.get(source.id);
691
+ const id = source.group ?? (known && known.size > 0 ? [...known.keys()][0] : source.id);
692
+ return [
693
+ {
694
+ key: `${source.id}::${id}`,
695
+ id,
696
+ label: known?.get(id) ?? placeholderLabel(state.error === null ? 'loading' : 'error'),
697
+ sourceId: source.id,
698
+ items: [],
699
+ loading: state.loading,
700
+ error: state.error,
701
+ },
702
+ ];
703
+ }
704
+ /** Streams the grouped results for every query emitted on input$ (spec §5). */
705
+ function createSearchEngine(options) {
706
+ const sources = sortSources(options.sources);
707
+ if (sources.length === 0)
708
+ return of([]);
709
+ const learned = options.learned ?? new Map();
710
+ const placeholderLabel = options.placeholderLabel ?? ((state) => DEFAULT_SPOTLIGHT_TEXTS[state]);
711
+ const input$ = options.input$.pipe(shareReplay({ bufferSize: 1, refCount: true }));
712
+ return combineLatest(sources.map((reg) => runSource(reg, sources, input$, options, learned).pipe(map((state) => toGroupStates(reg, state, learned, placeholderLabel))))).pipe(map((lists) => lists.flat()));
713
+ }
714
+
715
+ const DEFAULT_HOTKEYS = ['ctrl.space', 'mod.k'];
716
+ const MODIFIERS = new Set(['ctrl', 'alt', 'shift', 'meta', 'mod']);
717
+ const KEY_ALIASES = {
718
+ space: ' ',
719
+ slash: '/',
720
+ period: '.',
721
+ comma: ',',
722
+ dash: '-',
723
+ esc: 'escape',
724
+ escape: 'escape',
725
+ enter: 'enter',
726
+ };
727
+ /** Parses 'ctrl.space', 'mod.k', 'alt.shift.slash', … 'mod' = Cmd on macOS, Ctrl elsewhere. */
728
+ function parseHotkey(spec, isMac) {
729
+ const parts = spec.toLowerCase().split('.').filter((p) => p !== '');
730
+ const key = parts.pop();
731
+ if (key === undefined)
732
+ throw new Error(`[ng-spotlight] invalid hotkey "${spec}"`);
733
+ for (const modifier of parts) {
734
+ if (!MODIFIERS.has(modifier))
735
+ throw new Error(`[ng-spotlight] unknown modifier "${modifier}" in hotkey "${spec}"`);
736
+ }
737
+ const mods = new Set(parts);
738
+ return {
739
+ ctrl: mods.has('ctrl') || (mods.has('mod') && !isMac),
740
+ alt: mods.has('alt'),
741
+ shift: mods.has('shift'),
742
+ meta: mods.has('meta') || (mods.has('mod') && isMac),
743
+ key: KEY_ALIASES[key] ?? key,
744
+ };
745
+ }
746
+ function matchesHotkey(event, hotkey) {
747
+ if (event.isComposing)
748
+ return false;
749
+ // Autofill and synthetic keydown events can arrive without a key string.
750
+ if (typeof event.key !== 'string')
751
+ return false;
752
+ if (event.ctrlKey !== hotkey.ctrl ||
753
+ event.altKey !== hotkey.alt ||
754
+ event.shiftKey !== hotkey.shift ||
755
+ event.metaKey !== hotkey.meta) {
756
+ return false;
757
+ }
758
+ const key = event.key.toLowerCase();
759
+ if (hotkey.key === ' ')
760
+ return key === ' ' || event.code === 'Space';
761
+ if (key === hotkey.key)
762
+ return true;
763
+ // On macOS, Alt changes event.key (Alt+P gives 'π'); compare the physical key instead.
764
+ if (/^[a-z]$/.test(hotkey.key))
765
+ return event.code === `Key${hotkey.key.toUpperCase()}`;
766
+ if (/^[0-9]$/.test(hotkey.key))
767
+ return event.code === `Digit${hotkey.key}`;
768
+ return false;
769
+ }
770
+ function detectMac() {
771
+ return typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
772
+ }
773
+
774
+ const RECENT_GROUP_ID = '__recent';
775
+ /**
776
+ * How long select() waits for a run() action before it closes the palette anyway (spec §5.9:
777
+ * run() is awaited, then the palette closes). A run() that settles later still records Recent and
778
+ * emits `selected` (or reports its error); it never locks the palette.
779
+ */
780
+ const RUN_WAIT_MS = 5000;
781
+ let nextInstanceId = 0;
782
+ /** Settings only the first (root or isolated) provideSpotlight() call may set. */
783
+ const IGNORED_IN_CHILD = ['hotkeys', 'texts', 'recent', 'theme', 'onError'];
784
+ const defaultOnError = (error, source) => {
785
+ console.error(`[ng-spotlight] source "${source.id}" failed`, error);
786
+ };
787
+ function parseHotkeys(specs) {
788
+ const isMac = detectMac();
789
+ return specs.map((spec) => parseHotkey(spec, isMac));
790
+ }
791
+ function chipsFrom(groups) {
792
+ const seen = new Map();
793
+ for (const group of groups) {
794
+ if (group.items.length > 0 && !seen.has(group.id))
795
+ seen.set(group.id, group.label);
796
+ }
797
+ return [...seen].map(([id, label]) => ({ id, label }));
798
+ }
799
+ /**
800
+ * State and behaviour of one palette. Provided by provideSpotlight(); never providedIn root.
801
+ * Deliberately has no ngOnDestroy: a child injector re-provides the parent's instance and
802
+ * must not destroy it. Cleanup runs through the creating injector's DestroyRef.
803
+ */
804
+ class SpotlightService {
805
+ document = inject(DOCUMENT);
806
+ router = inject(Router, { optional: true });
807
+ regs = signal([], ...(ngDevMode ? [{ debugName: "regs" }] : /* istanbul ignore next */ []));
808
+ registrationOrder = 0;
809
+ configured = false;
810
+ /** Token of the select() whose action is still running; null when none is. */
811
+ selecting = null;
812
+ /** Incremented on every open(), so a late action never closes a palette opened after it. */
813
+ session = 0;
814
+ /** @internal Prefix for this instance's DOM ids. */
815
+ idPrefix = `sp-${nextInstanceId++}`;
816
+ _settings = {
817
+ hotkeys: parseHotkeys(DEFAULT_HOTKEYS),
818
+ theme: 'default',
819
+ recentMax: 8,
820
+ storage: localStorageRecent('default'),
821
+ onError: defaultOnError,
822
+ };
823
+ _texts = signal(resolveTexts(undefined, inject(Injector)), ...(ngDevMode ? [{ debugName: "_texts" }] : /* istanbul ignore next */ []));
824
+ _isOpen = signal(false, ...(ngDevMode ? [{ debugName: "_isOpen" }] : /* istanbul ignore next */ []));
825
+ isOpen = this._isOpen.asReadonly();
826
+ _query = signal('', ...(ngDevMode ? [{ debugName: "_query" }] : /* istanbul ignore next */ []));
827
+ /** @internal */
828
+ query = this._query.asReadonly();
829
+ _chip = signal(null, ...(ngDevMode ? [{ debugName: "_chip" }] : /* istanbul ignore next */ []));
830
+ /** @internal */
831
+ chip = this._chip.asReadonly();
832
+ _results = signal([], ...(ngDevMode ? [{ debugName: "_results" }] : /* istanbul ignore next */ []));
833
+ _recent = signal([], ...(ngDevMode ? [{ debugName: "_recent" }] : /* istanbul ignore next */ []));
834
+ _resolvedRuns = signal(new Map(), ...(ngDevMode ? [{ debugName: "_resolvedRuns" }] : /* istanbul ignore next */ []));
835
+ _activeKey = signal(null, ...(ngDevMode ? [{ debugName: "_activeKey" }] : /* istanbul ignore next */ []));
836
+ _chipGroups = signal([], ...(ngDevMode ? [{ debugName: "_chipGroups" }] : /* istanbul ignore next */ []));
837
+ selectedSubject = new Subject();
838
+ selected = this.selectedSubject.asObservable();
839
+ input$ = new BehaviorSubject({ query: '', chip: null });
840
+ retry$ = new Subject();
841
+ engine = null;
842
+ /** Group labels learned from answers; outlives the engine, which restarts on every open(). */
843
+ learnedGroups = new Map();
844
+ recentItems = computed(() => {
845
+ const runs = this._resolvedRuns();
846
+ const out = [];
847
+ for (const entry of this._recent()) {
848
+ const item = entryToItem(entry) ?? runs.get(`${entry.sourceId}::${entry.id}`) ?? null;
849
+ if (item)
850
+ out.push({ item, sourceId: entry.sourceId });
851
+ }
852
+ return out;
853
+ }, ...(ngDevMode ? [{ debugName: "recentItems" }] : /* istanbul ignore next */ []));
854
+ /** @internal Visible groups: Recent first on the empty query, then the sources' groups. */
855
+ groups = computed(() => {
856
+ const results = this._results();
857
+ if (this._query().trim() !== '' || this._chip() !== null)
858
+ return results;
859
+ const recent = this.recentItems();
860
+ if (recent.length === 0)
861
+ return results;
862
+ const recentKeys = new Set(recent.map((r) => `${r.sourceId}::${r.item.id}`));
863
+ const rest = results
864
+ .map((g) => ({ ...g, items: g.items.filter((i) => !recentKeys.has(`${g.sourceId}::${i.id}`)) }))
865
+ .filter((g) => g.items.length > 0 || g.loading || g.error !== null);
866
+ const recentGroup = {
867
+ key: RECENT_GROUP_ID,
868
+ id: RECENT_GROUP_ID,
869
+ label: this.texts.recent(),
870
+ sourceId: RECENT_GROUP_ID,
871
+ items: recent.map((r) => r.item),
872
+ loading: false,
873
+ error: null,
874
+ };
875
+ return [recentGroup, ...rest];
876
+ }, ...(ngDevMode ? [{ debugName: "groups" }] : /* istanbul ignore next */ []));
877
+ /** @internal Every selectable row in display order, including one retry row per failed source. */
878
+ options = computed(() => {
879
+ const out = [];
880
+ const recent = this.recentItems();
881
+ for (const group of this.groups()) {
882
+ if (group.error !== null) {
883
+ out.push({
884
+ key: `retry::${group.sourceId}`,
885
+ domId: `${this.idPrefix}-o${out.length}`,
886
+ kind: 'retry',
887
+ item: null,
888
+ sourceId: group.sourceId,
889
+ groupKey: group.key,
890
+ });
891
+ continue;
892
+ }
893
+ // A loading group still shows the previous query's rows; they must not be selectable.
894
+ if (group.loading)
895
+ continue;
896
+ group.items.forEach((item, index) => {
897
+ const sourceId = group.key === RECENT_GROUP_ID ? recent[index].sourceId : group.sourceId;
898
+ out.push({
899
+ key: group.key === RECENT_GROUP_ID ? `${RECENT_GROUP_ID}::${sourceId}::${item.id}` : `${group.key}::${item.id}`,
900
+ domId: `${this.idPrefix}-o${out.length}`,
901
+ kind: 'item',
902
+ item,
903
+ sourceId,
904
+ groupKey: group.key,
905
+ });
906
+ });
907
+ }
908
+ return out;
909
+ }, ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
910
+ /** @internal */
911
+ activeIndex = computed(() => {
912
+ const options = this.options();
913
+ if (options.length === 0)
914
+ return -1;
915
+ const key = this._activeKey();
916
+ if (key === null)
917
+ return 0;
918
+ const index = options.findIndex((o) => o.key === key);
919
+ return index === -1 ? 0 : index;
920
+ }, ...(ngDevMode ? [{ debugName: "activeIndex" }] : /* istanbul ignore next */ []));
921
+ /** @internal */
922
+ activeOption = computed(() => {
923
+ const index = this.activeIndex();
924
+ return index === -1 ? null : this.options()[index];
925
+ }, ...(ngDevMode ? [{ debugName: "activeOption" }] : /* istanbul ignore next */ []));
926
+ /** @internal */
927
+ chips = computed(() => [{ id: null, label: this.texts.all() }, ...this._chipGroups()], ...(ngDevMode ? [{ debugName: "chips" }] : /* istanbul ignore next */ []));
928
+ /** @internal */
929
+ loading = computed(() => this._results().some((g) => g.loading), ...(ngDevMode ? [{ debugName: "loading" }] : /* istanbul ignore next */ []));
930
+ /** @internal */
931
+ announcement = computed(() => {
932
+ const count = this.options().filter((o) => o.kind === 'item').length;
933
+ // Announce "Loading…" rather than "No results" while sources are still answering.
934
+ if (count === 0 && this.loading())
935
+ return this.texts.loading();
936
+ return this.texts.resultsAnnouncement(count);
937
+ }, ...(ngDevMode ? [{ debugName: "announcement" }] : /* istanbul ignore next */ []));
938
+ constructor() {
939
+ inject(DestroyRef).onDestroy(() => {
940
+ this.close();
941
+ this.selectedSubject.complete();
942
+ });
943
+ }
944
+ /** @internal */
945
+ get texts() {
946
+ return this._texts();
947
+ }
948
+ /** @internal */
949
+ get settings() {
950
+ return this._settings;
951
+ }
952
+ /** @internal Called by provideSpotlight(). The first call owns the settings; every call adds its sources. */
953
+ configure(config, injector) {
954
+ if (!this.configured) {
955
+ this.configured = true;
956
+ this._settings = {
957
+ hotkeys: parseHotkeys(config.hotkeys ?? DEFAULT_HOTKEYS),
958
+ theme: config.theme ?? 'default',
959
+ recentMax: config.recent?.max ?? 8,
960
+ storage: config.recent?.storage ?? localStorageRecent('default'),
961
+ onError: config.onError ?? defaultOnError,
962
+ };
963
+ this._texts.set(resolveTexts(config.texts, injector));
964
+ }
965
+ else if (typeof ngDevMode === 'undefined' || ngDevMode) {
966
+ const ignored = IGNORED_IN_CHILD.filter((key) => config[key] !== undefined);
967
+ if (ignored.length > 0) {
968
+ console.warn(`[ng-spotlight] a child provideSpotlight() only adds sources and http defaults; ${ignored.join(', ')} ` +
969
+ 'ignored. Set them in the first provideSpotlight() call, or pass isolated: true for a separate palette.');
970
+ }
971
+ }
972
+ const added = (config.sources ?? []).map((source) => ({
973
+ source,
974
+ injector,
975
+ order: this.registrationOrder++,
976
+ }));
977
+ if (added.length === 0)
978
+ return;
979
+ this.regs.update((regs) => [...regs, ...added]);
980
+ injector.get(DestroyRef).onDestroy(() => {
981
+ this.regs.update((regs) => regs.filter((reg) => !added.includes(reg)));
982
+ const removed = new Set(added.map((reg) => reg.source.id));
983
+ const stillRegistered = new Set(this.regs().map((reg) => reg.source.id));
984
+ this._resolvedRuns.update((runs) => {
985
+ const next = new Map(runs);
986
+ for (const key of runs.keys()) {
987
+ const sourceId = key.slice(0, key.indexOf('::'));
988
+ if (removed.has(sourceId) && !stillRegistered.has(sourceId))
989
+ next.delete(key);
990
+ }
991
+ return next;
992
+ });
993
+ this.restartEngine();
994
+ });
995
+ this.restartEngine();
996
+ }
997
+ open(query) {
998
+ if (this._isOpen()) {
999
+ if (query !== undefined)
1000
+ this.setQuery(query);
1001
+ return;
1002
+ }
1003
+ this._query.set(query ?? '');
1004
+ this._chip.set(null);
1005
+ this._activeKey.set(null);
1006
+ this._chipGroups.set([]);
1007
+ this.session++;
1008
+ this._isOpen.set(true);
1009
+ void this.loadRecent();
1010
+ this.restartEngine();
1011
+ }
1012
+ close() {
1013
+ if (!this._isOpen())
1014
+ return;
1015
+ this.selecting = null;
1016
+ this._isOpen.set(false);
1017
+ this.engine?.unsubscribe();
1018
+ this.engine = null;
1019
+ this._results.set([]);
1020
+ this._query.set('');
1021
+ this._chip.set(null);
1022
+ this._activeKey.set(null);
1023
+ }
1024
+ /** @internal */
1025
+ toggle() {
1026
+ if (this._isOpen())
1027
+ this.close();
1028
+ else
1029
+ this.open();
1030
+ }
1031
+ /** @internal */
1032
+ setQuery(query) {
1033
+ this._query.set(query);
1034
+ this._activeKey.set(null);
1035
+ this.input$.next({ query, chip: this._chip() });
1036
+ }
1037
+ /** @internal */
1038
+ setChip(chip) {
1039
+ this._chip.set(chip);
1040
+ this._activeKey.set(null);
1041
+ this.input$.next({ query: this._query(), chip });
1042
+ }
1043
+ /** @internal */
1044
+ cycleChip(direction) {
1045
+ const chips = this.chips();
1046
+ if (chips.length < 2)
1047
+ return;
1048
+ const index = chips.findIndex((c) => c.id === this._chip());
1049
+ this.setChip(chips[(index + direction + chips.length) % chips.length].id);
1050
+ }
1051
+ /** @internal */
1052
+ moveActive(delta) {
1053
+ const options = this.options();
1054
+ if (options.length === 0)
1055
+ return;
1056
+ const n = options.length;
1057
+ const next = (((this.activeIndex() + delta) % n) + n) % n;
1058
+ this._activeKey.set(options[next].key);
1059
+ }
1060
+ /** @internal */
1061
+ setActive(key) {
1062
+ this._activeKey.set(key);
1063
+ }
1064
+ /** @internal */
1065
+ retry(sourceId) {
1066
+ this.retry$.next(sourceId);
1067
+ }
1068
+ /** @internal Runs the action, records Recent, closes and emits `selected` (spec §5.9). */
1069
+ async select(option, modifiers = {}) {
1070
+ if (option.kind === 'retry' || option.item === null) {
1071
+ this.retry(option.sourceId);
1072
+ return;
1073
+ }
1074
+ if (this.selecting !== null)
1075
+ return;
1076
+ const token = {};
1077
+ this.selecting = token;
1078
+ const session = this.session;
1079
+ const item = option.item;
1080
+ const source = this.regs().find((reg) => reg.source.id === option.sourceId)?.source ?? {
1081
+ id: option.sourceId,
1082
+ search: () => [],
1083
+ };
1084
+ const done = this.runAction(item.action, modifiers.newTab === true, source).then(() => null, (error) => ({ error }));
1085
+ let timer;
1086
+ const waited = new Promise((resolve) => (timer = setTimeout(() => resolve('waiting'), RUN_WAIT_MS)));
1087
+ const first = await Promise.race([done, waited]);
1088
+ clearTimeout(timer);
1089
+ if (this.selecting === token)
1090
+ this.selecting = null;
1091
+ const finish = (outcome) => {
1092
+ if (outcome === null)
1093
+ this.remember(item, option.sourceId);
1094
+ else
1095
+ this.report(outcome.error, source);
1096
+ // Don't close a palette the user opened again after this select().
1097
+ if (this.session === session)
1098
+ this.close();
1099
+ if (outcome === null)
1100
+ this.selectedSubject.next(item);
1101
+ };
1102
+ if (first !== 'waiting') {
1103
+ finish(first);
1104
+ return;
1105
+ }
1106
+ if (this.session === session)
1107
+ this.close();
1108
+ void done.then(finish);
1109
+ }
1110
+ async runAction(action, newTab, source) {
1111
+ if ('run' in action) {
1112
+ await action.run();
1113
+ return;
1114
+ }
1115
+ if ('route' in action) {
1116
+ const router = this.router;
1117
+ if (!router)
1118
+ throw new Error('[ng-spotlight] a route action needs provideRouter()');
1119
+ const navigation = typeof action.route === 'string' ? router.navigateByUrl(action.route) : router.navigate(action.route);
1120
+ void navigation.catch((error) => this.report(error, source));
1121
+ return;
1122
+ }
1123
+ if (!isSafeUrl(action.url)) {
1124
+ throw new Error(`[ng-spotlight] blocked a url with an unsafe scheme: ${JSON.stringify(action.url)}`);
1125
+ }
1126
+ const view = this.document.defaultView;
1127
+ if (!view)
1128
+ return;
1129
+ if (action.newTab === true || newTab)
1130
+ view.open(action.url, '_blank', 'noopener');
1131
+ else
1132
+ view.location.assign(action.url);
1133
+ }
1134
+ remember(item, sourceId) {
1135
+ if ('run' in item.action) {
1136
+ const key = `${sourceId}::${item.id}`;
1137
+ this._resolvedRuns.update((runs) => new Map(runs).set(key, item));
1138
+ }
1139
+ const next = pushRecent(this._recent(), toRecentEntry(item, sourceId), this._settings.recentMax);
1140
+ this._recent.set(next);
1141
+ const storage = this._settings.storage;
1142
+ void Promise.resolve()
1143
+ .then(() => storage.save(next))
1144
+ .catch((error) => console.warn('[ng-spotlight] could not save recent items', error));
1145
+ }
1146
+ async loadRecent() {
1147
+ let loaded;
1148
+ try {
1149
+ loaded = await this._settings.storage.load();
1150
+ }
1151
+ catch (error) {
1152
+ console.warn('[ng-spotlight] could not load recent items', error);
1153
+ loaded = [];
1154
+ }
1155
+ const entries = Array.isArray(loaded)
1156
+ ? loaded.filter(isRecentEntry).slice(0, this._settings.recentMax)
1157
+ : [];
1158
+ this._recent.set(entries);
1159
+ await this.resolveRunEntries(entries);
1160
+ }
1161
+ /** run() entries are stored without an action; look them up in their source by id. */
1162
+ async resolveRunEntries(entries) {
1163
+ const pending = entries.filter((e) => e.action === undefined && !this._resolvedRuns().has(`${e.sourceId}::${e.id}`));
1164
+ for (const sourceId of new Set(pending.map((e) => e.sourceId))) {
1165
+ const reg = this.regs().find((r) => r.source.id === sourceId);
1166
+ // Sources that need a query would be hit with an empty one just to open the palette.
1167
+ if (!reg || (reg.source.minLength ?? 0) > 0)
1168
+ continue;
1169
+ try {
1170
+ const result = await firstValueFrom(toObservableResult(reg.source.search('', { signal: new AbortController().signal, chip: null, injector: reg.injector })).pipe(timeout(reg.source.timeoutMs ?? DEFAULT_TIMEOUT_MS)));
1171
+ const items = normalizeResult(result, reg.source.group ?? reg.source.id).flatMap((g) => g.items);
1172
+ this._resolvedRuns.update((runs) => {
1173
+ const next = new Map(runs);
1174
+ for (const item of items)
1175
+ next.set(`${sourceId}::${item.id}`, item);
1176
+ return next;
1177
+ });
1178
+ }
1179
+ catch {
1180
+ // An unresolvable run() entry stays hidden.
1181
+ }
1182
+ }
1183
+ }
1184
+ restartEngine() {
1185
+ if (!this._isOpen())
1186
+ return;
1187
+ this.engine?.unsubscribe();
1188
+ this.input$ = new BehaviorSubject({ query: this._query(), chip: this._chip() });
1189
+ this.engine = createSearchEngine({
1190
+ sources: this.regs(),
1191
+ input$: this.input$,
1192
+ retry$: this.retry$,
1193
+ recentIds: (sourceId) => new Set(this._recent().flatMap((entry) => (entry.sourceId === sourceId ? [entry.id] : []))),
1194
+ onError: (error, source) => this.report(error, source),
1195
+ learned: this.learnedGroups,
1196
+ placeholderLabel: (state) => (state === 'loading' ? this.texts.loading() : this.texts.error()),
1197
+ }).subscribe((groups) => {
1198
+ this._results.set(groups);
1199
+ if (this._chip() === null)
1200
+ this._chipGroups.set(chipsFrom(groups));
1201
+ });
1202
+ }
1203
+ report(error, source) {
1204
+ try {
1205
+ this._settings.onError(error, source);
1206
+ }
1207
+ catch (handlerError) {
1208
+ console.error('[ng-spotlight] onError threw', handlerError);
1209
+ }
1210
+ }
1211
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1212
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightService });
1213
+ }
1214
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightService, decorators: [{
1215
+ type: Injectable
1216
+ }], ctorParameters: () => [] });
1217
+
1218
+ const SPOTLIGHT_LOCAL_CONFIG = new InjectionToken('SPOTLIGHT_LOCAL_CONFIG');
1219
+ /**
1220
+ * Sets up a palette. Call it once in the app config; call it again in a child injector (for
1221
+ * example a lazy route's `providers`) to add sources there. A child call only contributes
1222
+ * `sources` and `http` defaults: its `texts`, `hotkeys`, `recent`, `theme` and `onError` are
1223
+ * ignored (dev mode warns), since the first call owns the palette. Pass `isolated: true` for a
1224
+ * separate palette with its own settings. Pass a factory to build the config in an injection
1225
+ * context, e.g. `provideSpotlight(() => ({ texts: { placeholder: inject(I18n).t$('Search…') } }))`.
1226
+ */
1227
+ function provideSpotlight(config = {}) {
1228
+ return makeEnvironmentProviders([
1229
+ { provide: SPOTLIGHT_LOCAL_CONFIG, useFactory: typeof config === 'function' ? config : () => config },
1230
+ {
1231
+ provide: SpotlightService,
1232
+ useFactory: () => {
1233
+ const local = inject(SPOTLIGHT_LOCAL_CONFIG);
1234
+ const parent = local.isolated === true ? null : inject(SpotlightService, { skipSelf: true, optional: true });
1235
+ return parent ?? new SpotlightService();
1236
+ },
1237
+ },
1238
+ {
1239
+ provide: SPOTLIGHT_HTTP_DEFAULTS,
1240
+ useFactory: () => inject(SPOTLIGHT_LOCAL_CONFIG).http ?? inject(SPOTLIGHT_HTTP_DEFAULTS, { skipSelf: true, optional: true }) ?? {},
1241
+ },
1242
+ provideEnvironmentInitializer(() => {
1243
+ inject(SpotlightService).configure(inject(SPOTLIGHT_LOCAL_CONFIG), inject(EnvironmentInjector));
1244
+ }),
1245
+ ]);
1246
+ }
1247
+
1248
+ const SPOTLIGHT_RENDERER = new InjectionToken('SPOTLIGHT_RENDERER');
1249
+
1250
+ class SpotlightDefaultInput {
1251
+ ctx = input.required(...(ngDevMode ? [{ debugName: "ctx" }] : /* istanbul ignore next */ []));
1252
+ /** @internal */
1253
+ onInput(event) {
1254
+ this.ctx().onInput(event.target.value);
1255
+ }
1256
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightDefaultInput, deps: [], target: i0.ɵɵFactoryTarget.Component });
1257
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.25", type: SpotlightDefaultInput, isStandalone: true, selector: "spotlight-default-input", inputs: { ctx: { classPropertyName: "ctx", publicName: "ctx", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
1258
+ <input
1259
+ class="sp-input-el"
1260
+ type="text"
1261
+ role="combobox"
1262
+ autocomplete="off"
1263
+ spellcheck="false"
1264
+ aria-autocomplete="list"
1265
+ [id]="ctx().id"
1266
+ [value]="ctx().value"
1267
+ [attr.placeholder]="ctx().placeholder"
1268
+ [attr.aria-label]="ctx().label"
1269
+ [attr.aria-expanded]="ctx().expanded"
1270
+ [attr.aria-controls]="ctx().controls"
1271
+ [attr.aria-activedescendant]="ctx().activeDescendant"
1272
+ (input)="onInput($event)"
1273
+ (keydown)="ctx().onKeydown($event)"
1274
+ />
1275
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1276
+ }
1277
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightDefaultInput, decorators: [{
1278
+ type: Component,
1279
+ args: [{
1280
+ selector: 'spotlight-default-input',
1281
+ changeDetection: ChangeDetectionStrategy.OnPush,
1282
+ template: `
1283
+ <input
1284
+ class="sp-input-el"
1285
+ type="text"
1286
+ role="combobox"
1287
+ autocomplete="off"
1288
+ spellcheck="false"
1289
+ aria-autocomplete="list"
1290
+ [id]="ctx().id"
1291
+ [value]="ctx().value"
1292
+ [attr.placeholder]="ctx().placeholder"
1293
+ [attr.aria-label]="ctx().label"
1294
+ [attr.aria-expanded]="ctx().expanded"
1295
+ [attr.aria-controls]="ctx().controls"
1296
+ [attr.aria-activedescendant]="ctx().activeDescendant"
1297
+ (input)="onInput($event)"
1298
+ (keydown)="ctx().onKeydown($event)"
1299
+ />
1300
+ `,
1301
+ }]
1302
+ }], propDecorators: { ctx: [{ type: i0.Input, args: [{ isSignal: true, alias: "ctx", required: true }] }] } });
1303
+ class SpotlightDefaultChip {
1304
+ ctx = input.required(...(ngDevMode ? [{ debugName: "ctx" }] : /* istanbul ignore next */ []));
1305
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightDefaultChip, deps: [], target: i0.ɵɵFactoryTarget.Component });
1306
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.25", type: SpotlightDefaultChip, isStandalone: true, selector: "spotlight-default-chip", inputs: { ctx: { classPropertyName: "ctx", publicName: "ctx", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `<span class="sp-chip" [class.sp-chip-active]="ctx().active">{{ ctx().label }}</span>`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1307
+ }
1308
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightDefaultChip, decorators: [{
1309
+ type: Component,
1310
+ args: [{
1311
+ selector: 'spotlight-default-chip',
1312
+ changeDetection: ChangeDetectionStrategy.OnPush,
1313
+ template: `<span class="sp-chip" [class.sp-chip-active]="ctx().active">{{ ctx().label }}</span>`,
1314
+ }]
1315
+ }], propDecorators: { ctx: [{ type: i0.Input, args: [{ isSignal: true, alias: "ctx", required: true }] }] } });
1316
+ class SpotlightDefaultGroupHeader {
1317
+ ctx = input.required(...(ngDevMode ? [{ debugName: "ctx" }] : /* istanbul ignore next */ []));
1318
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightDefaultGroupHeader, deps: [], target: i0.ɵɵFactoryTarget.Component });
1319
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.25", type: SpotlightDefaultGroupHeader, isStandalone: true, selector: "spotlight-default-group-header", inputs: { ctx: { classPropertyName: "ctx", publicName: "ctx", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
1320
+ <span class="sp-group-label">{{ ctx().label }}</span>
1321
+ @if (ctx().total !== undefined) {
1322
+ <!-- The separator keeps the group's accessible name readable: "Customers, 2". -->
1323
+ <span style="position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0">, </span>
1324
+ <span class="sp-group-total">{{ ctx().total }}</span>
1325
+ }
1326
+ @if (ctx().loading) {
1327
+ <span class="sp-spinner" aria-hidden="true"></span>
1328
+ @if (ctx().loadingText) {
1329
+ <span
1330
+ style="position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0"
1331
+ >, {{ ctx().loadingText }}</span
1332
+ >
1333
+ }
1334
+ }
1335
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1336
+ }
1337
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightDefaultGroupHeader, decorators: [{
1338
+ type: Component,
1339
+ args: [{
1340
+ selector: 'spotlight-default-group-header',
1341
+ changeDetection: ChangeDetectionStrategy.OnPush,
1342
+ template: `
1343
+ <span class="sp-group-label">{{ ctx().label }}</span>
1344
+ @if (ctx().total !== undefined) {
1345
+ <!-- The separator keeps the group's accessible name readable: "Customers, 2". -->
1346
+ <span style="position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0">, </span>
1347
+ <span class="sp-group-total">{{ ctx().total }}</span>
1348
+ }
1349
+ @if (ctx().loading) {
1350
+ <span class="sp-spinner" aria-hidden="true"></span>
1351
+ @if (ctx().loadingText) {
1352
+ <span
1353
+ style="position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0"
1354
+ >, {{ ctx().loadingText }}</span
1355
+ >
1356
+ }
1357
+ }
1358
+ `,
1359
+ }]
1360
+ }], propDecorators: { ctx: [{ type: i0.Input, args: [{ isSignal: true, alias: "ctx", required: true }] }] } });
1361
+ class SpotlightDefaultBadge {
1362
+ ctx = input.required(...(ngDevMode ? [{ debugName: "ctx" }] : /* istanbul ignore next */ []));
1363
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightDefaultBadge, deps: [], target: i0.ɵɵFactoryTarget.Component });
1364
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.1.0", version: "21.2.25", type: SpotlightDefaultBadge, isStandalone: true, selector: "spotlight-default-badge", inputs: { ctx: { classPropertyName: "ctx", publicName: "ctx", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `<span class="sp-badge" [attr.data-tone]="ctx().badge.tone ?? 'info'">{{ ctx().badge.label }}</span>`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
1365
+ }
1366
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightDefaultBadge, decorators: [{
1367
+ type: Component,
1368
+ args: [{
1369
+ selector: 'spotlight-default-badge',
1370
+ changeDetection: ChangeDetectionStrategy.OnPush,
1371
+ template: `<span class="sp-badge" [attr.data-tone]="ctx().badge.tone ?? 'info'">{{ ctx().badge.label }}</span>`,
1372
+ }]
1373
+ }], propDecorators: { ctx: [{ type: i0.Input, args: [{ isSignal: true, alias: "ctx", required: true }] }] } });
1374
+ class SpotlightDefaultRow {
1375
+ ctx = input.required(...(ngDevMode ? [{ debugName: "ctx" }] : /* istanbul ignore next */ []));
1376
+ /** @internal */
1377
+ isImage(icon) {
1378
+ return /^(https?:|data:image\/|\/|\.)/.test(icon);
1379
+ }
1380
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightDefaultRow, deps: [], target: i0.ɵɵFactoryTarget.Component });
1381
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.25", type: SpotlightDefaultRow, isStandalone: true, selector: "spotlight-default-row", inputs: { ctx: { classPropertyName: "ctx", publicName: "ctx", isSignal: true, isRequired: true, transformFunction: null } }, ngImport: i0, template: `
1382
+ @let item = ctx().item;
1383
+ @if (item.icon) {
1384
+ <span class="sp-icon" aria-hidden="true">
1385
+ @if (isImage(item.icon)) {
1386
+ <img [src]="item.icon" alt="" />
1387
+ } @else {
1388
+ <i [class]="item.icon"></i>
1389
+ }
1390
+ </span>
1391
+ }
1392
+ <span class="sp-text">
1393
+ <span class="sp-title">{{ item.title }}</span>
1394
+ @if (item.subtitle) {
1395
+ <span class="sp-subtitle">{{ item.subtitle }}</span>
1396
+ }
1397
+ </span>
1398
+ @if (item.badges?.length) {
1399
+ <span class="sp-badges">
1400
+ @for (badge of item.badges ?? []; track $index) {
1401
+ <ng-container *ngComponentOutlet="ctx().badge; inputs: { ctx: { badge: badge } }" />
1402
+ }
1403
+ </span>
1404
+ }
1405
+ `, isInline: true, dependencies: [{ kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1406
+ }
1407
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightDefaultRow, decorators: [{
1408
+ type: Component,
1409
+ args: [{
1410
+ selector: 'spotlight-default-row',
1411
+ imports: [NgComponentOutlet],
1412
+ changeDetection: ChangeDetectionStrategy.OnPush,
1413
+ template: `
1414
+ @let item = ctx().item;
1415
+ @if (item.icon) {
1416
+ <span class="sp-icon" aria-hidden="true">
1417
+ @if (isImage(item.icon)) {
1418
+ <img [src]="item.icon" alt="" />
1419
+ } @else {
1420
+ <i [class]="item.icon"></i>
1421
+ }
1422
+ </span>
1423
+ }
1424
+ <span class="sp-text">
1425
+ <span class="sp-title">{{ item.title }}</span>
1426
+ @if (item.subtitle) {
1427
+ <span class="sp-subtitle">{{ item.subtitle }}</span>
1428
+ }
1429
+ </span>
1430
+ @if (item.badges?.length) {
1431
+ <span class="sp-badges">
1432
+ @for (badge of item.badges ?? []; track $index) {
1433
+ <ng-container *ngComponentOutlet="ctx().badge; inputs: { ctx: { badge: badge } }" />
1434
+ }
1435
+ </span>
1436
+ }
1437
+ `,
1438
+ }]
1439
+ }], propDecorators: { ctx: [{ type: i0.Input, args: [{ isSignal: true, alias: "ctx", required: true }] }] } });
1440
+ function resolveParts(renderer) {
1441
+ return {
1442
+ panelClass: renderer?.dialog?.panelClass ?? null,
1443
+ backdropClass: renderer?.dialog?.backdropClass ?? null,
1444
+ input: renderer?.input ?? SpotlightDefaultInput,
1445
+ chips: renderer?.chips ?? SpotlightDefaultChip,
1446
+ groupHeader: renderer?.groupHeader ?? SpotlightDefaultGroupHeader,
1447
+ row: renderer?.row ?? SpotlightDefaultRow,
1448
+ badge: renderer?.badge ?? SpotlightDefaultBadge,
1449
+ };
1450
+ }
1451
+
1452
+ /**
1453
+ * Decides which palette reacts when several `<spotlight-host>`s listen for the same hotkey
1454
+ * (for example the app shell's palette next to an `isolated` one), so one key press never opens
1455
+ * two palettes or toggles a shared one twice. Exactly one palette reacts:
1456
+ *
1457
+ * 1. an open palette, so the hotkey closes it (the one opened last if several are open);
1458
+ * 2. otherwise the palette opened most recently (by hotkey, trigger or `open()`);
1459
+ * 3. before any palette has been opened, the `<spotlight-host>` created first, normally the
1460
+ * app shell's.
1461
+ *
1462
+ * Only palettes whose own hotkeys match the event take part. Internal; not exported.
1463
+ */
1464
+ class SpotlightHotkeyArbiter {
1465
+ clients = [];
1466
+ clock = 0;
1467
+ /** The decision for each event, so every host's listener for one key press agrees. */
1468
+ decisions = new WeakMap();
1469
+ register(client) {
1470
+ this.clients.push(client);
1471
+ return () => {
1472
+ const index = this.clients.indexOf(client);
1473
+ if (index !== -1)
1474
+ this.clients.splice(index, 1);
1475
+ };
1476
+ }
1477
+ opened(client) {
1478
+ client.openedAt = ++this.clock;
1479
+ }
1480
+ /** The one palette that handles this hotkey event, or null when none listens for it. */
1481
+ handler(event) {
1482
+ const known = this.decisions.get(event);
1483
+ if (known !== undefined)
1484
+ return known;
1485
+ const candidates = this.clients.filter((c) => c.hotkeys().some((hotkey) => matchesHotkey(event, hotkey)));
1486
+ // Stable sort: equal openedAt (never opened) keeps creation order.
1487
+ const latestFirst = (list) => [...list].sort((a, b) => b.openedAt - a.openedAt);
1488
+ const choice = latestFirst(candidates.filter((c) => c.isOpen()))[0] ?? latestFirst(candidates)[0] ?? null;
1489
+ this.decisions.set(event, choice);
1490
+ return choice;
1491
+ }
1492
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightHotkeyArbiter, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1493
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightHotkeyArbiter, providedIn: 'root' });
1494
+ }
1495
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightHotkeyArbiter, decorators: [{
1496
+ type: Injectable,
1497
+ args: [{ providedIn: 'root' }]
1498
+ }] });
1499
+
1500
+ /**
1501
+ * The default theme: CSS variables only, light and dark. Variables sit on :where(:root)
1502
+ * (zero specificity) so apps override them with a plain :root or pane-class rule. Every
1503
+ * other rule is scoped under .sp-theme-default (set on the pane/backdrop by the host).
1504
+ */
1505
+ const SPOTLIGHT_DEFAULT_CSS = `
1506
+ :where(:root) {
1507
+ --sp-bg: #ffffff;
1508
+ --sp-fg: #1f2328;
1509
+ --sp-muted: #59636e;
1510
+ --sp-accent: #0969da;
1511
+ --sp-accent-fg: #ffffff;
1512
+ --sp-active-bg: #ddf4ff;
1513
+ --sp-border: #d1d9e0;
1514
+ --sp-backdrop: rgba(31, 35, 40, 0.4);
1515
+ --sp-radius: 12px;
1516
+ --sp-font: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
1517
+ --sp-shadow: 0 16px 48px rgba(31, 35, 40, 0.24);
1518
+ --sp-info: #0969da;
1519
+ --sp-success: #1a7f37;
1520
+ --sp-warn: #9a6700;
1521
+ --sp-danger: #d1242f;
1522
+ }
1523
+ @media (prefers-color-scheme: dark) {
1524
+ :where(:root:not(.sp-light)) {
1525
+ --sp-bg: #151b23;
1526
+ --sp-fg: #f0f6fc;
1527
+ --sp-muted: #9198a1;
1528
+ --sp-accent: #4493f8;
1529
+ --sp-accent-fg: #0d1117;
1530
+ --sp-active-bg: #1d2d44;
1531
+ --sp-border: #3d444d;
1532
+ --sp-backdrop: rgba(1, 4, 9, 0.6);
1533
+ --sp-shadow: 0 16px 48px rgba(1, 4, 9, 0.6);
1534
+ --sp-info: #4493f8;
1535
+ --sp-success: #3fb950;
1536
+ --sp-warn: #d29922;
1537
+ --sp-danger: #f85149;
1538
+ }
1539
+ }
1540
+ :where(.sp-dark) {
1541
+ --sp-bg: #151b23;
1542
+ --sp-fg: #f0f6fc;
1543
+ --sp-muted: #9198a1;
1544
+ --sp-accent: #4493f8;
1545
+ --sp-accent-fg: #0d1117;
1546
+ --sp-active-bg: #1d2d44;
1547
+ --sp-border: #3d444d;
1548
+ --sp-backdrop: rgba(1, 4, 9, 0.6);
1549
+ --sp-shadow: 0 16px 48px rgba(1, 4, 9, 0.6);
1550
+ --sp-info: #4493f8;
1551
+ --sp-success: #3fb950;
1552
+ --sp-warn: #d29922;
1553
+ --sp-danger: #f85149;
1554
+ }
1555
+ .sp-backdrop.sp-theme-default { background: var(--sp-backdrop); }
1556
+ .sp-theme-default .sp-dialog {
1557
+ box-sizing: border-box; display: flex; flex-direction: column; width: 100%; max-height: min(70vh, 640px);
1558
+ background: var(--sp-bg); color: var(--sp-fg); font-family: var(--sp-font); font-size: 14px;
1559
+ border: 1px solid var(--sp-border); border-radius: var(--sp-radius); box-shadow: var(--sp-shadow); overflow: hidden;
1560
+ }
1561
+ .sp-theme-default.sp-sheet .sp-dialog { height: 100%; max-height: none; border: 0; border-radius: 0; }
1562
+ .sp-theme-default .sp-input { display: flex; align-items: center; border-bottom: 1px solid var(--sp-border); }
1563
+ .sp-theme-default .sp-input:focus-within { box-shadow: inset 0 -2px 0 var(--sp-accent); }
1564
+ .sp-theme-default .sp-input-el {
1565
+ flex: 1; min-width: 0; padding: 16px; border: 0; outline: none; background: transparent; color: inherit; font: inherit; font-size: 16px;
1566
+ }
1567
+ .sp-theme-default .sp-input-el::placeholder { color: var(--sp-muted); }
1568
+ .sp-theme-default .sp-chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 12px; border-bottom: 1px solid var(--sp-border); }
1569
+ .sp-theme-default .sp-chip-button { padding: 0; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; border-radius: 999px; }
1570
+ .sp-theme-default .sp-chip { display: inline-block; padding: 2px 10px; border: 1px solid var(--sp-border); border-radius: 999px; color: var(--sp-muted); }
1571
+ .sp-theme-default .sp-chip-active { background: var(--sp-accent); border-color: var(--sp-accent); color: var(--sp-accent-fg); }
1572
+ .sp-theme-default .sp-list { flex: 1; overflow-y: auto; padding: 4px 0; outline: none; }
1573
+ .sp-theme-default .sp-group + .sp-group { border-top: 1px solid var(--sp-border); }
1574
+ .sp-theme-default .sp-group-header {
1575
+ display: flex; align-items: center; gap: 8px; padding: 8px 16px 4px; color: var(--sp-muted);
1576
+ font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em;
1577
+ }
1578
+ .sp-theme-default .sp-group-total { font-weight: 400; }
1579
+ .sp-theme-default .sp-option { display: flex; align-items: center; gap: 12px; padding: 8px 16px; cursor: pointer; }
1580
+ .sp-theme-default .sp-option[aria-selected='true'] { background: var(--sp-active-bg); box-shadow: inset 3px 0 0 var(--sp-accent); }
1581
+ .sp-theme-default .sp-icon { display: inline-flex; justify-content: center; width: 20px; color: var(--sp-muted); }
1582
+ .sp-theme-default .sp-icon img { width: 20px; height: 20px; object-fit: contain; }
1583
+ .sp-theme-default .sp-text { display: flex; flex: 1; flex-direction: column; min-width: 0; }
1584
+ .sp-theme-default .sp-title, .sp-theme-default .sp-subtitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1585
+ .sp-theme-default .sp-subtitle { color: var(--sp-muted); font-size: 12px; }
1586
+ .sp-theme-default .sp-badges { display: inline-flex; gap: 4px; }
1587
+ .sp-theme-default .sp-badge { padding: 0 6px; background: var(--sp-bg); border: 1px solid currentColor; border-radius: 4px; font-size: 11px; line-height: 18px; color: var(--sp-info); }
1588
+ .sp-theme-default .sp-badge[data-tone='success'] { color: var(--sp-success); }
1589
+ .sp-theme-default .sp-badge[data-tone='warn'] { color: var(--sp-warn); }
1590
+ .sp-theme-default .sp-badge[data-tone='danger'] { color: var(--sp-danger); }
1591
+ .sp-theme-default .sp-error { flex: 1; color: var(--sp-muted); }
1592
+ .sp-theme-default .sp-retry { color: var(--sp-accent); text-decoration: underline; }
1593
+ .sp-theme-default .sp-empty { padding: 24px 16px; text-align: center; color: var(--sp-muted); }
1594
+ .sp-theme-default .sp-footer {
1595
+ display: flex; flex-wrap: wrap; gap: 16px; padding: 8px 16px; border-top: 1px solid var(--sp-border); color: var(--sp-muted); font-size: 12px;
1596
+ }
1597
+ .sp-theme-default .sp-footer kbd { padding: 0 4px; border: 1px solid var(--sp-border); border-radius: 4px; font-family: inherit; }
1598
+ .sp-theme-default .sp-spinner {
1599
+ width: 10px; height: 10px; border: 2px solid var(--sp-border); border-top-color: var(--sp-accent); border-radius: 50%;
1600
+ animation: sp-spin 0.8s linear infinite;
1601
+ }
1602
+ @keyframes sp-spin { to { transform: rotate(360deg); } }
1603
+ @media (prefers-reduced-motion: reduce) { .sp-theme-default .sp-spinner { animation: none; } }
1604
+ `;
1605
+ /** Rendered by <spotlight-host> only when theme is 'default'; theme 'none' never loads this CSS. */
1606
+ class SpotlightDefaultTheme {
1607
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightDefaultTheme, deps: [], target: i0.ɵɵFactoryTarget.Component });
1608
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.25", type: SpotlightDefaultTheme, isStandalone: true, selector: "spotlight-theme-default", host: { styleAttribute: "display:none" }, ngImport: i0, template: '', isInline: true, styles: [":where(:root){--sp-bg: #ffffff;--sp-fg: #1f2328;--sp-muted: #59636e;--sp-accent: #0969da;--sp-accent-fg: #ffffff;--sp-active-bg: #ddf4ff;--sp-border: #d1d9e0;--sp-backdrop: rgba(31, 35, 40, .4);--sp-radius: 12px;--sp-font: system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;--sp-shadow: 0 16px 48px rgba(31, 35, 40, .24);--sp-info: #0969da;--sp-success: #1a7f37;--sp-warn: #9a6700;--sp-danger: #d1242f}@media(prefers-color-scheme:dark){:where(:root:not(.sp-light)){--sp-bg: #151b23;--sp-fg: #f0f6fc;--sp-muted: #9198a1;--sp-accent: #4493f8;--sp-accent-fg: #0d1117;--sp-active-bg: #1d2d44;--sp-border: #3d444d;--sp-backdrop: rgba(1, 4, 9, .6);--sp-shadow: 0 16px 48px rgba(1, 4, 9, .6);--sp-info: #4493f8;--sp-success: #3fb950;--sp-warn: #d29922;--sp-danger: #f85149}}:where(.sp-dark){--sp-bg: #151b23;--sp-fg: #f0f6fc;--sp-muted: #9198a1;--sp-accent: #4493f8;--sp-accent-fg: #0d1117;--sp-active-bg: #1d2d44;--sp-border: #3d444d;--sp-backdrop: rgba(1, 4, 9, .6);--sp-shadow: 0 16px 48px rgba(1, 4, 9, .6);--sp-info: #4493f8;--sp-success: #3fb950;--sp-warn: #d29922;--sp-danger: #f85149}.sp-backdrop.sp-theme-default{background:var(--sp-backdrop)}.sp-theme-default .sp-dialog{box-sizing:border-box;display:flex;flex-direction:column;width:100%;max-height:min(70vh,640px);background:var(--sp-bg);color:var(--sp-fg);font-family:var(--sp-font);font-size:14px;border:1px solid var(--sp-border);border-radius:var(--sp-radius);box-shadow:var(--sp-shadow);overflow:hidden}.sp-theme-default.sp-sheet .sp-dialog{height:100%;max-height:none;border:0;border-radius:0}.sp-theme-default .sp-input{display:flex;align-items:center;border-bottom:1px solid var(--sp-border)}.sp-theme-default .sp-input:focus-within{box-shadow:inset 0 -2px 0 var(--sp-accent)}.sp-theme-default .sp-input-el{flex:1;min-width:0;padding:16px;border:0;outline:none;background:transparent;color:inherit;font:inherit;font-size:16px}.sp-theme-default .sp-input-el::placeholder{color:var(--sp-muted)}.sp-theme-default .sp-chips{display:flex;flex-wrap:wrap;gap:6px;padding:8px 12px;border-bottom:1px solid var(--sp-border)}.sp-theme-default .sp-chip-button{padding:0;border:0;background:none;color:inherit;font:inherit;cursor:pointer;border-radius:999px}.sp-theme-default .sp-chip{display:inline-block;padding:2px 10px;border:1px solid var(--sp-border);border-radius:999px;color:var(--sp-muted)}.sp-theme-default .sp-chip-active{background:var(--sp-accent);border-color:var(--sp-accent);color:var(--sp-accent-fg)}.sp-theme-default .sp-list{flex:1;overflow-y:auto;padding:4px 0;outline:none}.sp-theme-default .sp-group+.sp-group{border-top:1px solid var(--sp-border)}.sp-theme-default .sp-group-header{display:flex;align-items:center;gap:8px;padding:8px 16px 4px;color:var(--sp-muted);font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.sp-theme-default .sp-group-total{font-weight:400}.sp-theme-default .sp-option{display:flex;align-items:center;gap:12px;padding:8px 16px;cursor:pointer}.sp-theme-default .sp-option[aria-selected=true]{background:var(--sp-active-bg);box-shadow:inset 3px 0 0 var(--sp-accent)}.sp-theme-default .sp-icon{display:inline-flex;justify-content:center;width:20px;color:var(--sp-muted)}.sp-theme-default .sp-icon img{width:20px;height:20px;object-fit:contain}.sp-theme-default .sp-text{display:flex;flex:1;flex-direction:column;min-width:0}.sp-theme-default .sp-title,.sp-theme-default .sp-subtitle{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sp-theme-default .sp-subtitle{color:var(--sp-muted);font-size:12px}.sp-theme-default .sp-badges{display:inline-flex;gap:4px}.sp-theme-default .sp-badge{padding:0 6px;background:var(--sp-bg);border:1px solid currentColor;border-radius:4px;font-size:11px;line-height:18px;color:var(--sp-info)}.sp-theme-default .sp-badge[data-tone=success]{color:var(--sp-success)}.sp-theme-default .sp-badge[data-tone=warn]{color:var(--sp-warn)}.sp-theme-default .sp-badge[data-tone=danger]{color:var(--sp-danger)}.sp-theme-default .sp-error{flex:1;color:var(--sp-muted)}.sp-theme-default .sp-retry{color:var(--sp-accent);text-decoration:underline}.sp-theme-default .sp-empty{padding:24px 16px;text-align:center;color:var(--sp-muted)}.sp-theme-default .sp-footer{display:flex;flex-wrap:wrap;gap:16px;padding:8px 16px;border-top:1px solid var(--sp-border);color:var(--sp-muted);font-size:12px}.sp-theme-default .sp-footer kbd{padding:0 4px;border:1px solid var(--sp-border);border-radius:4px;font-family:inherit}.sp-theme-default .sp-spinner{width:10px;height:10px;border:2px solid var(--sp-border);border-top-color:var(--sp-accent);border-radius:50%;animation:sp-spin .8s linear infinite}@keyframes sp-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.sp-theme-default .sp-spinner{animation:none}}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None });
1609
+ }
1610
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightDefaultTheme, decorators: [{
1611
+ type: Component,
1612
+ args: [{ selector: 'spotlight-theme-default', template: '', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, host: { style: 'display:none' }, styles: [":where(:root){--sp-bg: #ffffff;--sp-fg: #1f2328;--sp-muted: #59636e;--sp-accent: #0969da;--sp-accent-fg: #ffffff;--sp-active-bg: #ddf4ff;--sp-border: #d1d9e0;--sp-backdrop: rgba(31, 35, 40, .4);--sp-radius: 12px;--sp-font: system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif;--sp-shadow: 0 16px 48px rgba(31, 35, 40, .24);--sp-info: #0969da;--sp-success: #1a7f37;--sp-warn: #9a6700;--sp-danger: #d1242f}@media(prefers-color-scheme:dark){:where(:root:not(.sp-light)){--sp-bg: #151b23;--sp-fg: #f0f6fc;--sp-muted: #9198a1;--sp-accent: #4493f8;--sp-accent-fg: #0d1117;--sp-active-bg: #1d2d44;--sp-border: #3d444d;--sp-backdrop: rgba(1, 4, 9, .6);--sp-shadow: 0 16px 48px rgba(1, 4, 9, .6);--sp-info: #4493f8;--sp-success: #3fb950;--sp-warn: #d29922;--sp-danger: #f85149}}:where(.sp-dark){--sp-bg: #151b23;--sp-fg: #f0f6fc;--sp-muted: #9198a1;--sp-accent: #4493f8;--sp-accent-fg: #0d1117;--sp-active-bg: #1d2d44;--sp-border: #3d444d;--sp-backdrop: rgba(1, 4, 9, .6);--sp-shadow: 0 16px 48px rgba(1, 4, 9, .6);--sp-info: #4493f8;--sp-success: #3fb950;--sp-warn: #d29922;--sp-danger: #f85149}.sp-backdrop.sp-theme-default{background:var(--sp-backdrop)}.sp-theme-default .sp-dialog{box-sizing:border-box;display:flex;flex-direction:column;width:100%;max-height:min(70vh,640px);background:var(--sp-bg);color:var(--sp-fg);font-family:var(--sp-font);font-size:14px;border:1px solid var(--sp-border);border-radius:var(--sp-radius);box-shadow:var(--sp-shadow);overflow:hidden}.sp-theme-default.sp-sheet .sp-dialog{height:100%;max-height:none;border:0;border-radius:0}.sp-theme-default .sp-input{display:flex;align-items:center;border-bottom:1px solid var(--sp-border)}.sp-theme-default .sp-input:focus-within{box-shadow:inset 0 -2px 0 var(--sp-accent)}.sp-theme-default .sp-input-el{flex:1;min-width:0;padding:16px;border:0;outline:none;background:transparent;color:inherit;font:inherit;font-size:16px}.sp-theme-default .sp-input-el::placeholder{color:var(--sp-muted)}.sp-theme-default .sp-chips{display:flex;flex-wrap:wrap;gap:6px;padding:8px 12px;border-bottom:1px solid var(--sp-border)}.sp-theme-default .sp-chip-button{padding:0;border:0;background:none;color:inherit;font:inherit;cursor:pointer;border-radius:999px}.sp-theme-default .sp-chip{display:inline-block;padding:2px 10px;border:1px solid var(--sp-border);border-radius:999px;color:var(--sp-muted)}.sp-theme-default .sp-chip-active{background:var(--sp-accent);border-color:var(--sp-accent);color:var(--sp-accent-fg)}.sp-theme-default .sp-list{flex:1;overflow-y:auto;padding:4px 0;outline:none}.sp-theme-default .sp-group+.sp-group{border-top:1px solid var(--sp-border)}.sp-theme-default .sp-group-header{display:flex;align-items:center;gap:8px;padding:8px 16px 4px;color:var(--sp-muted);font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.04em}.sp-theme-default .sp-group-total{font-weight:400}.sp-theme-default .sp-option{display:flex;align-items:center;gap:12px;padding:8px 16px;cursor:pointer}.sp-theme-default .sp-option[aria-selected=true]{background:var(--sp-active-bg);box-shadow:inset 3px 0 0 var(--sp-accent)}.sp-theme-default .sp-icon{display:inline-flex;justify-content:center;width:20px;color:var(--sp-muted)}.sp-theme-default .sp-icon img{width:20px;height:20px;object-fit:contain}.sp-theme-default .sp-text{display:flex;flex:1;flex-direction:column;min-width:0}.sp-theme-default .sp-title,.sp-theme-default .sp-subtitle{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.sp-theme-default .sp-subtitle{color:var(--sp-muted);font-size:12px}.sp-theme-default .sp-badges{display:inline-flex;gap:4px}.sp-theme-default .sp-badge{padding:0 6px;background:var(--sp-bg);border:1px solid currentColor;border-radius:4px;font-size:11px;line-height:18px;color:var(--sp-info)}.sp-theme-default .sp-badge[data-tone=success]{color:var(--sp-success)}.sp-theme-default .sp-badge[data-tone=warn]{color:var(--sp-warn)}.sp-theme-default .sp-badge[data-tone=danger]{color:var(--sp-danger)}.sp-theme-default .sp-error{flex:1;color:var(--sp-muted)}.sp-theme-default .sp-retry{color:var(--sp-accent);text-decoration:underline}.sp-theme-default .sp-empty{padding:24px 16px;text-align:center;color:var(--sp-muted)}.sp-theme-default .sp-footer{display:flex;flex-wrap:wrap;gap:16px;padding:8px 16px;border-top:1px solid var(--sp-border);color:var(--sp-muted);font-size:12px}.sp-theme-default .sp-footer kbd{padding:0 4px;border:1px solid var(--sp-border);border-radius:4px;font-family:inherit}.sp-theme-default .sp-spinner{width:10px;height:10px;border:2px solid var(--sp-border);border-top-color:var(--sp-accent);border-radius:50%;animation:sp-spin .8s linear infinite}@keyframes sp-spin{to{transform:rotate(360deg)}}@media(prefers-reduced-motion:reduce){.sp-theme-default .sp-spinner{animation:none}}\n"] }]
1613
+ }] });
1614
+
1615
+ /** `<ng-template spotlightItem="customers" let-item>`: row for one group; without a value: for every group. */
1616
+ class SpotlightItemTemplate {
1617
+ spotlightItem = input('', ...(ngDevMode ? [{ debugName: "spotlightItem" }] : /* istanbul ignore next */ []));
1618
+ /** @internal */
1619
+ template = inject(TemplateRef);
1620
+ static ngTemplateContextGuard(_dir, _ctx) {
1621
+ return true;
1622
+ }
1623
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightItemTemplate, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1624
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.25", type: SpotlightItemTemplate, isStandalone: true, selector: "[spotlightItem]", inputs: { spotlightItem: { classPropertyName: "spotlightItem", publicName: "spotlightItem", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0 });
1625
+ }
1626
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightItemTemplate, decorators: [{
1627
+ type: Directive,
1628
+ args: [{ selector: '[spotlightItem]' }]
1629
+ }], propDecorators: { spotlightItem: [{ type: i0.Input, args: [{ isSignal: true, alias: "spotlightItem", required: false }] }] } });
1630
+ /** `<ng-template spotlightEmpty let-query>`: shown when a non-empty query has no results. */
1631
+ class SpotlightEmptyTemplate {
1632
+ /** @internal */
1633
+ template = inject(TemplateRef);
1634
+ static ngTemplateContextGuard(_dir, _ctx) {
1635
+ return true;
1636
+ }
1637
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightEmptyTemplate, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1638
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.25", type: SpotlightEmptyTemplate, isStandalone: true, selector: "[spotlightEmpty]", ngImport: i0 });
1639
+ }
1640
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightEmptyTemplate, decorators: [{
1641
+ type: Directive,
1642
+ args: [{ selector: '[spotlightEmpty]' }]
1643
+ }] });
1644
+ /** `<ng-template spotlightFooter>`: replaces the default key hints. */
1645
+ class SpotlightFooterTemplate {
1646
+ /** @internal */
1647
+ template = inject(TemplateRef);
1648
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightFooterTemplate, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1649
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.2.25", type: SpotlightFooterTemplate, isStandalone: true, selector: "[spotlightFooter]", ngImport: i0 });
1650
+ }
1651
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightFooterTemplate, decorators: [{
1652
+ type: Directive,
1653
+ args: [{ selector: '[spotlightFooter]' }]
1654
+ }] });
1655
+ /** Opens the palette on click. `spotlightTrigger="#"` opens it with an initial query. */
1656
+ class SpotlightTrigger {
1657
+ svc = inject(SpotlightService);
1658
+ spotlightTrigger = input('', ...(ngDevMode ? [{ debugName: "spotlightTrigger" }] : /* istanbul ignore next */ []));
1659
+ /** @internal */
1660
+ open() {
1661
+ this.svc.open(this.spotlightTrigger() || undefined);
1662
+ }
1663
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightTrigger, deps: [], target: i0.ɵɵFactoryTarget.Directive });
1664
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "17.1.0", version: "21.2.25", type: SpotlightTrigger, isStandalone: true, selector: "[spotlightTrigger]", inputs: { spotlightTrigger: { classPropertyName: "spotlightTrigger", publicName: "spotlightTrigger", isSignal: true, isRequired: false, transformFunction: null } }, host: { attributes: { "aria-haspopup": "dialog" }, listeners: { "click": "open()" } }, ngImport: i0 });
1665
+ }
1666
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightTrigger, decorators: [{
1667
+ type: Directive,
1668
+ args: [{
1669
+ selector: '[spotlightTrigger]',
1670
+ host: { '(click)': 'open()', 'aria-haspopup': 'dialog' },
1671
+ }]
1672
+ }], propDecorators: { spotlightTrigger: [{ type: i0.Input, args: [{ isSignal: true, alias: "spotlightTrigger", required: false }] }] } });
1673
+
1674
+ const PHONE_QUERY = '(max-width: 600px)';
1675
+ /** The palette. Place it once in the app shell. */
1676
+ class SpotlightHost {
1677
+ /** @internal */
1678
+ svc = inject(SpotlightService);
1679
+ overlay = inject(Overlay);
1680
+ focusTrapFactory = inject(FocusTrapFactory);
1681
+ breakpoints = inject(BreakpointObserver);
1682
+ viewContainerRef = inject(ViewContainerRef);
1683
+ injector = inject(Injector);
1684
+ document = inject(DOCUMENT);
1685
+ arbiter = inject(SpotlightHotkeyArbiter);
1686
+ hotkeyClient = {
1687
+ hotkeys: () => this.svc.settings.hotkeys,
1688
+ isOpen: () => this.svc.isOpen(),
1689
+ openedAt: 0,
1690
+ };
1691
+ /** @internal */
1692
+ parts = resolveParts(inject(SPOTLIGHT_RENDERER, { optional: true }));
1693
+ /** @internal */
1694
+ themeDefault = this.svc.settings.theme === 'default';
1695
+ /** @internal */
1696
+ ids = {
1697
+ prefix: this.svc.idPrefix,
1698
+ input: `${this.svc.idPrefix}-input`,
1699
+ listbox: `${this.svc.idPrefix}-listbox`,
1700
+ };
1701
+ panel = viewChild.required('panel');
1702
+ itemTemplates = contentChildren(SpotlightItemTemplate, ...(ngDevMode ? [{ debugName: "itemTemplates" }] : /* istanbul ignore next */ []));
1703
+ /** @internal */
1704
+ emptyTemplate = contentChild(SpotlightEmptyTemplate, ...(ngDevMode ? [{ debugName: "emptyTemplate" }] : /* istanbul ignore next */ []));
1705
+ /** @internal */
1706
+ footerTemplate = contentChild(SpotlightFooterTemplate, ...(ngDevMode ? [{ debugName: "footerTemplate" }] : /* istanbul ignore next */ []));
1707
+ overlayRef = null;
1708
+ focusTrap = null;
1709
+ restoreFocus = null;
1710
+ /** @internal */
1711
+ activeId = computed(() => this.svc.activeOption()?.domId ?? null, ...(ngDevMode ? [{ debugName: "activeId" }] : /* istanbul ignore next */ []));
1712
+ /** @internal */
1713
+ optionsByGroup = computed(() => {
1714
+ const byGroup = new Map();
1715
+ for (const option of this.svc.options()) {
1716
+ const list = byGroup.get(option.groupKey) ?? [];
1717
+ list.push(option);
1718
+ byGroup.set(option.groupKey, list);
1719
+ }
1720
+ return byGroup;
1721
+ }, ...(ngDevMode ? [{ debugName: "optionsByGroup" }] : /* istanbul ignore next */ []));
1722
+ /** @internal */
1723
+ showEmpty = computed(() => this.svc.query().trim() !== '' && this.svc.options().length === 0 && !this.svc.loading(), ...(ngDevMode ? [{ debugName: "showEmpty" }] : /* istanbul ignore next */ []));
1724
+ handleInput = (value) => this.svc.setQuery(value);
1725
+ handleKeydown = (event) => this.onInputKeydown(event);
1726
+ /** @internal */
1727
+ inputCtx = computed(() => ({
1728
+ id: this.ids.input,
1729
+ value: this.svc.query(),
1730
+ placeholder: this.texts.placeholder(),
1731
+ label: this.texts.placeholder(),
1732
+ expanded: this.svc.options().length > 0 || this.svc.loading(),
1733
+ controls: this.ids.listbox,
1734
+ activeDescendant: this.activeId(),
1735
+ onInput: this.handleInput,
1736
+ onKeydown: this.handleKeydown,
1737
+ }), ...(ngDevMode ? [{ debugName: "inputCtx" }] : /* istanbul ignore next */ []));
1738
+ constructor() {
1739
+ const unregister = this.arbiter.register(this.hotkeyClient);
1740
+ effect(() => {
1741
+ const open = this.svc.isOpen();
1742
+ untracked(() => {
1743
+ if (open) {
1744
+ this.arbiter.opened(this.hotkeyClient);
1745
+ this.attach();
1746
+ }
1747
+ else {
1748
+ this.detach();
1749
+ }
1750
+ });
1751
+ });
1752
+ afterRenderEffect(() => {
1753
+ const id = this.activeId();
1754
+ if (id === null || this.overlayRef === null)
1755
+ return;
1756
+ this.document.getElementById(id)?.scrollIntoView?.({ block: 'nearest' });
1757
+ });
1758
+ inject(DestroyRef).onDestroy(() => {
1759
+ unregister();
1760
+ this.svc.close();
1761
+ this.detach();
1762
+ });
1763
+ }
1764
+ /** @internal */
1765
+ get texts() {
1766
+ return this.svc.texts;
1767
+ }
1768
+ /** @internal A placeholder header already reads "Loading…"; don't announce it twice. */
1769
+ loadingTextFor(label) {
1770
+ const loading = this.texts.loading();
1771
+ return label === loading ? '' : loading;
1772
+ }
1773
+ /** @internal */
1774
+ templateFor(groupId) {
1775
+ const templates = this.itemTemplates();
1776
+ const match = templates.find((t) => t.spotlightItem() === groupId) ?? templates.find((t) => t.spotlightItem() === '');
1777
+ return match?.template ?? null;
1778
+ }
1779
+ /** @internal */
1780
+ choose(option, event) {
1781
+ void this.svc.select(option, { newTab: event.ctrlKey || event.metaKey });
1782
+ }
1783
+ /** @internal */
1784
+ onDocumentKeydown(event) {
1785
+ if (!this.svc.settings.hotkeys.some((hotkey) => matchesHotkey(event, hotkey)))
1786
+ return;
1787
+ event.preventDefault();
1788
+ if (event.repeat)
1789
+ return;
1790
+ // With several palettes on the page, exactly one reacts (see SpotlightHotkeyArbiter).
1791
+ if (this.arbiter.handler(event) !== this.hotkeyClient)
1792
+ return;
1793
+ this.svc.toggle();
1794
+ }
1795
+ onInputKeydown(event) {
1796
+ if (event.isComposing)
1797
+ return;
1798
+ switch (event.key) {
1799
+ case 'ArrowDown':
1800
+ event.preventDefault();
1801
+ this.svc.moveActive(1);
1802
+ break;
1803
+ case 'ArrowUp':
1804
+ event.preventDefault();
1805
+ this.svc.moveActive(-1);
1806
+ break;
1807
+ case 'Enter': {
1808
+ event.preventDefault();
1809
+ const option = this.svc.activeOption();
1810
+ if (option)
1811
+ void this.svc.select(option, { newTab: event.ctrlKey || event.metaKey });
1812
+ break;
1813
+ }
1814
+ case 'Tab':
1815
+ if (this.svc.chips().length > 1) {
1816
+ event.preventDefault();
1817
+ this.svc.cycleChip(event.shiftKey ? -1 : 1);
1818
+ }
1819
+ break;
1820
+ }
1821
+ }
1822
+ attach() {
1823
+ if (this.overlayRef)
1824
+ return;
1825
+ const active = this.document.activeElement;
1826
+ this.restoreFocus = active instanceof HTMLElement ? active : null;
1827
+ const sheet = this.breakpoints.isMatched(PHONE_QUERY);
1828
+ const theme = this.themeDefault ? ['sp-theme-default'] : [];
1829
+ const position = this.overlay.position().global();
1830
+ const ref = this.overlay.create({
1831
+ hasBackdrop: true,
1832
+ backdropClass: ['sp-backdrop', ...theme, ...(this.parts.backdropClass ? [this.parts.backdropClass] : [])],
1833
+ panelClass: ['sp-pane', ...theme, ...(sheet ? ['sp-sheet'] : []), ...(this.parts.panelClass ? [this.parts.panelClass] : [])],
1834
+ positionStrategy: sheet ? position.top('0').left('0') : position.centerHorizontally().top('12vh'),
1835
+ scrollStrategy: this.overlay.scrollStrategies.block(),
1836
+ width: sheet ? '100vw' : 'min(640px, calc(100vw - 32px))',
1837
+ height: sheet ? '100dvh' : undefined,
1838
+ });
1839
+ ref.attach(new TemplatePortal(this.panel(), this.viewContainerRef));
1840
+ ref.backdropClick().subscribe(() => this.svc.close());
1841
+ ref.keydownEvents().subscribe((event) => {
1842
+ if (event.key === 'Escape') {
1843
+ event.preventDefault();
1844
+ this.svc.close();
1845
+ }
1846
+ });
1847
+ this.focusTrap = this.focusTrapFactory.create(ref.overlayElement);
1848
+ this.overlayRef = ref;
1849
+ afterNextRender(() => this.overlayRef?.overlayElement.querySelector('[role="combobox"]')?.focus(), {
1850
+ injector: this.injector,
1851
+ });
1852
+ }
1853
+ detach() {
1854
+ if (!this.overlayRef)
1855
+ return;
1856
+ this.focusTrap?.destroy();
1857
+ this.focusTrap = null;
1858
+ this.overlayRef.dispose();
1859
+ this.overlayRef = null;
1860
+ const target = this.restoreFocus;
1861
+ this.restoreFocus = null;
1862
+ if (target?.isConnected)
1863
+ target.focus();
1864
+ }
1865
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightHost, deps: [], target: i0.ɵɵFactoryTarget.Component });
1866
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.25", type: SpotlightHost, isStandalone: true, selector: "spotlight-host", host: { listeners: { "document:keydown": "onDocumentKeydown($event)" } }, queries: [{ propertyName: "itemTemplates", predicate: SpotlightItemTemplate, isSignal: true }, { propertyName: "emptyTemplate", first: true, predicate: SpotlightEmptyTemplate, descendants: true, isSignal: true }, { propertyName: "footerTemplate", first: true, predicate: SpotlightFooterTemplate, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "panel", first: true, predicate: ["panel"], descendants: true, isSignal: true }], ngImport: i0, template: `
1867
+ @if (themeDefault) {
1868
+ <spotlight-theme-default />
1869
+ }
1870
+ <ng-template #panel>
1871
+ <div class="sp-dialog" role="dialog" aria-modal="true" [attr.aria-label]="texts.placeholder()">
1872
+ <div class="sp-input">
1873
+ <ng-container *ngComponentOutlet="parts.input; inputs: { ctx: inputCtx() }" />
1874
+ </div>
1875
+ @if (svc.chips().length > 1) {
1876
+ <div class="sp-chips">
1877
+ @for (chip of svc.chips(); track chip.id ?? '') {
1878
+ <button
1879
+ type="button"
1880
+ class="sp-chip-button"
1881
+ tabindex="-1"
1882
+ [attr.aria-pressed]="chip.id === svc.chip()"
1883
+ [attr.aria-label]="chip.label"
1884
+ (mousedown)="$event.preventDefault()"
1885
+ (click)="svc.setChip(chip.id)"
1886
+ >
1887
+ <ng-container
1888
+ *ngComponentOutlet="parts.chips; inputs: { ctx: { label: chip.label, active: chip.id === svc.chip() } }"
1889
+ />
1890
+ </button>
1891
+ }
1892
+ </div>
1893
+ }
1894
+ <div class="sp-list" role="listbox" tabindex="-1" [attr.aria-busy]="svc.loading() || null" [id]="ids.listbox" [attr.aria-label]="texts.placeholder()">
1895
+ @for (group of svc.groups(); track group.key; let g = $index) {
1896
+ <div class="sp-group" role="group" [attr.aria-labelledby]="ids.prefix + '-g' + g">
1897
+ <div class="sp-group-header" [id]="ids.prefix + '-g' + g">
1898
+ <ng-container
1899
+ *ngComponentOutlet="
1900
+ parts.groupHeader;
1901
+ inputs: { ctx: { label: group.label, total: group.total, loading: group.loading, loadingText: loadingTextFor(group.label) } }
1902
+ "
1903
+ />
1904
+ </div>
1905
+ @for (opt of optionsByGroup().get(group.key) ?? []; track opt.key) {
1906
+ <div
1907
+ class="sp-option"
1908
+ role="option"
1909
+ [id]="opt.domId"
1910
+ [attr.aria-selected]="opt.domId === activeId()"
1911
+ (mousedown)="$event.preventDefault()"
1912
+ (mousemove)="svc.setActive(opt.key)"
1913
+ (click)="choose(opt, $event)"
1914
+ >
1915
+ @if (opt.item; as item) {
1916
+ @let rowTemplate = templateFor(item.group);
1917
+ @if (rowTemplate) {
1918
+ <ng-container
1919
+ *ngTemplateOutlet="rowTemplate; context: { $implicit: item, active: opt.domId === activeId(), query: svc.query() }"
1920
+ />
1921
+ } @else {
1922
+ <ng-container
1923
+ *ngComponentOutlet="
1924
+ parts.row;
1925
+ inputs: { ctx: { item: item, active: opt.domId === activeId(), query: svc.query(), badge: parts.badge } }
1926
+ "
1927
+ />
1928
+ }
1929
+ } @else {
1930
+ <span class="sp-error">{{ texts.error() }}</span>
1931
+ <span class="sp-retry">{{ texts.retry() }}</span>
1932
+ }
1933
+ </div>
1934
+ }
1935
+ </div>
1936
+ }
1937
+ </div>
1938
+ @if (showEmpty()) {
1939
+ <div class="sp-empty">
1940
+ @if (emptyTemplate(); as empty) {
1941
+ <ng-container *ngTemplateOutlet="empty.template; context: { $implicit: svc.query() }" />
1942
+ } @else {
1943
+ {{ texts.noResults() }}
1944
+ }
1945
+ </div>
1946
+ }
1947
+ <div class="sp-footer">
1948
+ @if (footerTemplate(); as footer) {
1949
+ <ng-container *ngTemplateOutlet="footer.template" />
1950
+ } @else {
1951
+ <span><kbd>↑</kbd><kbd>↓</kbd> {{ texts.hintNavigate() }}</span>
1952
+ <span><kbd>↵</kbd> {{ texts.hintOpen() }}</span>
1953
+ <span><kbd>esc</kbd> {{ texts.hintClose() }}</span>
1954
+ }
1955
+ </div>
1956
+ <div
1957
+ role="status"
1958
+ aria-live="polite"
1959
+ style="position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0"
1960
+ >
1961
+ {{ svc.announcement() }}
1962
+ </div>
1963
+ </div>
1964
+ </ng-template>
1965
+ `, isInline: true, dependencies: [{ kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "component", type: SpotlightDefaultTheme, selector: "spotlight-theme-default" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1966
+ }
1967
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: SpotlightHost, decorators: [{
1968
+ type: Component,
1969
+ args: [{
1970
+ selector: 'spotlight-host',
1971
+ imports: [NgComponentOutlet, NgTemplateOutlet, SpotlightDefaultTheme],
1972
+ changeDetection: ChangeDetectionStrategy.OnPush,
1973
+ host: { '(document:keydown)': 'onDocumentKeydown($event)' },
1974
+ template: `
1975
+ @if (themeDefault) {
1976
+ <spotlight-theme-default />
1977
+ }
1978
+ <ng-template #panel>
1979
+ <div class="sp-dialog" role="dialog" aria-modal="true" [attr.aria-label]="texts.placeholder()">
1980
+ <div class="sp-input">
1981
+ <ng-container *ngComponentOutlet="parts.input; inputs: { ctx: inputCtx() }" />
1982
+ </div>
1983
+ @if (svc.chips().length > 1) {
1984
+ <div class="sp-chips">
1985
+ @for (chip of svc.chips(); track chip.id ?? '') {
1986
+ <button
1987
+ type="button"
1988
+ class="sp-chip-button"
1989
+ tabindex="-1"
1990
+ [attr.aria-pressed]="chip.id === svc.chip()"
1991
+ [attr.aria-label]="chip.label"
1992
+ (mousedown)="$event.preventDefault()"
1993
+ (click)="svc.setChip(chip.id)"
1994
+ >
1995
+ <ng-container
1996
+ *ngComponentOutlet="parts.chips; inputs: { ctx: { label: chip.label, active: chip.id === svc.chip() } }"
1997
+ />
1998
+ </button>
1999
+ }
2000
+ </div>
2001
+ }
2002
+ <div class="sp-list" role="listbox" tabindex="-1" [attr.aria-busy]="svc.loading() || null" [id]="ids.listbox" [attr.aria-label]="texts.placeholder()">
2003
+ @for (group of svc.groups(); track group.key; let g = $index) {
2004
+ <div class="sp-group" role="group" [attr.aria-labelledby]="ids.prefix + '-g' + g">
2005
+ <div class="sp-group-header" [id]="ids.prefix + '-g' + g">
2006
+ <ng-container
2007
+ *ngComponentOutlet="
2008
+ parts.groupHeader;
2009
+ inputs: { ctx: { label: group.label, total: group.total, loading: group.loading, loadingText: loadingTextFor(group.label) } }
2010
+ "
2011
+ />
2012
+ </div>
2013
+ @for (opt of optionsByGroup().get(group.key) ?? []; track opt.key) {
2014
+ <div
2015
+ class="sp-option"
2016
+ role="option"
2017
+ [id]="opt.domId"
2018
+ [attr.aria-selected]="opt.domId === activeId()"
2019
+ (mousedown)="$event.preventDefault()"
2020
+ (mousemove)="svc.setActive(opt.key)"
2021
+ (click)="choose(opt, $event)"
2022
+ >
2023
+ @if (opt.item; as item) {
2024
+ @let rowTemplate = templateFor(item.group);
2025
+ @if (rowTemplate) {
2026
+ <ng-container
2027
+ *ngTemplateOutlet="rowTemplate; context: { $implicit: item, active: opt.domId === activeId(), query: svc.query() }"
2028
+ />
2029
+ } @else {
2030
+ <ng-container
2031
+ *ngComponentOutlet="
2032
+ parts.row;
2033
+ inputs: { ctx: { item: item, active: opt.domId === activeId(), query: svc.query(), badge: parts.badge } }
2034
+ "
2035
+ />
2036
+ }
2037
+ } @else {
2038
+ <span class="sp-error">{{ texts.error() }}</span>
2039
+ <span class="sp-retry">{{ texts.retry() }}</span>
2040
+ }
2041
+ </div>
2042
+ }
2043
+ </div>
2044
+ }
2045
+ </div>
2046
+ @if (showEmpty()) {
2047
+ <div class="sp-empty">
2048
+ @if (emptyTemplate(); as empty) {
2049
+ <ng-container *ngTemplateOutlet="empty.template; context: { $implicit: svc.query() }" />
2050
+ } @else {
2051
+ {{ texts.noResults() }}
2052
+ }
2053
+ </div>
2054
+ }
2055
+ <div class="sp-footer">
2056
+ @if (footerTemplate(); as footer) {
2057
+ <ng-container *ngTemplateOutlet="footer.template" />
2058
+ } @else {
2059
+ <span><kbd>↑</kbd><kbd>↓</kbd> {{ texts.hintNavigate() }}</span>
2060
+ <span><kbd>↵</kbd> {{ texts.hintOpen() }}</span>
2061
+ <span><kbd>esc</kbd> {{ texts.hintClose() }}</span>
2062
+ }
2063
+ </div>
2064
+ <div
2065
+ role="status"
2066
+ aria-live="polite"
2067
+ style="position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0"
2068
+ >
2069
+ {{ svc.announcement() }}
2070
+ </div>
2071
+ </div>
2072
+ </ng-template>
2073
+ `,
2074
+ }]
2075
+ }], ctorParameters: () => [], propDecorators: { panel: [{ type: i0.ViewChild, args: ['panel', { isSignal: true }] }], itemTemplates: [{ type: i0.ContentChildren, args: [i0.forwardRef(() => SpotlightItemTemplate), { isSignal: true }] }], emptyTemplate: [{ type: i0.ContentChild, args: [i0.forwardRef(() => SpotlightEmptyTemplate), { isSignal: true }] }], footerTemplate: [{ type: i0.ContentChild, args: [i0.forwardRef(() => SpotlightFooterTemplate), { isSignal: true }] }] } });
2076
+
2077
+ // Public API for @tocdk-oss/ng-spotlight. Every change here must show up in api/ng-spotlight.api.md
2078
+ // (npm run check:api). Members tagged internal in their JSDoc are stripped from the published .d.ts.
2079
+
2080
+ /**
2081
+ * Generated bundle index. Do not edit.
2082
+ */
2083
+
2084
+ export { SPOTLIGHT_RENDERER, SpotlightDefaultBadge, SpotlightDefaultChip, SpotlightDefaultGroupHeader, SpotlightDefaultInput, SpotlightDefaultRow, SpotlightEmptyTemplate, SpotlightFooterTemplate, SpotlightHost, SpotlightItemTemplate, SpotlightService, SpotlightTrigger, httpSource, localStorageRecent, provideSpotlight, routeSource, spotlightRouteAudit, staticSource };
2085
+ //# sourceMappingURL=tocdk-oss-ng-spotlight.mjs.map