@flxgde/gigamenu 0.2.0 → 0.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.
- package/README.md +304 -15
- package/fesm2022/flxgde-gigamenu.mjs +1103 -158
- package/fesm2022/flxgde-gigamenu.mjs.map +1 -1
- package/package.json +1 -1
- package/styles.css +2 -2
- package/types/flxgde-gigamenu.d.ts +175 -48
|
@@ -15,6 +15,9 @@ const DEFAULT_CONFIG = {
|
|
|
15
15
|
maxResults: 10,
|
|
16
16
|
autoDiscoverRoutes: true,
|
|
17
17
|
argSeparator: ' ',
|
|
18
|
+
darkModeClass: 'dark',
|
|
19
|
+
autocompleteTabBehavior: 'cycle',
|
|
20
|
+
autocompleteDismiss: 'on-type',
|
|
18
21
|
};
|
|
19
22
|
/**
|
|
20
23
|
* Helper function to define a command with type safety.
|
|
@@ -23,12 +26,19 @@ function defineCommand(command) {
|
|
|
23
26
|
return command;
|
|
24
27
|
}
|
|
25
28
|
|
|
29
|
+
const DEFAULT_PROVIDER_OPTIONS = {
|
|
30
|
+
minQueryLength: 2,
|
|
31
|
+
debounceMs: 200,
|
|
32
|
+
group: '',
|
|
33
|
+
};
|
|
26
34
|
class GigamenuService {
|
|
27
35
|
_router;
|
|
28
36
|
_items = signal(new Map(), ...(ngDevMode ? [{ debugName: "_items" }] : []));
|
|
37
|
+
_providers = signal(new Map(), ...(ngDevMode ? [{ debugName: "_providers" }] : []));
|
|
29
38
|
_isOpen = signal(false, ...(ngDevMode ? [{ debugName: "_isOpen" }] : []));
|
|
30
39
|
_config = signal(DEFAULT_CONFIG, ...(ngDevMode ? [{ debugName: "_config" }] : []));
|
|
31
40
|
items = computed(() => Array.from(this._items().values()), ...(ngDevMode ? [{ debugName: "items" }] : []));
|
|
41
|
+
providers = this._providers.asReadonly();
|
|
32
42
|
isOpen = this._isOpen.asReadonly();
|
|
33
43
|
config = this._config.asReadonly();
|
|
34
44
|
/**
|
|
@@ -55,6 +65,10 @@ class GigamenuService {
|
|
|
55
65
|
toggle() {
|
|
56
66
|
this._isOpen.update((v) => !v);
|
|
57
67
|
}
|
|
68
|
+
toggleDarkMode() {
|
|
69
|
+
const darkModeClass = this._config().darkModeClass ?? 'dark';
|
|
70
|
+
document.documentElement.classList.toggle(darkModeClass);
|
|
71
|
+
}
|
|
58
72
|
registerItem(item) {
|
|
59
73
|
this._items.update((items) => {
|
|
60
74
|
const newItems = new Map(items);
|
|
@@ -69,6 +83,31 @@ class GigamenuService {
|
|
|
69
83
|
return newItems;
|
|
70
84
|
});
|
|
71
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Register a dynamic item provider. The provider is invoked when the user
|
|
88
|
+
* types in the menu (in action-selection mode) and its results are merged
|
|
89
|
+
* into the displayed items alongside statically registered items.
|
|
90
|
+
*/
|
|
91
|
+
registerProvider(id, provider, options = {}) {
|
|
92
|
+
const resolved = {
|
|
93
|
+
...DEFAULT_PROVIDER_OPTIONS,
|
|
94
|
+
...options,
|
|
95
|
+
};
|
|
96
|
+
this._providers.update((providers) => {
|
|
97
|
+
const next = new Map(providers);
|
|
98
|
+
next.set(id, { provider, options: resolved });
|
|
99
|
+
return next;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
unregisterProvider(id) {
|
|
103
|
+
this._providers.update((providers) => {
|
|
104
|
+
if (!providers.has(id))
|
|
105
|
+
return providers;
|
|
106
|
+
const next = new Map(providers);
|
|
107
|
+
next.delete(id);
|
|
108
|
+
return next;
|
|
109
|
+
});
|
|
110
|
+
}
|
|
72
111
|
registerCommand(command) {
|
|
73
112
|
this.registerItem({
|
|
74
113
|
...command,
|
|
@@ -78,17 +117,22 @@ class GigamenuService {
|
|
|
78
117
|
registerPage(page) {
|
|
79
118
|
// Extract parameter names from path (e.g., /users/:id -> ['id'])
|
|
80
119
|
const paramNames = this.extractParamNames(page.path);
|
|
120
|
+
// Use manually specified params if provided, otherwise use extracted ones
|
|
121
|
+
const finalParams = page.params ?? (paramNames.length > 0 ? paramNames : undefined);
|
|
81
122
|
this.registerItem({
|
|
82
123
|
...page,
|
|
83
124
|
category: 'page',
|
|
84
|
-
params:
|
|
125
|
+
params: finalParams,
|
|
126
|
+
// Preserve paramProviders if specified
|
|
127
|
+
paramProviders: page.paramProviders,
|
|
85
128
|
action: (args) => {
|
|
86
129
|
let path = page.path;
|
|
87
|
-
|
|
130
|
+
const paramsToReplace = finalParams ?? [];
|
|
131
|
+
if (paramsToReplace.length > 0 && args) {
|
|
88
132
|
// Split args by whitespace to get parameter values
|
|
89
133
|
const argValues = args.trim().split(/\s+/);
|
|
90
134
|
// Replace each parameter with corresponding arg value
|
|
91
|
-
|
|
135
|
+
paramsToReplace.forEach((param, index) => {
|
|
92
136
|
if (argValues[index]) {
|
|
93
137
|
path = path.replace(`:${param}`, argValues[index]);
|
|
94
138
|
}
|
|
@@ -285,6 +329,634 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImpor
|
|
|
285
329
|
}]
|
|
286
330
|
}] });
|
|
287
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Handles parsing of gigamenu query input.
|
|
334
|
+
* Supports quoted strings for labels/values containing spaces.
|
|
335
|
+
*/
|
|
336
|
+
class QueryParser {
|
|
337
|
+
separator;
|
|
338
|
+
constructor(separator = ' ') {
|
|
339
|
+
this.separator = separator;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Parse a query string into search term and arguments.
|
|
343
|
+
* Simple split on first separator - no quote handling.
|
|
344
|
+
*/
|
|
345
|
+
parseQuery(query) {
|
|
346
|
+
const sepIndex = query.indexOf(this.separator);
|
|
347
|
+
if (sepIndex === -1) {
|
|
348
|
+
return { searchTerm: query, args: '', hasSeparator: false };
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
searchTerm: query.substring(0, sepIndex),
|
|
352
|
+
args: query.substring(sepIndex + this.separator.length),
|
|
353
|
+
hasSeparator: true,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Parse an arguments string into an array.
|
|
358
|
+
* Simple space-based splitting - quotes are ignored.
|
|
359
|
+
*/
|
|
360
|
+
parseArgs(args) {
|
|
361
|
+
if (!args) {
|
|
362
|
+
return { values: [], display: [] };
|
|
363
|
+
}
|
|
364
|
+
// Simple split on spaces, filter out empty strings
|
|
365
|
+
const parts = args.split(' ').filter(part => part.length > 0);
|
|
366
|
+
return { values: parts, display: parts };
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Strip quotes from a string if it's quoted (legacy, kept for compatibility).
|
|
370
|
+
*/
|
|
371
|
+
stripQuotes(str) {
|
|
372
|
+
if ((str.startsWith("'") && str.endsWith("'")) ||
|
|
373
|
+
(str.startsWith('"') && str.endsWith('"'))) {
|
|
374
|
+
return str.slice(1, -1);
|
|
375
|
+
}
|
|
376
|
+
return str;
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Return the string as-is (no escaping needed).
|
|
380
|
+
*/
|
|
381
|
+
escapeIfNeeded(str) {
|
|
382
|
+
return str;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Check if a search term matches a label (case-insensitive).
|
|
386
|
+
*/
|
|
387
|
+
matchesLabel(searchTerm, label) {
|
|
388
|
+
const trimmed = searchTerm.trim().toLowerCase();
|
|
389
|
+
return trimmed === label.toLowerCase();
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Build a query string from search term and args.
|
|
393
|
+
*/
|
|
394
|
+
buildQuery(searchTerm, args) {
|
|
395
|
+
if (args.length === 0) {
|
|
396
|
+
return searchTerm;
|
|
397
|
+
}
|
|
398
|
+
return searchTerm + this.separator + args.join(' ');
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Represents the different states of the gigamenu input.
|
|
404
|
+
* New paradigm: input handles ONE thing at a time.
|
|
405
|
+
*/
|
|
406
|
+
var InputState;
|
|
407
|
+
(function (InputState) {
|
|
408
|
+
/** Menu is closed */
|
|
409
|
+
InputState["Closed"] = "closed";
|
|
410
|
+
/** User is searching/selecting an action (page or command) */
|
|
411
|
+
InputState["ActionSelection"] = "actionSelection";
|
|
412
|
+
/** User is inputting a parameter value */
|
|
413
|
+
InputState["ParameterInput"] = "parameterInput";
|
|
414
|
+
})(InputState || (InputState = {}));
|
|
415
|
+
/**
|
|
416
|
+
* Compute the current input state based on menu status.
|
|
417
|
+
*/
|
|
418
|
+
function computeInputState(ctx) {
|
|
419
|
+
if (!ctx.isOpen) {
|
|
420
|
+
return InputState.Closed;
|
|
421
|
+
}
|
|
422
|
+
if (ctx.hasLockedAction) {
|
|
423
|
+
return InputState.ParameterInput;
|
|
424
|
+
}
|
|
425
|
+
return InputState.ActionSelection;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Scroll the selected menu item into view.
|
|
430
|
+
*/
|
|
431
|
+
function scrollSelectedIntoView(container, selectedIndex) {
|
|
432
|
+
if (!container)
|
|
433
|
+
return;
|
|
434
|
+
const selectedButton = container.querySelector(`[data-index="${selectedIndex}"]`);
|
|
435
|
+
if (selectedButton) {
|
|
436
|
+
selectedButton.scrollIntoView({ block: 'nearest' });
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Scroll the selected autocomplete suggestion into view.
|
|
441
|
+
*/
|
|
442
|
+
function scrollAutocompleteIntoView(container, selectedIndex) {
|
|
443
|
+
if (!container)
|
|
444
|
+
return;
|
|
445
|
+
const selectedButton = container.querySelector(`[data-autocomplete-index="${selectedIndex}"]`);
|
|
446
|
+
if (selectedButton) {
|
|
447
|
+
selectedButton.scrollIntoView({ block: 'nearest' });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Filter items based on search query.
|
|
453
|
+
*/
|
|
454
|
+
function filterItems(items, searchTerm, queryParser) {
|
|
455
|
+
const normalizedTerm = searchTerm.toLowerCase().trim();
|
|
456
|
+
if (!normalizedTerm) {
|
|
457
|
+
return items;
|
|
458
|
+
}
|
|
459
|
+
return items.filter((item) => matchesQuery(item, normalizedTerm, queryParser));
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Check if an item matches the search query.
|
|
463
|
+
* Uses word-based matching across label, description, and keywords.
|
|
464
|
+
*/
|
|
465
|
+
function matchesQuery(item, query, _queryParser) {
|
|
466
|
+
const searchableText = [
|
|
467
|
+
item.label,
|
|
468
|
+
item.description,
|
|
469
|
+
...(item.keywords ?? []),
|
|
470
|
+
]
|
|
471
|
+
.filter(Boolean)
|
|
472
|
+
.join(' ')
|
|
473
|
+
.toLowerCase();
|
|
474
|
+
const words = query.split(/\s+/);
|
|
475
|
+
return words.every((word) => searchableText.includes(word));
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Sort items by frecency scores (frequency + recency).
|
|
479
|
+
*/
|
|
480
|
+
function sortByFrecency(items, scores) {
|
|
481
|
+
if (scores.size === 0)
|
|
482
|
+
return items;
|
|
483
|
+
return [...items].sort((a, b) => {
|
|
484
|
+
const scoreA = scores.get(a.id) ?? 0;
|
|
485
|
+
const scoreB = scores.get(b.id) ?? 0;
|
|
486
|
+
return scoreB - scoreA;
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Build searchable text from an item for matching.
|
|
491
|
+
*/
|
|
492
|
+
function buildSearchableText(item) {
|
|
493
|
+
return [item.label, item.description, ...(item.keywords ?? [])]
|
|
494
|
+
.filter(Boolean)
|
|
495
|
+
.join(' ')
|
|
496
|
+
.toLowerCase();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Compute the current parameter index being edited.
|
|
501
|
+
* Returns null if no parameter is being edited.
|
|
502
|
+
*/
|
|
503
|
+
function computeCurrentParamIndex(item, args, argsCount) {
|
|
504
|
+
if (!item || !item.params || item.params.length === 0)
|
|
505
|
+
return null;
|
|
506
|
+
const endsWithSpace = args.endsWith(' ');
|
|
507
|
+
// If user is still typing a param (no trailing space) and has typed at least one arg
|
|
508
|
+
if (!endsWithSpace && argsCount > 0 && argsCount <= item.params.length) {
|
|
509
|
+
return argsCount - 1; // Currently editing the last typed arg
|
|
510
|
+
}
|
|
511
|
+
// If we have fewer args than params, we're about to type the next param
|
|
512
|
+
if (argsCount < item.params.length)
|
|
513
|
+
return argsCount;
|
|
514
|
+
// All params complete (with trailing space confirming completion)
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Get the name of the current parameter being edited.
|
|
519
|
+
*/
|
|
520
|
+
function computeCurrentParamName(item, paramIndex) {
|
|
521
|
+
if (paramIndex === null || !item || !item.params)
|
|
522
|
+
return null;
|
|
523
|
+
return item.params[paramIndex] ?? null;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Compute the current partial value of the parameter being edited.
|
|
527
|
+
*/
|
|
528
|
+
function computeCurrentParamValue(args, argsArray, paramIndex) {
|
|
529
|
+
const trimmedArgs = args.trim();
|
|
530
|
+
if (!trimmedArgs)
|
|
531
|
+
return '';
|
|
532
|
+
if (paramIndex === null)
|
|
533
|
+
return '';
|
|
534
|
+
const endsWithSpace = args.endsWith(' ');
|
|
535
|
+
// If we're on the last arg and still typing
|
|
536
|
+
if (!endsWithSpace && argsArray.length === paramIndex + 1) {
|
|
537
|
+
return argsArray[paramIndex] ?? '';
|
|
538
|
+
}
|
|
539
|
+
// If we're starting a new arg (after a space)
|
|
540
|
+
if (endsWithSpace || argsArray.length === paramIndex) {
|
|
541
|
+
return '';
|
|
542
|
+
}
|
|
543
|
+
return '';
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Check if the selected item can be executed (has all required params).
|
|
547
|
+
*/
|
|
548
|
+
function computeCanExecute(item, argsCount) {
|
|
549
|
+
if (!item)
|
|
550
|
+
return false;
|
|
551
|
+
if (!item.params || item.params.length === 0)
|
|
552
|
+
return true;
|
|
553
|
+
return argsCount >= item.params.length;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Get completed arguments for display (excludes the incomplete arg being typed).
|
|
557
|
+
*/
|
|
558
|
+
function computeCompletedArgsDisplay(display, args) {
|
|
559
|
+
const endsWithSpace = args.endsWith(' ');
|
|
560
|
+
// If args ends with space, all args are complete
|
|
561
|
+
if (endsWithSpace || !args)
|
|
562
|
+
return display;
|
|
563
|
+
// Otherwise, exclude the last incomplete arg (shown in currentParamValue)
|
|
564
|
+
return display.slice(0, -1);
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Get actual values for args (substituting labels with values from selected options).
|
|
568
|
+
*/
|
|
569
|
+
function computeArgsValues(argsArray, selectedOptions) {
|
|
570
|
+
return argsArray.map((arg, index) => {
|
|
571
|
+
const option = selectedOptions.get(index);
|
|
572
|
+
// If we have a selected option for this param and the current arg matches its label, use the value
|
|
573
|
+
if (option && arg === option.label) {
|
|
574
|
+
return option.value;
|
|
575
|
+
}
|
|
576
|
+
return arg;
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Check if the selected item has autocomplete available for the current param.
|
|
581
|
+
*/
|
|
582
|
+
function computeHasAutocomplete(item, paramName) {
|
|
583
|
+
if (!item || !paramName)
|
|
584
|
+
return false;
|
|
585
|
+
return !!(item.paramProviders?.[paramName]);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Get color class for a parameter index.
|
|
590
|
+
*/
|
|
591
|
+
function getParamColor(index) {
|
|
592
|
+
return PARAM_COLORS[index % PARAM_COLORS.length];
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Create context for item template.
|
|
596
|
+
*/
|
|
597
|
+
function createItemContext(item, index, selectedIndex) {
|
|
598
|
+
return {
|
|
599
|
+
$implicit: item,
|
|
600
|
+
index,
|
|
601
|
+
selected: selectedIndex === index,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* Create context for empty state template.
|
|
606
|
+
*/
|
|
607
|
+
function createEmptyContext(query) {
|
|
608
|
+
return {
|
|
609
|
+
$implicit: query,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Create context for footer template.
|
|
614
|
+
*/
|
|
615
|
+
function createFooterContext(filteredCount, totalCount) {
|
|
616
|
+
return {
|
|
617
|
+
$implicit: filteredCount,
|
|
618
|
+
total: totalCount,
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Get the initial/reset state for the menu.
|
|
624
|
+
*/
|
|
625
|
+
function getInitialMenuState() {
|
|
626
|
+
return {
|
|
627
|
+
query: '',
|
|
628
|
+
selectedIndex: 0,
|
|
629
|
+
showAutocomplete: false,
|
|
630
|
+
autocompleteSelectedIndex: 0,
|
|
631
|
+
selectedParamOptions: new Map(),
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Check if an input element is currently focused.
|
|
636
|
+
*/
|
|
637
|
+
function isInputFocused() {
|
|
638
|
+
const activeElement = document.activeElement;
|
|
639
|
+
if (!activeElement)
|
|
640
|
+
return false;
|
|
641
|
+
const tagName = activeElement.tagName.toLowerCase();
|
|
642
|
+
return (tagName === 'input' ||
|
|
643
|
+
tagName === 'textarea' ||
|
|
644
|
+
activeElement.isContentEditable);
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Check if the current search term matches the selected item's label.
|
|
648
|
+
*/
|
|
649
|
+
function isSearchTermMatchingItem(searchTerm, itemLabel, queryParser) {
|
|
650
|
+
return queryParser.matchesLabel(searchTerm, itemLabel);
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* Complete the selected item's label in the search input.
|
|
654
|
+
* Returns the new query string.
|
|
655
|
+
*/
|
|
656
|
+
function completeItemLabel(item, separator, queryParser) {
|
|
657
|
+
const escapedLabel = queryParser.escapeIfNeeded(item.label);
|
|
658
|
+
const hasParams = item.params && item.params.length > 0;
|
|
659
|
+
return hasParams ? escapedLabel + separator : escapedLabel;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Fetch autocomplete suggestions from a provider.
|
|
664
|
+
*/
|
|
665
|
+
async function fetchAutocompleteSuggestions(provider, query, cache) {
|
|
666
|
+
// Handle static array provider
|
|
667
|
+
if (Array.isArray(provider)) {
|
|
668
|
+
const cacheKey = `${JSON.stringify(provider)}-${query}`;
|
|
669
|
+
if (cache.has(cacheKey)) {
|
|
670
|
+
return { options: cache.get(cacheKey), isAsync: false };
|
|
671
|
+
}
|
|
672
|
+
cache.set(cacheKey, provider);
|
|
673
|
+
return { options: provider, isAsync: false };
|
|
674
|
+
}
|
|
675
|
+
// Handle async function provider (server-side filtering)
|
|
676
|
+
const result = await Promise.resolve(provider(query));
|
|
677
|
+
return { options: result, isAsync: true };
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Filter suggestions client-side based on query.
|
|
681
|
+
*/
|
|
682
|
+
function filterSuggestionsClientSide(options, query) {
|
|
683
|
+
const lowerQuery = query.toLowerCase().trim();
|
|
684
|
+
if (!lowerQuery)
|
|
685
|
+
return options;
|
|
686
|
+
return options.filter((opt) => opt.label.toLowerCase().includes(lowerQuery));
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Compute typeahead ghost text suggestion.
|
|
690
|
+
* Returns the portion of the label that extends beyond the typed text.
|
|
691
|
+
*/
|
|
692
|
+
function computeTypeaheadSuggestion(suggestions, selectedIndex, paramValue) {
|
|
693
|
+
if (suggestions.length === 0)
|
|
694
|
+
return null;
|
|
695
|
+
const suggestion = suggestions[selectedIndex] ?? suggestions[0];
|
|
696
|
+
if (!suggestion)
|
|
697
|
+
return null;
|
|
698
|
+
// Return portion of label that extends beyond typed text (case-insensitive prefix match)
|
|
699
|
+
const suggestionLabel = suggestion.label;
|
|
700
|
+
if (suggestionLabel.toLowerCase().startsWith(paramValue.toLowerCase())) {
|
|
701
|
+
return suggestionLabel.slice(paramValue.length);
|
|
702
|
+
}
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* Build the new query string after selecting an autocomplete option.
|
|
707
|
+
*/
|
|
708
|
+
function buildQueryWithSelection(ctx, option, addTrailingSpace) {
|
|
709
|
+
if (ctx.paramIndex === null)
|
|
710
|
+
return ctx.searchTerm;
|
|
711
|
+
// Quote labels that contain spaces
|
|
712
|
+
const escapedLabel = ctx.queryParser.escapeIfNeeded(option.label);
|
|
713
|
+
// Replace current param with the selected option's label
|
|
714
|
+
const newArgs = [...ctx.argsArray];
|
|
715
|
+
newArgs[ctx.paramIndex] = escapedLabel;
|
|
716
|
+
// Build new query with or without trailing space
|
|
717
|
+
const baseQuery = ctx.searchTerm + ctx.separator + newArgs.join(' ');
|
|
718
|
+
return addTrailingSpace ? baseQuery + ' ' : baseQuery;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Process autocomplete suggestion selection.
|
|
722
|
+
* Returns the new query and the selected option for tracking.
|
|
723
|
+
*/
|
|
724
|
+
function processAutocompleteSuggestionSelection(suggestions, selectedIdx, ctx, addTrailingSpace) {
|
|
725
|
+
const option = suggestions[selectedIdx];
|
|
726
|
+
if (!option || ctx.paramIndex === null)
|
|
727
|
+
return null;
|
|
728
|
+
const newQuery = buildQueryWithSelection(ctx, option, addTrailingSpace);
|
|
729
|
+
return {
|
|
730
|
+
newQuery,
|
|
731
|
+
selectedOption: option,
|
|
732
|
+
paramIndex: ctx.paramIndex,
|
|
733
|
+
};
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Process autocomplete suggestion selection with cycling to next.
|
|
737
|
+
* zsh-style: select current and prepare for next Tab press.
|
|
738
|
+
*/
|
|
739
|
+
function processAutocompleteSuggestionAndCycle(suggestions, currentIdx, ctx) {
|
|
740
|
+
if (suggestions.length === 0)
|
|
741
|
+
return null;
|
|
742
|
+
const option = suggestions[currentIdx];
|
|
743
|
+
if (!option || ctx.paramIndex === null)
|
|
744
|
+
return null;
|
|
745
|
+
// Don't add trailing space - keep cursor position for cycling
|
|
746
|
+
const newQuery = buildQueryWithSelection(ctx, option, false);
|
|
747
|
+
// Cycle to next suggestion
|
|
748
|
+
const nextIdx = (currentIdx + 1) % suggestions.length;
|
|
749
|
+
return {
|
|
750
|
+
newQuery,
|
|
751
|
+
selectedOption: option,
|
|
752
|
+
paramIndex: ctx.paramIndex,
|
|
753
|
+
nextIndex: nextIdx,
|
|
754
|
+
};
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Handle global keydown events (Ctrl+K, /, Escape).
|
|
759
|
+
* Returns action if handled, null otherwise.
|
|
760
|
+
*/
|
|
761
|
+
function handleGlobalKeydown(event, ctx) {
|
|
762
|
+
// Ctrl/Cmd + K: Toggle menu
|
|
763
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
|
|
764
|
+
return { type: 'toggle' };
|
|
765
|
+
}
|
|
766
|
+
// /: Open menu (only when no input is focused)
|
|
767
|
+
if (event.key === '/' && !ctx.isInputFocused) {
|
|
768
|
+
return { type: 'open' };
|
|
769
|
+
}
|
|
770
|
+
// Escape: Close menu (only when input is NOT focused - let input handler deal with it)
|
|
771
|
+
if (event.key === 'Escape' && ctx.isOpen && !ctx.isInputFocused) {
|
|
772
|
+
return { type: 'close' };
|
|
773
|
+
}
|
|
774
|
+
return null;
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Handle keyboard events in ActionSelection state.
|
|
778
|
+
*/
|
|
779
|
+
function handleActionSelectionKeydown(event, ctx) {
|
|
780
|
+
const actions = [];
|
|
781
|
+
const item = ctx.selectedItem;
|
|
782
|
+
switch (event.key) {
|
|
783
|
+
case 'ArrowDown':
|
|
784
|
+
actions.push({
|
|
785
|
+
type: 'setSelectedIndex',
|
|
786
|
+
value: Math.min(ctx.selectedIndex + 1, ctx.items.length - 1),
|
|
787
|
+
});
|
|
788
|
+
actions.push({ type: 'scrollIntoView' });
|
|
789
|
+
break;
|
|
790
|
+
case 'ArrowUp':
|
|
791
|
+
actions.push({
|
|
792
|
+
type: 'setSelectedIndex',
|
|
793
|
+
value: Math.max(ctx.selectedIndex - 1, 0),
|
|
794
|
+
});
|
|
795
|
+
actions.push({ type: 'scrollIntoView' });
|
|
796
|
+
break;
|
|
797
|
+
case 'Tab':
|
|
798
|
+
if (item) {
|
|
799
|
+
// Tab: always tries to go to params first, or execute if no params
|
|
800
|
+
if (item.params && item.params.length > 0) {
|
|
801
|
+
actions.push({ type: 'lockAction', item });
|
|
802
|
+
}
|
|
803
|
+
else {
|
|
804
|
+
actions.push({ type: 'executeAction', item });
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
break;
|
|
808
|
+
case 'Enter':
|
|
809
|
+
if (item) {
|
|
810
|
+
const hasRequiredParams = item.params && item.params.length > 0 && !hasOnlyOptionalParams(item);
|
|
811
|
+
if (hasRequiredParams) {
|
|
812
|
+
// Has required params - go to parameter input
|
|
813
|
+
actions.push({ type: 'lockAction', item });
|
|
814
|
+
}
|
|
815
|
+
else {
|
|
816
|
+
// No params or only optional - execute immediately
|
|
817
|
+
actions.push({ type: 'executeAction', item });
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
break;
|
|
821
|
+
case 'Escape':
|
|
822
|
+
actions.push({ type: 'close' });
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
return actions;
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Handle keyboard events in ParameterInput state.
|
|
829
|
+
*/
|
|
830
|
+
function handleParameterInputKeydown(event, ctx) {
|
|
831
|
+
const actions = [];
|
|
832
|
+
const item = ctx.lockedItem;
|
|
833
|
+
const params = item.params ?? [];
|
|
834
|
+
const currentParam = params[ctx.currentParamIndex];
|
|
835
|
+
const isLastParam = ctx.currentParamIndex >= params.length - 1;
|
|
836
|
+
const hasSuggestions = ctx.suggestions.length > 0;
|
|
837
|
+
switch (event.key) {
|
|
838
|
+
case 'ArrowDown':
|
|
839
|
+
if (hasSuggestions) {
|
|
840
|
+
actions.push({
|
|
841
|
+
type: 'setSelectedIndex',
|
|
842
|
+
value: Math.min(ctx.selectedSuggestionIndex + 1, ctx.suggestions.length - 1),
|
|
843
|
+
});
|
|
844
|
+
actions.push({ type: 'scrollIntoView' });
|
|
845
|
+
}
|
|
846
|
+
break;
|
|
847
|
+
case 'ArrowUp':
|
|
848
|
+
if (hasSuggestions) {
|
|
849
|
+
actions.push({
|
|
850
|
+
type: 'setSelectedIndex',
|
|
851
|
+
value: Math.max(ctx.selectedSuggestionIndex - 1, 0),
|
|
852
|
+
});
|
|
853
|
+
actions.push({ type: 'scrollIntoView' });
|
|
854
|
+
}
|
|
855
|
+
break;
|
|
856
|
+
case 'Tab':
|
|
857
|
+
// Tab: select suggestion (if any), then go to next param or execute
|
|
858
|
+
if (hasSuggestions) {
|
|
859
|
+
actions.push({ type: 'selectSuggestion', option: ctx.suggestions[ctx.selectedSuggestionIndex] });
|
|
860
|
+
}
|
|
861
|
+
if (isLastParam) {
|
|
862
|
+
// Execute - args will be built at dispatch time from current state
|
|
863
|
+
actions.push({ type: 'executeLockedAction' });
|
|
864
|
+
}
|
|
865
|
+
else {
|
|
866
|
+
actions.push({ type: 'nextParameter' });
|
|
867
|
+
}
|
|
868
|
+
break;
|
|
869
|
+
case 'Enter':
|
|
870
|
+
// Enter: select suggestion if any
|
|
871
|
+
if (hasSuggestions && ctx.selectedSuggestionIndex >= 0) {
|
|
872
|
+
actions.push({ type: 'selectSuggestion', option: ctx.suggestions[ctx.selectedSuggestionIndex] });
|
|
873
|
+
}
|
|
874
|
+
if (isLastParam) {
|
|
875
|
+
// Execute - args will be built at dispatch time from current state
|
|
876
|
+
actions.push({ type: 'executeLockedAction' });
|
|
877
|
+
}
|
|
878
|
+
else {
|
|
879
|
+
// More params - go to next (Enter acts like Tab when more required params)
|
|
880
|
+
actions.push({ type: 'nextParameter' });
|
|
881
|
+
}
|
|
882
|
+
break;
|
|
883
|
+
case 'Escape':
|
|
884
|
+
// Escape: go back one step (like Backspace on empty)
|
|
885
|
+
if (ctx.currentParamIndex > 0) {
|
|
886
|
+
// Go to previous parameter
|
|
887
|
+
actions.push({ type: 'previousParameter' });
|
|
888
|
+
}
|
|
889
|
+
else {
|
|
890
|
+
// At first param, go back to action selection
|
|
891
|
+
actions.push({ type: 'unlockAction' });
|
|
892
|
+
}
|
|
893
|
+
break;
|
|
894
|
+
case 'Backspace':
|
|
895
|
+
// Backspace when query is empty: go back
|
|
896
|
+
if (ctx.query === '') {
|
|
897
|
+
if (ctx.currentParamIndex > 0) {
|
|
898
|
+
// Go to previous parameter
|
|
899
|
+
actions.push({ type: 'previousParameter' });
|
|
900
|
+
}
|
|
901
|
+
else {
|
|
902
|
+
// At first param, go back to action selection
|
|
903
|
+
actions.push({ type: 'unlockAction' });
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
break;
|
|
907
|
+
}
|
|
908
|
+
return actions;
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Check if item has only optional parameters (none required).
|
|
912
|
+
* For now, we treat all params as required. Can be extended later.
|
|
913
|
+
*/
|
|
914
|
+
function hasOnlyOptionalParams(_item) {
|
|
915
|
+
// TODO: Add optional param support to GigamenuItem type
|
|
916
|
+
return false;
|
|
917
|
+
}
|
|
918
|
+
/**
|
|
919
|
+
* Handle zsh-like keyboard shortcuts (Ctrl+W, Ctrl+U).
|
|
920
|
+
* Returns the new query if handled, null otherwise.
|
|
921
|
+
*/
|
|
922
|
+
function handleZshShortcuts(event, query) {
|
|
923
|
+
// Ctrl+W or Alt+Backspace or Ctrl+Backspace: Delete last word
|
|
924
|
+
if ((event.ctrlKey && event.key === 'w') ||
|
|
925
|
+
(event.altKey && event.key === 'Backspace') ||
|
|
926
|
+
(event.ctrlKey && event.key === 'Backspace')) {
|
|
927
|
+
return deleteLastWord(query);
|
|
928
|
+
}
|
|
929
|
+
// Ctrl+U: Clear line
|
|
930
|
+
if (event.ctrlKey && event.key === 'u') {
|
|
931
|
+
return '';
|
|
932
|
+
}
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Delete the last word from the query.
|
|
937
|
+
*/
|
|
938
|
+
function deleteLastWord(query) {
|
|
939
|
+
if (!query)
|
|
940
|
+
return '';
|
|
941
|
+
let newQuery = query.replace(/\s+$/, '');
|
|
942
|
+
const lastSpaceIndex = newQuery.lastIndexOf(' ');
|
|
943
|
+
if (lastSpaceIndex !== -1) {
|
|
944
|
+
newQuery = newQuery.substring(0, lastSpaceIndex);
|
|
945
|
+
}
|
|
946
|
+
else {
|
|
947
|
+
newQuery = '';
|
|
948
|
+
}
|
|
949
|
+
return newQuery;
|
|
950
|
+
}
|
|
951
|
+
/**
|
|
952
|
+
* Check if any actions were generated.
|
|
953
|
+
*/
|
|
954
|
+
function hasActions(actions) {
|
|
955
|
+
return actions.length > 0;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Scroll utilities
|
|
959
|
+
|
|
288
960
|
const STORAGE_KEY = 'gigamenu_frecency';
|
|
289
961
|
const GLOBAL_KEY = '__global__';
|
|
290
962
|
const MAX_ENTRIES_PER_TERM = 10;
|
|
@@ -458,88 +1130,114 @@ class GigamenuComponent {
|
|
|
458
1130
|
headerTemplate = contentChild(GigamenuHeaderTemplate, ...(ngDevMode ? [{ debugName: "headerTemplate" }] : []));
|
|
459
1131
|
footerTemplate = contentChild(GigamenuFooterTemplate, ...(ngDevMode ? [{ debugName: "footerTemplate" }] : []));
|
|
460
1132
|
panelTemplate = contentChild(GigamenuPanelTemplate, ...(ngDevMode ? [{ debugName: "panelTemplate" }] : []));
|
|
1133
|
+
// Core state signals
|
|
461
1134
|
query = signal('', ...(ngDevMode ? [{ debugName: "query" }] : []));
|
|
462
1135
|
selectedIndex = signal(0, ...(ngDevMode ? [{ debugName: "selectedIndex" }] : []));
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
1136
|
+
// Step-by-step input state
|
|
1137
|
+
lockedAction = signal(null, ...(ngDevMode ? [{ debugName: "lockedAction" }] : []));
|
|
1138
|
+
paramValues = signal([], ...(ngDevMode ? [{ debugName: "paramValues" }] : []));
|
|
1139
|
+
// Autocomplete state signals
|
|
1140
|
+
autocompleteSuggestions = signal([], ...(ngDevMode ? [{ debugName: "autocompleteSuggestions" }] : []));
|
|
1141
|
+
autocompleteCache = new Map();
|
|
1142
|
+
selectedParamOptions = signal(new Map(), ...(ngDevMode ? [{ debugName: "selectedParamOptions" }] : []));
|
|
1143
|
+
// Dynamic provider state
|
|
1144
|
+
providerResults = signal(new Map(), ...(ngDevMode ? [{ debugName: "providerResults" }] : []));
|
|
1145
|
+
providerTimers = new Map();
|
|
1146
|
+
providerGeneration = new Map();
|
|
1147
|
+
// Query parsing (simplified - only used for filtering)
|
|
1148
|
+
queryParser = computed(() => {
|
|
475
1149
|
const separator = this.service.config().argSeparator ?? ' ';
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
return q.substring(sepIndex + separator.length);
|
|
480
|
-
}, ...(ngDevMode ? [{ debugName: "args" }] : []));
|
|
481
|
-
/** Whether the query contains a separator (for display purposes) */
|
|
482
|
-
hasSeparator = computed(() => {
|
|
483
|
-
const q = this.query();
|
|
484
|
-
const separator = this.service.config().argSeparator ?? ' ';
|
|
485
|
-
return q.includes(separator);
|
|
486
|
-
}, ...(ngDevMode ? [{ debugName: "hasSeparator" }] : []));
|
|
487
|
-
/** Parsed arguments as array */
|
|
488
|
-
argsArray = computed(() => {
|
|
489
|
-
const args = this.args();
|
|
490
|
-
if (!args)
|
|
491
|
-
return [];
|
|
492
|
-
return args.split(/\s+/).filter(Boolean);
|
|
493
|
-
}, ...(ngDevMode ? [{ debugName: "argsArray" }] : []));
|
|
494
|
-
/** Currently selected item */
|
|
1150
|
+
return new QueryParser(separator);
|
|
1151
|
+
}, ...(ngDevMode ? [{ debugName: "queryParser" }] : []));
|
|
1152
|
+
// Selected item from display list
|
|
495
1153
|
selectedItem = computed(() => {
|
|
496
|
-
const items = this.
|
|
1154
|
+
const items = this.displayItems();
|
|
497
1155
|
const index = this.selectedIndex();
|
|
498
1156
|
return items[index] ?? null;
|
|
499
1157
|
}, ...(ngDevMode ? [{ debugName: "selectedItem" }] : []));
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
1158
|
+
currentParamIndex = computed(() => {
|
|
1159
|
+
const action = this.lockedAction();
|
|
1160
|
+
if (!action || !action.params)
|
|
1161
|
+
return null;
|
|
1162
|
+
const filled = this.paramValues().length;
|
|
1163
|
+
if (filled >= action.params.length)
|
|
1164
|
+
return null;
|
|
1165
|
+
return filled;
|
|
1166
|
+
}, ...(ngDevMode ? [{ debugName: "currentParamIndex" }] : []));
|
|
1167
|
+
currentParamName = computed(() => {
|
|
1168
|
+
const action = this.lockedAction();
|
|
1169
|
+
const idx = this.currentParamIndex();
|
|
1170
|
+
if (!action || idx === null || !action.params)
|
|
1171
|
+
return null;
|
|
1172
|
+
return action.params[idx] ?? null;
|
|
1173
|
+
}, ...(ngDevMode ? [{ debugName: "currentParamName" }] : []));
|
|
1174
|
+
hasAutocomplete = computed(() => {
|
|
1175
|
+
const action = this.lockedAction();
|
|
1176
|
+
const paramName = this.currentParamName();
|
|
1177
|
+
if (!action || !paramName)
|
|
504
1178
|
return false;
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
}
|
|
1179
|
+
return computeHasAutocomplete(action, paramName);
|
|
1180
|
+
}, ...(ngDevMode ? [{ debugName: "hasAutocomplete" }] : []));
|
|
1181
|
+
currentState = computed(() => {
|
|
1182
|
+
return computeInputState({
|
|
1183
|
+
isOpen: this.service.isOpen(),
|
|
1184
|
+
hasLockedAction: this.lockedAction() !== null,
|
|
1185
|
+
});
|
|
1186
|
+
}, ...(ngDevMode ? [{ debugName: "currentState" }] : []));
|
|
513
1187
|
filteredItems = computed(() => {
|
|
514
|
-
const searchTerm = this.
|
|
1188
|
+
const searchTerm = this.query().toLowerCase().trim();
|
|
515
1189
|
const items = this.service.items();
|
|
516
1190
|
const maxResults = this.service.config().maxResults ?? 10;
|
|
517
1191
|
if (!searchTerm) {
|
|
518
|
-
// No query: sort by frecency scores from empty searches
|
|
519
1192
|
const scores = this.frecency.getScores('');
|
|
520
|
-
return
|
|
1193
|
+
return sortByFrecency(items, scores).slice(0, maxResults);
|
|
521
1194
|
}
|
|
522
|
-
|
|
523
|
-
const matched = items.filter((item) => this.matchesQuery(item, searchTerm));
|
|
524
|
-
// Sort by frecency for this search term
|
|
1195
|
+
const matched = filterItems(items, searchTerm, this.queryParser());
|
|
525
1196
|
const scores = this.frecency.getScores(searchTerm);
|
|
526
|
-
return
|
|
1197
|
+
return sortByFrecency(matched, scores).slice(0, maxResults);
|
|
527
1198
|
}, ...(ngDevMode ? [{ debugName: "filteredItems" }] : []));
|
|
1199
|
+
// Display items: actions in ActionSelection, suggestions in ParameterInput
|
|
1200
|
+
displayItems = computed(() => {
|
|
1201
|
+
const state = this.currentState();
|
|
1202
|
+
if (state === InputState.ParameterInput) {
|
|
1203
|
+
// In parameter mode, show autocomplete suggestions as items
|
|
1204
|
+
return this.autocompleteSuggestions().map((opt) => ({
|
|
1205
|
+
id: `suggestion-${opt.value}`,
|
|
1206
|
+
label: opt.label,
|
|
1207
|
+
description: opt.value !== opt.label ? opt.value : undefined,
|
|
1208
|
+
category: 'command',
|
|
1209
|
+
action: () => { }, // Handled via selectSuggestion action
|
|
1210
|
+
}));
|
|
1211
|
+
}
|
|
1212
|
+
// Action selection: static items + dynamic provider results
|
|
1213
|
+
const staticItems = this.filteredItems();
|
|
1214
|
+
const dynamic = [];
|
|
1215
|
+
for (const items of this.providerResults().values()) {
|
|
1216
|
+
dynamic.push(...items);
|
|
1217
|
+
}
|
|
1218
|
+
return [...staticItems, ...dynamic];
|
|
1219
|
+
}, ...(ngDevMode ? [{ debugName: "displayItems" }] : []));
|
|
1220
|
+
// Template helper
|
|
1221
|
+
getParamColor = getParamColor;
|
|
528
1222
|
constructor(service, frecency, platformId) {
|
|
529
1223
|
this.service = service;
|
|
530
1224
|
this.frecency = frecency;
|
|
531
1225
|
this.isBrowser = isPlatformBrowser(platformId);
|
|
1226
|
+
// Focus effect
|
|
532
1227
|
effect(() => {
|
|
533
1228
|
if (this.service.isOpen() && this.isBrowser) {
|
|
534
1229
|
setTimeout(() => this.searchInput()?.nativeElement.focus(), 0);
|
|
535
1230
|
}
|
|
536
1231
|
});
|
|
1232
|
+
// Frecency auto-select effect (only in ActionSelection mode)
|
|
537
1233
|
effect(() => {
|
|
1234
|
+
const state = this.currentState();
|
|
1235
|
+
if (state !== InputState.ActionSelection)
|
|
1236
|
+
return;
|
|
538
1237
|
const items = this.filteredItems();
|
|
539
|
-
const
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
const topMatch = this.frecency.getTopMatch(searchTerm);
|
|
1238
|
+
const query = this.query();
|
|
1239
|
+
if (query && items.length > 0) {
|
|
1240
|
+
const topMatch = this.frecency.getTopMatch(query);
|
|
543
1241
|
if (topMatch) {
|
|
544
1242
|
const idx = items.findIndex((item) => item.id === topMatch);
|
|
545
1243
|
if (idx !== -1) {
|
|
@@ -550,60 +1248,216 @@ class GigamenuComponent {
|
|
|
550
1248
|
}
|
|
551
1249
|
this.selectedIndex.set(0);
|
|
552
1250
|
});
|
|
1251
|
+
// Dynamic provider effect (only in ActionSelection mode)
|
|
1252
|
+
effect(() => {
|
|
1253
|
+
const state = this.currentState();
|
|
1254
|
+
const providers = this.service.providers();
|
|
1255
|
+
const query = this.query();
|
|
1256
|
+
if (state !== InputState.ActionSelection) {
|
|
1257
|
+
this.clearProviderResults();
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
// Cancel timers for providers that no longer exist
|
|
1261
|
+
for (const id of this.providerTimers.keys()) {
|
|
1262
|
+
if (!providers.has(id)) {
|
|
1263
|
+
clearTimeout(this.providerTimers.get(id));
|
|
1264
|
+
this.providerTimers.delete(id);
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
// Drop results from removed providers
|
|
1268
|
+
const currentResults = this.providerResults();
|
|
1269
|
+
if (currentResults.size > 0) {
|
|
1270
|
+
let changed = false;
|
|
1271
|
+
const next = new Map(currentResults);
|
|
1272
|
+
for (const id of next.keys()) {
|
|
1273
|
+
if (!providers.has(id)) {
|
|
1274
|
+
next.delete(id);
|
|
1275
|
+
changed = true;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
if (changed)
|
|
1279
|
+
this.providerResults.set(next);
|
|
1280
|
+
}
|
|
1281
|
+
// Schedule each provider
|
|
1282
|
+
providers.forEach((registered, id) => {
|
|
1283
|
+
const trimmed = query.trim();
|
|
1284
|
+
if (trimmed.length < registered.options.minQueryLength) {
|
|
1285
|
+
// Clear any previous results for this provider when below threshold
|
|
1286
|
+
if (this.providerResults().has(id)) {
|
|
1287
|
+
this.providerResults.update((map) => {
|
|
1288
|
+
const next = new Map(map);
|
|
1289
|
+
next.delete(id);
|
|
1290
|
+
return next;
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
const existing = this.providerTimers.get(id);
|
|
1294
|
+
if (existing) {
|
|
1295
|
+
clearTimeout(existing);
|
|
1296
|
+
this.providerTimers.delete(id);
|
|
1297
|
+
}
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
const existing = this.providerTimers.get(id);
|
|
1301
|
+
if (existing)
|
|
1302
|
+
clearTimeout(existing);
|
|
1303
|
+
const generation = (this.providerGeneration.get(id) ?? 0) + 1;
|
|
1304
|
+
this.providerGeneration.set(id, generation);
|
|
1305
|
+
const timer = setTimeout(async () => {
|
|
1306
|
+
this.providerTimers.delete(id);
|
|
1307
|
+
try {
|
|
1308
|
+
const raw = await registered.provider(trimmed);
|
|
1309
|
+
if (this.providerGeneration.get(id) !== generation)
|
|
1310
|
+
return;
|
|
1311
|
+
const items = this.normalizeProviderItems(raw, id, registered.options.group);
|
|
1312
|
+
this.providerResults.update((map) => {
|
|
1313
|
+
const next = new Map(map);
|
|
1314
|
+
next.set(id, items);
|
|
1315
|
+
return next;
|
|
1316
|
+
});
|
|
1317
|
+
}
|
|
1318
|
+
catch (err) {
|
|
1319
|
+
console.error(`gigamenu provider "${id}" failed:`, err);
|
|
1320
|
+
}
|
|
1321
|
+
}, registered.options.debounceMs);
|
|
1322
|
+
this.providerTimers.set(id, timer);
|
|
1323
|
+
});
|
|
1324
|
+
});
|
|
1325
|
+
// Autocomplete effect (only in ParameterInput mode)
|
|
1326
|
+
effect(() => {
|
|
1327
|
+
const action = this.lockedAction();
|
|
1328
|
+
const paramIndex = this.currentParamIndex();
|
|
1329
|
+
const paramName = this.currentParamName();
|
|
1330
|
+
const paramValue = this.query(); // In ParameterInput, query is the param value
|
|
1331
|
+
if (!action || paramIndex === null || !paramName) {
|
|
1332
|
+
this.autocompleteSuggestions.set([]);
|
|
1333
|
+
return;
|
|
1334
|
+
}
|
|
1335
|
+
const provider = action.paramProviders?.[paramName];
|
|
1336
|
+
if (!provider) {
|
|
1337
|
+
this.autocompleteSuggestions.set([]);
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
this.fetchSuggestions(provider, paramValue);
|
|
1341
|
+
});
|
|
553
1342
|
}
|
|
554
1343
|
onGlobalKeydown(event) {
|
|
555
1344
|
if (!this.isBrowser)
|
|
556
1345
|
return;
|
|
557
|
-
|
|
1346
|
+
const action = handleGlobalKeydown(event, {
|
|
1347
|
+
isOpen: this.service.isOpen(),
|
|
1348
|
+
isInputFocused: isInputFocused(),
|
|
1349
|
+
});
|
|
1350
|
+
if (action) {
|
|
558
1351
|
event.preventDefault();
|
|
559
|
-
this.
|
|
560
|
-
return;
|
|
1352
|
+
this.dispatchAction(action);
|
|
561
1353
|
}
|
|
562
|
-
|
|
1354
|
+
}
|
|
1355
|
+
onInputKeydown(event) {
|
|
1356
|
+
const state = this.currentState();
|
|
1357
|
+
// Handle zsh-like shortcuts first
|
|
1358
|
+
const newQuery = handleZshShortcuts(event, this.query());
|
|
1359
|
+
if (newQuery !== null) {
|
|
563
1360
|
event.preventDefault();
|
|
564
|
-
this.
|
|
1361
|
+
this.query.set(newQuery);
|
|
565
1362
|
return;
|
|
566
1363
|
}
|
|
567
|
-
|
|
1364
|
+
// Get actions from state-specific handler
|
|
1365
|
+
const actions = this.getActionsForState(state, event);
|
|
1366
|
+
if (hasActions(actions)) {
|
|
568
1367
|
event.preventDefault();
|
|
569
|
-
this.
|
|
1368
|
+
actions.forEach((action) => this.dispatchAction(action));
|
|
570
1369
|
}
|
|
571
1370
|
}
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
1371
|
+
getActionsForState(state, event) {
|
|
1372
|
+
switch (state) {
|
|
1373
|
+
case InputState.ActionSelection: {
|
|
1374
|
+
const ctx = {
|
|
1375
|
+
items: this.displayItems(),
|
|
1376
|
+
selectedIndex: this.selectedIndex(),
|
|
1377
|
+
selectedItem: this.selectedItem(),
|
|
1378
|
+
};
|
|
1379
|
+
return handleActionSelectionKeydown(event, ctx);
|
|
1380
|
+
}
|
|
1381
|
+
case InputState.ParameterInput: {
|
|
1382
|
+
const action = this.lockedAction();
|
|
1383
|
+
if (!action)
|
|
1384
|
+
return [];
|
|
1385
|
+
const ctx = {
|
|
1386
|
+
lockedItem: action,
|
|
1387
|
+
currentParamIndex: this.currentParamIndex() ?? 0,
|
|
1388
|
+
paramValues: this.paramValues(),
|
|
1389
|
+
query: this.query(),
|
|
1390
|
+
suggestions: this.autocompleteSuggestions(),
|
|
1391
|
+
selectedSuggestionIndex: this.selectedIndex(),
|
|
1392
|
+
};
|
|
1393
|
+
return handleParameterInputKeydown(event, ctx);
|
|
1394
|
+
}
|
|
1395
|
+
default:
|
|
1396
|
+
return [];
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
dispatchAction(action) {
|
|
1400
|
+
switch (action.type) {
|
|
1401
|
+
case 'setSelectedIndex':
|
|
1402
|
+
this.selectedIndex.set(action.value);
|
|
579
1403
|
break;
|
|
580
|
-
case '
|
|
581
|
-
|
|
582
|
-
this.selectedIndex.update((i) => Math.max(i - 1, 0));
|
|
583
|
-
this.scrollSelectedIntoView();
|
|
1404
|
+
case 'setQuery':
|
|
1405
|
+
this.query.set(action.value);
|
|
584
1406
|
break;
|
|
585
|
-
case '
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
1407
|
+
case 'executeAction':
|
|
1408
|
+
// Execute an action directly (no params)
|
|
1409
|
+
this.executeItemWithArgs(action.item, []);
|
|
1410
|
+
break;
|
|
1411
|
+
case 'executeLockedAction':
|
|
1412
|
+
// Build args from current state (includes any changes from selectSuggestion)
|
|
1413
|
+
const lockedItem = this.lockedAction();
|
|
1414
|
+
if (lockedItem) {
|
|
1415
|
+
const args = this.buildArgsWithValues();
|
|
1416
|
+
this.executeItemWithArgs(lockedItem, args);
|
|
592
1417
|
}
|
|
593
1418
|
break;
|
|
594
|
-
case '
|
|
595
|
-
event.preventDefault();
|
|
1419
|
+
case 'close':
|
|
596
1420
|
this.close();
|
|
597
1421
|
break;
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
1422
|
+
case 'toggle':
|
|
1423
|
+
this.service.toggle();
|
|
1424
|
+
break;
|
|
1425
|
+
case 'open':
|
|
1426
|
+
this.service.open();
|
|
1427
|
+
break;
|
|
1428
|
+
case 'scrollIntoView':
|
|
1429
|
+
scrollSelectedIntoView(this.listContainer()?.nativeElement ?? null, this.selectedIndex());
|
|
1430
|
+
break;
|
|
1431
|
+
case 'lockAction':
|
|
1432
|
+
this.lockedAction.set(action.item);
|
|
1433
|
+
this.query.set('');
|
|
1434
|
+
this.selectedIndex.set(0);
|
|
1435
|
+
break;
|
|
1436
|
+
case 'unlockAction':
|
|
1437
|
+
this.lockedAction.set(null);
|
|
1438
|
+
this.paramValues.set([]);
|
|
1439
|
+
this.query.set('');
|
|
1440
|
+
this.selectedIndex.set(0);
|
|
1441
|
+
break;
|
|
1442
|
+
case 'nextParameter':
|
|
1443
|
+
// Push current query value to paramValues, clear query
|
|
1444
|
+
this.paramValues.update((values) => [...values, this.query()]);
|
|
1445
|
+
this.query.set('');
|
|
1446
|
+
this.selectedIndex.set(0);
|
|
1447
|
+
break;
|
|
1448
|
+
case 'previousParameter':
|
|
1449
|
+
// Pop last param value back to query
|
|
1450
|
+
const values = this.paramValues();
|
|
1451
|
+
if (values.length > 0) {
|
|
1452
|
+
const lastValue = values[values.length - 1];
|
|
1453
|
+
this.paramValues.update((v) => v.slice(0, -1));
|
|
1454
|
+
this.query.set(lastValue);
|
|
1455
|
+
this.selectedIndex.set(0);
|
|
1456
|
+
}
|
|
1457
|
+
break;
|
|
1458
|
+
case 'selectSuggestion':
|
|
1459
|
+
this.selectSuggestion(action.option);
|
|
1460
|
+
break;
|
|
607
1461
|
}
|
|
608
1462
|
}
|
|
609
1463
|
onQueryChange(event) {
|
|
@@ -615,101 +1469,192 @@ class GigamenuComponent {
|
|
|
615
1469
|
this.close();
|
|
616
1470
|
}
|
|
617
1471
|
}
|
|
618
|
-
|
|
619
|
-
//
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
1472
|
+
onItemClick(item, index) {
|
|
1473
|
+
// Only proceed if this is the selected item
|
|
1474
|
+
if (this.selectedIndex() !== index) {
|
|
1475
|
+
this.selectedIndex.set(index);
|
|
1476
|
+
return;
|
|
1477
|
+
}
|
|
1478
|
+
const state = this.currentState();
|
|
1479
|
+
if (state === InputState.ParameterInput) {
|
|
1480
|
+
// In parameter mode, clicking selects the suggestion
|
|
1481
|
+
const suggestions = this.autocompleteSuggestions();
|
|
1482
|
+
const option = suggestions[index];
|
|
1483
|
+
if (option) {
|
|
1484
|
+
this.selectSuggestion(option);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
else {
|
|
1488
|
+
// In action selection mode, trigger the action selection
|
|
1489
|
+
const actions = handleActionSelectionKeydown(new KeyboardEvent('keydown', { key: 'Enter' }), {
|
|
1490
|
+
items: this.displayItems(),
|
|
1491
|
+
selectedIndex: index,
|
|
1492
|
+
selectedItem: item,
|
|
1493
|
+
});
|
|
1494
|
+
actions.forEach((action) => this.dispatchAction(action));
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
executeItemWithArgs(item, args) {
|
|
1498
|
+
// Record frecency for the action
|
|
1499
|
+
this.frecency.recordSelection(this.query(), item.id);
|
|
1500
|
+
// Build args string from array
|
|
1501
|
+
const argsStr = args.length > 0 ? args.join(' ') : undefined;
|
|
624
1502
|
this.close();
|
|
625
|
-
item.action(
|
|
1503
|
+
item.action(argsStr);
|
|
1504
|
+
}
|
|
1505
|
+
selectSuggestion(option) {
|
|
1506
|
+
// Set the query to the selected option's label (for display)
|
|
1507
|
+
this.query.set(option.label);
|
|
1508
|
+
// Track the selected option for value substitution when executing
|
|
1509
|
+
const paramIndex = this.currentParamIndex();
|
|
1510
|
+
if (paramIndex !== null) {
|
|
1511
|
+
this.selectedParamOptions.update((map) => {
|
|
1512
|
+
const newMap = new Map(map);
|
|
1513
|
+
newMap.set(paramIndex, option);
|
|
1514
|
+
return newMap;
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
626
1517
|
}
|
|
627
|
-
|
|
1518
|
+
/**
|
|
1519
|
+
* Build args array using values from selectedParamOptions when available.
|
|
1520
|
+
* For each param, if a suggestion was selected, use its value; otherwise use the typed text.
|
|
1521
|
+
*/
|
|
1522
|
+
buildArgsWithValues() {
|
|
1523
|
+
const paramValues = this.paramValues();
|
|
1524
|
+
const currentQuery = this.query();
|
|
1525
|
+
const selectedOptions = this.selectedParamOptions();
|
|
1526
|
+
const args = [];
|
|
1527
|
+
// Add completed param values (use selected option's value if available)
|
|
1528
|
+
for (let i = 0; i < paramValues.length; i++) {
|
|
1529
|
+
const selectedOption = selectedOptions.get(i);
|
|
1530
|
+
if (selectedOption) {
|
|
1531
|
+
args.push(selectedOption.value);
|
|
1532
|
+
}
|
|
1533
|
+
else {
|
|
1534
|
+
args.push(paramValues[i]);
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
// Add current param value (use selected option's value if available)
|
|
1538
|
+
if (currentQuery) {
|
|
1539
|
+
const currentParamIdx = this.currentParamIndex();
|
|
1540
|
+
if (currentParamIdx !== null) {
|
|
1541
|
+
const selectedOption = selectedOptions.get(currentParamIdx);
|
|
1542
|
+
if (selectedOption) {
|
|
1543
|
+
args.push(selectedOption.value);
|
|
1544
|
+
}
|
|
1545
|
+
else {
|
|
1546
|
+
args.push(currentQuery);
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
else {
|
|
1550
|
+
args.push(currentQuery);
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
return args.filter(Boolean);
|
|
1554
|
+
}
|
|
1555
|
+
// Template context methods
|
|
628
1556
|
getItemContext(item, index) {
|
|
629
|
-
return
|
|
630
|
-
$implicit: item,
|
|
631
|
-
index,
|
|
632
|
-
selected: this.selectedIndex() === index,
|
|
633
|
-
};
|
|
1557
|
+
return createItemContext(item, index, this.selectedIndex());
|
|
634
1558
|
}
|
|
635
1559
|
getEmptyContext() {
|
|
636
|
-
return
|
|
637
|
-
|
|
638
|
-
|
|
1560
|
+
return createEmptyContext(this.query());
|
|
1561
|
+
}
|
|
1562
|
+
getFooterContext() {
|
|
1563
|
+
return createFooterContext(this.displayItems().length, this.service.items().length);
|
|
639
1564
|
}
|
|
640
1565
|
getHeaderContext() {
|
|
641
1566
|
return {
|
|
642
1567
|
$implicit: this.query(),
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
1568
|
+
query: this.query(),
|
|
1569
|
+
lockedAction: this.lockedAction(),
|
|
1570
|
+
paramValues: this.paramValues(),
|
|
1571
|
+
currentParamName: this.currentParamName(),
|
|
1572
|
+
placeholder: this.service.config().placeholder ?? '',
|
|
646
1573
|
onQueryChange: (value) => this.query.set(value),
|
|
647
1574
|
onKeydown: (event) => this.onInputKeydown(event),
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
}
|
|
651
|
-
getFooterContext() {
|
|
652
|
-
return {
|
|
653
|
-
$implicit: this.filteredItems().length,
|
|
654
|
-
total: this.service.items().length,
|
|
1575
|
+
onUnlockAction: () => this.unlockActionFromUI(),
|
|
1576
|
+
onGoToParam: (index) => this.goToParam(index),
|
|
655
1577
|
};
|
|
656
1578
|
}
|
|
657
1579
|
getPanelContext() {
|
|
658
1580
|
return {
|
|
659
|
-
$implicit: this.
|
|
1581
|
+
$implicit: this.displayItems(),
|
|
1582
|
+
items: this.displayItems(),
|
|
660
1583
|
query: this.query(),
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
hasSeparator: this.hasSeparator(),
|
|
1584
|
+
lockedAction: this.lockedAction(),
|
|
1585
|
+
paramValues: this.paramValues(),
|
|
664
1586
|
selectedIndex: this.selectedIndex(),
|
|
665
|
-
executeItem: (item) => this.executeItem(item),
|
|
666
|
-
setSelectedIndex: (index) => this.selectedIndex.set(index),
|
|
667
|
-
setQuery: (query) => this.query.set(query),
|
|
668
|
-
close: () => this.close(),
|
|
669
1587
|
placeholder: this.service.config().placeholder ?? '',
|
|
1588
|
+
onItemClick: (item, index) => this.onItemClick(item, index),
|
|
1589
|
+
onSelectIndex: (index) => this.selectedIndex.set(index),
|
|
1590
|
+
onQueryChange: (query) => this.query.set(query),
|
|
1591
|
+
onClose: () => this.close(),
|
|
670
1592
|
};
|
|
671
1593
|
}
|
|
1594
|
+
// Autocomplete methods
|
|
1595
|
+
async fetchSuggestions(provider, query) {
|
|
1596
|
+
try {
|
|
1597
|
+
const { options, isAsync } = await fetchAutocompleteSuggestions(provider, query, this.autocompleteCache);
|
|
1598
|
+
const filtered = isAsync ? options : filterSuggestionsClientSide(options, query);
|
|
1599
|
+
this.autocompleteSuggestions.set(filtered);
|
|
1600
|
+
this.selectedIndex.set(0);
|
|
1601
|
+
}
|
|
1602
|
+
catch (error) {
|
|
1603
|
+
console.error('Error fetching autocomplete suggestions:', error);
|
|
1604
|
+
this.autocompleteSuggestions.set([]);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
672
1607
|
close() {
|
|
673
1608
|
this.service.close();
|
|
674
1609
|
this.query.set('');
|
|
675
1610
|
this.selectedIndex.set(0);
|
|
1611
|
+
this.lockedAction.set(null);
|
|
1612
|
+
this.paramValues.set([]);
|
|
1613
|
+
this.autocompleteCache.clear();
|
|
1614
|
+
this.selectedParamOptions.set(new Map());
|
|
1615
|
+
this.clearProviderResults();
|
|
676
1616
|
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
}
|
|
1617
|
+
clearProviderResults() {
|
|
1618
|
+
for (const timer of this.providerTimers.values())
|
|
1619
|
+
clearTimeout(timer);
|
|
1620
|
+
this.providerTimers.clear();
|
|
1621
|
+
// Bump all generations to invalidate any in-flight responses
|
|
1622
|
+
for (const id of this.providerGeneration.keys()) {
|
|
1623
|
+
this.providerGeneration.set(id, (this.providerGeneration.get(id) ?? 0) + 1);
|
|
1624
|
+
}
|
|
1625
|
+
if (this.providerResults().size > 0) {
|
|
1626
|
+
this.providerResults.set(new Map());
|
|
1627
|
+
}
|
|
685
1628
|
}
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
item
|
|
689
|
-
item.
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
}
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
1629
|
+
normalizeProviderItems(raw, providerId, group) {
|
|
1630
|
+
return raw.map((item) => ({
|
|
1631
|
+
...item,
|
|
1632
|
+
category: item.category ?? 'command',
|
|
1633
|
+
providerId,
|
|
1634
|
+
group: item.group ?? (group || undefined),
|
|
1635
|
+
}));
|
|
1636
|
+
}
|
|
1637
|
+
// Template helper for going back from breadcrumb
|
|
1638
|
+
unlockActionFromUI() {
|
|
1639
|
+
this.dispatchAction({ type: 'unlockAction' });
|
|
1640
|
+
}
|
|
1641
|
+
goToParam(index) {
|
|
1642
|
+
// Go back to a specific parameter
|
|
1643
|
+
const values = this.paramValues();
|
|
1644
|
+
if (index < values.length) {
|
|
1645
|
+
// Set query to the value at that index
|
|
1646
|
+
this.query.set(values[index]);
|
|
1647
|
+
// Keep only values before that index
|
|
1648
|
+
this.paramValues.set(values.slice(0, index));
|
|
1649
|
+
this.selectedIndex.set(0);
|
|
1650
|
+
}
|
|
706
1651
|
}
|
|
707
1652
|
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 });
|
|
708
|
-
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
|
|
1653
|
+
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 min-h-50 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=\"border-b border-neutral-200 dark:border-neutral-700\">\n <!-- Breadcrumb (when action is locked) -->\n @if (lockedAction(); as action) {\n <div class=\"flex flex-wrap items-center gap-1 px-4 py-2 text-sm\">\n <button\n type=\"button\"\n (click)=\"unlockActionFromUI()\"\n class=\"inline-flex items-center gap-1.5 rounded-md bg-blue-100 px-2 py-1 font-medium text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:text-blue-300 dark:hover:bg-blue-900/60\"\n >\n @if (action.icon) {\n <span>{{ action.icon }}</span>\n }\n {{ action.label }}\n </button>\n @for (value of paramValues(); track $index) {\n <span class=\"text-neutral-400\">\u203A</span>\n <button\n type=\"button\"\n (click)=\"goToParam($index)\"\n [class]=\"'rounded-md px-2 py-1 font-medium hover:opacity-80 ' + getParamColor($index)\"\n >\n <span class=\"text-xs opacity-60\">{{ action.params?.[$index] }}:</span>\n {{ value }}\n </button>\n }\n @if (currentParamName(); as paramName) {\n <span class=\"text-neutral-400\">\u203A</span>\n <span class=\"text-neutral-500 dark:text-neutral-400 italic\">{{ paramName }}:</span>\n }\n </div>\n }\n\n <!-- Search input row -->\n <div class=\"flex items-center px-4\">\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 <input\n #searchInput\n type=\"text\"\n [placeholder]=\"lockedAction() ? (currentParamName() ?? 'Enter value...') : service.config().placeholder\"\n [value]=\"query()\"\n (input)=\"onQueryChange($event)\"\n (keydown)=\"onInputKeydown($event)\"\n class=\"w-full px-3 py-4 text-base text-neutral-900 placeholder-neutral-400 outline-none dark:text-neutral-100 bg-transparent\"\n />\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 </div>\n }\n\n <!-- Items list -->\n <div #listContainer class=\"max-h-80 overflow-y-auto p-2\">\n @if (displayItems().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 @if (lockedAction()) {\n @if (hasAutocomplete()) {\n Type to search...\n } @else {\n Enter a value and press Tab or Enter\n }\n } @else {\n No results found\n }\n </div>\n }\n } @else {\n @for (item of displayItems(); 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)=\"onItemClick(item, i)\"\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 (!lockedAction()) {\n <!-- Action mode: show icon -->\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 }\n <div class=\"flex-1 min-w-0\">\n <div class=\"truncate font-medium text-neutral-900 dark:text-neutral-100\">\n {{ item.label }}@if (!lockedAction() && item.params) { @for (param of item.params; track $index) {<span class=\"whitespace-pre\"> </span><span [class]=\"getParamColor($index)\"><{{ param }}></span>}@if (item.paramProviders) {<span class=\"ml-2 inline-flex items-center rounded border border-neutral-300 bg-neutral-100 px-1 py-0.5 text-[10px] font-normal text-neutral-500 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-400\">Tab</span>}}\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 @if (!lockedAction()) {\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 }\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\">Tab</kbd>\n @if (lockedAction()) {\n next\n } @else {\n params\n }\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 @if (lockedAction()) {\n execute\n } @else {\n select\n }\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"] }] });
|
|
709
1654
|
}
|
|
710
1655
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.0.6", ngImport: i0, type: GigamenuComponent, decorators: [{
|
|
711
1656
|
type: Component,
|
|
712
|
-
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
|
|
1657
|
+
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 min-h-50 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=\"border-b border-neutral-200 dark:border-neutral-700\">\n <!-- Breadcrumb (when action is locked) -->\n @if (lockedAction(); as action) {\n <div class=\"flex flex-wrap items-center gap-1 px-4 py-2 text-sm\">\n <button\n type=\"button\"\n (click)=\"unlockActionFromUI()\"\n class=\"inline-flex items-center gap-1.5 rounded-md bg-blue-100 px-2 py-1 font-medium text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:text-blue-300 dark:hover:bg-blue-900/60\"\n >\n @if (action.icon) {\n <span>{{ action.icon }}</span>\n }\n {{ action.label }}\n </button>\n @for (value of paramValues(); track $index) {\n <span class=\"text-neutral-400\">\u203A</span>\n <button\n type=\"button\"\n (click)=\"goToParam($index)\"\n [class]=\"'rounded-md px-2 py-1 font-medium hover:opacity-80 ' + getParamColor($index)\"\n >\n <span class=\"text-xs opacity-60\">{{ action.params?.[$index] }}:</span>\n {{ value }}\n </button>\n }\n @if (currentParamName(); as paramName) {\n <span class=\"text-neutral-400\">\u203A</span>\n <span class=\"text-neutral-500 dark:text-neutral-400 italic\">{{ paramName }}:</span>\n }\n </div>\n }\n\n <!-- Search input row -->\n <div class=\"flex items-center px-4\">\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 <input\n #searchInput\n type=\"text\"\n [placeholder]=\"lockedAction() ? (currentParamName() ?? 'Enter value...') : service.config().placeholder\"\n [value]=\"query()\"\n (input)=\"onQueryChange($event)\"\n (keydown)=\"onInputKeydown($event)\"\n class=\"w-full px-3 py-4 text-base text-neutral-900 placeholder-neutral-400 outline-none dark:text-neutral-100 bg-transparent\"\n />\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 </div>\n }\n\n <!-- Items list -->\n <div #listContainer class=\"max-h-80 overflow-y-auto p-2\">\n @if (displayItems().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 @if (lockedAction()) {\n @if (hasAutocomplete()) {\n Type to search...\n } @else {\n Enter a value and press Tab or Enter\n }\n } @else {\n No results found\n }\n </div>\n }\n } @else {\n @for (item of displayItems(); 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)=\"onItemClick(item, i)\"\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 (!lockedAction()) {\n <!-- Action mode: show icon -->\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 }\n <div class=\"flex-1 min-w-0\">\n <div class=\"truncate font-medium text-neutral-900 dark:text-neutral-100\">\n {{ item.label }}@if (!lockedAction() && item.params) { @for (param of item.params; track $index) {<span class=\"whitespace-pre\"> </span><span [class]=\"getParamColor($index)\"><{{ param }}></span>}@if (item.paramProviders) {<span class=\"ml-2 inline-flex items-center rounded border border-neutral-300 bg-neutral-100 px-1 py-0.5 text-[10px] font-normal text-neutral-500 dark:border-neutral-600 dark:bg-neutral-700 dark:text-neutral-400\">Tab</span>}}\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 @if (!lockedAction()) {\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 }\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\">Tab</kbd>\n @if (lockedAction()) {\n next\n } @else {\n params\n }\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 @if (lockedAction()) {\n execute\n } @else {\n select\n }\n </span>\n </div>\n <span>gigamenu</span>\n </div>\n }\n </div>\n }\n</div>\n}\n", styles: [":host{display:contents}\n"] }]
|
|
713
1658
|
}], ctorParameters: () => [{ type: GigamenuService }, { type: FrecencyService }, { type: undefined, decorators: [{
|
|
714
1659
|
type: Inject,
|
|
715
1660
|
args: [PLATFORM_ID]
|