@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.
- package/.github/workflows/publish.yml +31 -0
- package/README.md +127 -0
- package/ng-package.json +8 -0
- package/package.json +39 -0
- package/src/lib/frecency.service.ts +185 -0
- package/src/lib/gigamenu-templates.directive.ts +178 -0
- package/src/lib/gigamenu.component.html +170 -0
- package/src/lib/gigamenu.component.ts +305 -0
- package/src/lib/gigamenu.service.ts +143 -0
- package/src/lib/types.ts +101 -0
- package/src/public-api.ts +6 -0
- package/tsconfig.json +14 -0
- package/tsconfig.lib.json +12 -0
- package/tsconfig.lib.prod.json +6 -0
|
@@ -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
|
package/ng-package.json
ADDED
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
|
+
}
|
package/src/lib/types.ts
ADDED
|
@@ -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
|
+
}
|
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
|
+
}
|