@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.
@@ -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: paramNames.length > 0 ? paramNames : undefined,
125
+ params: finalParams,
126
+ // Preserve paramProviders if specified
127
+ paramProviders: page.paramProviders,
85
128
  action: (args) => {
86
129
  let path = page.path;
87
- if (paramNames.length > 0 && args) {
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
- paramNames.forEach((param, index) => {
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
- /** Parsed search term (before first separator) */
464
- searchTerm = computed(() => {
465
- const q = this.query();
466
- const separator = this.service.config().argSeparator ?? ' ';
467
- const sepIndex = q.indexOf(separator);
468
- if (sepIndex === -1)
469
- return q;
470
- return q.substring(0, sepIndex);
471
- }, ...(ngDevMode ? [{ debugName: "searchTerm" }] : []));
472
- /** Parsed arguments (after first separator) */
473
- args = computed(() => {
474
- const q = this.query();
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
- const sepIndex = q.indexOf(separator);
477
- if (sepIndex === -1)
478
- return '';
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.filteredItems();
1154
+ const items = this.displayItems();
497
1155
  const index = this.selectedIndex();
498
1156
  return items[index] ?? null;
499
1157
  }, ...(ngDevMode ? [{ debugName: "selectedItem" }] : []));
500
- /** Whether the selected item can be executed (has all required params) */
501
- canExecute = computed(() => {
502
- const item = this.selectedItem();
503
- if (!item)
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
- if (!item.params || item.params.length === 0)
506
- return true;
507
- return this.argsArray().length >= item.params.length;
508
- }, ...(ngDevMode ? [{ debugName: "canExecute" }] : []));
509
- /** Get color class for a parameter index */
510
- getParamColor(index) {
511
- return PARAM_COLORS[index % PARAM_COLORS.length];
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.searchTerm().toLowerCase().trim();
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 this.sortByFrecency(items, scores).slice(0, maxResults);
1193
+ return sortByFrecency(items, scores).slice(0, maxResults);
521
1194
  }
522
- // Filter matching items using only search term (not args)
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 this.sortByFrecency(matched, scores).slice(0, maxResults);
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 searchTerm = this.searchTerm();
540
- // Check for auto-select based on frecency
541
- if (searchTerm && items.length > 0) {
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
- if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
1346
+ const action = handleGlobalKeydown(event, {
1347
+ isOpen: this.service.isOpen(),
1348
+ isInputFocused: isInputFocused(),
1349
+ });
1350
+ if (action) {
558
1351
  event.preventDefault();
559
- this.service.toggle();
560
- return;
1352
+ this.dispatchAction(action);
561
1353
  }
562
- if (event.key === '/' && !this.isInputFocused()) {
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.service.open();
1361
+ this.query.set(newQuery);
565
1362
  return;
566
1363
  }
567
- if (event.key === 'Escape' && this.service.isOpen()) {
1364
+ // Get actions from state-specific handler
1365
+ const actions = this.getActionsForState(state, event);
1366
+ if (hasActions(actions)) {
568
1367
  event.preventDefault();
569
- this.close();
1368
+ actions.forEach((action) => this.dispatchAction(action));
570
1369
  }
571
1370
  }
572
- onInputKeydown(event) {
573
- const items = this.filteredItems();
574
- switch (event.key) {
575
- case 'ArrowDown':
576
- event.preventDefault();
577
- this.selectedIndex.update((i) => Math.min(i + 1, items.length - 1));
578
- this.scrollSelectedIntoView();
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 'ArrowUp':
581
- event.preventDefault();
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 'Enter':
586
- event.preventDefault();
587
- if (this.canExecute()) {
588
- const selected = items[this.selectedIndex()];
589
- if (selected) {
590
- this.executeItem(selected);
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 'Escape':
595
- event.preventDefault();
1419
+ case 'close':
596
1420
  this.close();
597
1421
  break;
598
- }
599
- }
600
- scrollSelectedIntoView() {
601
- const container = this.listContainer()?.nativeElement;
602
- if (!container)
603
- return;
604
- const selectedButton = container.querySelector(`[data-index="${this.selectedIndex()}"]`);
605
- if (selectedButton) {
606
- selectedButton.scrollIntoView({ block: 'nearest' });
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
- executeItem(item) {
619
- // Record the selection for frecency learning (use search term, not full query)
620
- const searchTerm = this.searchTerm();
621
- this.frecency.recordSelection(searchTerm, item.id);
622
- // Get args before closing (which resets query)
623
- const args = this.args() || undefined;
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(args);
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
- // Template context getters
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
- $implicit: this.query(),
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
- searchTerm: this.searchTerm(),
644
- args: this.args(),
645
- hasSeparator: this.hasSeparator(),
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
- placeholder: this.service.config().placeholder ?? '',
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.filteredItems(),
1581
+ $implicit: this.displayItems(),
1582
+ items: this.displayItems(),
660
1583
  query: this.query(),
661
- searchTerm: this.searchTerm(),
662
- args: this.args(),
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
- sortByFrecency(items, scores) {
678
- if (scores.size === 0)
679
- return items;
680
- return [...items].sort((a, b) => {
681
- const scoreA = scores.get(a.id) ?? 0;
682
- const scoreB = scores.get(b.id) ?? 0;
683
- return scoreB - scoreA;
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
- matchesQuery(item, query) {
687
- const searchableText = [
688
- item.label,
689
- item.description,
690
- ...(item.keywords ?? []),
691
- ]
692
- .filter(Boolean)
693
- .join(' ')
694
- .toLowerCase();
695
- const words = query.split(/\s+/);
696
- return words.every((word) => searchableText.includes(word));
697
- }
698
- isInputFocused() {
699
- const activeElement = document.activeElement;
700
- if (!activeElement)
701
- return false;
702
- const tagName = activeElement.tagName.toLowerCase();
703
- return (tagName === 'input' ||
704
- tagName === 'textarea' ||
705
- activeElement.isContentEditable);
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 <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>@for (arg of argsArray(); track $index) {<span class=\"whitespace-pre text-neutral-900 dark:text-neutral-100\">{{ service.config().argSeparator }}</span><span [class]=\"getParamColor($index)\">{{ arg }}</span>}@if (hasSeparator() && args().endsWith(' ')) {<span class=\"whitespace-pre text-neutral-900 dark:text-neutral-100\">{{ service.config().argSeparator }}</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)=\"selectedIndex() === i && canExecute() && 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 ? (canExecute() ? 'bg-neutral-100 dark:bg-neutral-800' : 'bg-red-50 dark:bg-red-900/20 ring-1 ring-red-200 dark:ring-red-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 }}@if (item.params) { @for (param of item.params; track $index) {<span class=\"whitespace-pre\"> </span><span [class]=\"getParamColor($index)\">&lt;{{ param }}&gt;</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 <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"] }] });
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)\">&lt;{{ param }}&gt;</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 <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>@for (arg of argsArray(); track $index) {<span class=\"whitespace-pre text-neutral-900 dark:text-neutral-100\">{{ service.config().argSeparator }}</span><span [class]=\"getParamColor($index)\">{{ arg }}</span>}@if (hasSeparator() && args().endsWith(' ')) {<span class=\"whitespace-pre text-neutral-900 dark:text-neutral-100\">{{ service.config().argSeparator }}</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)=\"selectedIndex() === i && canExecute() && 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 ? (canExecute() ? 'bg-neutral-100 dark:bg-neutral-800' : 'bg-red-50 dark:bg-red-900/20 ring-1 ring-red-200 dark:ring-red-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 }}@if (item.params) { @for (param of item.params; track $index) {<span class=\"whitespace-pre\"> </span><span [class]=\"getParamColor($index)\">&lt;{{ param }}&gt;</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 <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"] }]
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)\">&lt;{{ param }}&gt;</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]