@flxgde/gigamenu 0.0.1 → 0.0.2

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.
@@ -1,305 +0,0 @@
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
- }
@@ -1,143 +0,0 @@
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
- }
package/src/lib/types.ts DELETED
@@ -1,101 +0,0 @@
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
- }
package/src/public-api.ts DELETED
@@ -1,6 +0,0 @@
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 DELETED
@@ -1,14 +0,0 @@
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
- }
package/tsconfig.lib.json DELETED
@@ -1,12 +0,0 @@
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
- }
@@ -1,6 +0,0 @@
1
- {
2
- "extends": "./tsconfig.lib.json",
3
- "compilerOptions": {
4
- "declarationMap": false
5
- }
6
- }