@vekron/vekron-ui 0.0.0-stage → 0.1.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.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/bin/vekron-ui.js +362 -0
  3. package/package.json +45 -3
  4. package/src/accordion/accordion.component.ts +64 -0
  5. package/src/accordion/public-api.ts +1 -0
  6. package/src/autocomplete/autocomplete.component.ts +144 -0
  7. package/src/autocomplete/public-api.ts +1 -0
  8. package/src/avatar/avatar.component.ts +23 -0
  9. package/src/avatar/public-api.ts +1 -0
  10. package/src/badge/badge.component.ts +27 -0
  11. package/src/badge/public-api.ts +2 -0
  12. package/src/breadcrumb/breadcrumb.component.ts +32 -0
  13. package/src/breadcrumb/public-api.ts +2 -0
  14. package/src/button/button.component.ts +50 -0
  15. package/src/button/public-api.ts +2 -0
  16. package/src/carousel/carousel.component.ts +76 -0
  17. package/src/carousel/public-api.ts +2 -0
  18. package/src/checkbox/checkbox.component.ts +92 -0
  19. package/src/checkbox/public-api.ts +1 -0
  20. package/src/chip/chip.component.ts +20 -0
  21. package/src/chip/public-api.ts +1 -0
  22. package/src/confirm-dialog/confirm-dialog.component.ts +38 -0
  23. package/src/confirm-dialog/public-api.ts +1 -0
  24. package/src/confirm-popup/confirm-popup.component.ts +42 -0
  25. package/src/confirm-popup/public-api.ts +1 -0
  26. package/src/context-menu/context-menu.component.ts +43 -0
  27. package/src/context-menu/public-api.ts +1 -0
  28. package/src/datepicker/datepicker.component.ts +205 -0
  29. package/src/datepicker/public-api.ts +1 -0
  30. package/src/dialog/dialog.component.ts +77 -0
  31. package/src/dialog/public-api.ts +1 -0
  32. package/src/divider/divider.component.ts +17 -0
  33. package/src/divider/public-api.ts +1 -0
  34. package/src/file-upload/file-upload.component.ts +110 -0
  35. package/src/file-upload/public-api.ts +1 -0
  36. package/src/input-number/input-number.component.ts +123 -0
  37. package/src/input-number/public-api.ts +1 -0
  38. package/src/input-otp/input-otp.component.ts +129 -0
  39. package/src/input-otp/public-api.ts +1 -0
  40. package/src/input-password/input-password.component.ts +127 -0
  41. package/src/input-password/public-api.ts +1 -0
  42. package/src/input-text/input-text.component.ts +106 -0
  43. package/src/input-text/public-api.ts +1 -0
  44. package/src/menu/menu.component.ts +35 -0
  45. package/src/menu/public-api.ts +1 -0
  46. package/src/message/message.component.ts +17 -0
  47. package/src/message/public-api.ts +1 -0
  48. package/src/paginator/paginator.component.ts +62 -0
  49. package/src/paginator/public-api.ts +1 -0
  50. package/src/pdf-viewer/pdf-viewer.component.ts +16 -0
  51. package/src/pdf-viewer/public-api.ts +1 -0
  52. package/src/pick-list/pick-list.component.ts +68 -0
  53. package/src/pick-list/public-api.ts +1 -0
  54. package/src/popover/popover.component.ts +22 -0
  55. package/src/popover/public-api.ts +1 -0
  56. package/src/progress-bar/progress-bar.component.ts +27 -0
  57. package/src/progress-bar/public-api.ts +1 -0
  58. package/src/progress-spinner/progress-spinner.component.ts +27 -0
  59. package/src/progress-spinner/public-api.ts +1 -0
  60. package/src/public-api.ts +46 -0
  61. package/src/radio-button/public-api.ts +1 -0
  62. package/src/radio-button/radio-button.component.ts +98 -0
  63. package/src/registry.json +184 -0
  64. package/src/scroll-top/public-api.ts +1 -0
  65. package/src/scroll-top/scroll-top.component.ts +36 -0
  66. package/src/select/public-api.ts +1 -0
  67. package/src/select/select.component.ts +116 -0
  68. package/src/select-button/public-api.ts +1 -0
  69. package/src/select-button/select-button.component.ts +70 -0
  70. package/src/skeleton/public-api.ts +1 -0
  71. package/src/skeleton/skeleton.component.ts +11 -0
  72. package/src/split-button/public-api.ts +1 -0
  73. package/src/split-button/split-button.component.ts +39 -0
  74. package/src/stepper/public-api.ts +2 -0
  75. package/src/stepper/stepper.component.ts +45 -0
  76. package/src/styles.css +63 -0
  77. package/src/table/public-api.ts +2 -0
  78. package/src/table/table.component.ts +47 -0
  79. package/src/tabs/public-api.ts +1 -0
  80. package/src/tabs/tabs.component.ts +76 -0
  81. package/src/tag/public-api.ts +1 -0
  82. package/src/tag/tag.component.ts +22 -0
  83. package/src/textarea/public-api.ts +1 -0
  84. package/src/textarea/textarea.component.ts +106 -0
  85. package/src/toast/public-api.ts +2 -0
  86. package/src/toast/toast.component.ts +60 -0
  87. package/src/toggle-button/public-api.ts +1 -0
  88. package/src/toggle-button/toggle-button.component.ts +80 -0
  89. package/src/toggle-switch/public-api.ts +1 -0
  90. package/src/toggle-switch/toggle-switch.component.ts +96 -0
  91. package/src/toolbar/public-api.ts +1 -0
  92. package/src/toolbar/toolbar.component.ts +17 -0
  93. package/src/tooltip/public-api.ts +1 -0
  94. package/src/tooltip/tooltip.component.ts +30 -0
  95. package/src/tree/public-api.ts +2 -0
  96. package/src/tree/tree.component.ts +64 -0
  97. package/src/utils/classes.ts +19 -0
  98. package/src/utils/cn.ts +6 -0
  99. package/src/utils/models.ts +13 -0
  100. package/src/utils/public-api.ts +3 -0
  101. package/README.md +0 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vekron
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,362 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from "node:child_process";
3
+ import {
4
+ access,
5
+ copyFile,
6
+ mkdir,
7
+ readFile,
8
+ readdir,
9
+ writeFile,
10
+ } from "node:fs/promises";
11
+ import path from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const packageRoot = path.resolve(
15
+ path.dirname(fileURLToPath(import.meta.url)),
16
+ "..",
17
+ );
18
+ const sourceRoot = path.join(packageRoot, "src");
19
+
20
+ const command = process.argv[2];
21
+ const commandArgs = process.argv.slice(3);
22
+
23
+ if (command === "list") {
24
+ await list();
25
+ } else if (command === "init") {
26
+ await init(commandArgs);
27
+ } else if (command === "add") {
28
+ await add(commandArgs);
29
+ } else {
30
+ printHelp();
31
+ if (command !== undefined && command !== "help" && command !== "--help") {
32
+ process.exitCode = 1;
33
+ }
34
+ }
35
+
36
+ function printHelp() {
37
+ console.log(`vekron-ui
38
+
39
+ vekron-ui init [--path src/app/shared/ui]
40
+ vekron-ui add <componente...> [--all] [--overwrite] [--path src/app/shared/ui]
41
+ vekron-ui list
42
+
43
+ init prepara Tailwind, el alias @ui y las dependencias.
44
+ add copia el código del componente al proyecto, con sus dependencias.
45
+ --all copia el catálogo completo.
46
+ `);
47
+ }
48
+
49
+ async function list() {
50
+ const registry = await readRegistry();
51
+ const names = Object.keys(registry.components).sort();
52
+ for (const name of names) {
53
+ const component = registry.components[name];
54
+ console.log(`${name.padEnd(18)} ${component.description}`);
55
+ }
56
+ }
57
+
58
+ async function init(args) {
59
+ const projectRoot = await findProjectRoot(process.cwd());
60
+ const parsed = parseArgs(args);
61
+ const config = await resolveConfig(projectRoot, parsed.path);
62
+ await ensureDependencies(projectRoot);
63
+ await ensurePaths(projectRoot, config.path);
64
+ await copySupport(projectRoot, config.path, false);
65
+ await ensureStyleImport(projectRoot, config);
66
+ await writeConfig(projectRoot, config);
67
+ console.log(`Listo. Alias @ui -> ${config.path}`);
68
+ }
69
+
70
+ async function add(args) {
71
+ const projectRoot = await findProjectRoot(process.cwd());
72
+ const parsed = parseArgs(args);
73
+ const config = await resolveConfig(projectRoot, parsed.path);
74
+ const registry = await readRegistry();
75
+ const requested = parsed.all
76
+ ? Object.keys(registry.components)
77
+ : parsed.names;
78
+ if (requested.length === 0) {
79
+ throw new Error("Indica al menos un componente o usa --all.");
80
+ }
81
+ const ordered = [];
82
+ const seen = new Set();
83
+ for (const name of requested) {
84
+ resolveComponent(name, registry, ordered, seen);
85
+ }
86
+ await ensureDependencies(projectRoot);
87
+ await ensurePaths(projectRoot, config.path);
88
+ await copySupport(projectRoot, config.path, parsed.overwrite);
89
+ const destinationRoot = path.join(projectRoot, config.path);
90
+ for (const name of ordered) {
91
+ await copyComponent(name, destinationRoot, parsed.overwrite);
92
+ }
93
+ await updateBarrel(destinationRoot, ordered);
94
+ await ensureStyleImport(projectRoot, config);
95
+ await writeConfig(projectRoot, config);
96
+ console.log(`Instalados: ${ordered.join(", ")}`);
97
+ }
98
+
99
+ function parseArgs(args) {
100
+ const parsed = { all: false, overwrite: false, path: "", names: [] };
101
+ for (let index = 0; index < args.length; index += 1) {
102
+ const arg = args[index];
103
+ if (arg === "--all") {
104
+ parsed.all = true;
105
+ continue;
106
+ }
107
+ if (arg === "--overwrite") {
108
+ parsed.overwrite = true;
109
+ continue;
110
+ }
111
+ if (arg === "--path") {
112
+ const value = args[index + 1];
113
+ if (value === undefined || value.startsWith("--")) {
114
+ throw new Error("Falta el valor de --path.");
115
+ }
116
+ parsed.path = value;
117
+ index += 1;
118
+ continue;
119
+ }
120
+ if (arg.startsWith("--")) {
121
+ throw new Error(`Opción desconocida: ${arg}`);
122
+ }
123
+ parsed.names.push(arg);
124
+ }
125
+ return parsed;
126
+ }
127
+
128
+ async function readRegistry() {
129
+ const text = await readFile(path.join(sourceRoot, "registry.json"), "utf8");
130
+ return JSON.parse(text);
131
+ }
132
+
133
+ function resolveComponent(name, registry, ordered, seen) {
134
+ if (seen.has(name)) {
135
+ return;
136
+ }
137
+ const component = registry.components[name];
138
+ if (component === undefined) {
139
+ throw new Error(
140
+ `No existe el componente "${name}". Ejecuta vekron-ui list.`,
141
+ );
142
+ }
143
+ seen.add(name);
144
+ for (const dependency of component.dependencies) {
145
+ resolveComponent(dependency, registry, ordered, seen);
146
+ }
147
+ ordered.push(name);
148
+ }
149
+
150
+ async function findProjectRoot(start) {
151
+ let current = path.resolve(start);
152
+ for (;;) {
153
+ if (await exists(path.join(current, "angular.json"))) {
154
+ return current;
155
+ }
156
+ const parent = path.dirname(current);
157
+ if (parent === current) {
158
+ throw new Error(
159
+ "No encontré angular.json. Ejecuta el comando dentro de un proyecto Angular.",
160
+ );
161
+ }
162
+ current = parent;
163
+ }
164
+ }
165
+
166
+ async function resolveConfig(projectRoot, pathOverride) {
167
+ const configPath = path.join(projectRoot, "vekron-ui.json");
168
+ let stored = { path: "src/app/shared/ui", style: "src/styles.css" };
169
+ if (await exists(configPath)) {
170
+ stored = JSON.parse(await readFile(configPath, "utf8"));
171
+ }
172
+ if (pathOverride.length > 0) {
173
+ stored.path = pathOverride;
174
+ }
175
+ if (typeof stored.path !== "string" || stored.path.length === 0) {
176
+ throw new Error("vekron-ui.json no tiene path.");
177
+ }
178
+ if (typeof stored.style !== "string" || stored.style.length === 0) {
179
+ throw new Error("vekron-ui.json no tiene style.");
180
+ }
181
+ return stored;
182
+ }
183
+
184
+ async function writeConfig(projectRoot, config) {
185
+ await writeFile(
186
+ path.join(projectRoot, "vekron-ui.json"),
187
+ `${JSON.stringify(config, null, 2)}\n`,
188
+ );
189
+ }
190
+
191
+ async function ensureDependencies(projectRoot) {
192
+ const packageJson = JSON.parse(
193
+ await readFile(path.join(projectRoot, "package.json"), "utf8"),
194
+ );
195
+ const declared = {
196
+ ...packageJson.dependencies,
197
+ ...packageJson.devDependencies,
198
+ };
199
+ const required = ["class-variance-authority", "clsx", "tailwind-merge"];
200
+ const missing = required.filter((name) => declared[name] === undefined);
201
+ if (missing.length === 0) {
202
+ return;
203
+ }
204
+ const manager = await detectPackageManager(projectRoot);
205
+ const installArgs =
206
+ manager === "npm" ? ["install", ...missing] : ["add", ...missing];
207
+ const result = spawnSync(manager, installArgs, {
208
+ cwd: projectRoot,
209
+ stdio: "inherit",
210
+ shell: process.platform === "win32",
211
+ });
212
+ if (result.status !== 0) {
213
+ throw new Error(`No se pudieron instalar: ${missing.join(", ")}.`);
214
+ }
215
+ }
216
+
217
+ async function detectPackageManager(projectRoot) {
218
+ if (await exists(path.join(projectRoot, "pnpm-lock.yaml"))) {
219
+ return "pnpm";
220
+ }
221
+ if (await exists(path.join(projectRoot, "yarn.lock"))) {
222
+ return "yarn";
223
+ }
224
+ if (await exists(path.join(projectRoot, "package-lock.json"))) {
225
+ return "npm";
226
+ }
227
+ return "npm";
228
+ }
229
+
230
+ async function ensurePaths(projectRoot, uiPath) {
231
+ const tsconfigPath = path.join(projectRoot, "tsconfig.json");
232
+ const text = await readFile(tsconfigPath, "utf8");
233
+ if (text.includes('"@ui"')) {
234
+ return;
235
+ }
236
+ const uiImport = `${uiPath}/index.ts`.replaceAll("\\", "/");
237
+ const uiStar = `${uiPath}/*`.replaceAll("\\", "/");
238
+ const pathsBlock = `"paths": {\n "@ui": ["${uiImport}"],\n "@ui/*": ["${uiStar}"],`;
239
+ if (text.includes('"paths"')) {
240
+ const next = text.replace('"paths": {', pathsBlock);
241
+ await writeFile(tsconfigPath, next);
242
+ return;
243
+ }
244
+ if (!text.includes('"compilerOptions"')) {
245
+ throw new Error("tsconfig.json no tiene compilerOptions.");
246
+ }
247
+ const next = text.replace(
248
+ '"compilerOptions": {',
249
+ `"compilerOptions": {\n "baseUrl": ".",\n ${pathsBlock}`,
250
+ );
251
+ await writeFile(tsconfigPath, next);
252
+ }
253
+
254
+ async function ensureStyleImport(projectRoot, config) {
255
+ const stylePath = path.join(projectRoot, config.style);
256
+ const uiStylePath = path.join(projectRoot, config.path, "styles.css");
257
+ const relative = path
258
+ .relative(path.dirname(stylePath), uiStylePath)
259
+ .replaceAll("\\", "/");
260
+ const importPath = relative.startsWith(".") ? relative : `./${relative}`;
261
+ const importLine = `@import '${importPath}';`;
262
+ const text = await readFile(stylePath, "utf8");
263
+ if (text.includes(importLine)) {
264
+ return;
265
+ }
266
+ const tailwindSingle = "@import 'tailwindcss';";
267
+ const tailwindDouble = '@import "tailwindcss";';
268
+ let anchor = "";
269
+ if (text.includes(tailwindSingle)) {
270
+ anchor = tailwindSingle;
271
+ } else if (text.includes(tailwindDouble)) {
272
+ anchor = tailwindDouble;
273
+ } else {
274
+ throw new Error(
275
+ `${config.style} tiene que importar tailwindcss antes de los tokens de vekron-ui.`,
276
+ );
277
+ }
278
+ await writeFile(stylePath, text.replace(anchor, `${anchor}\n${importLine}`));
279
+ }
280
+
281
+ async function copySupport(projectRoot, uiPath, overwrite) {
282
+ const destinationRoot = path.join(projectRoot, uiPath);
283
+ await mkdir(destinationRoot, { recursive: true });
284
+ await copyTree(
285
+ path.join(sourceRoot, "utils"),
286
+ path.join(destinationRoot, "utils"),
287
+ overwrite,
288
+ );
289
+ await copyFileIfNeeded(
290
+ path.join(sourceRoot, "styles.css"),
291
+ path.join(destinationRoot, "styles.css"),
292
+ overwrite,
293
+ );
294
+ const barrel = path.join(destinationRoot, "index.ts");
295
+ if (!(await exists(barrel))) {
296
+ await writeFile(barrel, "");
297
+ }
298
+ }
299
+
300
+ async function copyComponent(name, destinationRoot, overwrite) {
301
+ await copyTree(
302
+ path.join(sourceRoot, name),
303
+ path.join(destinationRoot, name),
304
+ overwrite,
305
+ );
306
+ }
307
+
308
+ async function copyTree(from, to, overwrite) {
309
+ await mkdir(to, { recursive: true });
310
+ const entries = await readdir(from, { withFileTypes: true });
311
+ for (const entry of entries) {
312
+ const source = path.join(from, entry.name);
313
+ const target = path.join(to, entry.name);
314
+ if (entry.isDirectory()) {
315
+ await copyTree(source, target, overwrite);
316
+ continue;
317
+ }
318
+ if (!entry.name.endsWith(".ts") && !entry.name.endsWith(".css")) {
319
+ continue;
320
+ }
321
+ await copyFileIfNeeded(source, target, overwrite);
322
+ }
323
+ }
324
+
325
+ async function copyFileIfNeeded(source, target, overwrite) {
326
+ if ((await exists(target)) && !overwrite) {
327
+ console.log(`se mantiene ${target}`);
328
+ return;
329
+ }
330
+ await copyFile(source, target);
331
+ }
332
+
333
+ async function updateBarrel(destinationRoot, names) {
334
+ const barrelPath = path.join(destinationRoot, "index.ts");
335
+ let current = "";
336
+ if (await exists(barrelPath)) {
337
+ current = await readFile(barrelPath, "utf8");
338
+ }
339
+ let next = current;
340
+ if (next.length > 0 && !next.endsWith("\n")) {
341
+ next += "\n";
342
+ }
343
+ for (const name of names) {
344
+ if (next.includes(`./${name}/`)) {
345
+ continue;
346
+ }
347
+ next += `export * from './${name}/public-api';\n`;
348
+ }
349
+ await writeFile(barrelPath, next);
350
+ }
351
+
352
+ async function exists(filePath) {
353
+ try {
354
+ await access(filePath);
355
+ return true;
356
+ } catch (error) {
357
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") {
358
+ return false;
359
+ }
360
+ throw error;
361
+ }
362
+ }
package/package.json CHANGED
@@ -1,6 +1,48 @@
1
1
  {
2
2
  "name": "@vekron/vekron-ui",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "Librería de componentes Angular. Se instala completa o componente por componente.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "vekron-ui": "./bin/vekron-ui.js"
9
+ },
10
+ "style": "./src/styles.css",
11
+ "exports": {
12
+ "./styles.css": "./src/styles.css",
13
+ "./package.json": {
14
+ "default": "./package.json"
15
+ },
16
+ ".": {
17
+ "types": "./types/vekron-vekron-ui.d.ts",
18
+ "default": "./fesm2022/vekron-vekron-ui.mjs"
19
+ }
20
+ },
21
+ "files": [
22
+ "bin",
23
+ "src",
24
+ "README.md"
25
+ ],
26
+ "sideEffects": false,
27
+ "peerDependencies": {
28
+ "@angular/common": "^22.2.0",
29
+ "@angular/core": "^22.2.0",
30
+ "@angular/forms": "^22.2.0",
31
+ "@angular/platform-browser": "^22.2.0",
32
+ "class-variance-authority": "^0.7.1",
33
+ "clsx": "^2.1.1",
34
+ "tailwind-merge": "^3.7.0",
35
+ "tailwindcss": ">=4.1.0"
36
+ },
37
+ "dependencies": {
38
+ "tslib": "^2.3.0"
39
+ },
40
+ "engines": {
41
+ "node": ">=22"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "module": "fesm2022/vekron-vekron-ui.mjs",
47
+ "typings": "types/vekron-vekron-ui.d.ts"
6
48
  }
