@meshmakers/shared-ui 3.4.1480 → 3.5.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.
Files changed (56) hide show
  1. package/README.md +48 -0
  2. package/assets/octobot/lg/octo_blink.webp +0 -0
  3. package/assets/octobot/lg/octo_blink_still.png +0 -0
  4. package/assets/octobot/lg/octo_idle.webp +0 -0
  5. package/assets/octobot/lg/octo_idle_still.png +0 -0
  6. package/assets/octobot/lg/octo_look.webp +0 -0
  7. package/assets/octobot/lg/octo_look_still.png +0 -0
  8. package/assets/octobot/lg/octo_swim.webp +0 -0
  9. package/assets/octobot/lg/octo_swim_still.png +0 -0
  10. package/assets/octobot/lg/octo_thinking.webp +0 -0
  11. package/assets/octobot/lg/octo_thinking_still.png +0 -0
  12. package/assets/octobot/lg/octo_wave.webp +0 -0
  13. package/assets/octobot/lg/octo_wave_still.png +0 -0
  14. package/assets/octobot/md/octo_blink.webp +0 -0
  15. package/assets/octobot/md/octo_blink_still.png +0 -0
  16. package/assets/octobot/md/octo_idle.webp +0 -0
  17. package/assets/octobot/md/octo_idle_still.png +0 -0
  18. package/assets/octobot/md/octo_look.webp +0 -0
  19. package/assets/octobot/md/octo_look_still.png +0 -0
  20. package/assets/octobot/md/octo_swim.webp +0 -0
  21. package/assets/octobot/md/octo_swim_still.png +0 -0
  22. package/assets/octobot/md/octo_thinking.webp +0 -0
  23. package/assets/octobot/md/octo_thinking_still.png +0 -0
  24. package/assets/octobot/md/octo_wave.webp +0 -0
  25. package/assets/octobot/md/octo_wave_still.png +0 -0
  26. package/assets/octobot/sm/octo_blink.webp +0 -0
  27. package/assets/octobot/sm/octo_blink_still.png +0 -0
  28. package/assets/octobot/sm/octo_idle.webp +0 -0
  29. package/assets/octobot/sm/octo_idle_still.png +0 -0
  30. package/assets/octobot/sm/octo_look.webp +0 -0
  31. package/assets/octobot/sm/octo_look_still.png +0 -0
  32. package/assets/octobot/sm/octo_swim.webp +0 -0
  33. package/assets/octobot/sm/octo_swim_still.png +0 -0
  34. package/assets/octobot/sm/octo_thinking.webp +0 -0
  35. package/assets/octobot/sm/octo_thinking_still.png +0 -0
  36. package/assets/octobot/sm/octo_wave.webp +0 -0
  37. package/assets/octobot/sm/octo_wave_still.png +0 -0
  38. package/fesm2022/meshmakers-shared-ui-assistant-core.mjs +940 -0
  39. package/fesm2022/meshmakers-shared-ui-assistant-core.mjs.map +1 -0
  40. package/fesm2022/meshmakers-shared-ui-assistant.mjs +819 -0
  41. package/fesm2022/meshmakers-shared-ui-assistant.mjs.map +1 -0
  42. package/fesm2022/meshmakers-shared-ui-command-palette.mjs +817 -0
  43. package/fesm2022/meshmakers-shared-ui-command-palette.mjs.map +1 -0
  44. package/fesm2022/meshmakers-shared-ui-shell.mjs +1738 -0
  45. package/fesm2022/meshmakers-shared-ui-shell.mjs.map +1 -0
  46. package/fesm2022/meshmakers-shared-ui-testing.mjs +92 -0
  47. package/fesm2022/meshmakers-shared-ui-testing.mjs.map +1 -0
  48. package/fesm2022/meshmakers-shared-ui.mjs +1550 -203
  49. package/fesm2022/meshmakers-shared-ui.mjs.map +1 -1
  50. package/package.json +28 -2
  51. package/types/meshmakers-shared-ui-assistant-core.d.ts +732 -0
  52. package/types/meshmakers-shared-ui-assistant.d.ts +258 -0
  53. package/types/meshmakers-shared-ui-command-palette.d.ts +398 -0
  54. package/types/meshmakers-shared-ui-shell.d.ts +939 -0
  55. package/types/meshmakers-shared-ui-testing.d.ts +41 -0
  56. package/types/meshmakers-shared-ui.d.ts +1004 -154
