angular-agents-skills 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +205 -0
- package/adapters/claude/index.ts +53 -0
- package/adapters/codex/index.ts +55 -0
- package/adapters/copilot/index.ts +45 -0
- package/adapters/cursor/index.ts +51 -0
- package/adapters/opencode/index.ts +63 -0
- package/agents/angular-architect/agent.md +143 -0
- package/agents/angular-architect/configs/claude.yaml +3 -0
- package/agents/angular-architect/configs/codex.yaml +2 -0
- package/agents/angular-architect/configs/opencode.yaml +5 -0
- package/agents/angular-migrator/agent.md +146 -0
- package/agents/angular-migrator/configs/claude.yaml +3 -0
- package/agents/angular-migrator/configs/codex.yaml +2 -0
- package/agents/angular-migrator/configs/opencode.yaml +5 -0
- package/agents/angular-reviewer/agent.md +74 -0
- package/agents/angular-reviewer/configs/claude.yaml +3 -0
- package/agents/angular-reviewer/configs/codex.yaml +2 -0
- package/agents/angular-reviewer/configs/opencode.yaml +5 -0
- package/dist/adapters/claude/index.js +45 -0
- package/dist/adapters/codex/index.js +46 -0
- package/dist/adapters/copilot/index.js +37 -0
- package/dist/adapters/cursor/index.js +43 -0
- package/dist/adapters/opencode/index.js +53 -0
- package/dist/src/cli.js +293 -0
- package/dist/src/index.js +6 -0
- package/dist/src/registry.js +13 -0
- package/dist/src/types.js +1 -0
- package/package.json +45 -0
- package/skills/architecture/injection-tokens/SKILL.md +82 -0
- package/skills/architecture/overlay-animation-lifecycle/SKILL.md +98 -0
- package/skills/components/content-projection-ng/SKILL.md +89 -0
- package/skills/components/dynamic-components/SKILL.md +74 -0
- package/skills/components/modern-host-bindings/SKILL.md +71 -0
- package/skills/components/viewchild-contentchild-signals/SKILL.md +66 -0
- package/skills/libraries/library-versioning/SKILL.md +49 -0
- package/skills/libraries/monorepo-ng-packagr/SKILL.md +69 -0
- package/skills/libraries/standalone-component-library/SKILL.md +103 -0
- package/skills/performance/control-flow-syntax/SKILL.md +94 -0
- package/skills/performance/defer-blocks/SKILL.md +83 -0
- package/skills/quality/pr-reviewer/SKILL.md +131 -0
- package/skills/quality/vitest-angular-components/SKILL.md +88 -0
- package/skills/reactivity/signals-effects/SKILL.md +63 -0
- package/skills/reactivity/signals-inputs-outputs/SKILL.md +76 -0
- package/skills/reactivity/signals-state-management/SKILL.md +70 -0
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { resolve, join, dirname } from 'node:path';
|
|
3
|
+
import { parse as parseYaml } from 'yaml';
|
|
4
|
+
import { registerAdapter, getAdapter, listAdapters, hasAdapter } from './registry.js';
|
|
5
|
+
import { createOpenCodeAdapter } from '../adapters/opencode/index.js';
|
|
6
|
+
import { createClaudeAdapter } from '../adapters/claude/index.js';
|
|
7
|
+
import { createCodexAdapter } from '../adapters/codex/index.js';
|
|
8
|
+
import { createCursorAdapter } from '../adapters/cursor/index.js';
|
|
9
|
+
import { createCopilotAdapter } from '../adapters/copilot/index.js';
|
|
10
|
+
registerAdapter(createOpenCodeAdapter());
|
|
11
|
+
registerAdapter(createClaudeAdapter());
|
|
12
|
+
registerAdapter(createCodexAdapter());
|
|
13
|
+
registerAdapter(createCursorAdapter());
|
|
14
|
+
registerAdapter(createCopilotAdapter());
|
|
15
|
+
function loadAgent(agentName, agentsDir) {
|
|
16
|
+
const agentPath = join(agentsDir, agentName);
|
|
17
|
+
if (!existsSync(agentPath)) {
|
|
18
|
+
throw new Error(`Agent "${agentName}" not found at ${agentPath}`);
|
|
19
|
+
}
|
|
20
|
+
const mdPath = join(agentPath, 'agent.md');
|
|
21
|
+
if (!existsSync(mdPath)) {
|
|
22
|
+
throw new Error(`Agent "${agentName}" missing agent.md at ${mdPath}`);
|
|
23
|
+
}
|
|
24
|
+
const mdContent = readFileSync(mdPath, 'utf-8');
|
|
25
|
+
const name = agentName;
|
|
26
|
+
const description = mdContent.split('\n')[0]?.replace(/^#\s+/, '') || agentName;
|
|
27
|
+
return {
|
|
28
|
+
metadata: { name, description },
|
|
29
|
+
instructions: { content: mdContent },
|
|
30
|
+
basePath: agentPath,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function loadAgentConfig(agentName, ai, agentsDir) {
|
|
34
|
+
const configPath = join(agentsDir, agentName, 'configs', `${ai}.yaml`);
|
|
35
|
+
if (!existsSync(configPath)) {
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
const configContent = readFileSync(configPath, 'utf-8');
|
|
39
|
+
return parseYaml(configContent);
|
|
40
|
+
}
|
|
41
|
+
function loadSkill(skillName, skillsDir) {
|
|
42
|
+
const skillPath = findSkillPath(skillName, skillsDir);
|
|
43
|
+
if (!skillPath) {
|
|
44
|
+
throw new Error(`Skill "${skillName}" not found at ${skillsDir}`);
|
|
45
|
+
}
|
|
46
|
+
const mdContent = readFileSync(join(skillPath, 'SKILL.md'), 'utf-8');
|
|
47
|
+
const parsed = parseYaml(mdContent.split('---')[1] || '');
|
|
48
|
+
return {
|
|
49
|
+
content: mdContent,
|
|
50
|
+
metadata: parsed || {},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function findSkillPath(skillName, skillsDir) {
|
|
54
|
+
if (!existsSync(skillsDir)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const categories = readdirSync(skillsDir).filter((entry) => {
|
|
58
|
+
return statSync(join(skillsDir, entry)).isDirectory();
|
|
59
|
+
});
|
|
60
|
+
for (const category of categories) {
|
|
61
|
+
const categoryPath = join(skillsDir, category);
|
|
62
|
+
const skills = readdirSync(categoryPath).filter((entry) => {
|
|
63
|
+
return statSync(join(categoryPath, entry)).isDirectory();
|
|
64
|
+
});
|
|
65
|
+
for (const skill of skills) {
|
|
66
|
+
if (skill === skillName) {
|
|
67
|
+
return join(categoryPath, skill);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function listAgents(agentsDir) {
|
|
74
|
+
if (!existsSync(agentsDir)) {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
return readdirSync(agentsDir).filter((entry) => {
|
|
78
|
+
const entryPath = join(agentsDir, entry);
|
|
79
|
+
return statSync(entryPath).isDirectory() && existsSync(join(entryPath, 'agent.md'));
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function listSkills(skillsDir) {
|
|
83
|
+
if (!existsSync(skillsDir)) {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
const skills = [];
|
|
87
|
+
const categories = readdirSync(skillsDir).filter((entry) => {
|
|
88
|
+
return statSync(join(skillsDir, entry)).isDirectory();
|
|
89
|
+
});
|
|
90
|
+
for (const category of categories) {
|
|
91
|
+
const categoryPath = join(skillsDir, category);
|
|
92
|
+
const categorySkills = readdirSync(categoryPath).filter((entry) => {
|
|
93
|
+
const skillPath = join(categoryPath, entry);
|
|
94
|
+
return statSync(skillPath).isDirectory() && existsSync(join(skillPath, 'SKILL.md'));
|
|
95
|
+
});
|
|
96
|
+
for (const skill of categorySkills) {
|
|
97
|
+
skills.push(skill);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return skills;
|
|
101
|
+
}
|
|
102
|
+
function installAgent(agentName, ai, agentsDir, targetDir) {
|
|
103
|
+
const adapter = getAdapter(ai);
|
|
104
|
+
if (!adapter) {
|
|
105
|
+
const available = listAdapters().join(', ');
|
|
106
|
+
throw new Error(`Unknown AI "${ai}". Available: ${available}`);
|
|
107
|
+
}
|
|
108
|
+
const agent = loadAgent(agentName, agentsDir);
|
|
109
|
+
const config = loadAgentConfig(agentName, ai, agentsDir);
|
|
110
|
+
const output = adapter.generate(agent, config);
|
|
111
|
+
const installPath = adapter.getInstallPath(agentName, targetDir);
|
|
112
|
+
const dir = dirname(installPath);
|
|
113
|
+
mkdirSync(dir, { recursive: true });
|
|
114
|
+
writeFileSync(installPath, output.content, 'utf-8');
|
|
115
|
+
console.log(`Installed agent "${agentName}" for ${adapter.name} → ${installPath}`);
|
|
116
|
+
}
|
|
117
|
+
function installAgentAll(agentName, agentsDir, targetDir) {
|
|
118
|
+
for (const aiName of listAdapters()) {
|
|
119
|
+
const adapter = getAdapter(aiName);
|
|
120
|
+
const base = targetDir || adapter.defaultDir;
|
|
121
|
+
const aiTarget = join(base, aiName);
|
|
122
|
+
installAgent(agentName, aiName, agentsDir, aiTarget);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function installSkill(skillName, ai, skillsDir, targetDir) {
|
|
126
|
+
const adapter = getAdapter(ai);
|
|
127
|
+
if (!adapter) {
|
|
128
|
+
const available = listAdapters().join(', ');
|
|
129
|
+
throw new Error(`Unknown AI "${ai}". Available: ${available}`);
|
|
130
|
+
}
|
|
131
|
+
const skill = loadSkill(skillName, skillsDir);
|
|
132
|
+
const filename = `${skillName}.md`;
|
|
133
|
+
const base = targetDir || join(adapter.defaultDir, 'skills');
|
|
134
|
+
const installPath = join(base, filename);
|
|
135
|
+
const dir = dirname(installPath);
|
|
136
|
+
mkdirSync(dir, { recursive: true });
|
|
137
|
+
writeFileSync(installPath, skill.content, 'utf-8');
|
|
138
|
+
console.log(`Installed skill "${skillName}" for ${adapter.name} → ${installPath}`);
|
|
139
|
+
}
|
|
140
|
+
function installSkillAll(skillName, skillsDir, targetDir) {
|
|
141
|
+
for (const aiName of listAdapters()) {
|
|
142
|
+
const adapter = getAdapter(aiName);
|
|
143
|
+
const base = targetDir || join(adapter.defaultDir, 'skills');
|
|
144
|
+
const aiTarget = join(base, aiName);
|
|
145
|
+
installSkill(skillName, aiName, skillsDir, aiTarget);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function printHelp() {
|
|
149
|
+
console.log(`
|
|
150
|
+
angular-ai — Install Angular agents and skills for AI coding tools
|
|
151
|
+
|
|
152
|
+
Usage:
|
|
153
|
+
npx angular-ai agent <name> --ai <tool> [--target <path>]
|
|
154
|
+
npx angular-ai agent <name> --all [--target <path>]
|
|
155
|
+
npx angular-ai skill <name> --ai <tool> [--target <path>]
|
|
156
|
+
npx angular-ai skill <name> --all [--target <path>]
|
|
157
|
+
npx angular-ai list agents
|
|
158
|
+
npx angular-ai list skills
|
|
159
|
+
npx angular-ai list adapters
|
|
160
|
+
|
|
161
|
+
Options:
|
|
162
|
+
--ai <tool> Target AI tool: ${listAdapters().join(', ')}
|
|
163
|
+
--all Install for all available AI tools
|
|
164
|
+
--target <path> Custom target directory
|
|
165
|
+
|
|
166
|
+
Examples:
|
|
167
|
+
npx angular-ai agent angular-architect --ai opencode
|
|
168
|
+
npx angular-ai skill standalone-component-library --ai claude
|
|
169
|
+
npx angular-ai agent angular-architect --ai opencode --target ./my-agents
|
|
170
|
+
npx angular-ai skill signals-state-management --all
|
|
171
|
+
`);
|
|
172
|
+
}
|
|
173
|
+
function main() {
|
|
174
|
+
const args = process.argv.slice(2);
|
|
175
|
+
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
|
176
|
+
printHelp();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const command = args[0];
|
|
180
|
+
if (command === 'list') {
|
|
181
|
+
const type = args[1];
|
|
182
|
+
if (type === 'agents') {
|
|
183
|
+
const agents = listAgents(resolve('agents'));
|
|
184
|
+
if (agents.length === 0) {
|
|
185
|
+
console.log('No agents found.');
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
console.log('Available agents:');
|
|
189
|
+
for (const agent of agents) {
|
|
190
|
+
console.log(` - ${agent}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (type === 'skills') {
|
|
196
|
+
const skills = listSkills(resolve('skills'));
|
|
197
|
+
if (skills.length === 0) {
|
|
198
|
+
console.log('No skills found.');
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
console.log('Available skills:');
|
|
202
|
+
for (const skill of skills) {
|
|
203
|
+
console.log(` - ${skill}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (type === 'adapters') {
|
|
209
|
+
console.log('Available adapters:');
|
|
210
|
+
for (const name of listAdapters()) {
|
|
211
|
+
const adapter = getAdapter(name);
|
|
212
|
+
console.log(` - ${name}: ${adapter.description}`);
|
|
213
|
+
}
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
console.error(`Unknown list type: ${type}. Use "agents", "skills", or "adapters".`);
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
if (command === 'agent') {
|
|
220
|
+
const agentName = args[1];
|
|
221
|
+
if (!agentName) {
|
|
222
|
+
console.error('Error: agent name is required.');
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
const aiIndex = args.indexOf('--ai');
|
|
226
|
+
const allFlag = args.includes('--all');
|
|
227
|
+
const targetIndex = args.indexOf('--target');
|
|
228
|
+
const targetDir = targetIndex !== -1 ? args[targetIndex + 1] : undefined;
|
|
229
|
+
const agentsDir = resolve('agents');
|
|
230
|
+
if (!allFlag && aiIndex === -1) {
|
|
231
|
+
console.error('Error: specify --ai <tool> or --all');
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
if (allFlag) {
|
|
236
|
+
installAgentAll(agentName, agentsDir, targetDir);
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
const ai = args[aiIndex + 1];
|
|
240
|
+
if (!ai) {
|
|
241
|
+
console.error('Error: --ai requires a value');
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
installAgent(agentName, ai, agentsDir, targetDir);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
console.error(`Error: ${err.message}`);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (command === 'skill') {
|
|
254
|
+
const skillName = args[1];
|
|
255
|
+
if (!skillName) {
|
|
256
|
+
console.error('Error: skill name is required.');
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
const aiIndex = args.indexOf('--ai');
|
|
260
|
+
const allFlag = args.includes('--all');
|
|
261
|
+
const targetIndex = args.indexOf('--target');
|
|
262
|
+
const targetDir = targetIndex !== -1 ? args[targetIndex + 1] : undefined;
|
|
263
|
+
const skillsDir = resolve('skills');
|
|
264
|
+
if (!allFlag && aiIndex === -1) {
|
|
265
|
+
console.error('Error: specify --ai <tool> or --all');
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
if (allFlag) {
|
|
270
|
+
installSkillAll(skillName, skillsDir, targetDir);
|
|
271
|
+
}
|
|
272
|
+
else {
|
|
273
|
+
const ai = args[aiIndex + 1];
|
|
274
|
+
if (!ai) {
|
|
275
|
+
console.error('Error: --ai requires a value');
|
|
276
|
+
process.exit(1);
|
|
277
|
+
}
|
|
278
|
+
installSkill(skillName, ai, skillsDir, targetDir);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
console.error(`Error: ${err.message}`);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
console.error(`Unknown command: ${command}`);
|
|
288
|
+
printHelp();
|
|
289
|
+
process.exit(1);
|
|
290
|
+
}
|
|
291
|
+
main();
|
|
292
|
+
export { loadAgent, loadAgentConfig, loadSkill, listAgents, listSkills, installAgent, installAgentAll, installSkill, installSkillAll };
|
|
293
|
+
export { registerAdapter, getAdapter, listAdapters, hasAdapter };
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { registerAdapter, getAdapter, listAdapters, hasAdapter } from './registry.js';
|
|
2
|
+
export { createOpenCodeAdapter } from '../adapters/opencode/index.js';
|
|
3
|
+
export { createClaudeAdapter } from '../adapters/claude/index.js';
|
|
4
|
+
export { createCodexAdapter } from '../adapters/codex/index.js';
|
|
5
|
+
export { createCursorAdapter } from '../adapters/cursor/index.js';
|
|
6
|
+
export { createCopilotAdapter } from '../adapters/copilot/index.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
const adapters = new Map();
|
|
2
|
+
export function registerAdapter(adapter) {
|
|
3
|
+
adapters.set(adapter.name, adapter);
|
|
4
|
+
}
|
|
5
|
+
export function getAdapter(name) {
|
|
6
|
+
return adapters.get(name);
|
|
7
|
+
}
|
|
8
|
+
export function listAdapters() {
|
|
9
|
+
return Array.from(adapters.keys());
|
|
10
|
+
}
|
|
11
|
+
export function hasAdapter(name) {
|
|
12
|
+
return adapters.has(name);
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "angular-agents-skills",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Install Angular agents and skills for AI coding tools",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"angular-ai": "dist/src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsc --watch",
|
|
12
|
+
"test": "node --test dist/tests/**/*.test.js",
|
|
13
|
+
"test:watch": "node --test --watch dist/tests/**/*.test.js",
|
|
14
|
+
"lint": "tsc --noEmit",
|
|
15
|
+
"prepublishOnly": "npm run build"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"angular",
|
|
19
|
+
"ai",
|
|
20
|
+
"agents",
|
|
21
|
+
"opencode",
|
|
22
|
+
"claude",
|
|
23
|
+
"codex",
|
|
24
|
+
"skills",
|
|
25
|
+
"coding"
|
|
26
|
+
],
|
|
27
|
+
"author": "Dayerlin Bustamante",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"yaml": "^2.7.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"typescript": "^5.7.0",
|
|
34
|
+
"@types/node": "^22.0.0"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18.0.0"
|
|
38
|
+
},
|
|
39
|
+
"files": [
|
|
40
|
+
"dist",
|
|
41
|
+
"agents",
|
|
42
|
+
"skills",
|
|
43
|
+
"adapters"
|
|
44
|
+
]
|
|
45
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: injection-tokens
|
|
3
|
+
description: Define and consume typed InjectionTokens for configuration objects and adapter/strategy patterns, including optional injection with inject().
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Injection Tokens Pattern
|
|
7
|
+
|
|
8
|
+
This skill covers creating and using typed `InjectionToken`s for configuration and pluggable adapter/strategy implementations, combined with the functional `inject()` API.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
- A library needs a way for consumers to provide configuration at the app/module level (e.g. default modal settings).
|
|
12
|
+
- A component needs a pluggable "adapter" whose implementation is swapped per-feature (e.g. data source adapters for a dropdown).
|
|
13
|
+
- A component needs to optionally read something that only exists in certain contexts (e.g. a dialog reference that's only present when opened via an overlay service).
|
|
14
|
+
|
|
15
|
+
## Defining a configuration token
|
|
16
|
+
```typescript
|
|
17
|
+
export interface IModalConfiguration {
|
|
18
|
+
closeOnBackdropClick: boolean;
|
|
19
|
+
backdropClass?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const MODAL_CONFIGURATION: InjectionToken<IModalConfiguration> =
|
|
23
|
+
new InjectionToken<IModalConfiguration>('MODAL_CONFIGURATION');
|
|
24
|
+
```
|
|
25
|
+
Consumers provide a value at bootstrap or feature level:
|
|
26
|
+
```typescript
|
|
27
|
+
providers: [
|
|
28
|
+
{ provide: MODAL_CONFIGURATION, useValue: { closeOnBackdropClick: true } }
|
|
29
|
+
]
|
|
30
|
+
```
|
|
31
|
+
Service consumes it:
|
|
32
|
+
```typescript
|
|
33
|
+
@Injectable({ providedIn: 'root' })
|
|
34
|
+
export class ModalService {
|
|
35
|
+
private readonly modalConfig = inject(MODAL_CONFIGURATION);
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Adapter / strategy token pattern
|
|
40
|
+
Used when a component needs an interchangeable implementation without hard-coding which one:
|
|
41
|
+
```typescript
|
|
42
|
+
export interface IDropdownAdapter {
|
|
43
|
+
fetchItems(query: string): Observable<any[]>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const DropdownAdapterToken = new InjectionToken<IDropdownAdapter>('DropdownAdapter');
|
|
47
|
+
```
|
|
48
|
+
```typescript
|
|
49
|
+
export class DropdownComponent {
|
|
50
|
+
readonly dropdownAdapter = inject(DropdownAdapterToken);
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
Consumers plug in a concrete implementation per usage:
|
|
54
|
+
```typescript
|
|
55
|
+
providers: [
|
|
56
|
+
{ provide: DropdownAdapterToken, useClass: RemoteDropdownAdapter }
|
|
57
|
+
]
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Optional injection
|
|
61
|
+
Use `{ optional: true }` when a token is only available in some contexts (e.g. component rendered directly vs. rendered inside an overlay):
|
|
62
|
+
```typescript
|
|
63
|
+
private readonly dialogRef = inject(DIALOG_REF, { optional: true });
|
|
64
|
+
|
|
65
|
+
close(): void {
|
|
66
|
+
this.dialogRef?.close();
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
Always guard usage with `?.` or an explicit `if` check when injecting optionally.
|
|
70
|
+
|
|
71
|
+
## Other `inject()` flags
|
|
72
|
+
```typescript
|
|
73
|
+
inject(SomeService, { self: true }); // only look at the current injector
|
|
74
|
+
inject(SomeService, { skipSelf: true }); // skip the current injector, look at ancestors
|
|
75
|
+
inject(SomeService, { host: true }); // stop searching at the host component boundary
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Guidelines
|
|
79
|
+
- Prefer an `InjectionToken` over an abstract class when the "contract" is a plain interface (interfaces don't exist at runtime, so they can't be used as DI tokens directly).
|
|
80
|
+
- Name tokens in SCREAMING_SNAKE_CASE-ish convention matching the library prefix (`MODAL_CONFIGURATION`) for configuration, and `PascalCase + Token` suffix for adapters (`DropdownAdapterToken`) — but stay consistent within one library.
|
|
81
|
+
- Always type the `InjectionToken<T>` generic — untyped tokens defeat the purpose of DI type safety.
|
|
82
|
+
- Provide a sensible default via a factory (`{ providedIn: 'root', factory: () => defaultConfig }`) when the token is optional at the library level but required internally.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: overlay-animation-lifecycle
|
|
3
|
+
description: Coordinate enter/leave CSS animations for overlay components (modal, popover, toast, tooltip) using animationend events and Promise.all, without the Angular animations package.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Overlay Animation Lifecycle
|
|
7
|
+
|
|
8
|
+
This skill covers driving open/close animations for dynamically created overlay components (see `dynamic-component-creation`) using native CSS animations and the `animationend` DOM event, instead of `@angular/animations`.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
- Building/maintaining a modal, popover, toast, tooltip, or slide-in panel service that needs an enter animation on open and a coordinated exit animation on close (including an optional backdrop).
|
|
12
|
+
- Avoiding the runtime cost/complexity of `@angular/animations` for simple CSS keyframe-based transitions.
|
|
13
|
+
|
|
14
|
+
## Component contract
|
|
15
|
+
Each overlay-capable component implements two methods returning a CSS class name:
|
|
16
|
+
```typescript
|
|
17
|
+
export interface IAnimatable {
|
|
18
|
+
getEnterAnimationClass(): AnimationTypes;
|
|
19
|
+
getLeaveAnimationClass(): AnimationTypes;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Modal container
|
|
23
|
+
getEnterAnimationClass(): AnimationTypes {
|
|
24
|
+
return 'modal-enter';
|
|
25
|
+
}
|
|
26
|
+
getLeaveAnimationClass(): AnimationTypes {
|
|
27
|
+
return 'modal-leave';
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
```scss
|
|
31
|
+
.modal-enter { animation: modal-fade-in 200ms ease-out; }
|
|
32
|
+
.modal-leave { animation: modal-fade-out 150ms ease-in; }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Opening: apply the class, wait for it to finish
|
|
36
|
+
```typescript
|
|
37
|
+
open(): void {
|
|
38
|
+
this.beforeOpened$.next(undefined);
|
|
39
|
+
|
|
40
|
+
const componentElement = this.componentRef.location.nativeElement as HTMLElement;
|
|
41
|
+
|
|
42
|
+
const onOpenEnd = (event: AnimationEvent) => {
|
|
43
|
+
if (event.target === componentElement) {
|
|
44
|
+
this.afterOpened$.next();
|
|
45
|
+
componentElement.removeEventListener('animationend', onOpenEnd);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
componentElement.addEventListener('animationend', onOpenEnd);
|
|
49
|
+
|
|
50
|
+
this.enterAnimationClass = this.componentRef.instance.getEnterAnimationClass();
|
|
51
|
+
if (this.enterAnimationClass) {
|
|
52
|
+
componentElement.classList.add(this.enterAnimationClass);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
Always check `event.target === componentElement` — `animationend` bubbles, so a nested animated child could otherwise fire the handler prematurely.
|
|
57
|
+
|
|
58
|
+
## Closing: coordinate multiple elements (component + backdrop) with `Promise.all`
|
|
59
|
+
```typescript
|
|
60
|
+
close(data: unknown = null): void {
|
|
61
|
+
const animationPromises: Promise<void>[] = [];
|
|
62
|
+
|
|
63
|
+
const createEndPromise = (element: HTMLElement): Promise<void> =>
|
|
64
|
+
new Promise(resolve => {
|
|
65
|
+
const onEnd = (event: AnimationEvent) => {
|
|
66
|
+
if (event.target === element) {
|
|
67
|
+
element.removeEventListener('animationend', onEnd);
|
|
68
|
+
resolve();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
element.addEventListener('animationend', onEnd);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const componentElement = this.componentRef.location.nativeElement as HTMLElement;
|
|
75
|
+
componentElement.classList.add(this.componentRef.instance.getLeaveAnimationClass());
|
|
76
|
+
animationPromises.push(createEndPromise(componentElement));
|
|
77
|
+
|
|
78
|
+
if (this.backdropElement) {
|
|
79
|
+
this.backdropElement.classList.add('backdrop-leave');
|
|
80
|
+
animationPromises.push(createEndPromise(this.backdropElement));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
Promise.all(animationPromises).then(() => {
|
|
84
|
+
this.afterClosed$.next(data);
|
|
85
|
+
// safe to destroy/detach the component now — see dynamic-component-creation
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Why this pattern over `@angular/animations`
|
|
91
|
+
- Zero runtime dependency; keyframes live entirely in SCSS alongside the component's other styles (see design tokens/theming skills).
|
|
92
|
+
- Works uniformly for components created dynamically outside a template (no `[@trigger]` binding needed on a manually inserted element).
|
|
93
|
+
- `Promise.all` naturally expresses "wait for N independent animations (component + backdrop) before finalizing teardown".
|
|
94
|
+
|
|
95
|
+
## Pitfalls
|
|
96
|
+
- If a component defines an enter/leave class but the corresponding CSS animation doesn't exist (or `animation-duration: 0`), `animationend` never fires and the promise never resolves — always verify the class has an actual `@keyframes` animation attached, not just a `transition`.
|
|
97
|
+
- Remove the event listener after it fires (`removeEventListener`) to avoid leaks if the same element is reused.
|
|
98
|
+
- Always destroy/remove the DOM node only **after** the close animation promise resolves, otherwise the exit animation is skipped.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: content-projection-ng
|
|
3
|
+
description: Project content into Angular components using ng-content, select, and ngProjectAs, including named/multi-slot projection and optional template directives.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Content Projection (`ng-content` / `ngProjectAs`)
|
|
7
|
+
|
|
8
|
+
This skill covers advanced content projection patterns used to build flexible, composable UI components.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
- A component (e.g. input, card, modal) needs to accept arbitrary consumer-provided markup in specific "slots" (label, left icon, footer, etc.).
|
|
12
|
+
- A consumer needs to project a component that doesn't natively match a `select` query, using `ngProjectAs` to make it match.
|
|
13
|
+
- A component needs to detect whether optional content was actually provided.
|
|
14
|
+
|
|
15
|
+
## Basic single slot
|
|
16
|
+
```html
|
|
17
|
+
<ng-content />
|
|
18
|
+
```
|
|
19
|
+
Projects everything not matched by a more specific `select` elsewhere in the template.
|
|
20
|
+
|
|
21
|
+
## Named slots with `select`
|
|
22
|
+
```html
|
|
23
|
+
<!-- ui-input template -->
|
|
24
|
+
<ng-content select="[slot=left]" />
|
|
25
|
+
<input #input ... />
|
|
26
|
+
<ng-content select="[slot=right]" />
|
|
27
|
+
<ng-content ngProjectAs="ui-label" />
|
|
28
|
+
```
|
|
29
|
+
Consumers target a slot either by a matching element/attribute selector:
|
|
30
|
+
```html
|
|
31
|
+
<ui-input>
|
|
32
|
+
<span slot="left">$</span>
|
|
33
|
+
<ui-label>Amount</ui-label>
|
|
34
|
+
</ui-input>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## `ngProjectAs`
|
|
38
|
+
Use when the element you want to project doesn't match the component's `select` selector by itself (e.g. you're projecting a generic `<div>` but the target slot expects `ui-label`):
|
|
39
|
+
```html
|
|
40
|
+
<div ngProjectAs="ui-label">Custom label markup</div>
|
|
41
|
+
```
|
|
42
|
+
`ngProjectAs` only affects *projection matching*; it does not change the rendered tag.
|
|
43
|
+
|
|
44
|
+
## Detecting whether content was projected
|
|
45
|
+
Combine with `contentChild()`/`viewChild()` (see `viewchild-contentchild-signals` skill) and check element children:
|
|
46
|
+
```typescript
|
|
47
|
+
constructor() {
|
|
48
|
+
effect(() => {
|
|
49
|
+
if (this.slotLeft().nativeElement.children.length > 0) {
|
|
50
|
+
this.slotLeftClassValue.set(true); // toggles host class for spacing/layout
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
```html
|
|
56
|
+
<div #slotLeft class="ui-slot-left"><ng-content select="[slot=left]" /></div>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Optional template directives (structural content projection)
|
|
60
|
+
For complex optional templates (custom item renderer, header, footer), define a directive applied to `<ng-template>` and read it with `contentChild()`:
|
|
61
|
+
```typescript
|
|
62
|
+
@Directive({ selector: 'ng-template[uiDropdownItemTmpl]' })
|
|
63
|
+
export class DropdownItemTmplDirective {
|
|
64
|
+
templateRef = inject(TemplateRef);
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
```html
|
|
68
|
+
<!-- consumer -->
|
|
69
|
+
<ui-dropdown>
|
|
70
|
+
<ng-template uiDropdownItemTmpl let-item>{{ item.label }}</ng-template>
|
|
71
|
+
</ui-dropdown>
|
|
72
|
+
```
|
|
73
|
+
```typescript
|
|
74
|
+
// component
|
|
75
|
+
itemTmpl = contentChild(DropdownItemTmplDirective);
|
|
76
|
+
```
|
|
77
|
+
```html
|
|
78
|
+
<!-- render with NgTemplateOutlet when provided, else a default row -->
|
|
79
|
+
@if (itemTmpl(); as tmpl) {
|
|
80
|
+
<ng-container [ngTemplateOutlet]="tmpl.templateRef" [ngTemplateOutletContext]="{ $implicit: item }" />
|
|
81
|
+
} @else {
|
|
82
|
+
<div class="default-item">{{ item[keyText()] }}</div>
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Pitfalls
|
|
87
|
+
- `select` selectors are evaluated once at compile time against the light DOM structure — dynamically changing an element's attribute after render won't re-route projection.
|
|
88
|
+
- Don't forget to import `NgTemplateOutlet` in the component's standalone `imports` array when using `[ngTemplateOutlet]`.
|
|
89
|
+
- Order of `<ng-content>` tags in the template determines render order, not the order elements appear in the consumer's markup.
|