@@ -0,0 +1,64 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ computed,
5
+ inject,
6
+ input,
7
+ model,
8
+ } from '@angular/core';
9
+
10
+ @Component({
11
+ selector: 'ui-accordion',
12
+ changeDetection: ChangeDetectionStrategy.OnPush,
13
+ template: `
14
+ <div class="w-full border-t border-border">
15
+ <ng-content />
16
+ </div>
17
+ `,
18
+ })
19
+ export class UiAccordionComponent {
20
+ readonly openId = model('');
21
+
22
+ toggle(itemId: string): void {
23
+ if (this.openId() === itemId) {
24
+ this.openId.set('');
25
+ return;
26
+ }
27
+ this.openId.set(itemId);
28
+ }
29
+ }
30
+
31
+ @Component({
32
+ selector: 'ui-accordion-item',
33
+ changeDetection: ChangeDetectionStrategy.OnPush,
34
+ template: `
35
+ <div class="border-b border-border">
36
+ <button type="button" class="flex w-full items-center justify-between py-3 text-left text-sm font-medium" (click)="toggle()">
37
+ {{ title() }}
38
+ <span aria-hidden="true">{{ marker() }}</span>
39
+ </button>
40
+ @if (expanded()) {
41
+ <div class="pb-3 text-sm text-muted-foreground">
42
+ <ng-content />
43
+ </div>
44
+ }
45
+ </div>
46
+ `,
47
+ })
48
+ export class UiAccordionItemComponent {
49
+ readonly itemId = input.required<string>();
50
+ readonly title = input.required<string>();
51
+ private readonly accordion = inject(UiAccordionComponent);
52
+ readonly expanded = computed(() => this.accordion.openId() === this.itemId());
53
+
54
+ toggle(): void {
55
+ this.accordion.toggle(this.itemId());
56
+ }
57
+
58
+ marker(): string {
59
+ if (this.expanded()) {
60
+ return '−';
61
+ }
62
+ return '+';
63
+ }
64
+ }
@@ -0,0 +1 @@
1
+ export { UiAccordionComponent, UiAccordionItemComponent } from './accordion.component';
@@ -0,0 +1,144 @@
1
+ import {
2
+ ChangeDetectionStrategy,
3
+ Component,
4
+ computed,
5
+ forwardRef,
6
+ input,
7
+ model,
8
+ signal,
9
+ } from '@angular/core';
10
+ import { type ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
11
+ import { uiFieldClass, uiLabelClass, uiPanelClass } from '../utils/classes';
12
+ import { type UiOption } from '../utils/models';
13
+
14
+ @Component({
15
+ selector: 'ui-autocomplete',
16
+ changeDetection: ChangeDetectionStrategy.OnPush,
17
+ template: `
18
+ <div class="relative flex w-full flex-col gap-1.5">
19
+ @if (label().length > 0) {
20
+ <label [class]="labelClass" [attr.for]="resolvedId()">{{ label() }}</label>
21
+ }
22
+ <input
23
+ [id]="resolvedId()"
24
+ [class]="fieldClass"
25
+ [placeholder]="placeholder()"
26
+ [disabled]="isDisabled()"
27
+ [value]="query()"
28
+ role="combobox"
29
+ [attr.aria-expanded]="open()"
30
+ (input)="onQuery($event)"
31
+ (focus)="open.set(true)"
32
+ (blur)="markTouched()"
33
+ />
34
+ @if (open()) {
35
+ <div [class]="panelClass + ' absolute top-full right-0 left-0 mt-1'">
36
+ @for (option of filtered(); track option.value) {
37
+ <button
38
+ type="button"
39
+ class="flex w-full rounded-ui px-3 py-2 text-left text-sm hover:bg-accent disabled:opacity-50"
40
+ [disabled]="option.disabled === true"
41
+ (mousedown)="choose($event, option)"
42
+ >
43
+ {{ option.label }}
44
+ </button>
45
+ } @empty {
46
+ <p class="px-3 py-2 text-sm text-muted-foreground">Sin coincidencias.</p>
47
+ }
48
+ </div>
49
+ }
50
+ </div>
51
+ `,
52
+ providers: [
53
+ {
54
+ provide: NG_VALUE_ACCESSOR,
55
+ useExisting: forwardRef(() => UiAutocompleteComponent),
56
+ multi: true,
57
+ },
58
+ ],
59
+ })
60
+ export class UiAutocompleteComponent implements ControlValueAccessor {
61
+ readonly value = model('');
62
+ readonly options = input.required<readonly UiOption[]>();
63
+ readonly label = input('');
64
+ readonly placeholder = input('Buscar');
65
+ readonly inputId = input('');
66
+ readonly disabled = input(false);
67
+ readonly query = signal('');
68
+ readonly open = signal(false);
69
+ private readonly formDisabled = signal(false);
70
+ private readonly generatedId = crypto.randomUUID();
71
+ private onChange: (value: string) => void = () => undefined;
72
+ private onTouched: () => void = () => undefined;
73
+ readonly fieldClass = uiFieldClass;
74
+ readonly labelClass = uiLabelClass;
75
+ readonly panelClass = uiPanelClass;
76
+ readonly isDisabled = computed(() => this.disabled() || this.formDisabled());
77
+ readonly resolvedId = computed(() => {
78
+ const provided = this.inputId();
79
+ if (provided.length > 0) {
80
+ return provided;
81
+ }
82
+ return this.generatedId;
83
+ });
84
+ readonly filtered = computed(() => {
85
+ const query = this.query().trim().toLowerCase();
86
+ if (query.length === 0) {
87
+ return this.options();
88
+ }
89
+ return this.options().filter((option) => option.label.toLowerCase().includes(query));
90
+ });
91
+
92
+ onQuery(event: Event): void {
93
+ const target = event.target;
94
+ if (!(target instanceof HTMLInputElement)) {
95
+ throw new Error('ui-autocomplete esperaba un HTMLInputElement.');
96
+ }
97
+ this.query.set(target.value);
98
+ this.open.set(true);
99
+ }
100
+
101
+ choose(event: MouseEvent, option: UiOption): void {
102
+ event.preventDefault();
103
+ if (option.disabled === true) {
104
+ return;
105
+ }
106
+ this.value.set(option.value);
107
+ this.query.set(option.label);
108
+ this.onChange(option.value);
109
+ this.open.set(false);
110
+ }
111
+
112
+ markTouched(): void {
113
+ this.onTouched();
114
+ this.open.set(false);
115
+ }
116
+
117
+ writeValue(value: string | null): void {
118
+ if (value === null) {
119
+ this.value.set('');
120
+ this.query.set('');
121
+ return;
122
+ }
123
+ this.value.set(value);
124
+ for (const option of this.options()) {
125
+ if (option.value === value) {
126
+ this.query.set(option.label);
127
+ return;
128
+ }
129
+ }
130
+ this.query.set(value);
131
+ }
132
+
133
+ registerOnChange(fn: (value: string) => void): void {
134
+ this.onChange = fn;
135
+ }
136
+
137
+ registerOnTouched(fn: () => void): void {
138
+ this.onTouched = fn;
139
+ }
140
+
141
+ setDisabledState(isDisabled: boolean): void {
142
+ this.formDisabled.set(isDisabled);
143
+ }
144
+ }
@@ -0,0 +1 @@
1
+ export { UiAutocompleteComponent } from './autocomplete.component';
@@ -0,0 +1,23 @@
1
+ import { ChangeDetectionStrategy, computed, Component, input, signal } from '@angular/core';
2
+
3
+ @Component({
4
+ selector: 'ui-avatar',
5
+ changeDetection: ChangeDetectionStrategy.OnPush,
6
+ template: `
7
+ <span class="inline-flex size-10 items-center justify-center overflow-hidden rounded-full bg-muted text-sm font-medium text-foreground">
8
+ @if (showImage()) {
9
+ <img class="size-full object-cover" [src]="src()" [alt]="alt()" (error)="failed.set(true)" />
10
+ } @else {
11
+ {{ initials() }}
12
+ }
13
+ </span>
14
+ `,
15
+ host: { class: 'inline-flex' },
16
+ })
17
+ export class UiAvatarComponent {
18
+ readonly src = input('');
19
+ readonly alt = input('');
20
+ readonly initials = input('');
21
+ readonly failed = signal(false);
22
+ readonly showImage = computed(() => this.src().length > 0 && !this.failed());
23
+ }
@@ -0,0 +1 @@
1
+ export { UiAvatarComponent } from './avatar.component';