@@ -0,0 +1,817 @@
1
+ import * as i0 from '@angular/core';
2
+ import { InjectionToken, inject, computed, signal, Injectable, input, DestroyRef, viewChild, effect, untracked, afterNextRender, ChangeDetectionStrategy, Component } from '@angular/core';
3
+ import { Router } from '@angular/router';
4
+ import { NotificationDisplayService } from '@meshmakers/shared-ui';
5
+ import { of, defer, timer, combineLatest, BehaviorSubject } from 'rxjs';
6
+ import { map, switchMap, catchError, startWith, distinctUntilChanged } from 'rxjs/operators';
7
+ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
8
+ import { SVGIconComponent } from '@progress/kendo-angular-icons';
9
+
10
+ /** Multi-provider token for the palette sources: `{ provide: PALETTE_PROVIDERS, useClass: MyProvider, multi: true }`. */
11
+ const PALETTE_PROVIDERS = new InjectionToken('PALETTE_PROVIDERS');
12
+ const PALETTE_RECENT_ITEMS_SOURCE = new InjectionToken('PALETTE_RECENT_ITEMS_SOURCE');
13
+
14
+ const DEFAULT_COMMAND_PALETTE_MESSAGES = {
15
+ dialogLabel: 'Command palette',
16
+ inputLabel: 'Search pages, entities, boards, tenants and commands',
17
+ placeholder: 'Search pages, entities, boards…',
18
+ cancel: 'Cancel',
19
+ results: 'Results',
20
+ actionsFor: 'Actions for {label}',
21
+ actionsHeader: '{label} · Actions',
22
+ tabForActions: 'Tab: actions',
23
+ oneResult: '1 result',
24
+ manyResults: '{count} results',
25
+ noResultsFor: 'No results for “{text}”',
26
+ typeToSearch: 'Type to search',
27
+ footerMove: 'move',
28
+ footerOpen: 'open',
29
+ footerNewTab: 'new tab',
30
+ footerActions: 'actions',
31
+ prefixActions: 'actions',
32
+ prefixEntities: 'entities',
33
+ prefixBoards: 'boards',
34
+ prefixTenants: 'tenants',
35
+ groupRecent: 'Recent',
36
+ groupPage: 'Pages',
37
+ groupAction: 'Actions',
38
+ groupEntity: 'Entities',
39
+ groupBoard: 'Boards',
40
+ groupTenant: 'Tenants',
41
+ groupAi: 'Assistant',
42
+ copied: '{label} copied',
43
+ copyFailed: 'Failed to copy to clipboard',
44
+ };
45
+ /** App-wide translations of the command palette (partial). */
46
+ const COMMAND_PALETTE_MESSAGES = new InjectionToken('COMMAND_PALETTE_MESSAGES');
47
+ /** Merges overrides over {@link DEFAULT_COMMAND_PALETTE_MESSAGES}, later sources winning; null/undefined members are ignored. */
48
+ function resolveCommandPaletteMessages(...overrides) {
49
+ const resolved = { ...DEFAULT_COMMAND_PALETTE_MESSAGES };
50
+ for (const messages of overrides) {
51
+ if (!messages) {
52
+ continue;
53
+ }
54
+ for (const key of Object.keys(messages)) {
55
+ const value = messages[key];
56
+ if (value !== undefined && value !== null) {
57
+ resolved[key] = value;
58
+ }
59
+ }
60
+ }
61
+ return resolved;
62
+ }
63
+ /** Replaces `{name}` placeholders; unknown placeholders stay as they are. */
64
+ function formatCommandPaletteMessage(message, values) {
65
+ return message.replace(/\{(\w+)\}/g, (match, key) => Object.prototype.hasOwnProperty.call(values, key) ? String(values[key]) : match);
66
+ }
67
+ /** The group header of a result group. */
68
+ function paletteGroupLabel(group, messages) {
69
+ switch (group) {
70
+ case 'recent': return messages.groupRecent;
71
+ case 'page': return messages.groupPage;
72
+ case 'action': return messages.groupAction;
73
+ case 'entity': return messages.groupEntity;
74
+ case 'board': return messages.groupBoard;
75
+ case 'tenant': return messages.groupTenant;
76
+ case 'ai': return messages.groupAi;
77
+ }
78
+ }
79
+ /**
80
+ * {@link COMMAND_PALETTE_MESSAGES} merged with a component input (the input wins).
81
+ * Call it in an injection context.
82
+ * @internal
83
+ */
84
+ function commandPaletteMessages(input) {
85
+ const injected = inject(COMMAND_PALETTE_MESSAGES, { optional: true });
86
+ return computed(() => resolveCommandPaletteMessages(injected, input()));
87
+ }
88
+
89
+ /**
90
+ * Opens and closes the command palette (Cmd/Ctrl+K). The host (AppComponent) renders
91
+ * the overlay and binds the global hotkey; anything else — the top bar's
92
+ * "Search ⌘K" trigger, a Home tile, an empty state — calls {@link open}.
93
+ *
94
+ * Focus returns to the element that had it when the palette opened.
95
+ */
96
+ class CommandPaletteService {
97
+ _isOpen = signal(false, /* @ts-ignore */
98
+ ...(ngDevMode ? [{ debugName: "_isOpen" }] : /* istanbul ignore next */ []));
99
+ _request = signal({ query: '' }, /* @ts-ignore */
100
+ ...(ngDevMode ? [{ debugName: "_request" }] : /* istanbul ignore next */ []));
101
+ returnFocusTo = null;
102
+ /** Whether the palette is shown. */
103
+ isOpen = this._isOpen.asReadonly();
104
+ /** The latest open request; the palette adopts its query (also while already open). */
105
+ request = this._request.asReadonly();
106
+ /**
107
+ * Shows the palette. `initialQuery` pre-fills the input, e.g. `'>'` for
108
+ * actions or `'/'` for the tenant list. Calling it while open replaces the query.
109
+ */
110
+ open(initialQuery = '') {
111
+ if (!this._isOpen()) {
112
+ const active = document.activeElement;
113
+ this.returnFocusTo = active instanceof HTMLElement && active !== document.body ? active : null;
114
+ }
115
+ this._request.set({ query: initialQuery });
116
+ this._isOpen.set(true);
117
+ }
118
+ /** Hides the palette and gives focus back to where it was. */
119
+ close() {
120
+ if (!this._isOpen()) {
121
+ return;
122
+ }
123
+ this._isOpen.set(false);
124
+ const target = this.returnFocusTo;
125
+ this.returnFocusTo = null;
126
+ if (target?.isConnected) {
127
+ target.focus();
128
+ }
129
+ }
130
+ /** Opens when closed, closes when open. */
131
+ toggle(initialQuery = '') {
132
+ if (this._isOpen()) {
133
+ this.close();
134
+ }
135
+ else {
136
+ this.open(initialQuery);
137
+ }
138
+ }
139
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CommandPaletteService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
140
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CommandPaletteService, providedIn: 'root' });
141
+ }
142
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CommandPaletteService, decorators: [{
143
+ type: Injectable,
144
+ args: [{ providedIn: 'root' }]
145
+ }] });
146
+ /**
147
+ * Maps a document keydown to a palette action (ui-concept §4.3):
148
+ *
149
+ * - Cmd+K (macOS) / Ctrl+K toggles — except inside a Monaco code editor,
150
+ * whose own Cmd+K chords (comment, format …) must keep working;
151
+ * - `/` opens when no text field, text role, grid or Kendo widget is focused;
152
+ * - everything else, and events another handler already consumed, is ignored.
153
+ */
154
+ function paletteHotkeyAction(event, isOpen) {
155
+ if (event.defaultPrevented || event.isComposing) {
156
+ return null;
157
+ }
158
+ const key = event.key?.toLowerCase();
159
+ const target = event.target instanceof Element ? event.target : null;
160
+ if (key === 'k' && (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey) {
161
+ return target?.closest('.monaco-editor') ? null : 'toggle';
162
+ }
163
+ if (event.key === '/' && !isOpen && !event.metaKey && !event.ctrlKey && !event.altKey && !isEditable(target)) {
164
+ return 'open';
165
+ }
166
+ return null;
167
+ }
168
+ /**
169
+ * Targets where `/` is typed or means something else: text fields, ARIA text
170
+ * roles, grids (Kendo grid keyboard navigation) and Kendo widgets.
171
+ */
172
+ const TEXT_ENTRY_SELECTOR = [
173
+ 'input', 'textarea', 'select', '[contenteditable=""]', '[contenteditable="true"]',
174
+ '[role="combobox"]', '[role="textbox"]', '[role="searchbox"]', '[role="spinbutton"]',
175
+ '[role="grid"]', '[role="gridcell"]', '[role="treegrid"]',
176
+ '.k-grid', 'kendo-grid', '.k-input', '.k-widget', '.monaco-editor'
177
+ ].join(', ');
178
+ function isEditable(element) {
179
+ if (!element) {
180
+ return false;
181
+ }
182
+ if (element instanceof HTMLElement && element.isContentEditable) {
183
+ return true;
184
+ }
185
+ return element.closest(TEXT_ENTRY_SELECTOR) !== null;
186
+ }
187
+
188
+ /**
189
+ * Navigation and clipboard helpers shared by the palette providers. All
190
+ * palette targets are absolute router URLs (`/<tenant>/…`), so a result
191
+ * behaves the same whichever page the palette was opened on.
192
+ */
193
+ class PaletteNavigationService {
194
+ router = inject(Router);
195
+ notifications = inject(NotificationDisplayService);
196
+ messages = resolveCommandPaletteMessages(inject(COMMAND_PALETTE_MESSAGES, { optional: true }));
197
+ /** The tenant of the current URL (first path segment), or null outside a tenant. */
198
+ currentTenantId() {
199
+ const path = (this.router.url ?? '').split('#')[0].split('?')[0];
200
+ const first = path.split('/').find(segment => segment.length > 0);
201
+ return first ? decodeURIComponent(first.split(';')[0]) : null;
202
+ }
203
+ /**
204
+ * Turns a command link into an absolute URL: links starting with `/` are
205
+ * absolute already, all others are relative to the tenant (as the drawer
206
+ * resolves them via `navigateRelativeToRoute`).
207
+ */
208
+ tenantUrl(link) {
209
+ if (link.startsWith('/')) {
210
+ return link;
211
+ }
212
+ const tenantId = this.currentTenantId();
213
+ const relative = link.replace(/^\.?\/?/, '');
214
+ return tenantId ? `/${encodeURIComponent(tenantId)}${relative ? '/' + relative : ''}` : `/${relative}`;
215
+ }
216
+ /** Opens an absolute router URL in place or in a new browser tab. */
217
+ async open(url, mode = 'open') {
218
+ if (mode === 'newTab') {
219
+ window.open(this.toHref(url), '_blank', 'noopener');
220
+ return;
221
+ }
222
+ await this.router.navigateByUrl(url);
223
+ }
224
+ /** Opens an external link (href) in a new tab, as the drawer does. */
225
+ openExternal(href, target = '_blank') {
226
+ window.open(href, target, 'noopener,noreferrer');
227
+ }
228
+ /** Copies a value and confirms it with a short notification. */
229
+ async copy(value, label) {
230
+ try {
231
+ await navigator.clipboard.writeText(value);
232
+ this.notifications.showSuccess(formatCommandPaletteMessage(this.messages.copied, { label }), 2000);
233
+ }
234
+ catch (error) {
235
+ console.error('Failed to copy to clipboard:', error);
236
+ this.notifications.showError(this.messages.copyFailed);
237
+ }
238
+ }
239
+ toHref(url) {
240
+ try {
241
+ return new URL(`.${url}`, document.baseURI).href;
242
+ }
243
+ catch {
244
+ return url;
245
+ }
246
+ }
247
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: PaletteNavigationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
248
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: PaletteNavigationService, providedIn: 'root' });
249
+ }
250
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: PaletteNavigationService, decorators: [{
251
+ type: Injectable,
252
+ args: [{ providedIn: 'root' }]
253
+ }] });
254
+
255
+ /** Typed prefixes that force a group (ui-concept §4.2 rule 4). */
256
+ const PALETTE_PREFIXES = {
257
+ '>': 'action',
258
+ '@': 'entity',
259
+ '#': 'board',
260
+ '/': 'tenant',
261
+ '?': 'ai'
262
+ };
263
+ /**
264
+ * Splits the raw input into an optional scope and the search text:
265
+ * `"> theme"` → scope `action`, text `theme`; `"edge"` → no scope.
266
+ * Only the first non-blank character counts as a prefix.
267
+ */
268
+ function parsePaletteQuery(raw) {
269
+ const trimmed = (raw ?? '').trim();
270
+ const first = trimmed.charAt(0);
271
+ const scope = PALETTE_PREFIXES[first] ?? null;
272
+ if (scope) {
273
+ return { raw, prefix: first, scope, text: trimmed.slice(1).trim() };
274
+ }
275
+ return { raw, prefix: null, scope: null, text: trimmed };
276
+ }
277
+ /** A runtime id: 24 hex characters (MongoDB ObjectId). */
278
+ function isRtId(text) {
279
+ return /^[0-9a-f]{24}$/i.test(text);
280
+ }
281
+
282
+ /**
283
+ * Runs every provider for every query and merges their rows.
284
+ *
285
+ * - A provider whose groups do not include the query's scope is skipped (its rows clear at once).
286
+ * - A new query cancels the provider's running search (`switchMap` unsubscribes).
287
+ * - Providers with `debounceMs` start only after the query stood still that long; until
288
+ * then their previous rows stay, so remote results do not flicker away while typing
289
+ * (the ranking drops rows that no longer match).
290
+ * - An error drops only that provider's rows.
291
+ *
292
+ * The output emits on every provider answer, so local rows show immediately
293
+ * and remote rows stream in.
294
+ */
295
+ function searchProviders(providers, query$) {
296
+ if (providers.length === 0) {
297
+ return query$.pipe(map(() => []));
298
+ }
299
+ const streams = providers.map(provider => query$.pipe(switchMap(query => {
300
+ if (query.scope && !provider.groups.includes(query.scope)) {
301
+ return of([]);
302
+ }
303
+ const search$ = defer(() => provider.search(query)).pipe(catchError(error => {
304
+ console.warn(`Command palette provider '${provider.id}' failed:`, error);
305
+ return of([]);
306
+ }));
307
+ return provider.debounceMs ? timer(provider.debounceMs).pipe(switchMap(() => search$)) : search$;
308
+ }), startWith([])));
309
+ return combineLatest(streams).pipe(map(lists => lists.flat()));
310
+ }
311
+
312
+ /** Match tiers: prefix > word-start > subsequence (ui-concept §4.2 rule 2). */
313
+ var MatchTier;
314
+ (function (MatchTier) {
315
+ MatchTier[MatchTier["None"] = 0] = "None";
316
+ MatchTier[MatchTier["Fuzzy"] = 1] = "Fuzzy";
317
+ MatchTier[MatchTier["WordStart"] = 2] = "WordStart";
318
+ MatchTier[MatchTier["Prefix"] = 3] = "Prefix";
319
+ })(MatchTier || (MatchTier = {}));
320
+ const TIER_WEIGHT = 100;
321
+ /** A keyword/path/id match counts like a label match of the same tier minus this. */
322
+ const SECONDARY_PENALTY = 40;
323
+ /** Frecency adds at most this — less than the gap between tiers, so it only orders within a tier. */
324
+ const FRECENCY_WEIGHT = 30;
325
+ /** `rankBoost` (pinned boards) adds at most this: more than any frecency, so pinned rows lead; both together stay below the tier gap. */
326
+ const RANK_BOOST_WEIGHT = 35;
327
+ const EXACT_MATCH_SCORE = 10_000;
328
+ /** Short queries (≤ 3 chars) list pages and actions first, longer ones entities (§4.2 rule 3). */
329
+ const SHORT_QUERY_ORDER = ['recent', 'page', 'action', 'entity', 'board', 'tenant', 'ai'];
330
+ const LONG_QUERY_ORDER = ['entity', 'page', 'board', 'action', 'tenant', 'recent', 'ai'];
331
+ /** Rows per group: a few for an empty query, more for a search, many when scoped by a prefix. */
332
+ const LIMIT_EMPTY = 6;
333
+ const LIMIT_SEARCH = 8;
334
+ const LIMIT_SCOPED = 50;
335
+ /** How well `text` matches `query` (both compared case-insensitively). */
336
+ function matchTier(text, query) {
337
+ if (!text || !query) {
338
+ return MatchTier.None;
339
+ }
340
+ const haystack = text.toLowerCase();
341
+ const needle = query.toLowerCase();
342
+ if (haystack.startsWith(needle)) {
343
+ return MatchTier.Prefix;
344
+ }
345
+ if (haystack.split(/[\s/.\-_:›,()]+/).some(word => word.startsWith(needle))) {
346
+ return MatchTier.WordStart;
347
+ }
348
+ let index = 0;
349
+ for (const char of haystack) {
350
+ if (char === needle[index]) {
351
+ index++;
352
+ if (index === needle.length) {
353
+ return MatchTier.Fuzzy;
354
+ }
355
+ }
356
+ }
357
+ return MatchTier.None;
358
+ }
359
+ /**
360
+ * Score of one result for a query: 0 means "does not match". `frecency` is
361
+ * the normalised (0…1) frecency of the result's recent key.
362
+ */
363
+ function scoreResult(result, text, frecency = 0) {
364
+ const boost = Math.max(0, Math.min(1, frecency)) * FRECENCY_WEIGHT
365
+ + Math.max(0, Math.min(1, result.rankBoost ?? 0)) * RANK_BOOST_WEIGHT;
366
+ if (result.exactMatch) {
367
+ return EXACT_MATCH_SCORE + boost;
368
+ }
369
+ if (!text) {
370
+ return 1 + boost;
371
+ }
372
+ const labelTier = matchTier(result.label, text);
373
+ const secondary = [...(result.keywords ?? []), ...(result.path ?? []), result.typeHint]
374
+ .reduce((best, candidate) => Math.max(best, matchTier(candidate, text)), MatchTier.None);
375
+ const labelScore = labelTier * TIER_WEIGHT;
376
+ const secondaryScore = secondary > MatchTier.None ? secondary * TIER_WEIGHT - SECONDARY_PENALTY : 0;
377
+ const base = Math.max(labelScore, secondaryScore);
378
+ if (base === 0) {
379
+ return result.alwaysShow ? 1 : 0;
380
+ }
381
+ return base + boost;
382
+ }
383
+ /**
384
+ * Orders the rows of all providers into sections. Pure: the same input gives
385
+ * the same output, so the palette can re-rank whenever a provider streams in
386
+ * without the list jumping around for unchanged rows.
387
+ *
388
+ * - non-matching rows are dropped (except `alwaysShow`),
389
+ * - rows sort by score within their group, ties by label,
390
+ * - groups follow the short/long query order; a group holding an exact id match goes first,
391
+ * - the assistant row (group `ai`) always stays last.
392
+ */
393
+ function rankResults(results, query, frecencyOf = () => 0) {
394
+ const text = query.text;
395
+ const scored = new Map();
396
+ const seen = new Set();
397
+ for (const result of results) {
398
+ if (seen.has(result.id)) {
399
+ continue;
400
+ }
401
+ seen.add(result.id);
402
+ const frecency = result.recentKey ? frecencyOf(result.recentKey) : 0;
403
+ const score = scoreResult(result, text, frecency);
404
+ if (score <= 0) {
405
+ continue;
406
+ }
407
+ const bucket = scored.get(result.group) ?? [];
408
+ bucket.push({ result, score });
409
+ scored.set(result.group, bucket);
410
+ }
411
+ const limit = query.scope ? LIMIT_SCOPED : text ? LIMIT_SEARCH : LIMIT_EMPTY;
412
+ const order = text.length <= 3 ? SHORT_QUERY_ORDER : LONG_QUERY_ORDER;
413
+ const groups = [];
414
+ for (const [group, bucket] of scored) {
415
+ bucket.sort((a, b) => b.score - a.score || a.result.label.localeCompare(b.result.label));
416
+ groups.push({
417
+ group,
418
+ exact: bucket.some(entry => entry.result.exactMatch === true),
419
+ results: bucket.slice(0, limit).map(entry => entry.result)
420
+ });
421
+ }
422
+ groups.sort((a, b) => {
423
+ if (a.group === 'ai' || b.group === 'ai') {
424
+ return a.group === 'ai' ? 1 : -1;
425
+ }
426
+ if (a.exact !== b.exact) {
427
+ return a.exact ? -1 : 1;
428
+ }
429
+ return order.indexOf(a.group) - order.indexOf(b.group);
430
+ });
431
+ return groups.map(({ group, results: rows }) => ({ group, results: rows }));
432
+ }
433
+ /** Splits a label into plain and highlighted parts for the first case-insensitive occurrence of `text`. */
434
+ function highlightSegments(label, text) {
435
+ if (!text) {
436
+ return [{ text: label, match: false }];
437
+ }
438
+ const index = label.toLowerCase().indexOf(text.toLowerCase());
439
+ if (index < 0) {
440
+ return [{ text: label, match: false }];
441
+ }
442
+ return [
443
+ { text: label.slice(0, index), match: false },
444
+ { text: label.slice(index, index + text.length), match: true },
445
+ { text: label.slice(index + text.length), match: false }
446
+ ].filter(segment => segment.text.length > 0);
447
+ }
448
+ /**
449
+ * Keeps what the user already sees in place while results stream in for the
450
+ * SAME query (ui-concept §4.2 rule 5): groups already shown keep their
451
+ * position and new groups are appended below them (the assistant row stays
452
+ * last); inside a group, rows already shown keep their order and late rows
453
+ * are appended. Rows a provider no longer returns disappear. For a new query
454
+ * the fresh ranking is used as is.
455
+ */
456
+ function stabilizeRanking(previous, next) {
457
+ if (previous.length === 0) {
458
+ return [...next];
459
+ }
460
+ const nextByGroup = new Map(next.map(section => [section.group, section]));
461
+ const ordered = [];
462
+ for (const old of previous) {
463
+ const fresh = nextByGroup.get(old.group);
464
+ if (!fresh || old.group === 'ai') {
465
+ continue;
466
+ }
467
+ const freshIds = new Set(fresh.results.map(result => result.id));
468
+ const freshById = new Map(fresh.results.map(result => [result.id, result]));
469
+ const kept = old.results.filter(result => freshIds.has(result.id)).map(result => freshById.get(result.id));
470
+ const keptIds = new Set(kept.map(result => result.id));
471
+ ordered.push({ group: old.group, results: [...kept, ...fresh.results.filter(result => !keptIds.has(result.id))] });
472
+ nextByGroup.delete(old.group);
473
+ }
474
+ for (const section of next) {
475
+ if (nextByGroup.has(section.group) && section.group !== 'ai') {
476
+ ordered.push(section);
477
+ }
478
+ }
479
+ const ai = nextByGroup.get('ai') ?? next.find(section => section.group === 'ai');
480
+ if (ai) {
481
+ ordered.push(ai);
482
+ }
483
+ return ordered;
484
+ }
485
+
486
+ /**
487
+ * The command palette overlay (Cmd/Ctrl+K, ui-concept §4). Render it only while
488
+ * {@link CommandPaletteService.isOpen} — one component instance is one palette session:
489
+ *
490
+ * ```html
491
+ * @if (commandPalette.isOpen()) { <mm-command-palette /> }
492
+ * ```
493
+ *
494
+ * Results come from the {@link PALETTE_PROVIDERS}; the frecency boost from the optional
495
+ * {@link PALETTE_RECENT_ITEMS_SOURCE}.
496
+ *
497
+ * Accessibility: modal dialog; the input is a combobox driving a listbox via
498
+ * `aria-activedescendant`, so focus never leaves the input (Tab is taken for
499
+ * the actions sub-menu, which keeps keyboard focus trapped in the dialog);
500
+ * focus goes back to the invoking element on close (the service does that).
501
+ */
502
+ class CommandPaletteComponent {
503
+ palette = inject(CommandPaletteService);
504
+ providers = inject(PALETTE_PROVIDERS, { optional: true }) ?? [];
505
+ recents = inject(PALETTE_RECENT_ITEMS_SOURCE, { optional: true });
506
+ /** Translations; members left out fall back to {@link COMMAND_PALETTE_MESSAGES}, then English. */
507
+ messages = input(null, /* @ts-ignore */
508
+ ...(ngDevMode ? [{ debugName: "messages" }] : /* istanbul ignore next */ []));
509
+ m = commandPaletteMessages(this.messages);
510
+ destroyRef = inject(DestroyRef);
511
+ input = viewChild.required('input', /* @ts-ignore */
512
+ ...(ngDevMode ? [{ debugName: "input" }] : /* istanbul ignore next */ []));
513
+ query = signal('', /* @ts-ignore */
514
+ ...(ngDevMode ? [{ debugName: "query" }] : /* istanbul ignore next */ []));
515
+ query$ = new BehaviorSubject('');
516
+ ranked = signal([], /* @ts-ignore */
517
+ ...(ngDevMode ? [{ debugName: "ranked" }] : /* istanbul ignore next */ []));
518
+ parsed = computed(() => parsePaletteQuery(this.query()), /* @ts-ignore */
519
+ ...(ngDevMode ? [{ debugName: "parsed" }] : /* istanbul ignore next */ []));
520
+ results = computed(() => this.ranked().flatMap(section => section.results), /* @ts-ignore */
521
+ ...(ngDevMode ? [{ debugName: "results" }] : /* istanbul ignore next */ []));
522
+ /** Id of the highlighted row; null means "the first row" (reset whenever the user types). */
523
+ activeId = signal(null, /* @ts-ignore */
524
+ ...(ngDevMode ? [{ debugName: "activeId" }] : /* istanbul ignore next */ []));
525
+ activeIndex = computed(() => {
526
+ const results = this.results();
527
+ const id = this.activeId();
528
+ const index = id === null ? -1 : results.findIndex(result => result.id === id);
529
+ return index >= 0 ? index : results.length > 0 ? 0 : -1;
530
+ }, /* @ts-ignore */
531
+ ...(ngDevMode ? [{ debugName: "activeIndex" }] : /* istanbul ignore next */ []));
532
+ /** The result whose actions sub-menu is open (Tab), or null. */
533
+ actionsFor = signal(null, /* @ts-ignore */
534
+ ...(ngDevMode ? [{ debugName: "actionsFor" }] : /* istanbul ignore next */ []));
535
+ activeActionIndex = signal(0, /* @ts-ignore */
536
+ ...(ngDevMode ? [{ debugName: "activeActionIndex" }] : /* istanbul ignore next */ []));
537
+ sections = computed(() => {
538
+ const text = this.parsed().text;
539
+ let index = 0;
540
+ return this.ranked().map(section => ({
541
+ group: section.group,
542
+ label: paletteGroupLabel(section.group, this.m()),
543
+ rows: section.results.map(result => ({
544
+ index: index++,
545
+ result,
546
+ segments: result.group === 'ai' ? [{ text: result.label, match: false }] : highlightSegments(result.label, text),
547
+ side: [result.path?.length ? `${result.path.join(' › ')} ›` : '', result.description ?? ''].filter(Boolean).join(' ')
548
+ }))
549
+ }));
550
+ }, /* @ts-ignore */
551
+ ...(ngDevMode ? [{ debugName: "sections" }] : /* istanbul ignore next */ []));
552
+ activeDescendant = computed(() => {
553
+ if (this.actionsFor()) {
554
+ return `cp-action-${this.activeActionIndex()}`;
555
+ }
556
+ const index = this.activeIndex();
557
+ return index >= 0 ? `cp-option-${index}` : null;
558
+ }, /* @ts-ignore */
559
+ ...(ngDevMode ? [{ debugName: "activeDescendant" }] : /* istanbul ignore next */ []));
560
+ /** Whether a listbox with options is shown (bound to the combobox's aria-expanded). */
561
+ expanded = computed(() => this.actionsFor() ? (this.actionsFor()?.actions?.length ?? 0) > 0 : this.results().length > 0, /* @ts-ignore */
562
+ ...(ngDevMode ? [{ debugName: "expanded" }] : /* istanbul ignore next */ []));
563
+ statusText = computed(() => {
564
+ const count = this.results().length;
565
+ return count === 1 ? this.m().oneResult : formatCommandPaletteMessage(this.m().manyResults, { count });
566
+ }, /* @ts-ignore */
567
+ ...(ngDevMode ? [{ debugName: "statusText" }] : /* istanbul ignore next */ []));
568
+ emptyText = computed(() => this.parsed().text ? formatCommandPaletteMessage(this.m().noResultsFor, { text: this.parsed().text }) : this.m().typeToSearch, /* @ts-ignore */
569
+ ...(ngDevMode ? [{ debugName: "emptyText" }] : /* istanbul ignore next */ []));
570
+ get modifierLabel() {
571
+ return /Mac|iPhone|iPad/i.test(navigator.platform || navigator.userAgent) ? '⌘' : 'Ctrl';
572
+ }
573
+ constructor() {
574
+ // open('…') while already open replaces the query (e.g. the top bar's trigger).
575
+ effect(() => {
576
+ const request = this.palette.request();
577
+ untracked(() => this.setQuery(request.query));
578
+ });
579
+ afterNextRender(() => this.focusInput());
580
+ }
581
+ ngOnInit() {
582
+ for (const provider of this.providers) {
583
+ provider.reset?.();
584
+ }
585
+ this.setQuery(this.palette.request().query);
586
+ // Frecency is read once per session; a palette session is short.
587
+ const now = Date.now();
588
+ const frecency = this.recents?.frecencies(now) ?? new Map();
589
+ const frecencyOf = (key) => frecency.get(key) ?? 0;
590
+ const parsed$ = this.query$.pipe(distinctUntilChanged(), map(raw => parsePaletteQuery(raw)));
591
+ // Late answers for the same query only fill in below what is shown (§4.2 rule 5).
592
+ let rankedFor = null;
593
+ searchProviders(this.providers, parsed$).pipe(map(results => {
594
+ const raw = this.query$.value;
595
+ const fresh = rankResults(results, parsePaletteQuery(raw), frecencyOf);
596
+ const ranked = raw === rankedFor ? stabilizeRanking(this.ranked(), fresh) : fresh;
597
+ rankedFor = raw;
598
+ return ranked;
599
+ }), takeUntilDestroyed(this.destroyRef)).subscribe(ranked => this.ranked.set(ranked));
600
+ }
601
+ groupLabel(group) {
602
+ return paletteGroupLabel(group, this.m());
603
+ }
604
+ format(message, values) {
605
+ return formatCommandPaletteMessage(message, values);
606
+ }
607
+ onInput(event) {
608
+ this.setQuery(event.target.value);
609
+ }
610
+ onKeydown(event) {
611
+ switch (event.key) {
612
+ case 'ArrowDown':
613
+ event.preventDefault();
614
+ this.move(1);
615
+ break;
616
+ case 'ArrowUp':
617
+ event.preventDefault();
618
+ this.move(-1);
619
+ break;
620
+ case 'Home':
621
+ event.preventDefault();
622
+ this.moveTo(0);
623
+ break;
624
+ case 'End':
625
+ event.preventDefault();
626
+ this.moveTo(Number.MAX_SAFE_INTEGER);
627
+ break;
628
+ case 'Enter': {
629
+ event.preventDefault();
630
+ const target = this.actionsFor();
631
+ if (target) {
632
+ const action = target.actions?.[this.activeActionIndex()];
633
+ if (action) {
634
+ void this.runAction(action);
635
+ }
636
+ }
637
+ else {
638
+ const result = this.results()[this.activeIndex()];
639
+ if (result) {
640
+ void this.execute(result, event.metaKey || event.ctrlKey ? 'newTab' : 'open');
641
+ }
642
+ }
643
+ break;
644
+ }
645
+ case 'Tab':
646
+ // Keeps focus in the dialog (trap) and toggles the actions sub-menu.
647
+ event.preventDefault();
648
+ if (event.shiftKey || this.actionsFor()) {
649
+ this.actionsFor.set(null);
650
+ }
651
+ else {
652
+ this.openActions();
653
+ }
654
+ break;
655
+ case 'Escape':
656
+ event.preventDefault();
657
+ event.stopPropagation();
658
+ if (this.actionsFor()) {
659
+ this.actionsFor.set(null);
660
+ }
661
+ else {
662
+ this.palette.close();
663
+ }
664
+ break;
665
+ }
666
+ }
667
+ /** Clicks on the dialog's chrome (header, footer, group headers) must not take focus from the input. */
668
+ onDialogMouseDown(event) {
669
+ if (event.target !== this.input().nativeElement) {
670
+ event.preventDefault();
671
+ }
672
+ }
673
+ close() {
674
+ this.palette.close();
675
+ }
676
+ onScrimMouseDown(event) {
677
+ if (event.target === event.currentTarget) {
678
+ this.palette.close();
679
+ }
680
+ }
681
+ onOptionHover(result) {
682
+ if (this.activeId() !== result.id) {
683
+ this.activeId.set(result.id);
684
+ }
685
+ }
686
+ onOptionClick(result, event) {
687
+ void this.execute(result, event.metaKey || event.ctrlKey ? 'newTab' : 'open');
688
+ }
689
+ /** Runs a result. The palette closes first; an outcome with a query reopens it with that query. */
690
+ async execute(result, mode) {
691
+ await this.finish(() => result.run(mode));
692
+ }
693
+ async runAction(action) {
694
+ await this.finish(() => action.run());
695
+ }
696
+ async finish(run) {
697
+ this.palette.close();
698
+ try {
699
+ const outcome = await run();
700
+ if (outcome && typeof outcome.query === 'string') {
701
+ this.palette.open(outcome.query);
702
+ }
703
+ }
704
+ catch (error) {
705
+ console.error('Command palette: running the result failed', error);
706
+ }
707
+ }
708
+ openActions() {
709
+ const result = this.results()[this.activeIndex()];
710
+ if (result?.actions?.length) {
711
+ this.actionsFor.set(result);
712
+ this.activeActionIndex.set(0);
713
+ }
714
+ }
715
+ move(delta) {
716
+ const count = this.actionsFor() ? this.actionsFor()?.actions?.length ?? 0 : this.results().length;
717
+ if (count === 0) {
718
+ return;
719
+ }
720
+ const current = this.actionsFor() ? this.activeActionIndex() : Math.max(0, this.activeIndex());
721
+ this.moveTo((current + delta + count) % count);
722
+ }
723
+ moveTo(index) {
724
+ const target = this.actionsFor();
725
+ if (target) {
726
+ const count = target.actions?.length ?? 0;
727
+ if (count > 0) {
728
+ this.activeActionIndex.set(Math.min(index, count - 1));
729
+ }
730
+ }
731
+ else {
732
+ const results = this.results();
733
+ if (results.length === 0) {
734
+ return;
735
+ }
736
+ this.activeId.set(results[Math.min(index, results.length - 1)].id);
737
+ }
738
+ this.scrollActiveIntoView();
739
+ }
740
+ setQuery(value) {
741
+ if (value === this.query() && value === this.query$.value) {
742
+ return;
743
+ }
744
+ this.query.set(value);
745
+ this.activeId.set(null);
746
+ this.actionsFor.set(null);
747
+ this.query$.next(value);
748
+ }
749
+ focusInput() {
750
+ const element = this.input().nativeElement;
751
+ element.focus();
752
+ const end = element.value.length;
753
+ element.setSelectionRange(end, end);
754
+ }
755
+ scrollActiveIntoView() {
756
+ const id = this.activeDescendant();
757
+ const element = id ? document.getElementById(id) : null;
758
+ if (element && typeof element.scrollIntoView === 'function') {
759
+ element.scrollIntoView({ block: 'nearest' });
760
+ }
761
+ }
762
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CommandPaletteComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
763
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: CommandPaletteComponent, isStandalone: true, selector: "mm-command-palette", inputs: { messages: { classPropertyName: "messages", publicName: "messages", isSignal: true, isRequired: false, transformFunction: null } }, viewQueries: [{ propertyName: "input", first: true, predicate: ["input"], descendants: true, isSignal: true }], ngImport: i0, template: "<div class=\"cp-scrim\" (mousedown)=\"onScrimMouseDown($event)\">\n <div class=\"cp-dialog\" role=\"dialog\" aria-modal=\"true\" [attr.aria-label]=\"m().dialogLabel\" (keydown)=\"onKeydown($event)\" (mousedown)=\"onDialogMouseDown($event)\">\n <div class=\"cp-input-row\">\n <svg class=\"cp-search-icon\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg>\n <input\n #input\n class=\"cp-input\"\n type=\"text\"\n role=\"combobox\"\n [attr.aria-label]=\"m().inputLabel\"\n [attr.aria-expanded]=\"expanded()\"\n aria-autocomplete=\"list\"\n [attr.aria-controls]=\"actionsFor() ? 'cp-actions' : 'cp-listbox'\"\n [attr.aria-activedescendant]=\"activeDescendant()\"\n autocomplete=\"off\"\n spellcheck=\"false\"\n [placeholder]=\"m().placeholder\"\n [value]=\"query()\"\n (input)=\"onInput($event)\" />\n <kbd class=\"cp-esc\" aria-hidden=\"true\">esc</kbd>\n <!-- Phones: full-screen palette without scrim and Esc key \u2014 an explicit way out. -->\n <button type=\"button\" class=\"cp-close\" (click)=\"close()\">{{ m().cancel }}</button>\n </div>\n\n @if (actionsFor(); as target) {\n <div class=\"cp-list\" id=\"cp-actions\" role=\"listbox\" [attr.aria-label]=\"format(m().actionsFor, { label: target.label })\">\n <div class=\"cp-group-header\" role=\"presentation\">{{ format(m().actionsHeader, { label: target.label }) }}</div>\n @for (action of target.actions ?? []; track action.id; let i = $index) {\n <div\n class=\"cp-option\"\n role=\"option\"\n [id]=\"'cp-action-' + i\"\n [attr.aria-selected]=\"i === activeActionIndex()\"\n (mousedown)=\"$event.preventDefault()\"\n (mousemove)=\"activeActionIndex.set(i)\"\n (click)=\"runAction(action)\">\n <span class=\"cp-icon\" aria-hidden=\"true\">\u25B8</span>\n <span class=\"cp-label\">{{ action.label }}</span>\n <span class=\"cp-side\"></span>\n <span class=\"cp-tab-col\">\n @if (action.hint) {\n <kbd aria-hidden=\"true\">{{ action.hint }}</kbd>\n }\n </span>\n </div>\n }\n </div>\n } @else {\n <div class=\"cp-list\" id=\"cp-listbox\" role=\"listbox\" [attr.aria-label]=\"m().results\">\n @for (section of sections(); track section.group) {\n <div class=\"cp-section\" [class.cp-section-ai]=\"section.group === 'ai'\" role=\"group\" [attr.aria-labelledby]=\"'cp-group-' + section.group\">\n <div class=\"cp-group-header\" role=\"presentation\" [id]=\"'cp-group-' + section.group\">{{ section.label }}</div>\n @for (row of section.rows; track row.result.id) {\n <div\n class=\"cp-option\"\n role=\"option\"\n [class.cp-ai]=\"row.result.group === 'ai'\"\n [id]=\"'cp-option-' + row.index\"\n [attr.aria-selected]=\"row.index === activeIndex()\"\n (mousedown)=\"$event.preventDefault()\"\n (mousemove)=\"onOptionHover(row.result)\"\n (click)=\"onOptionClick(row.result, $event)\">\n <span class=\"cp-icon\" aria-hidden=\"true\">\n @if (row.result.icon) {\n <kendo-svgicon [icon]=\"row.result.icon\" size=\"small\"></kendo-svgicon>\n } @else {\n {{ row.result.glyph ?? '\u00B7' }}\n }\n </span>\n <!-- One line on purpose: whitespace between the segments would show as gaps in the label. -->\n <span class=\"cp-label\">@for (segment of row.segments; track $index) {@if (segment.match) {<mark>{{ segment.text }}</mark>}@else{<span>{{ segment.text }}</span>}}</span>\n <span class=\"cp-side\">\n @if (row.side) {\n <span class=\"cp-description\" [title]=\"row.side\">{{ row.side }}</span>\n }\n @if (row.result.typeHint) {\n <code class=\"cp-hint\">{{ row.result.typeHint }}</code>\n }\n </span>\n <!-- Own grid column, so the Tab key lines up on every row. -->\n <span class=\"cp-tab-col\" aria-hidden=\"true\">\n @if (row.result.actions?.length) {\n <kbd class=\"cp-tab-hint\" [title]=\"m().tabForActions\">\u21E5</kbd>\n }\n </span>\n </div>\n }\n </div>\n } @empty {\n <div class=\"cp-empty\" role=\"presentation\">{{ emptyText() }}</div>\n }\n </div>\n }\n\n <div class=\"cp-footer\" aria-hidden=\"true\">\n <span><kbd>\u2191</kbd><kbd>\u2193</kbd> {{ m().footerMove }}</span>\n <span><kbd>\u21B5</kbd> {{ m().footerOpen }}</span>\n <span><kbd>{{ modifierLabel }}\u21B5</kbd> {{ m().footerNewTab }}</span>\n <span><kbd>\u21E5</kbd> {{ m().footerActions }}</span>\n <span class=\"cp-prefixes\">\n <span class=\"cp-prefix\"><kbd>&gt;</kbd> {{ m().prefixActions }}</span>\n <span class=\"cp-prefix\"><kbd>&#64;</kbd> {{ m().prefixEntities }}</span>\n <span class=\"cp-prefix\"><kbd>#</kbd> {{ m().prefixBoards }}</span>\n <span class=\"cp-prefix\"><kbd>/</kbd> {{ m().prefixTenants }}</span>\n </span>\n </div>\n <div class=\"cp-status\" role=\"status\" aria-live=\"polite\">{{ statusText() }}</div>\n </div>\n</div>\n", styles: [":host{display:contents;--cp-scrim: var(--theme-scrim, color-mix(in srgb, var(--theme-bg-app) 70%, transparent))}.cp-scrim{position:fixed;inset:0;z-index:12000;display:flex;align-items:flex-start;justify-content:center;padding:72px 16px 16px;background:var(--cp-scrim);animation:cp-fade var(--theme-motion-fast, .12s) cubic-bezier(.2,0,0,1)}.cp-dialog{display:flex;flex-direction:column;width:min(640px,100%);max-height:min(560px,100vh - 96px);overflow:hidden;background:var(--theme-bg-overlay);color:var(--theme-text-primary);border-radius:var(--theme-radius-lg, 8px);box-shadow:var(--theme-shadow-popup);font-family:var(--theme-font-ui, inherit)}.cp-input-row{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--theme-border-subtle)}.cp-input-row:focus-within{box-shadow:inset 0 -2px 0 var(--theme-accent, var(--octo-mint, #64ceb9))}.cp-search-icon{flex:none;width:18px;height:18px;fill:none;stroke:var(--theme-text-muted);stroke-width:1.5}.cp-input{flex:1;min-width:0;border:0;outline:0;background:none;color:var(--theme-text-primary);font:inherit;font-size:15px}.cp-input::placeholder{color:var(--theme-text-muted)}.cp-list{overflow:auto;padding:6px;overscroll-behavior:contain}.cp-section-ai{margin-top:4px;border-top:1px solid var(--theme-border-subtle);padding-top:4px}.cp-section-ai .cp-group-header{display:none}.cp-group-header{padding:8px 10px 4px;font-size:11.5px;font-weight:500;color:var(--theme-text-muted)}.cp-option{display:grid;grid-template-columns:22px minmax(6rem,1fr) fit-content(45%) 28px;gap:10px;align-items:center;padding:7px 10px;border-radius:var(--theme-radius-sm, 4px);cursor:pointer}.cp-option[aria-selected=true]{background:var(--theme-accent-subtle, rgba(100, 206, 185, .12))}.cp-option[aria-selected=true] .cp-icon{color:var(--theme-accent, var(--octo-mint, #64ceb9))}.cp-option.cp-ai .cp-icon,.cp-option.cp-ai .cp-label{color:var(--theme-ai, #8b6fd0)}.cp-icon{display:flex;align-items:center;justify-content:center;color:var(--theme-text-muted);font:500 13px var(--theme-font-mono, \"Roboto Mono\", monospace)}.cp-label{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.cp-label mark{background:none;color:var(--theme-accent, var(--octo-mint, #64ceb9));font-weight:500}.cp-side{display:flex;align-items:center;justify-content:flex-end;gap:8px;min-width:0;font-size:12px;color:var(--theme-text-secondary);white-space:nowrap}.cp-description{overflow:hidden;text-overflow:ellipsis}.cp-tab-col{display:flex;justify-content:flex-end}.cp-hint{overflow:hidden;max-width:200px;text-overflow:ellipsis;font-family:var(--theme-font-mono, \"Roboto Mono\", monospace);font-size:11.5px;color:var(--theme-text-muted)}kbd{display:inline-block;min-width:18px;padding:0 4px;border:1px solid var(--theme-border-strong);border-radius:var(--theme-radius-xs, 2px);font:11px var(--theme-font-mono, \"Roboto Mono\", monospace);line-height:16px;text-align:center;color:var(--theme-text-secondary)}.cp-esc{flex:none}.cp-close{display:none;flex:none;padding:4px 6px;border:0;background:none;color:var(--theme-text-accent);font:inherit;cursor:pointer}.cp-close:focus-visible{outline:none;box-shadow:var(--theme-focus-ring)}.cp-empty{padding:24px;text-align:center;color:var(--theme-text-muted)}.cp-footer{display:flex;flex-wrap:wrap;align-items:center;gap:4px 12px;padding:8px 16px;border-top:1px solid var(--theme-border-subtle);font-size:11px;color:var(--theme-text-muted)}.cp-footer>span{white-space:nowrap}.cp-footer kbd{margin-right:2px}.cp-prefixes{display:contents}.cp-prefixes .cp-prefix{white-space:nowrap}@media(min-width:1100px){.cp-dialog{width:min(720px,100%)}.cp-prefixes>.cp-prefix:first-child{margin-left:auto}}.cp-status{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}@keyframes cp-fade{0%{opacity:0}to{opacity:1}}@media(prefers-reduced-motion:reduce){.cp-scrim{animation:none}}@media(max-width:640px){.cp-scrim{padding:0}.cp-dialog{width:100%;height:100dvh;max-height:none;border-radius:0}.cp-list{flex:1 1 auto}.cp-option{grid-template-columns:22px minmax(6rem,1fr) fit-content(40%) 28px}.cp-hint,.cp-prefixes,.cp-esc{display:none}.cp-close{display:inline-flex}}\n"], dependencies: [{ kind: "component", type: SVGIconComponent, selector: "kendo-svg-icon, kendo-svgicon", inputs: ["icon", "variant"], exportAs: ["kendoSVGIcon"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
764
+ }
765
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: CommandPaletteComponent, decorators: [{
766
+ type: Component,
767
+ args: [{ selector: 'mm-command-palette', imports: [SVGIconComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"cp-scrim\" (mousedown)=\"onScrimMouseDown($event)\">\n <div class=\"cp-dialog\" role=\"dialog\" aria-modal=\"true\" [attr.aria-label]=\"m().dialogLabel\" (keydown)=\"onKeydown($event)\" (mousedown)=\"onDialogMouseDown($event)\">\n <div class=\"cp-input-row\">\n <svg class=\"cp-search-icon\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><circle cx=\"11\" cy=\"11\" r=\"7\"/><path d=\"m20 20-3.5-3.5\"/></svg>\n <input\n #input\n class=\"cp-input\"\n type=\"text\"\n role=\"combobox\"\n [attr.aria-label]=\"m().inputLabel\"\n [attr.aria-expanded]=\"expanded()\"\n aria-autocomplete=\"list\"\n [attr.aria-controls]=\"actionsFor() ? 'cp-actions' : 'cp-listbox'\"\n [attr.aria-activedescendant]=\"activeDescendant()\"\n autocomplete=\"off\"\n spellcheck=\"false\"\n [placeholder]=\"m().placeholder\"\n [value]=\"query()\"\n (input)=\"onInput($event)\" />\n <kbd class=\"cp-esc\" aria-hidden=\"true\">esc</kbd>\n <!-- Phones: full-screen palette without scrim and Esc key \u2014 an explicit way out. -->\n <button type=\"button\" class=\"cp-close\" (click)=\"close()\">{{ m().cancel }}</button>\n </div>\n\n @if (actionsFor(); as target) {\n <div class=\"cp-list\" id=\"cp-actions\" role=\"listbox\" [attr.aria-label]=\"format(m().actionsFor, { label: target.label })\">\n <div class=\"cp-group-header\" role=\"presentation\">{{ format(m().actionsHeader, { label: target.label }) }}</div>\n @for (action of target.actions ?? []; track action.id; let i = $index) {\n <div\n class=\"cp-option\"\n role=\"option\"\n [id]=\"'cp-action-' + i\"\n [attr.aria-selected]=\"i === activeActionIndex()\"\n (mousedown)=\"$event.preventDefault()\"\n (mousemove)=\"activeActionIndex.set(i)\"\n (click)=\"runAction(action)\">\n <span class=\"cp-icon\" aria-hidden=\"true\">\u25B8</span>\n <span class=\"cp-label\">{{ action.label }}</span>\n <span class=\"cp-side\"></span>\n <span class=\"cp-tab-col\">\n @if (action.hint) {\n <kbd aria-hidden=\"true\">{{ action.hint }}</kbd>\n }\n </span>\n </div>\n }\n </div>\n } @else {\n <div class=\"cp-list\" id=\"cp-listbox\" role=\"listbox\" [attr.aria-label]=\"m().results\">\n @for (section of sections(); track section.group) {\n <div class=\"cp-section\" [class.cp-section-ai]=\"section.group === 'ai'\" role=\"group\" [attr.aria-labelledby]=\"'cp-group-' + section.group\">\n <div class=\"cp-group-header\" role=\"presentation\" [id]=\"'cp-group-' + section.group\">{{ section.label }}</div>\n @for (row of section.rows; track row.result.id) {\n <div\n class=\"cp-option\"\n role=\"option\"\n [class.cp-ai]=\"row.result.group === 'ai'\"\n [id]=\"'cp-option-' + row.index\"\n [attr.aria-selected]=\"row.index === activeIndex()\"\n (mousedown)=\"$event.preventDefault()\"\n (mousemove)=\"onOptionHover(row.result)\"\n (click)=\"onOptionClick(row.result, $event)\">\n <span class=\"cp-icon\" aria-hidden=\"true\">\n @if (row.result.icon) {\n <kendo-svgicon [icon]=\"row.result.icon\" size=\"small\"></kendo-svgicon>\n } @else {\n {{ row.result.glyph ?? '\u00B7' }}\n }\n </span>\n <!-- One line on purpose: whitespace between the segments would show as gaps in the label. -->\n <span class=\"cp-label\">@for (segment of row.segments; track $index) {@if (segment.match) {<mark>{{ segment.text }}</mark>}@else{<span>{{ segment.text }}</span>}}</span>\n <span class=\"cp-side\">\n @if (row.side) {\n <span class=\"cp-description\" [title]=\"row.side\">{{ row.side }}</span>\n }\n @if (row.result.typeHint) {\n <code class=\"cp-hint\">{{ row.result.typeHint }}</code>\n }\n </span>\n <!-- Own grid column, so the Tab key lines up on every row. -->\n <span class=\"cp-tab-col\" aria-hidden=\"true\">\n @if (row.result.actions?.length) {\n <kbd class=\"cp-tab-hint\" [title]=\"m().tabForActions\">\u21E5</kbd>\n }\n </span>\n </div>\n }\n </div>\n } @empty {\n <div class=\"cp-empty\" role=\"presentation\">{{ emptyText() }}</div>\n }\n </div>\n }\n\n <div class=\"cp-footer\" aria-hidden=\"true\">\n <span><kbd>\u2191</kbd><kbd>\u2193</kbd> {{ m().footerMove }}</span>\n <span><kbd>\u21B5</kbd> {{ m().footerOpen }}</span>\n <span><kbd>{{ modifierLabel }}\u21B5</kbd> {{ m().footerNewTab }}</span>\n <span><kbd>\u21E5</kbd> {{ m().footerActions }}</span>\n <span class=\"cp-prefixes\">\n <span class=\"cp-prefix\"><kbd>&gt;</kbd> {{ m().prefixActions }}</span>\n <span class=\"cp-prefix\"><kbd>&#64;</kbd> {{ m().prefixEntities }}</span>\n <span class=\"cp-prefix\"><kbd>#</kbd> {{ m().prefixBoards }}</span>\n <span class=\"cp-prefix\"><kbd>/</kbd> {{ m().prefixTenants }}</span>\n </span>\n </div>\n <div class=\"cp-status\" role=\"status\" aria-live=\"polite\">{{ statusText() }}</div>\n </div>\n</div>\n", styles: [":host{display:contents;--cp-scrim: var(--theme-scrim, color-mix(in srgb, var(--theme-bg-app) 70%, transparent))}.cp-scrim{position:fixed;inset:0;z-index:12000;display:flex;align-items:flex-start;justify-content:center;padding:72px 16px 16px;background:var(--cp-scrim);animation:cp-fade var(--theme-motion-fast, .12s) cubic-bezier(.2,0,0,1)}.cp-dialog{display:flex;flex-direction:column;width:min(640px,100%);max-height:min(560px,100vh - 96px);overflow:hidden;background:var(--theme-bg-overlay);color:var(--theme-text-primary);border-radius:var(--theme-radius-lg, 8px);box-shadow:var(--theme-shadow-popup);font-family:var(--theme-font-ui, inherit)}.cp-input-row{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--theme-border-subtle)}.cp-input-row:focus-within{box-shadow:inset 0 -2px 0 var(--theme-accent, var(--octo-mint, #64ceb9))}.cp-search-icon{flex:none;width:18px;height:18px;fill:none;stroke:var(--theme-text-muted);stroke-width:1.5}.cp-input{flex:1;min-width:0;border:0;outline:0;background:none;color:var(--theme-text-primary);font:inherit;font-size:15px}.cp-input::placeholder{color:var(--theme-text-muted)}.cp-list{overflow:auto;padding:6px;overscroll-behavior:contain}.cp-section-ai{margin-top:4px;border-top:1px solid var(--theme-border-subtle);padding-top:4px}.cp-section-ai .cp-group-header{display:none}.cp-group-header{padding:8px 10px 4px;font-size:11.5px;font-weight:500;color:var(--theme-text-muted)}.cp-option{display:grid;grid-template-columns:22px minmax(6rem,1fr) fit-content(45%) 28px;gap:10px;align-items:center;padding:7px 10px;border-radius:var(--theme-radius-sm, 4px);cursor:pointer}.cp-option[aria-selected=true]{background:var(--theme-accent-subtle, rgba(100, 206, 185, .12))}.cp-option[aria-selected=true] .cp-icon{color:var(--theme-accent, var(--octo-mint, #64ceb9))}.cp-option.cp-ai .cp-icon,.cp-option.cp-ai .cp-label{color:var(--theme-ai, #8b6fd0)}.cp-icon{display:flex;align-items:center;justify-content:center;color:var(--theme-text-muted);font:500 13px var(--theme-font-mono, \"Roboto Mono\", monospace)}.cp-label{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.cp-label mark{background:none;color:var(--theme-accent, var(--octo-mint, #64ceb9));font-weight:500}.cp-side{display:flex;align-items:center;justify-content:flex-end;gap:8px;min-width:0;font-size:12px;color:var(--theme-text-secondary);white-space:nowrap}.cp-description{overflow:hidden;text-overflow:ellipsis}.cp-tab-col{display:flex;justify-content:flex-end}.cp-hint{overflow:hidden;max-width:200px;text-overflow:ellipsis;font-family:var(--theme-font-mono, \"Roboto Mono\", monospace);font-size:11.5px;color:var(--theme-text-muted)}kbd{display:inline-block;min-width:18px;padding:0 4px;border:1px solid var(--theme-border-strong);border-radius:var(--theme-radius-xs, 2px);font:11px var(--theme-font-mono, \"Roboto Mono\", monospace);line-height:16px;text-align:center;color:var(--theme-text-secondary)}.cp-esc{flex:none}.cp-close{display:none;flex:none;padding:4px 6px;border:0;background:none;color:var(--theme-text-accent);font:inherit;cursor:pointer}.cp-close:focus-visible{outline:none;box-shadow:var(--theme-focus-ring)}.cp-empty{padding:24px;text-align:center;color:var(--theme-text-muted)}.cp-footer{display:flex;flex-wrap:wrap;align-items:center;gap:4px 12px;padding:8px 16px;border-top:1px solid var(--theme-border-subtle);font-size:11px;color:var(--theme-text-muted)}.cp-footer>span{white-space:nowrap}.cp-footer kbd{margin-right:2px}.cp-prefixes{display:contents}.cp-prefixes .cp-prefix{white-space:nowrap}@media(min-width:1100px){.cp-dialog{width:min(720px,100%)}.cp-prefixes>.cp-prefix:first-child{margin-left:auto}}.cp-status{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}@keyframes cp-fade{0%{opacity:0}to{opacity:1}}@media(prefers-reduced-motion:reduce){.cp-scrim{animation:none}}@media(max-width:640px){.cp-scrim{padding:0}.cp-dialog{width:100%;height:100dvh;max-height:none;border-radius:0}.cp-list{flex:1 1 auto}.cp-option{grid-template-columns:22px minmax(6rem,1fr) fit-content(40%) 28px}.cp-hint,.cp-prefixes,.cp-esc{display:none}.cp-close{display:inline-flex}}\n"] }]
768
+ }], ctorParameters: () => [], propDecorators: { messages: [{ type: i0.Input, args: [{ isSignal: true, alias: "messages", required: false }] }], input: [{ type: i0.ViewChild, args: ['input', { isSignal: true }] }] } });
769
+
770
+ /*
771
+ * Public API Surface of @meshmakers/shared-ui/command-palette (AB#5621)
772
+ */
773
+ /**
774
+ * The command palette (Cmd/Ctrl+K) of the OctoMesh Refinery Studio as an app-agnostic building
775
+ * block: the overlay component, the open/close service with the hotkey mapping, query parsing,
776
+ * multi-provider search and ranking. It knows no data source of its own — every row comes from a
777
+ * {@link PALETTE_PROVIDERS} entry the host registers (pages, actions, entities, tenants …).
778
+ *
779
+ * Usage:
780
+ *
781
+ * ```ts
782
+ * import { CommandPaletteComponent, CommandPaletteService, PALETTE_PROVIDERS, PaletteProvider,
783
+ * paletteHotkeyAction } from '@meshmakers/shared-ui/command-palette';
784
+ *
785
+ * // app.config.ts
786
+ * providers: [
787
+ * { provide: PALETTE_PROVIDERS, useClass: PagesPaletteProvider, multi: true },
788
+ * { provide: PALETTE_PROVIDERS, useClass: TenantsPaletteProvider, multi: true },
789
+ * ]
790
+ *
791
+ * // AppComponent: host the overlay and bind the hotkeys
792
+ * template: `@if (palette.isOpen()) { <mm-command-palette /> }`
793
+ * @HostListener('document:keydown', ['$event'])
794
+ * onKeydown(event: KeyboardEvent): void {
795
+ * const action = paletteHotkeyAction(event, this.palette.isOpen());
796
+ * if (action) { event.preventDefault(); action === 'toggle' ? this.palette.toggle() : this.palette.open(); }
797
+ * }
798
+ * ```
799
+ *
800
+ * A provider implements {@link PaletteProvider}: `search(query)` returns an Observable of
801
+ * {@link PaletteResult}s (a new query cancels the previous search; `debounceMs` for remote
802
+ * sources; an error drops only that provider's rows), `reset()` runs whenever the palette opens.
803
+ * {@link PaletteNavigationService} offers tenant-relative navigation and clipboard helpers.
804
+ *
805
+ * Optional providers:
806
+ * - `PALETTE_RECENT_ITEMS_SOURCE` — frecency per `recentKey` (boosts recently used rows);
807
+ * - `COMMAND_PALETTE_MESSAGES` — translations (`Partial<CommandPaletteMessages>`, English defaults;
808
+ * the component's `messages` input wins over the token).
809
+ */
810
+ // --- Contract ---
811
+
812
+ /**
813
+ * Generated bundle index. Do not edit.
814
+ */
815
+
816
+ export { COMMAND_PALETTE_MESSAGES, CommandPaletteComponent, CommandPaletteService, DEFAULT_COMMAND_PALETTE_MESSAGES, MatchTier, PALETTE_PREFIXES, PALETTE_PROVIDERS, PALETTE_RECENT_ITEMS_SOURCE, PaletteNavigationService, formatCommandPaletteMessage, highlightSegments, isRtId, matchTier, paletteGroupLabel, paletteHotkeyAction, parsePaletteQuery, rankResults, resolveCommandPaletteMessages, scoreResult, searchProviders, stabilizeRanking };
817
+ //# sourceMappingURL=meshmakers-shared-ui-command-palette.mjs.map