@flxgde/gigamenu 0.0.1 → 0.0.3

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,645 @@
1
+ import * as i0 from '@angular/core';
2
+ import { signal, computed, Injectable, inject, TemplateRef, Directive, PLATFORM_ID, Inject, viewChild, contentChild, effect, HostListener, Component } from '@angular/core';
3
+ import * as i1 from '@angular/router';
4
+ import { isPlatformBrowser, NgTemplateOutlet } from '@angular/common';
5
+
6
+ const DEFAULT_CONFIG = {
7
+ placeholder: 'Search pages and commands...',
8
+ maxResults: 10,
9
+ autoDiscoverRoutes: true,
10
+ argSeparator: ' ',
11
+ };
12
+ /**
13
+ * Helper function to define a command with type safety.
14
+ */
15
+ function defineCommand(command) {
16
+ return command;
17
+ }
18
+
19
+ class GigamenuService {
20
+ router;
21
+ _items = signal(new Map(), ...(ngDevMode ? [{ debugName: "_items" }] : []));
22
+ _isOpen = signal(false, ...(ngDevMode ? [{ debugName: "_isOpen" }] : []));
23
+ _config = signal(DEFAULT_CONFIG, ...(ngDevMode ? [{ debugName: "_config" }] : []));
24
+ items = computed(() => Array.from(this._items().values()), ...(ngDevMode ? [{ debugName: "items" }] : []));
25
+ isOpen = this._isOpen.asReadonly();
26
+ config = this._config.asReadonly();
27
+ constructor(router) {
28
+ this.router = router;
29
+ }
30
+ configure(config) {
31
+ this._config.update((current) => ({ ...current, ...config }));
32
+ }
33
+ open() {
34
+ this._isOpen.set(true);
35
+ }
36
+ close() {
37
+ this._isOpen.set(false);
38
+ }
39
+ toggle() {
40
+ this._isOpen.update((v) => !v);
41
+ }
42
+ registerItem(item) {
43
+ this._items.update((items) => {
44
+ const newItems = new Map(items);
45
+ newItems.set(item.id, item);
46
+ return newItems;
47
+ });
48
+ }
49
+ unregisterItem(id) {
50
+ this._items.update((items) => {
51
+ const newItems = new Map(items);
52
+ newItems.delete(id);
53
+ return newItems;
54
+ });
55
+ }
56
+ registerCommand(command) {
57
+ this.registerItem({
58
+ ...command,
59
+ category: 'command',
60
+ });
61
+ }
62
+ registerPage(page) {
63
+ this.registerItem({
64
+ ...page,
65
+ category: 'page',
66
+ action: () => this.router.navigate([page.path]),
67
+ });
68
+ }
69
+ discoverRoutes(routesOrOptions, maybeOptions) {
70
+ let routes;
71
+ let options;
72
+ if (Array.isArray(routesOrOptions)) {
73
+ routes = routesOrOptions;
74
+ options = maybeOptions;
75
+ }
76
+ else {
77
+ routes = this.router.config;
78
+ options = routesOrOptions;
79
+ }
80
+ this.extractPagesFromRoutes(routes, '', options?.filter);
81
+ }
82
+ extractPagesFromRoutes(routes, parentPath, filter) {
83
+ for (const route of routes) {
84
+ if (route.redirectTo !== undefined)
85
+ continue;
86
+ const fullPath = parentPath
87
+ ? `${parentPath}/${route.path ?? ''}`
88
+ : route.path ?? '';
89
+ if (route.path !== undefined && route.path !== '**') {
90
+ const routeInfo = {
91
+ path: route.path,
92
+ fullPath: `/${fullPath}`,
93
+ data: route.data,
94
+ title: typeof route.title === 'string' ? route.title : undefined,
95
+ };
96
+ // Apply filter if provided
97
+ if (filter && !filter(routeInfo)) {
98
+ // Still process children even if this route is filtered
99
+ if (route.children) {
100
+ this.extractPagesFromRoutes(route.children, fullPath, filter);
101
+ }
102
+ continue;
103
+ }
104
+ const label = routeInfo.title || this.pathToLabel(route.path || 'Home');
105
+ this.registerPage({
106
+ id: `page:${fullPath || '/'}`,
107
+ label,
108
+ path: `/${fullPath}`,
109
+ description: `Navigate to ${label}`,
110
+ });
111
+ }
112
+ if (route.children) {
113
+ this.extractPagesFromRoutes(route.children, fullPath, filter);
114
+ }
115
+ }
116
+ }
117
+ pathToLabel(path) {
118
+ return path
119
+ .split('/')
120
+ .pop()
121
+ .replace(/[-_]/g, ' ')
122
+ .replace(/\b\w/g, (c) => c.toUpperCase());
123
+ }
124
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: GigamenuService, deps: [{ token: i1.Router }], target: i0.ɵɵFactoryTarget.Injectable });
125
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: GigamenuService, providedIn: 'root' });
126
+ }
127
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: GigamenuService, decorators: [{
128
+ type: Injectable,
129
+ args: [{ providedIn: 'root' }]
130
+ }], ctorParameters: () => [{ type: i1.Router }] });
131
+
132
+ /**
133
+ * Template directive for customizing menu item rendering.
134
+ *
135
+ * @example
136
+ * ```html
137
+ * <gm-gigamenu>
138
+ * <ng-template gmItem let-item let-selected="selected" let-index="index">
139
+ * <div [class.active]="selected">
140
+ * {{ item.label }}
141
+ * </div>
142
+ * </ng-template>
143
+ * </gm-gigamenu>
144
+ * ```
145
+ */
146
+ class GigamenuItemTemplate {
147
+ template = inject(TemplateRef);
148
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: GigamenuItemTemplate, deps: [], target: i0.ɵɵFactoryTarget.Directive });
149
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.6", type: GigamenuItemTemplate, isStandalone: true, selector: "[gmItem]", ngImport: i0 });
150
+ }
151
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: GigamenuItemTemplate, decorators: [{
152
+ type: Directive,
153
+ args: [{
154
+ selector: '[gmItem]',
155
+ standalone: true,
156
+ }]
157
+ }] });
158
+ /**
159
+ * Template directive for customizing empty state rendering.
160
+ *
161
+ * @example
162
+ * ```html
163
+ * <gm-gigamenu>
164
+ * <ng-template gmEmpty let-query>
165
+ * <div>No results for "{{ query }}"</div>
166
+ * </ng-template>
167
+ * </gm-gigamenu>
168
+ * ```
169
+ */
170
+ class GigamenuEmptyTemplate {
171
+ template = inject(TemplateRef);
172
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: GigamenuEmptyTemplate, deps: [], target: i0.ɵɵFactoryTarget.Directive });
173
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.6", type: GigamenuEmptyTemplate, isStandalone: true, selector: "[gmEmpty]", ngImport: i0 });
174
+ }
175
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: GigamenuEmptyTemplate, decorators: [{
176
+ type: Directive,
177
+ args: [{
178
+ selector: '[gmEmpty]',
179
+ standalone: true,
180
+ }]
181
+ }] });
182
+ /**
183
+ * Template directive for customizing header/search input rendering.
184
+ *
185
+ * @example
186
+ * ```html
187
+ * <gm-gigamenu>
188
+ * <ng-template gmHeader let-query let-onQueryChange="onQueryChange" let-onKeydown="onKeydown">
189
+ * <input [value]="query" (input)="onQueryChange($event.target.value)" (keydown)="onKeydown($event)" />
190
+ * </ng-template>
191
+ * </gm-gigamenu>
192
+ * ```
193
+ */
194
+ class GigamenuHeaderTemplate {
195
+ template = inject(TemplateRef);
196
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: GigamenuHeaderTemplate, deps: [], target: i0.ɵɵFactoryTarget.Directive });
197
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.6", type: GigamenuHeaderTemplate, isStandalone: true, selector: "[gmHeader]", ngImport: i0 });
198
+ }
199
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: GigamenuHeaderTemplate, decorators: [{
200
+ type: Directive,
201
+ args: [{
202
+ selector: '[gmHeader]',
203
+ standalone: true,
204
+ }]
205
+ }] });
206
+ /**
207
+ * Template directive for customizing footer rendering.
208
+ *
209
+ * @example
210
+ * ```html
211
+ * <gm-gigamenu>
212
+ * <ng-template gmFooter let-count let-total="total">
213
+ * <div>Showing {{ count }} of {{ total }} items</div>
214
+ * </ng-template>
215
+ * </gm-gigamenu>
216
+ * ```
217
+ */
218
+ class GigamenuFooterTemplate {
219
+ template = inject(TemplateRef);
220
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: GigamenuFooterTemplate, deps: [], target: i0.ɵɵFactoryTarget.Directive });
221
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.6", type: GigamenuFooterTemplate, isStandalone: true, selector: "[gmFooter]", ngImport: i0 });
222
+ }
223
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: GigamenuFooterTemplate, decorators: [{
224
+ type: Directive,
225
+ args: [{
226
+ selector: '[gmFooter]',
227
+ standalone: true,
228
+ }]
229
+ }] });
230
+ class GigamenuPanelTemplate {
231
+ template = inject(TemplateRef);
232
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: GigamenuPanelTemplate, deps: [], target: i0.ɵɵFactoryTarget.Directive });
233
+ static ɵdir = i0.ɵɵngDeclareDirective({ minVersion: "14.0.0", version: "21.0.6", type: GigamenuPanelTemplate, isStandalone: true, selector: "[gmPanel]", ngImport: i0 });
234
+ }
235
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: GigamenuPanelTemplate, decorators: [{
236
+ type: Directive,
237
+ args: [{
238
+ selector: '[gmPanel]',
239
+ standalone: true,
240
+ }]
241
+ }] });
242
+
243
+ const STORAGE_KEY = 'gigamenu_frecency';
244
+ const GLOBAL_KEY = '__global__';
245
+ const MAX_ENTRIES_PER_TERM = 10;
246
+ const MAX_TERMS = 100;
247
+ const DECAY_FACTOR = 0.9;
248
+ class FrecencyService {
249
+ data = {};
250
+ isBrowser;
251
+ constructor(platformId) {
252
+ this.isBrowser = isPlatformBrowser(platformId);
253
+ this.load();
254
+ }
255
+ /**
256
+ * Record that an item was selected for a given search term.
257
+ */
258
+ recordSelection(searchTerm, itemId) {
259
+ // Always record to global for overall frecency
260
+ this.recordToKey(GLOBAL_KEY, itemId);
261
+ // Also record to specific search term if provided
262
+ const normalizedTerm = this.normalizeTerm(searchTerm);
263
+ if (normalizedTerm) {
264
+ this.recordToKey(normalizedTerm, itemId);
265
+ }
266
+ this.pruneAndSave();
267
+ }
268
+ recordToKey(key, itemId) {
269
+ if (!this.data[key]) {
270
+ this.data[key] = [];
271
+ }
272
+ const entries = this.data[key];
273
+ const existing = entries.find((e) => e.itemId === itemId);
274
+ if (existing) {
275
+ existing.count++;
276
+ existing.lastUsed = Date.now();
277
+ }
278
+ else {
279
+ entries.push({
280
+ itemId,
281
+ count: 1,
282
+ lastUsed: Date.now(),
283
+ });
284
+ }
285
+ // Keep only top entries
286
+ this.data[key] = entries
287
+ .sort((a, b) => this.calculateScore(b) - this.calculateScore(a))
288
+ .slice(0, MAX_ENTRIES_PER_TERM);
289
+ }
290
+ /**
291
+ * Get frecency scores for items matching a search term.
292
+ * Returns a map of itemId -> score (higher is better).
293
+ */
294
+ getScores(searchTerm) {
295
+ const scores = new Map();
296
+ const normalizedTerm = this.normalizeTerm(searchTerm);
297
+ // If no search term, return global scores
298
+ if (!normalizedTerm) {
299
+ const globalEntries = this.data[GLOBAL_KEY];
300
+ if (globalEntries) {
301
+ for (const entry of globalEntries) {
302
+ scores.set(entry.itemId, this.calculateScore(entry));
303
+ }
304
+ }
305
+ return scores;
306
+ }
307
+ // Exact match for search term
308
+ const exactEntries = this.data[normalizedTerm];
309
+ if (exactEntries) {
310
+ for (const entry of exactEntries) {
311
+ const score = this.calculateScore(entry);
312
+ scores.set(entry.itemId, score);
313
+ }
314
+ }
315
+ // Prefix matches (for partial typing)
316
+ for (const [term, entries] of Object.entries(this.data)) {
317
+ if (term !== GLOBAL_KEY && term !== normalizedTerm && term.startsWith(normalizedTerm)) {
318
+ for (const entry of entries) {
319
+ const currentScore = scores.get(entry.itemId) ?? 0;
320
+ // Prefix matches get reduced weight
321
+ const prefixScore = this.calculateScore(entry) * 0.5;
322
+ scores.set(entry.itemId, Math.max(currentScore, prefixScore));
323
+ }
324
+ }
325
+ }
326
+ return scores;
327
+ }
328
+ /**
329
+ * Get the most likely item for a search term (for auto-selection).
330
+ * Returns itemId if there's a strong match, null otherwise.
331
+ */
332
+ getTopMatch(searchTerm) {
333
+ const normalizedTerm = this.normalizeTerm(searchTerm);
334
+ if (!normalizedTerm)
335
+ return null;
336
+ const entries = this.data[normalizedTerm];
337
+ if (!entries || entries.length === 0)
338
+ return null;
339
+ const topEntry = entries[0];
340
+ const score = this.calculateScore(topEntry);
341
+ // Only auto-select if strong confidence (used multiple times recently)
342
+ if (topEntry.count >= 2 && score > 5) {
343
+ return topEntry.itemId;
344
+ }
345
+ return null;
346
+ }
347
+ calculateScore(entry) {
348
+ const ageInHours = (Date.now() - entry.lastUsed) / (60 * 60 * 24);
349
+ const recencyScore = Math.pow(DECAY_FACTOR, ageInHours);
350
+ return entry.count * recencyScore;
351
+ }
352
+ normalizeTerm(term) {
353
+ return term.toLowerCase().trim();
354
+ }
355
+ load() {
356
+ if (!this.isBrowser)
357
+ return;
358
+ try {
359
+ const stored = localStorage.getItem(STORAGE_KEY);
360
+ if (stored) {
361
+ this.data = JSON.parse(stored);
362
+ }
363
+ }
364
+ catch {
365
+ this.data = {};
366
+ }
367
+ }
368
+ pruneAndSave() {
369
+ if (!this.isBrowser)
370
+ return;
371
+ // Prune old terms if we have too many
372
+ const terms = Object.keys(this.data);
373
+ if (terms.length > MAX_TERMS) {
374
+ const termScores = terms.map((term) => ({
375
+ term,
376
+ maxScore: Math.max(...this.data[term].map((e) => this.calculateScore(e))),
377
+ }));
378
+ termScores.sort((a, b) => b.maxScore - a.maxScore);
379
+ const keepTerms = new Set(termScores.slice(0, MAX_TERMS).map((t) => t.term));
380
+ for (const term of terms) {
381
+ if (!keepTerms.has(term)) {
382
+ delete this.data[term];
383
+ }
384
+ }
385
+ }
386
+ try {
387
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(this.data));
388
+ }
389
+ catch {
390
+ // Storage full or unavailable
391
+ }
392
+ }
393
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: FrecencyService, deps: [{ token: PLATFORM_ID }], target: i0.ɵɵFactoryTarget.Injectable });
394
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: FrecencyService, providedIn: 'root' });
395
+ }
396
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: FrecencyService, decorators: [{
397
+ type: Injectable,
398
+ args: [{ providedIn: 'root' }]
399
+ }], ctorParameters: () => [{ type: undefined, decorators: [{
400
+ type: Inject,
401
+ args: [PLATFORM_ID]
402
+ }] }] });
403
+
404
+ class GigamenuComponent {
405
+ service;
406
+ frecency;
407
+ searchInput = viewChild('searchInput', ...(ngDevMode ? [{ debugName: "searchInput" }] : []));
408
+ listContainer = viewChild('listContainer', ...(ngDevMode ? [{ debugName: "listContainer" }] : []));
409
+ isBrowser;
410
+ // Template queries
411
+ itemTemplate = contentChild(GigamenuItemTemplate, ...(ngDevMode ? [{ debugName: "itemTemplate" }] : []));
412
+ emptyTemplate = contentChild(GigamenuEmptyTemplate, ...(ngDevMode ? [{ debugName: "emptyTemplate" }] : []));
413
+ headerTemplate = contentChild(GigamenuHeaderTemplate, ...(ngDevMode ? [{ debugName: "headerTemplate" }] : []));
414
+ footerTemplate = contentChild(GigamenuFooterTemplate, ...(ngDevMode ? [{ debugName: "footerTemplate" }] : []));
415
+ panelTemplate = contentChild(GigamenuPanelTemplate, ...(ngDevMode ? [{ debugName: "panelTemplate" }] : []));
416
+ query = signal('', ...(ngDevMode ? [{ debugName: "query" }] : []));
417
+ selectedIndex = signal(0, ...(ngDevMode ? [{ debugName: "selectedIndex" }] : []));
418
+ /** Parsed search term (before first separator) */
419
+ searchTerm = computed(() => {
420
+ const q = this.query();
421
+ const separator = this.service.config().argSeparator ?? ' ';
422
+ const sepIndex = q.indexOf(separator);
423
+ if (sepIndex === -1)
424
+ return q;
425
+ return q.substring(0, sepIndex);
426
+ }, ...(ngDevMode ? [{ debugName: "searchTerm" }] : []));
427
+ /** Parsed arguments (after first separator) */
428
+ args = computed(() => {
429
+ const q = this.query();
430
+ const separator = this.service.config().argSeparator ?? ' ';
431
+ const sepIndex = q.indexOf(separator);
432
+ if (sepIndex === -1)
433
+ return '';
434
+ return q.substring(sepIndex + separator.length);
435
+ }, ...(ngDevMode ? [{ debugName: "args" }] : []));
436
+ filteredItems = computed(() => {
437
+ const searchTerm = this.searchTerm().toLowerCase().trim();
438
+ const items = this.service.items();
439
+ const maxResults = this.service.config().maxResults ?? 10;
440
+ if (!searchTerm) {
441
+ // No query: sort by frecency scores from empty searches
442
+ const scores = this.frecency.getScores('');
443
+ return this.sortByFrecency(items, scores).slice(0, maxResults);
444
+ }
445
+ // Filter matching items using only search term (not args)
446
+ const matched = items.filter((item) => this.matchesQuery(item, searchTerm));
447
+ // Sort by frecency for this search term
448
+ const scores = this.frecency.getScores(searchTerm);
449
+ return this.sortByFrecency(matched, scores).slice(0, maxResults);
450
+ }, ...(ngDevMode ? [{ debugName: "filteredItems" }] : []));
451
+ constructor(service, frecency, platformId) {
452
+ this.service = service;
453
+ this.frecency = frecency;
454
+ this.isBrowser = isPlatformBrowser(platformId);
455
+ effect(() => {
456
+ if (this.service.isOpen() && this.isBrowser) {
457
+ setTimeout(() => this.searchInput()?.nativeElement.focus(), 0);
458
+ }
459
+ });
460
+ effect(() => {
461
+ const items = this.filteredItems();
462
+ const searchTerm = this.searchTerm();
463
+ // Check for auto-select based on frecency
464
+ if (searchTerm && items.length > 0) {
465
+ const topMatch = this.frecency.getTopMatch(searchTerm);
466
+ if (topMatch) {
467
+ const idx = items.findIndex((item) => item.id === topMatch);
468
+ if (idx !== -1) {
469
+ this.selectedIndex.set(idx);
470
+ return;
471
+ }
472
+ }
473
+ }
474
+ this.selectedIndex.set(0);
475
+ });
476
+ }
477
+ onGlobalKeydown(event) {
478
+ if (!this.isBrowser)
479
+ return;
480
+ if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
481
+ event.preventDefault();
482
+ this.service.toggle();
483
+ return;
484
+ }
485
+ if (event.key === '/' && !this.isInputFocused()) {
486
+ event.preventDefault();
487
+ this.service.open();
488
+ return;
489
+ }
490
+ if (event.key === 'Escape' && this.service.isOpen()) {
491
+ event.preventDefault();
492
+ this.close();
493
+ }
494
+ }
495
+ onInputKeydown(event) {
496
+ const items = this.filteredItems();
497
+ switch (event.key) {
498
+ case 'ArrowDown':
499
+ event.preventDefault();
500
+ this.selectedIndex.update((i) => Math.min(i + 1, items.length - 1));
501
+ this.scrollSelectedIntoView();
502
+ break;
503
+ case 'ArrowUp':
504
+ event.preventDefault();
505
+ this.selectedIndex.update((i) => Math.max(i - 1, 0));
506
+ this.scrollSelectedIntoView();
507
+ break;
508
+ case 'Enter':
509
+ event.preventDefault();
510
+ const selected = items[this.selectedIndex()];
511
+ if (selected) {
512
+ this.executeItem(selected);
513
+ }
514
+ break;
515
+ case 'Escape':
516
+ event.preventDefault();
517
+ this.close();
518
+ break;
519
+ }
520
+ }
521
+ scrollSelectedIntoView() {
522
+ const container = this.listContainer()?.nativeElement;
523
+ if (!container)
524
+ return;
525
+ const selectedButton = container.querySelector(`[data-index="${this.selectedIndex()}"]`);
526
+ if (selectedButton) {
527
+ selectedButton.scrollIntoView({ block: 'nearest' });
528
+ }
529
+ }
530
+ onQueryChange(event) {
531
+ const value = event.target.value;
532
+ this.query.set(value);
533
+ }
534
+ onBackdropClick(event) {
535
+ if (event.target === event.currentTarget) {
536
+ this.close();
537
+ }
538
+ }
539
+ executeItem(item) {
540
+ // Record the selection for frecency learning (use search term, not full query)
541
+ const searchTerm = this.searchTerm();
542
+ this.frecency.recordSelection(searchTerm, item.id);
543
+ // Get args before closing (which resets query)
544
+ const args = this.args() || undefined;
545
+ this.close();
546
+ item.action(args);
547
+ }
548
+ // Template context getters
549
+ getItemContext(item, index) {
550
+ return {
551
+ $implicit: item,
552
+ index,
553
+ selected: this.selectedIndex() === index,
554
+ };
555
+ }
556
+ getEmptyContext() {
557
+ return {
558
+ $implicit: this.query(),
559
+ };
560
+ }
561
+ getHeaderContext() {
562
+ return {
563
+ $implicit: this.query(),
564
+ searchTerm: this.searchTerm(),
565
+ args: this.args(),
566
+ onQueryChange: (value) => this.query.set(value),
567
+ onKeydown: (event) => this.onInputKeydown(event),
568
+ placeholder: this.service.config().placeholder ?? '',
569
+ };
570
+ }
571
+ getFooterContext() {
572
+ return {
573
+ $implicit: this.filteredItems().length,
574
+ total: this.service.items().length,
575
+ };
576
+ }
577
+ getPanelContext() {
578
+ return {
579
+ $implicit: this.filteredItems(),
580
+ query: this.query(),
581
+ searchTerm: this.searchTerm(),
582
+ args: this.args(),
583
+ selectedIndex: this.selectedIndex(),
584
+ executeItem: (item) => this.executeItem(item),
585
+ setSelectedIndex: (index) => this.selectedIndex.set(index),
586
+ setQuery: (query) => this.query.set(query),
587
+ close: () => this.close(),
588
+ placeholder: this.service.config().placeholder ?? '',
589
+ };
590
+ }
591
+ close() {
592
+ this.service.close();
593
+ this.query.set('');
594
+ this.selectedIndex.set(0);
595
+ }
596
+ sortByFrecency(items, scores) {
597
+ if (scores.size === 0)
598
+ return items;
599
+ return [...items].sort((a, b) => {
600
+ const scoreA = scores.get(a.id) ?? 0;
601
+ const scoreB = scores.get(b.id) ?? 0;
602
+ return scoreB - scoreA;
603
+ });
604
+ }
605
+ matchesQuery(item, query) {
606
+ const searchableText = [
607
+ item.label,
608
+ item.description,
609
+ ...(item.keywords ?? []),
610
+ ]
611
+ .filter(Boolean)
612
+ .join(' ')
613
+ .toLowerCase();
614
+ const words = query.split(/\s+/);
615
+ return words.every((word) => searchableText.includes(word));
616
+ }
617
+ isInputFocused() {
618
+ const activeElement = document.activeElement;
619
+ if (!activeElement)
620
+ return false;
621
+ const tagName = activeElement.tagName.toLowerCase();
622
+ return (tagName === 'input' ||
623
+ tagName === 'textarea' ||
624
+ activeElement.isContentEditable);
625
+ }
626
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: GigamenuComponent, deps: [{ token: GigamenuService }, { token: FrecencyService }, { token: PLATFORM_ID }], target: i0.ɵɵFactoryTarget.Component });
627
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.0.6", type: GigamenuComponent, isStandalone: true, selector: "gm-gigamenu", host: { listeners: { "document:keydown": "onGlobalKeydown($event)" } }, queries: [{ propertyName: "itemTemplate", first: true, predicate: GigamenuItemTemplate, descendants: true, isSignal: true }, { propertyName: "emptyTemplate", first: true, predicate: GigamenuEmptyTemplate, descendants: true, isSignal: true }, { propertyName: "headerTemplate", first: true, predicate: GigamenuHeaderTemplate, descendants: true, isSignal: true }, { propertyName: "footerTemplate", first: true, predicate: GigamenuFooterTemplate, descendants: true, isSignal: true }, { propertyName: "panelTemplate", first: true, predicate: GigamenuPanelTemplate, descendants: true, isSignal: true }], viewQueries: [{ propertyName: "searchInput", first: true, predicate: ["searchInput"], descendants: true, isSignal: true }, { propertyName: "listContainer", first: true, predicate: ["listContainer"], descendants: true, isSignal: true }], ngImport: i0, template: "@if (service.isOpen()) {\n<div\n class=\"fixed inset-0 z-50 flex items-start justify-center pt-[15vh]\"\n (click)=\"onBackdropClick($event)\"\n>\n <div class=\"fixed inset-0 bg-black/50 backdrop-blur-sm\"></div>\n\n <!-- Custom panel template -->\n @if (panelTemplate(); as pt) {\n <ng-container\n [ngTemplateOutlet]=\"pt.template\"\n [ngTemplateOutletContext]=\"getPanelContext()\"\n ></ng-container>\n } @else {\n <!-- Default panel -->\n <div\n class=\"relative z-10 w-full max-w-xl overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-2xl dark:border-neutral-700 dark:bg-neutral-900\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label=\"Command menu\"\n >\n <!-- Header -->\n @if (headerTemplate(); as ht) {\n <ng-container\n [ngTemplateOutlet]=\"ht.template\"\n [ngTemplateOutletContext]=\"getHeaderContext()\"\n ></ng-container>\n } @else {\n <div class=\"flex items-center border-b border-neutral-200 px-4 dark:border-neutral-700\">\n <svg\n class=\"h-5 w-5 text-neutral-400\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"2\"\n d=\"M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z\"\n />\n </svg>\n <div class=\"relative flex-1\">\n <!-- Styled overlay for syntax highlighting -->\n <div\n class=\"pointer-events-none absolute inset-0 flex items-center px-3 py-4 text-base\"\n aria-hidden=\"true\"\n >\n <span class=\"text-neutral-900 dark:text-neutral-100\">{{ searchTerm() }}</span>@if (args()) {<span class=\"text-neutral-900 dark:text-neutral-100\">{{ service.config().argSeparator }}</span><span class=\"text-purple-600 dark:text-purple-400\">{{ args() }}</span>}\n </div>\n <!-- Transparent input on top -->\n <input\n #searchInput\n type=\"text\"\n [placeholder]=\"service.config().placeholder\"\n [value]=\"query()\"\n (input)=\"onQueryChange($event)\"\n (keydown)=\"onInputKeydown($event)\"\n class=\"relative z-10 w-full bg-transparent px-3 py-4 text-base text-transparent caret-neutral-900 placeholder-neutral-400 outline-none dark:caret-neutral-100\"\n />\n </div>\n <kbd\n class=\"rounded border border-neutral-200 bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-500 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-400\"\n >\n ESC\n </kbd>\n </div>\n }\n\n <!-- Items list -->\n <div #listContainer class=\"max-h-80 overflow-y-auto p-2\">\n @if (filteredItems().length === 0) {\n <!-- Empty state -->\n @if (emptyTemplate(); as et) {\n <ng-container\n [ngTemplateOutlet]=\"et.template\"\n [ngTemplateOutletContext]=\"getEmptyContext()\"\n ></ng-container>\n } @else {\n <div class=\"px-3 py-8 text-center text-neutral-500\">\n No results found\n </div>\n }\n } @else {\n @for (item of filteredItems(); track item.id; let i = $index) {\n <!-- Custom item template -->\n @if (itemTemplate(); as it) {\n <ng-container\n [ngTemplateOutlet]=\"it.template\"\n [ngTemplateOutletContext]=\"getItemContext(item, i)\"\n ></ng-container>\n } @else {\n <!-- Default item -->\n <button\n type=\"button\"\n (click)=\"executeItem(item)\"\n (mouseenter)=\"selectedIndex.set(i)\"\n [attr.data-index]=\"i\"\n [class]=\"\n 'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors ' +\n (selectedIndex() === i\n ? 'bg-neutral-100 dark:bg-neutral-800'\n : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50')\n \"\n >\n @if (item.iconClass) {\n <i [class]=\"item.iconClass + ' text-lg text-neutral-600 dark:text-neutral-300'\"></i>\n } @else if (item.icon) {\n <span class=\"text-lg\">{{ item.icon }}</span>\n } @else {\n <span\n class=\"flex h-6 w-6 items-center justify-center rounded bg-neutral-200 text-xs font-medium text-neutral-600 dark:bg-neutral-700 dark:text-neutral-300\"\n >\n {{ item.category === 'page' ? 'P' : 'C' }}\n </span>\n }\n <div class=\"flex-1 min-w-0\">\n <div class=\"truncate font-medium text-neutral-900 dark:text-neutral-100\">\n {{ item.label }}\n </div>\n @if (item.description) {\n <div class=\"truncate text-sm text-neutral-500 dark:text-neutral-400\">\n {{ item.description }}\n </div>\n }\n </div>\n <span\n class=\"rounded-full px-2 py-0.5 text-xs\"\n [class]=\"\n item.category === 'page'\n ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'\n : 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'\n \"\n >\n {{ item.category }}\n </span>\n </button>\n }\n }\n }\n </div>\n\n <!-- Footer -->\n @if (footerTemplate(); as ft) {\n <ng-container\n [ngTemplateOutlet]=\"ft.template\"\n [ngTemplateOutletContext]=\"getFooterContext()\"\n ></ng-container>\n } @else {\n <div\n class=\"flex items-center justify-between border-t border-neutral-200 px-4 py-2 text-xs text-neutral-500 dark:border-neutral-700\"\n >\n <div class=\"flex items-center gap-3\">\n <span class=\"flex items-center gap-1\">\n <kbd class=\"rounded border border-neutral-300 bg-neutral-100 px-1 dark:border-neutral-600 dark:bg-neutral-800\">\u2191</kbd>\n <kbd class=\"rounded border border-neutral-300 bg-neutral-100 px-1 dark:border-neutral-600 dark:bg-neutral-800\">\u2193</kbd>\n navigate\n </span>\n <span class=\"flex items-center gap-1\">\n <kbd class=\"rounded border border-neutral-300 bg-neutral-100 px-1 dark:border-neutral-600 dark:bg-neutral-800\">\u21B5</kbd>\n select\n </span>\n </div>\n <span>gigamenu</span>\n </div>\n }\n </div>\n }\n</div>\n}\n", styles: [":host{display:contents}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }] });
628
+ }
629
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: GigamenuComponent, decorators: [{
630
+ type: Component,
631
+ args: [{ selector: 'gm-gigamenu', standalone: true, imports: [NgTemplateOutlet], template: "@if (service.isOpen()) {\n<div\n class=\"fixed inset-0 z-50 flex items-start justify-center pt-[15vh]\"\n (click)=\"onBackdropClick($event)\"\n>\n <div class=\"fixed inset-0 bg-black/50 backdrop-blur-sm\"></div>\n\n <!-- Custom panel template -->\n @if (panelTemplate(); as pt) {\n <ng-container\n [ngTemplateOutlet]=\"pt.template\"\n [ngTemplateOutletContext]=\"getPanelContext()\"\n ></ng-container>\n } @else {\n <!-- Default panel -->\n <div\n class=\"relative z-10 w-full max-w-xl overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-2xl dark:border-neutral-700 dark:bg-neutral-900\"\n role=\"dialog\"\n aria-modal=\"true\"\n aria-label=\"Command menu\"\n >\n <!-- Header -->\n @if (headerTemplate(); as ht) {\n <ng-container\n [ngTemplateOutlet]=\"ht.template\"\n [ngTemplateOutletContext]=\"getHeaderContext()\"\n ></ng-container>\n } @else {\n <div class=\"flex items-center border-b border-neutral-200 px-4 dark:border-neutral-700\">\n <svg\n class=\"h-5 w-5 text-neutral-400\"\n fill=\"none\"\n stroke=\"currentColor\"\n viewBox=\"0 0 24 24\"\n >\n <path\n stroke-linecap=\"round\"\n stroke-linejoin=\"round\"\n stroke-width=\"2\"\n d=\"M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z\"\n />\n </svg>\n <div class=\"relative flex-1\">\n <!-- Styled overlay for syntax highlighting -->\n <div\n class=\"pointer-events-none absolute inset-0 flex items-center px-3 py-4 text-base\"\n aria-hidden=\"true\"\n >\n <span class=\"text-neutral-900 dark:text-neutral-100\">{{ searchTerm() }}</span>@if (args()) {<span class=\"text-neutral-900 dark:text-neutral-100\">{{ service.config().argSeparator }}</span><span class=\"text-purple-600 dark:text-purple-400\">{{ args() }}</span>}\n </div>\n <!-- Transparent input on top -->\n <input\n #searchInput\n type=\"text\"\n [placeholder]=\"service.config().placeholder\"\n [value]=\"query()\"\n (input)=\"onQueryChange($event)\"\n (keydown)=\"onInputKeydown($event)\"\n class=\"relative z-10 w-full bg-transparent px-3 py-4 text-base text-transparent caret-neutral-900 placeholder-neutral-400 outline-none dark:caret-neutral-100\"\n />\n </div>\n <kbd\n class=\"rounded border border-neutral-200 bg-neutral-100 px-1.5 py-0.5 text-xs text-neutral-500 dark:border-neutral-600 dark:bg-neutral-800 dark:text-neutral-400\"\n >\n ESC\n </kbd>\n </div>\n }\n\n <!-- Items list -->\n <div #listContainer class=\"max-h-80 overflow-y-auto p-2\">\n @if (filteredItems().length === 0) {\n <!-- Empty state -->\n @if (emptyTemplate(); as et) {\n <ng-container\n [ngTemplateOutlet]=\"et.template\"\n [ngTemplateOutletContext]=\"getEmptyContext()\"\n ></ng-container>\n } @else {\n <div class=\"px-3 py-8 text-center text-neutral-500\">\n No results found\n </div>\n }\n } @else {\n @for (item of filteredItems(); track item.id; let i = $index) {\n <!-- Custom item template -->\n @if (itemTemplate(); as it) {\n <ng-container\n [ngTemplateOutlet]=\"it.template\"\n [ngTemplateOutletContext]=\"getItemContext(item, i)\"\n ></ng-container>\n } @else {\n <!-- Default item -->\n <button\n type=\"button\"\n (click)=\"executeItem(item)\"\n (mouseenter)=\"selectedIndex.set(i)\"\n [attr.data-index]=\"i\"\n [class]=\"\n 'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors ' +\n (selectedIndex() === i\n ? 'bg-neutral-100 dark:bg-neutral-800'\n : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50')\n \"\n >\n @if (item.iconClass) {\n <i [class]=\"item.iconClass + ' text-lg text-neutral-600 dark:text-neutral-300'\"></i>\n } @else if (item.icon) {\n <span class=\"text-lg\">{{ item.icon }}</span>\n } @else {\n <span\n class=\"flex h-6 w-6 items-center justify-center rounded bg-neutral-200 text-xs font-medium text-neutral-600 dark:bg-neutral-700 dark:text-neutral-300\"\n >\n {{ item.category === 'page' ? 'P' : 'C' }}\n </span>\n }\n <div class=\"flex-1 min-w-0\">\n <div class=\"truncate font-medium text-neutral-900 dark:text-neutral-100\">\n {{ item.label }}\n </div>\n @if (item.description) {\n <div class=\"truncate text-sm text-neutral-500 dark:text-neutral-400\">\n {{ item.description }}\n </div>\n }\n </div>\n <span\n class=\"rounded-full px-2 py-0.5 text-xs\"\n [class]=\"\n item.category === 'page'\n ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'\n : 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'\n \"\n >\n {{ item.category }}\n </span>\n </button>\n }\n }\n }\n </div>\n\n <!-- Footer -->\n @if (footerTemplate(); as ft) {\n <ng-container\n [ngTemplateOutlet]=\"ft.template\"\n [ngTemplateOutletContext]=\"getFooterContext()\"\n ></ng-container>\n } @else {\n <div\n class=\"flex items-center justify-between border-t border-neutral-200 px-4 py-2 text-xs text-neutral-500 dark:border-neutral-700\"\n >\n <div class=\"flex items-center gap-3\">\n <span class=\"flex items-center gap-1\">\n <kbd class=\"rounded border border-neutral-300 bg-neutral-100 px-1 dark:border-neutral-600 dark:bg-neutral-800\">\u2191</kbd>\n <kbd class=\"rounded border border-neutral-300 bg-neutral-100 px-1 dark:border-neutral-600 dark:bg-neutral-800\">\u2193</kbd>\n navigate\n </span>\n <span class=\"flex items-center gap-1\">\n <kbd class=\"rounded border border-neutral-300 bg-neutral-100 px-1 dark:border-neutral-600 dark:bg-neutral-800\">\u21B5</kbd>\n select\n </span>\n </div>\n <span>gigamenu</span>\n </div>\n }\n </div>\n }\n</div>\n}\n", styles: [":host{display:contents}\n"] }]
632
+ }], ctorParameters: () => [{ type: GigamenuService }, { type: FrecencyService }, { type: undefined, decorators: [{
633
+ type: Inject,
634
+ args: [PLATFORM_ID]
635
+ }] }], propDecorators: { searchInput: [{ type: i0.ViewChild, args: ['searchInput', { isSignal: true }] }], listContainer: [{ type: i0.ViewChild, args: ['listContainer', { isSignal: true }] }], itemTemplate: [{ type: i0.ContentChild, args: [i0.forwardRef(() => GigamenuItemTemplate), { isSignal: true }] }], emptyTemplate: [{ type: i0.ContentChild, args: [i0.forwardRef(() => GigamenuEmptyTemplate), { isSignal: true }] }], headerTemplate: [{ type: i0.ContentChild, args: [i0.forwardRef(() => GigamenuHeaderTemplate), { isSignal: true }] }], footerTemplate: [{ type: i0.ContentChild, args: [i0.forwardRef(() => GigamenuFooterTemplate), { isSignal: true }] }], panelTemplate: [{ type: i0.ContentChild, args: [i0.forwardRef(() => GigamenuPanelTemplate), { isSignal: true }] }], onGlobalKeydown: [{
636
+ type: HostListener,
637
+ args: ['document:keydown', ['$event']]
638
+ }] } });
639
+
640
+ /**
641
+ * Generated bundle index. Do not edit.
642
+ */
643
+
644
+ export { DEFAULT_CONFIG, FrecencyService, GigamenuComponent, GigamenuEmptyTemplate, GigamenuFooterTemplate, GigamenuHeaderTemplate, GigamenuItemTemplate, GigamenuPanelTemplate, GigamenuService, defineCommand };
645
+ //# sourceMappingURL=flxgde-gigamenu.mjs.map