@flxgde/gigamenu 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ name: Build & Publish
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags:
7
+ - 'v*'
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: self-hosted
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+
15
+ - uses: actions/setup-node@v4
16
+ with:
17
+ node-version: '22'
18
+ registry-url: 'https://registry.npmjs.org'
19
+
20
+ - name: Install dependencies
21
+ run: npm ci
22
+
23
+ - name: Build
24
+ run: npm run build
25
+
26
+ - name: Publish
27
+ if: startsWith(github.ref, 'refs/tags/v')
28
+ working-directory: dist/gigamenu
29
+ run: npm publish --access public
30
+ env:
31
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
package/README.md ADDED
@@ -0,0 +1,127 @@
1
+ # Gigamenu
2
+
3
+ A keyboard-driven command palette menu for Angular applications. Inspired by VS Code's Command Palette, Spotlight, and Linear's command menu.
4
+
5
+ ## Features
6
+
7
+ - Keyboard shortcuts: `Ctrl/Cmd+K` and `/` (when no input is focused)
8
+ - Auto-discovery of routes from Angular Router
9
+ - Manual command registration API
10
+ - Full keyboard navigation (arrow keys, Enter, Escape)
11
+ - Fuzzy search filtering
12
+ - Dark mode support
13
+ - Tailwind CSS styling
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install gigamenu
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ ### 1. Import the component
24
+
25
+ ```typescript
26
+ import { Component, inject, OnInit } from '@angular/core';
27
+ import { GigamenuComponent, GigamenuService } from 'gigamenu';
28
+
29
+ @Component({
30
+ selector: 'app-root',
31
+ imports: [GigamenuComponent],
32
+ template: `
33
+ <router-outlet />
34
+ <gm-gigamenu />
35
+ `,
36
+ })
37
+ export class App implements OnInit {
38
+ private readonly gigamenu = inject(GigamenuService);
39
+
40
+ ngOnInit(): void {
41
+ // Auto-discover routes from Angular Router
42
+ this.gigamenu.discoverRoutes();
43
+ }
44
+ }
45
+ ```
46
+
47
+ ### 2. Register custom commands
48
+
49
+ ```typescript
50
+ this.gigamenu.registerCommand({
51
+ id: 'cmd:toggle-dark',
52
+ label: 'Toggle Dark Mode',
53
+ description: 'Switch between light and dark theme',
54
+ icon: '🌙',
55
+ keywords: ['theme', 'dark', 'light'],
56
+ action: () => document.documentElement.classList.toggle('dark'),
57
+ });
58
+ ```
59
+
60
+ ### 3. Register custom pages
61
+
62
+ ```typescript
63
+ this.gigamenu.registerPage({
64
+ id: 'page:dashboard',
65
+ label: 'Dashboard',
66
+ path: '/dashboard',
67
+ description: 'Go to the main dashboard',
68
+ });
69
+ ```
70
+
71
+ ## API
72
+
73
+ ### GigamenuService
74
+
75
+ | Method | Description |
76
+ |--------|-------------|
77
+ | `open()` | Open the menu |
78
+ | `close()` | Close the menu |
79
+ | `toggle()` | Toggle menu visibility |
80
+ | `discoverRoutes(routes?)` | Auto-discover pages from Angular Router |
81
+ | `registerCommand(command)` | Register a custom command |
82
+ | `registerPage(page)` | Register a custom page |
83
+ | `registerItem(item)` | Register a generic menu item |
84
+ | `unregisterItem(id)` | Remove an item by ID |
85
+ | `configure(config)` | Update configuration |
86
+
87
+ ### Configuration
88
+
89
+ ```typescript
90
+ interface GigamenuConfig {
91
+ placeholder?: string; // Search input placeholder
92
+ maxResults?: number; // Maximum items to show (default: 10)
93
+ autoDiscoverRoutes?: boolean; // Auto-discover on init
94
+ }
95
+ ```
96
+
97
+ ### Types
98
+
99
+ ```typescript
100
+ interface GigamenuItem {
101
+ id: string;
102
+ label: string;
103
+ description?: string;
104
+ icon?: string;
105
+ keywords?: string[];
106
+ category: 'page' | 'command';
107
+ action: () => void;
108
+ }
109
+ ```
110
+
111
+ ## Keyboard Shortcuts
112
+
113
+ | Shortcut | Action |
114
+ |----------|--------|
115
+ | `Ctrl/Cmd+K` | Open menu |
116
+ | `/` | Open menu (when no input focused) |
117
+ | `↑` / `↓` | Navigate items |
118
+ | `Enter` | Execute selected item |
119
+ | `Escape` | Close menu |
120
+
121
+ ## Styling
122
+
123
+ Gigamenu uses Tailwind CSS and supports dark mode via the `dark` class on `<html>`.
124
+
125
+ ## License
126
+
127
+ MIT
@@ -0,0 +1,8 @@
1
+ {
2
+ "$schema": "node_modules/ng-packagr/ng-package.schema.json",
3
+ "dest": "dist/gigamenu",
4
+ "lib": {
5
+ "entryFile": "src/public-api.ts"
6
+ },
7
+ "assets": ["README.md"]
8
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@flxgde/gigamenu",
3
+ "version": "0.0.1",
4
+ "description": "A keyboard-driven command palette menu for Angular applications",
5
+ "keywords": [
6
+ "angular",
7
+ "command-palette",
8
+ "keyboard-navigation",
9
+ "menu",
10
+ "spotlight",
11
+ "ctrl-k"
12
+ ],
13
+ "author": "",
14
+ "license": "MIT",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "https://github.com/flxgde/gigamenu"
18
+ },
19
+ "homepage": "https://github.com/flxgde/gigamenu#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/flxgde/gigamenu/issues"
22
+ },
23
+ "scripts": {
24
+ "build": "ng-packagr",
25
+ "build:prod": "ng-packagr -c tsconfig.lib.prod.json"
26
+ },
27
+ "sideEffects": false,
28
+ "peerDependencies": {
29
+ "@angular/common": "^21.0.0",
30
+ "@angular/core": "^21.0.0",
31
+ "@angular/router": "^21.0.0"
32
+ },
33
+ "devDependencies": {
34
+ "ng-packagr": "^21.0.0",
35
+ "@angular/compiler": "^21.0.0",
36
+ "@angular/compiler-cli": "^21.0.0",
37
+ "typescript": "~5.9.2"
38
+ }
39
+ }
@@ -0,0 +1,185 @@
1
+ import { Injectable, PLATFORM_ID, Inject } from '@angular/core';
2
+ import { isPlatformBrowser } from '@angular/common';
3
+
4
+ interface FrecencyEntry {
5
+ itemId: string;
6
+ count: number;
7
+ lastUsed: number;
8
+ }
9
+
10
+ interface FrecencyData {
11
+ [searchTerm: string]: FrecencyEntry[];
12
+ }
13
+
14
+ const STORAGE_KEY = 'gigamenu_frecency';
15
+ const GLOBAL_KEY = '__global__';
16
+ const MAX_ENTRIES_PER_TERM = 10;
17
+ const MAX_TERMS = 100;
18
+ const DECAY_FACTOR = 0.9;
19
+
20
+ @Injectable({ providedIn: 'root' })
21
+ export class FrecencyService {
22
+ private data: FrecencyData = {};
23
+ private readonly isBrowser: boolean;
24
+
25
+ constructor(@Inject(PLATFORM_ID) platformId: object) {
26
+ this.isBrowser = isPlatformBrowser(platformId);
27
+ this.load();
28
+ }
29
+
30
+ /**
31
+ * Record that an item was selected for a given search term.
32
+ */
33
+ recordSelection(searchTerm: string, itemId: string): void {
34
+ // Always record to global for overall frecency
35
+ this.recordToKey(GLOBAL_KEY, itemId);
36
+
37
+ // Also record to specific search term if provided
38
+ const normalizedTerm = this.normalizeTerm(searchTerm);
39
+ if (normalizedTerm) {
40
+ this.recordToKey(normalizedTerm, itemId);
41
+ }
42
+
43
+ this.pruneAndSave();
44
+ }
45
+
46
+ private recordToKey(key: string, itemId: string): void {
47
+ if (!this.data[key]) {
48
+ this.data[key] = [];
49
+ }
50
+
51
+ const entries = this.data[key];
52
+ const existing = entries.find((e) => e.itemId === itemId);
53
+
54
+ if (existing) {
55
+ existing.count++;
56
+ existing.lastUsed = Date.now();
57
+ } else {
58
+ entries.push({
59
+ itemId,
60
+ count: 1,
61
+ lastUsed: Date.now(),
62
+ });
63
+ }
64
+
65
+ // Keep only top entries
66
+ this.data[key] = entries
67
+ .sort((a, b) => this.calculateScore(b) - this.calculateScore(a))
68
+ .slice(0, MAX_ENTRIES_PER_TERM);
69
+ }
70
+
71
+ /**
72
+ * Get frecency scores for items matching a search term.
73
+ * Returns a map of itemId -> score (higher is better).
74
+ */
75
+ getScores(searchTerm: string): Map<string, number> {
76
+ const scores = new Map<string, number>();
77
+ const normalizedTerm = this.normalizeTerm(searchTerm);
78
+
79
+ // If no search term, return global scores
80
+ if (!normalizedTerm) {
81
+ const globalEntries = this.data[GLOBAL_KEY];
82
+ if (globalEntries) {
83
+ for (const entry of globalEntries) {
84
+ scores.set(entry.itemId, this.calculateScore(entry));
85
+ }
86
+ }
87
+ return scores;
88
+ }
89
+
90
+ // Exact match for search term
91
+ const exactEntries = this.data[normalizedTerm];
92
+ if (exactEntries) {
93
+ for (const entry of exactEntries) {
94
+ const score = this.calculateScore(entry);
95
+ scores.set(entry.itemId, score);
96
+ }
97
+ }
98
+
99
+ // Prefix matches (for partial typing)
100
+ for (const [term, entries] of Object.entries(this.data)) {
101
+ if (term !== GLOBAL_KEY && term !== normalizedTerm && term.startsWith(normalizedTerm)) {
102
+ for (const entry of entries) {
103
+ const currentScore = scores.get(entry.itemId) ?? 0;
104
+ // Prefix matches get reduced weight
105
+ const prefixScore = this.calculateScore(entry) * 0.5;
106
+ scores.set(entry.itemId, Math.max(currentScore, prefixScore));
107
+ }
108
+ }
109
+ }
110
+
111
+ return scores;
112
+ }
113
+
114
+ /**
115
+ * Get the most likely item for a search term (for auto-selection).
116
+ * Returns itemId if there's a strong match, null otherwise.
117
+ */
118
+ getTopMatch(searchTerm: string): string | null {
119
+ const normalizedTerm = this.normalizeTerm(searchTerm);
120
+ if (!normalizedTerm) return null;
121
+
122
+ const entries = this.data[normalizedTerm];
123
+ if (!entries || entries.length === 0) return null;
124
+
125
+ const topEntry = entries[0];
126
+ const score = this.calculateScore(topEntry);
127
+
128
+ // Only auto-select if strong confidence (used multiple times recently)
129
+ if (topEntry.count >= 2 && score > 5) {
130
+ return topEntry.itemId;
131
+ }
132
+
133
+ return null;
134
+ }
135
+
136
+ private calculateScore(entry: FrecencyEntry): number {
137
+ const ageInHours = (Date.now() - entry.lastUsed) / (60 * 60 * 24);
138
+ const recencyScore = Math.pow(DECAY_FACTOR, ageInHours);
139
+ return entry.count * recencyScore;
140
+ }
141
+
142
+ private normalizeTerm(term: string): string {
143
+ return term.toLowerCase().trim();
144
+ }
145
+
146
+ private load(): void {
147
+ if (!this.isBrowser) return;
148
+
149
+ try {
150
+ const stored = localStorage.getItem(STORAGE_KEY);
151
+ if (stored) {
152
+ this.data = JSON.parse(stored);
153
+ }
154
+ } catch {
155
+ this.data = {};
156
+ }
157
+ }
158
+
159
+ private pruneAndSave(): void {
160
+ if (!this.isBrowser) return;
161
+
162
+ // Prune old terms if we have too many
163
+ const terms = Object.keys(this.data);
164
+ if (terms.length > MAX_TERMS) {
165
+ const termScores = terms.map((term) => ({
166
+ term,
167
+ maxScore: Math.max(...this.data[term].map((e) => this.calculateScore(e))),
168
+ }));
169
+ termScores.sort((a, b) => b.maxScore - a.maxScore);
170
+
171
+ const keepTerms = new Set(termScores.slice(0, MAX_TERMS).map((t) => t.term));
172
+ for (const term of terms) {
173
+ if (!keepTerms.has(term)) {
174
+ delete this.data[term];
175
+ }
176
+ }
177
+ }
178
+
179
+ try {
180
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(this.data));
181
+ } catch {
182
+ // Storage full or unavailable
183
+ }
184
+ }
185
+ }
@@ -0,0 +1,178 @@
1
+ import { Directive, TemplateRef, inject } from '@angular/core';
2
+ import { GigamenuItem } from './types';
3
+
4
+ /**
5
+ * Context provided to the item template.
6
+ */
7
+ export interface GigamenuItemContext {
8
+ /** The menu item being rendered */
9
+ $implicit: GigamenuItem;
10
+ /** The index of the item in the filtered list */
11
+ index: number;
12
+ /** Whether this item is currently selected */
13
+ selected: boolean;
14
+ }
15
+
16
+ /**
17
+ * Context provided to the empty template.
18
+ */
19
+ export interface GigamenuEmptyContext {
20
+ /** The current search query */
21
+ $implicit: string;
22
+ }
23
+
24
+ /**
25
+ * Context provided to the header template.
26
+ */
27
+ export interface GigamenuHeaderContext {
28
+ /** The current full query */
29
+ $implicit: string;
30
+ /** The search term (before separator) */
31
+ searchTerm: string;
32
+ /** The arguments (after separator) */
33
+ args: string;
34
+ /** Callback to update the query */
35
+ onQueryChange: (value: string) => void;
36
+ /** Callback for keydown events */
37
+ onKeydown: (event: KeyboardEvent) => void;
38
+ /** Placeholder text */
39
+ placeholder: string;
40
+ }
41
+
42
+ /**
43
+ * Context provided to the footer template.
44
+ */
45
+ export interface GigamenuFooterContext {
46
+ /** Number of filtered items */
47
+ $implicit: number;
48
+ /** Total number of items */
49
+ total: number;
50
+ }
51
+
52
+ /**
53
+ * Template directive for customizing menu item rendering.
54
+ *
55
+ * @example
56
+ * ```html
57
+ * <gm-gigamenu>
58
+ * <ng-template gmItem let-item let-selected="selected" let-index="index">
59
+ * <div [class.active]="selected">
60
+ * {{ item.label }}
61
+ * </div>
62
+ * </ng-template>
63
+ * </gm-gigamenu>
64
+ * ```
65
+ */
66
+ @Directive({
67
+ selector: '[gmItem]',
68
+ standalone: true,
69
+ })
70
+ export class GigamenuItemTemplate {
71
+ readonly template = inject<TemplateRef<GigamenuItemContext>>(TemplateRef);
72
+ }
73
+
74
+ /**
75
+ * Template directive for customizing empty state rendering.
76
+ *
77
+ * @example
78
+ * ```html
79
+ * <gm-gigamenu>
80
+ * <ng-template gmEmpty let-query>
81
+ * <div>No results for "{{ query }}"</div>
82
+ * </ng-template>
83
+ * </gm-gigamenu>
84
+ * ```
85
+ */
86
+ @Directive({
87
+ selector: '[gmEmpty]',
88
+ standalone: true,
89
+ })
90
+ export class GigamenuEmptyTemplate {
91
+ readonly template = inject<TemplateRef<GigamenuEmptyContext>>(TemplateRef);
92
+ }
93
+
94
+ /**
95
+ * Template directive for customizing header/search input rendering.
96
+ *
97
+ * @example
98
+ * ```html
99
+ * <gm-gigamenu>
100
+ * <ng-template gmHeader let-query let-onQueryChange="onQueryChange" let-onKeydown="onKeydown">
101
+ * <input [value]="query" (input)="onQueryChange($event.target.value)" (keydown)="onKeydown($event)" />
102
+ * </ng-template>
103
+ * </gm-gigamenu>
104
+ * ```
105
+ */
106
+ @Directive({
107
+ selector: '[gmHeader]',
108
+ standalone: true,
109
+ })
110
+ export class GigamenuHeaderTemplate {
111
+ readonly template = inject<TemplateRef<GigamenuHeaderContext>>(TemplateRef);
112
+ }
113
+
114
+ /**
115
+ * Template directive for customizing footer rendering.
116
+ *
117
+ * @example
118
+ * ```html
119
+ * <gm-gigamenu>
120
+ * <ng-template gmFooter let-count let-total="total">
121
+ * <div>Showing {{ count }} of {{ total }} items</div>
122
+ * </ng-template>
123
+ * </gm-gigamenu>
124
+ * ```
125
+ */
126
+ @Directive({
127
+ selector: '[gmFooter]',
128
+ standalone: true,
129
+ })
130
+ export class GigamenuFooterTemplate {
131
+ readonly template = inject<TemplateRef<GigamenuFooterContext>>(TemplateRef);
132
+ }
133
+
134
+ /**
135
+ * Template directive for customizing the entire panel/dialog container.
136
+ * When provided, replaces the entire default panel structure.
137
+ *
138
+ * @example
139
+ * ```html
140
+ * <gm-gigamenu>
141
+ * <ng-template gmPanel let-items let-query="query" let-selectedIndex="selectedIndex">
142
+ * <div class="my-custom-panel">
143
+ * <!-- Custom implementation -->
144
+ * </div>
145
+ * </ng-template>
146
+ * </gm-gigamenu>
147
+ * ```
148
+ */
149
+ export interface GigamenuPanelContext {
150
+ /** Filtered items to display */
151
+ $implicit: GigamenuItem[];
152
+ /** Current full query */
153
+ query: string;
154
+ /** The search term (before separator) */
155
+ searchTerm: string;
156
+ /** The arguments (after separator) */
157
+ args: string;
158
+ /** Currently selected index */
159
+ selectedIndex: number;
160
+ /** Callback to execute an item */
161
+ executeItem: (item: GigamenuItem) => void;
162
+ /** Callback to update selection */
163
+ setSelectedIndex: (index: number) => void;
164
+ /** Callback to update query */
165
+ setQuery: (query: string) => void;
166
+ /** Callback to close the menu */
167
+ close: () => void;
168
+ /** Placeholder text from config */
169
+ placeholder: string;
170
+ }
171
+
172
+ @Directive({
173
+ selector: '[gmPanel]',
174
+ standalone: true,
175
+ })
176
+ export class GigamenuPanelTemplate {
177
+ readonly template = inject<TemplateRef<GigamenuPanelContext>>(TemplateRef);
178
+ }
@@ -0,0 +1,170 @@
1
+ @if (service.isOpen()) {
2
+ <div
3
+ class="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]"
4
+ (click)="onBackdropClick($event)"
5
+ >
6
+ <div class="fixed inset-0 bg-black/50 backdrop-blur-sm"></div>
7
+
8
+ <!-- Custom panel template -->
9
+ @if (panelTemplate(); as pt) {
10
+ <ng-container
11
+ [ngTemplateOutlet]="pt.template"
12
+ [ngTemplateOutletContext]="getPanelContext()"
13
+ ></ng-container>
14
+ } @else {
15
+ <!-- Default panel -->
16
+ <div
17
+ 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"
18
+ role="dialog"
19
+ aria-modal="true"
20
+ aria-label="Command menu"
21
+ >
22
+ <!-- Header -->
23
+ @if (headerTemplate(); as ht) {
24
+ <ng-container
25
+ [ngTemplateOutlet]="ht.template"
26
+ [ngTemplateOutletContext]="getHeaderContext()"
27
+ ></ng-container>
28
+ } @else {
29
+ <div class="flex items-center border-b border-neutral-200 px-4 dark:border-neutral-700">
30
+ <svg
31
+ class="h-5 w-5 text-neutral-400"
32
+ fill="none"
33
+ stroke="currentColor"
34
+ viewBox="0 0 24 24"
35
+ >
36
+ <path
37
+ stroke-linecap="round"
38
+ stroke-linejoin="round"
39
+ stroke-width="2"
40
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
41
+ />
42
+ </svg>
43
+ <div class="relative flex-1">
44
+ <!-- Styled overlay for syntax highlighting -->
45
+ <div
46
+ class="pointer-events-none absolute inset-0 flex items-center px-3 py-4 text-base"
47
+ aria-hidden="true"
48
+ >
49
+ <span class="text-neutral-900 dark:text-neutral-100">{{ searchTerm() }}</span>@if (args()) {<span class="text-neutral-900 dark:text-neutral-100">{{ service.config().argSeparator }}</span><span class="text-purple-600 dark:text-purple-400">{{ args() }}</span>}
50
+ </div>
51
+ <!-- Transparent input on top -->
52
+ <input
53
+ #searchInput
54
+ type="text"
55
+ [placeholder]="service.config().placeholder"
56
+ [value]="query()"
57
+ (input)="onQueryChange($event)"
58
+ (keydown)="onInputKeydown($event)"
59
+ 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"
60
+ />
61
+ </div>
62
+ <kbd
63
+ 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"
64
+ >
65
+ ESC
66
+ </kbd>
67
+ </div>
68
+ }
69
+
70
+ <!-- Items list -->
71
+ <div #listContainer class="max-h-80 overflow-y-auto p-2">
72
+ @if (filteredItems().length === 0) {
73
+ <!-- Empty state -->
74
+ @if (emptyTemplate(); as et) {
75
+ <ng-container
76
+ [ngTemplateOutlet]="et.template"
77
+ [ngTemplateOutletContext]="getEmptyContext()"
78
+ ></ng-container>
79
+ } @else {
80
+ <div class="px-3 py-8 text-center text-neutral-500">
81
+ No results found
82
+ </div>
83
+ }
84
+ } @else {
85
+ @for (item of filteredItems(); track item.id; let i = $index) {
86
+ <!-- Custom item template -->
87
+ @if (itemTemplate(); as it) {
88
+ <ng-container
89
+ [ngTemplateOutlet]="it.template"
90
+ [ngTemplateOutletContext]="getItemContext(item, i)"
91
+ ></ng-container>
92
+ } @else {
93
+ <!-- Default item -->
94
+ <button
95
+ type="button"
96
+ (click)="executeItem(item)"
97
+ (mouseenter)="selectedIndex.set(i)"
98
+ [attr.data-index]="i"
99
+ [class]="
100
+ 'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left transition-colors ' +
101
+ (selectedIndex() === i
102
+ ? 'bg-neutral-100 dark:bg-neutral-800'
103
+ : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/50')
104
+ "
105
+ >
106
+ @if (item.iconClass) {
107
+ <i [class]="item.iconClass + ' text-lg text-neutral-600 dark:text-neutral-300'"></i>
108
+ } @else if (item.icon) {
109
+ <span class="text-lg">{{ item.icon }}</span>
110
+ } @else {
111
+ <span
112
+ 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"
113
+ >
114
+ {{ item.category === 'page' ? 'P' : 'C' }}
115
+ </span>
116
+ }
117
+ <div class="flex-1 min-w-0">
118
+ <div class="truncate font-medium text-neutral-900 dark:text-neutral-100">
119
+ {{ item.label }}
120
+ </div>
121
+ @if (item.description) {
122
+ <div class="truncate text-sm text-neutral-500 dark:text-neutral-400">
123
+ {{ item.description }}
124
+ </div>
125
+ }
126
+ </div>
127
+ <span
128
+ class="rounded-full px-2 py-0.5 text-xs"
129
+ [class]="
130
+ item.category === 'page'
131
+ ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
132
+ : 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
133
+ "
134
+ >
135
+ {{ item.category }}
136
+ </span>
137
+ </button>
138
+ }
139
+ }
140
+ }
141
+ </div>
142
+
143
+ <!-- Footer -->
144
+ @if (footerTemplate(); as ft) {
145
+ <ng-container
146
+ [ngTemplateOutlet]="ft.template"
147
+ [ngTemplateOutletContext]="getFooterContext()"
148
+ ></ng-container>
149
+ } @else {
150
+ <div
151
+ class="flex items-center justify-between border-t border-neutral-200 px-4 py-2 text-xs text-neutral-500 dark:border-neutral-700"
152
+ >
153
+ <div class="flex items-center gap-3">
154
+ <span class="flex items-center gap-1">
155
+ <kbd class="rounded border border-neutral-300 bg-neutral-100 px-1 dark:border-neutral-600 dark:bg-neutral-800">↑</kbd>
156
+ <kbd class="rounded border border-neutral-300 bg-neutral-100 px-1 dark:border-neutral-600 dark:bg-neutral-800">↓</kbd>
157
+ navigate
158
+ </span>
159
+ <span class="flex items-center gap-1">
160
+ <kbd class="rounded border border-neutral-300 bg-neutral-100 px-1 dark:border-neutral-600 dark:bg-neutral-800">↵</kbd>
161
+ select
162
+ </span>
163
+ </div>
164
+ <span>gigamenu</span>
165
+ </div>
166
+ }
167
+ </div>
168
+ }
169
+ </div>
170
+ }
@@ -0,0 +1,305 @@
1
+ import {
2
+ Component,
3
+ signal,
4
+ computed,
5
+ effect,
6
+ ElementRef,
7
+ viewChild,
8
+ contentChild,
9
+ HostListener,
10
+ PLATFORM_ID,
11
+ Inject,
12
+ TemplateRef,
13
+ } from '@angular/core';
14
+ import { isPlatformBrowser, NgTemplateOutlet } from '@angular/common';
15
+ import { GigamenuService } from './gigamenu.service';
16
+ import { FrecencyService } from './frecency.service';
17
+ import { GigamenuItem } from './types';
18
+ import {
19
+ GigamenuItemTemplate,
20
+ GigamenuEmptyTemplate,
21
+ GigamenuHeaderTemplate,
22
+ GigamenuFooterTemplate,
23
+ GigamenuPanelTemplate,
24
+ GigamenuItemContext,
25
+ GigamenuEmptyContext,
26
+ GigamenuHeaderContext,
27
+ GigamenuFooterContext,
28
+ GigamenuPanelContext,
29
+ } from './gigamenu-templates.directive';
30
+
31
+ @Component({
32
+ selector: 'gm-gigamenu',
33
+ standalone: true,
34
+ imports: [NgTemplateOutlet],
35
+ templateUrl: 'gigamenu.component.html',
36
+ styles: `
37
+ :host {
38
+ display: contents;
39
+ }
40
+ `,
41
+ })
42
+ export class GigamenuComponent {
43
+ private readonly searchInput = viewChild<ElementRef<HTMLInputElement>>('searchInput');
44
+ private readonly listContainer = viewChild<ElementRef<HTMLDivElement>>('listContainer');
45
+ private readonly isBrowser: boolean;
46
+
47
+ // Template queries
48
+ protected readonly itemTemplate = contentChild(GigamenuItemTemplate);
49
+ protected readonly emptyTemplate = contentChild(GigamenuEmptyTemplate);
50
+ protected readonly headerTemplate = contentChild(GigamenuHeaderTemplate);
51
+ protected readonly footerTemplate = contentChild(GigamenuFooterTemplate);
52
+ protected readonly panelTemplate = contentChild(GigamenuPanelTemplate);
53
+
54
+ protected readonly query = signal('');
55
+ protected readonly selectedIndex = signal(0);
56
+
57
+ /** Parsed search term (before first separator) */
58
+ protected readonly searchTerm = computed(() => {
59
+ const q = this.query();
60
+ const separator = this.service.config().argSeparator ?? ' ';
61
+ const sepIndex = q.indexOf(separator);
62
+ if (sepIndex === -1) return q;
63
+ return q.substring(0, sepIndex);
64
+ });
65
+
66
+ /** Parsed arguments (after first separator) */
67
+ protected readonly args = computed(() => {
68
+ const q = this.query();
69
+ const separator = this.service.config().argSeparator ?? ' ';
70
+ const sepIndex = q.indexOf(separator);
71
+ if (sepIndex === -1) return '';
72
+ return q.substring(sepIndex + separator.length);
73
+ });
74
+
75
+ protected readonly filteredItems = computed(() => {
76
+ const searchTerm = this.searchTerm().toLowerCase().trim();
77
+ const items = this.service.items();
78
+ const maxResults = this.service.config().maxResults ?? 10;
79
+
80
+ if (!searchTerm) {
81
+ // No query: sort by frecency scores from empty searches
82
+ const scores = this.frecency.getScores('');
83
+ return this.sortByFrecency(items, scores).slice(0, maxResults);
84
+ }
85
+
86
+ // Filter matching items using only search term (not args)
87
+ const matched = items.filter((item) => this.matchesQuery(item, searchTerm));
88
+
89
+ // Sort by frecency for this search term
90
+ const scores = this.frecency.getScores(searchTerm);
91
+ return this.sortByFrecency(matched, scores).slice(0, maxResults);
92
+ });
93
+
94
+ constructor(
95
+ protected readonly service: GigamenuService,
96
+ private readonly frecency: FrecencyService,
97
+ @Inject(PLATFORM_ID) platformId: object
98
+ ) {
99
+ this.isBrowser = isPlatformBrowser(platformId);
100
+
101
+ effect(() => {
102
+ if (this.service.isOpen() && this.isBrowser) {
103
+ setTimeout(() => this.searchInput()?.nativeElement.focus(), 0);
104
+ }
105
+ });
106
+
107
+ effect(() => {
108
+ const items = this.filteredItems();
109
+ const searchTerm = this.searchTerm();
110
+
111
+ // Check for auto-select based on frecency
112
+ if (searchTerm && items.length > 0) {
113
+ const topMatch = this.frecency.getTopMatch(searchTerm);
114
+ if (topMatch) {
115
+ const idx = items.findIndex((item) => item.id === topMatch);
116
+ if (idx !== -1) {
117
+ this.selectedIndex.set(idx);
118
+ return;
119
+ }
120
+ }
121
+ }
122
+
123
+ this.selectedIndex.set(0);
124
+ });
125
+ }
126
+
127
+ @HostListener('document:keydown', ['$event'])
128
+ onGlobalKeydown(event: KeyboardEvent): void {
129
+ if (!this.isBrowser) return;
130
+
131
+ if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
132
+ event.preventDefault();
133
+ this.service.toggle();
134
+ return;
135
+ }
136
+
137
+ if (event.key === '/' && !this.isInputFocused()) {
138
+ event.preventDefault();
139
+ this.service.open();
140
+ return;
141
+ }
142
+
143
+ if (event.key === 'Escape' && this.service.isOpen()) {
144
+ event.preventDefault();
145
+ this.close();
146
+ }
147
+ }
148
+
149
+ protected onInputKeydown(event: KeyboardEvent): void {
150
+ const items = this.filteredItems();
151
+
152
+ switch (event.key) {
153
+ case 'ArrowDown':
154
+ event.preventDefault();
155
+ this.selectedIndex.update((i) => Math.min(i + 1, items.length - 1));
156
+ this.scrollSelectedIntoView();
157
+ break;
158
+
159
+ case 'ArrowUp':
160
+ event.preventDefault();
161
+ this.selectedIndex.update((i) => Math.max(i - 1, 0));
162
+ this.scrollSelectedIntoView();
163
+ break;
164
+
165
+ case 'Enter':
166
+ event.preventDefault();
167
+ const selected = items[this.selectedIndex()];
168
+ if (selected) {
169
+ this.executeItem(selected);
170
+ }
171
+ break;
172
+
173
+ case 'Escape':
174
+ event.preventDefault();
175
+ this.close();
176
+ break;
177
+ }
178
+ }
179
+
180
+ private scrollSelectedIntoView(): void {
181
+ const container = this.listContainer()?.nativeElement;
182
+ if (!container) return;
183
+
184
+ const selectedButton = container.querySelector(
185
+ `[data-index="${this.selectedIndex()}"]`
186
+ ) as HTMLElement | null;
187
+
188
+ if (selectedButton) {
189
+ selectedButton.scrollIntoView({ block: 'nearest' });
190
+ }
191
+ }
192
+
193
+ protected onQueryChange(event: Event): void {
194
+ const value = (event.target as HTMLInputElement).value;
195
+ this.query.set(value);
196
+ }
197
+
198
+ protected onBackdropClick(event: MouseEvent): void {
199
+ if (event.target === event.currentTarget) {
200
+ this.close();
201
+ }
202
+ }
203
+
204
+ protected executeItem(item: GigamenuItem): void {
205
+ // Record the selection for frecency learning (use search term, not full query)
206
+ const searchTerm = this.searchTerm();
207
+ this.frecency.recordSelection(searchTerm, item.id);
208
+
209
+ // Get args before closing (which resets query)
210
+ const args = this.args() || undefined;
211
+
212
+ this.close();
213
+ item.action(args);
214
+ }
215
+
216
+ // Template context getters
217
+ protected getItemContext(item: GigamenuItem, index: number): GigamenuItemContext {
218
+ return {
219
+ $implicit: item,
220
+ index,
221
+ selected: this.selectedIndex() === index,
222
+ };
223
+ }
224
+
225
+ protected getEmptyContext(): GigamenuEmptyContext {
226
+ return {
227
+ $implicit: this.query(),
228
+ };
229
+ }
230
+
231
+ protected getHeaderContext(): GigamenuHeaderContext {
232
+ return {
233
+ $implicit: this.query(),
234
+ searchTerm: this.searchTerm(),
235
+ args: this.args(),
236
+ onQueryChange: (value: string) => this.query.set(value),
237
+ onKeydown: (event: KeyboardEvent) => this.onInputKeydown(event),
238
+ placeholder: this.service.config().placeholder ?? '',
239
+ };
240
+ }
241
+
242
+ protected getFooterContext(): GigamenuFooterContext {
243
+ return {
244
+ $implicit: this.filteredItems().length,
245
+ total: this.service.items().length,
246
+ };
247
+ }
248
+
249
+ protected getPanelContext(): GigamenuPanelContext {
250
+ return {
251
+ $implicit: this.filteredItems(),
252
+ query: this.query(),
253
+ searchTerm: this.searchTerm(),
254
+ args: this.args(),
255
+ selectedIndex: this.selectedIndex(),
256
+ executeItem: (item: GigamenuItem) => this.executeItem(item),
257
+ setSelectedIndex: (index: number) => this.selectedIndex.set(index),
258
+ setQuery: (query: string) => this.query.set(query),
259
+ close: () => this.close(),
260
+ placeholder: this.service.config().placeholder ?? '',
261
+ };
262
+ }
263
+
264
+ private close(): void {
265
+ this.service.close();
266
+ this.query.set('');
267
+ this.selectedIndex.set(0);
268
+ }
269
+
270
+ private sortByFrecency(items: GigamenuItem[], scores: Map<string, number>): GigamenuItem[] {
271
+ if (scores.size === 0) return items;
272
+
273
+ return [...items].sort((a, b) => {
274
+ const scoreA = scores.get(a.id) ?? 0;
275
+ const scoreB = scores.get(b.id) ?? 0;
276
+ return scoreB - scoreA;
277
+ });
278
+ }
279
+
280
+ private matchesQuery(item: GigamenuItem, query: string): boolean {
281
+ const searchableText = [
282
+ item.label,
283
+ item.description,
284
+ ...(item.keywords ?? []),
285
+ ]
286
+ .filter(Boolean)
287
+ .join(' ')
288
+ .toLowerCase();
289
+
290
+ const words = query.split(/\s+/);
291
+ return words.every((word) => searchableText.includes(word));
292
+ }
293
+
294
+ private isInputFocused(): boolean {
295
+ const activeElement = document.activeElement;
296
+ if (!activeElement) return false;
297
+
298
+ const tagName = activeElement.tagName.toLowerCase();
299
+ return (
300
+ tagName === 'input' ||
301
+ tagName === 'textarea' ||
302
+ (activeElement as HTMLElement).isContentEditable
303
+ );
304
+ }
305
+ }
@@ -0,0 +1,143 @@
1
+ import { Injectable, signal, computed } from '@angular/core';
2
+ import { Router, Route, Routes } from '@angular/router';
3
+ import {
4
+ GigamenuItem,
5
+ GigamenuCommand,
6
+ GigamenuPage,
7
+ GigamenuConfig,
8
+ DEFAULT_CONFIG,
9
+ DiscoverRoutesOptions,
10
+ RouteInfo,
11
+ } from './types';
12
+
13
+ @Injectable({ providedIn: 'root' })
14
+ export class GigamenuService {
15
+ private readonly _items = signal<Map<string, GigamenuItem>>(new Map());
16
+ private readonly _isOpen = signal(false);
17
+ private readonly _config = signal<GigamenuConfig>(DEFAULT_CONFIG);
18
+
19
+ readonly items = computed(() => Array.from(this._items().values()));
20
+ readonly isOpen = this._isOpen.asReadonly();
21
+ readonly config = this._config.asReadonly();
22
+
23
+ constructor(private readonly router: Router) {}
24
+
25
+ configure(config: Partial<GigamenuConfig>): void {
26
+ this._config.update((current) => ({ ...current, ...config }));
27
+ }
28
+
29
+ open(): void {
30
+ this._isOpen.set(true);
31
+ }
32
+
33
+ close(): void {
34
+ this._isOpen.set(false);
35
+ }
36
+
37
+ toggle(): void {
38
+ this._isOpen.update((v) => !v);
39
+ }
40
+
41
+ registerItem(item: GigamenuItem): void {
42
+ this._items.update((items) => {
43
+ const newItems = new Map(items);
44
+ newItems.set(item.id, item);
45
+ return newItems;
46
+ });
47
+ }
48
+
49
+ unregisterItem(id: string): void {
50
+ this._items.update((items) => {
51
+ const newItems = new Map(items);
52
+ newItems.delete(id);
53
+ return newItems;
54
+ });
55
+ }
56
+
57
+ registerCommand(command: GigamenuCommand): void {
58
+ this.registerItem({
59
+ ...command,
60
+ category: 'command',
61
+ });
62
+ }
63
+
64
+ registerPage(page: GigamenuPage): void {
65
+ this.registerItem({
66
+ ...page,
67
+ category: 'page',
68
+ action: () => this.router.navigate([page.path]),
69
+ });
70
+ }
71
+
72
+ discoverRoutes(options?: DiscoverRoutesOptions): void;
73
+ discoverRoutes(routes?: Routes, options?: DiscoverRoutesOptions): void;
74
+ discoverRoutes(
75
+ routesOrOptions?: Routes | DiscoverRoutesOptions,
76
+ maybeOptions?: DiscoverRoutesOptions
77
+ ): void {
78
+ let routes: Routes;
79
+ let options: DiscoverRoutesOptions | undefined;
80
+
81
+ if (Array.isArray(routesOrOptions)) {
82
+ routes = routesOrOptions;
83
+ options = maybeOptions;
84
+ } else {
85
+ routes = this.router.config;
86
+ options = routesOrOptions;
87
+ }
88
+
89
+ this.extractPagesFromRoutes(routes, '', options?.filter);
90
+ }
91
+
92
+ private extractPagesFromRoutes(
93
+ routes: Routes,
94
+ parentPath: string,
95
+ filter?: (route: RouteInfo) => boolean
96
+ ): void {
97
+ for (const route of routes) {
98
+ if (route.redirectTo !== undefined) continue;
99
+
100
+ const fullPath = parentPath
101
+ ? `${parentPath}/${route.path ?? ''}`
102
+ : route.path ?? '';
103
+
104
+ if (route.path !== undefined && route.path !== '**') {
105
+ const routeInfo: RouteInfo = {
106
+ path: route.path,
107
+ fullPath: `/${fullPath}`,
108
+ data: route.data as Record<string, unknown> | undefined,
109
+ title: typeof route.title === 'string' ? route.title : undefined,
110
+ };
111
+
112
+ // Apply filter if provided
113
+ if (filter && !filter(routeInfo)) {
114
+ // Still process children even if this route is filtered
115
+ if (route.children) {
116
+ this.extractPagesFromRoutes(route.children, fullPath, filter);
117
+ }
118
+ continue;
119
+ }
120
+
121
+ const label = routeInfo.title || this.pathToLabel(route.path || 'Home');
122
+ this.registerPage({
123
+ id: `page:${fullPath || '/'}`,
124
+ label,
125
+ path: `/${fullPath}`,
126
+ description: `Navigate to ${label}`,
127
+ });
128
+ }
129
+
130
+ if (route.children) {
131
+ this.extractPagesFromRoutes(route.children, fullPath, filter);
132
+ }
133
+ }
134
+ }
135
+
136
+ private pathToLabel(path: string): string {
137
+ return path
138
+ .split('/')
139
+ .pop()!
140
+ .replace(/[-_]/g, ' ')
141
+ .replace(/\b\w/g, (c) => c.toUpperCase());
142
+ }
143
+ }
@@ -0,0 +1,101 @@
1
+ export type GigamenuItemCategory = 'page' | 'command';
2
+
3
+ export interface GigamenuItem {
4
+ id: string;
5
+ label: string;
6
+ description?: string;
7
+ /** Emoji or text icon */
8
+ icon?: string;
9
+ /** CSS class for icon libraries (e.g., 'pi pi-home', 'fa fa-home') */
10
+ iconClass?: string;
11
+ keywords?: string[];
12
+ category: GigamenuItemCategory;
13
+ /** Action to execute. Receives args string if user typed text after the separator. */
14
+ action: (args?: string) => void;
15
+ }
16
+
17
+ export interface GigamenuPage extends Omit<GigamenuItem, 'category' | 'action'> {
18
+ path: string;
19
+ }
20
+
21
+ export interface GigamenuCommand extends Omit<GigamenuItem, 'category'> {
22
+ shortcut?: string;
23
+ }
24
+
25
+ export interface GigamenuConfig {
26
+ placeholder?: string;
27
+ maxResults?: number;
28
+ autoDiscoverRoutes?: boolean;
29
+ /** Separator between search query and arguments (default: ' ') */
30
+ argSeparator?: string;
31
+ }
32
+
33
+ export const DEFAULT_CONFIG: GigamenuConfig = {
34
+ placeholder: 'Search pages and commands...',
35
+ maxResults: 10,
36
+ autoDiscoverRoutes: true,
37
+ argSeparator: ' ',
38
+ };
39
+
40
+ /**
41
+ * Base interface for defining a command in a separate file.
42
+ * Each command file should export a constant implementing this interface.
43
+ */
44
+ export interface CommandDefinition {
45
+ readonly id: string;
46
+ readonly label: string;
47
+ readonly description?: string;
48
+ readonly icon?: string;
49
+ readonly iconClass?: string;
50
+ readonly keywords?: string[];
51
+ readonly shortcut?: string;
52
+ execute(): void;
53
+ }
54
+
55
+ /**
56
+ * Helper function to define a command with type safety.
57
+ */
58
+ export function defineCommand(command: CommandDefinition): CommandDefinition {
59
+ return command;
60
+ }
61
+
62
+ /**
63
+ * Information about a route passed to the filter function.
64
+ */
65
+ export interface RouteInfo {
66
+ path: string;
67
+ fullPath: string;
68
+ data?: Record<string, unknown>;
69
+ title?: string;
70
+ }
71
+
72
+ /**
73
+ * Filter function to include/exclude routes from discovery.
74
+ * Return true to include the route, false to exclude it.
75
+ */
76
+ export type RouteFilter = (route: RouteInfo) => boolean;
77
+
78
+ /**
79
+ * Mapped page data returned from the map function.
80
+ */
81
+ export interface MappedPage {
82
+ label?: string;
83
+ description?: string;
84
+ icon?: string;
85
+ iconClass?: string;
86
+ keywords?: string[];
87
+ }
88
+
89
+ /**
90
+ * Map function to customize page data for discovered routes.
91
+ * Return partial page data to override defaults, or null to skip the route.
92
+ */
93
+ export type RouteMapper = (route: RouteInfo) => MappedPage | null;
94
+
95
+ /**
96
+ * Options for route discovery.
97
+ */
98
+ export interface DiscoverRoutesOptions {
99
+ filter?: RouteFilter;
100
+ map?: RouteMapper;
101
+ }
@@ -0,0 +1,6 @@
1
+ export * from './lib/types';
2
+ export { defineCommand } from './lib/types';
3
+ export * from './lib/gigamenu.service';
4
+ export * from './lib/gigamenu.component';
5
+ export * from './lib/frecency.service';
6
+ export * from './lib/gigamenu-templates.directive';
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ES2022",
5
+ "moduleResolution": "bundler",
6
+ "lib": ["ES2022", "DOM"],
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true,
11
+ "experimentalDecorators": true,
12
+ "useDefineForClassFields": false
13
+ }
14
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "out-tsc/lib",
5
+ "declaration": true,
6
+ "declarationMap": true,
7
+ "inlineSources": true,
8
+ "baseUrl": "."
9
+ },
10
+ "include": ["src/**/*.ts"],
11
+ "exclude": ["**/*.spec.ts"]
12
+ }
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "./tsconfig.lib.json",
3
+ "compilerOptions": {
4
+ "declarationMap": false
5
+ }
6
+ }