@flxgde/gigamenu 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +127 -127
- package/fesm2022/flxgde-gigamenu.mjs +645 -0
- package/fesm2022/flxgde-gigamenu.mjs.map +1 -0
- package/package.json +43 -39
- package/styles.css +2 -0
- package/types/flxgde-gigamenu.d.ts +336 -0
- package/.github/workflows/publish.yml +0 -31
- package/ng-package.json +0 -8
- package/src/lib/frecency.service.ts +0 -185
- package/src/lib/gigamenu-templates.directive.ts +0 -178
- package/src/lib/gigamenu.component.html +0 -170
- package/src/lib/gigamenu.component.ts +0 -305
- package/src/lib/gigamenu.service.ts +0 -143
- package/src/lib/types.ts +0 -101
- package/src/public-api.ts +0 -6
- package/tsconfig.json +0 -14
- package/tsconfig.lib.json +0 -12
- package/tsconfig.lib.prod.json +0 -6
|
@@ -1,185 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,178 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,170 +0,0 @@
|
|
|
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
|
-
}
|