@mimi-ng/cli 0.3.0 → 0.5.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 (50) hide show
  1. package/README.md +42 -1
  2. package/dist/init/css.js +1 -1
  3. package/dist/init/index.js +33 -0
  4. package/dist/init/paths.js +20 -0
  5. package/dist/registry.js +4 -1
  6. package/dist/registry.json +198 -1
  7. package/dist/templates/blocks/login-01/index.ts +1 -0
  8. package/dist/templates/blocks/login-01/login-01.html +174 -0
  9. package/dist/templates/blocks/login-01/login-01.ts +114 -0
  10. package/dist/templates/blocks/login-02/index.ts +1 -0
  11. package/dist/templates/blocks/login-02/login-02.html +186 -0
  12. package/dist/templates/blocks/login-02/login-02.ts +101 -0
  13. package/dist/templates/blocks/otp-01/index.ts +1 -0
  14. package/dist/templates/blocks/otp-01/otp-01.html +166 -0
  15. package/dist/templates/blocks/otp-01/otp-01.ts +221 -0
  16. package/dist/templates/blocks/pricing-01/index.ts +1 -0
  17. package/dist/templates/blocks/pricing-01/pricing-01.html +173 -0
  18. package/dist/templates/blocks/pricing-01/pricing-01.ts +178 -0
  19. package/dist/templates/blocks/settings-01/index.ts +1 -0
  20. package/dist/templates/blocks/settings-01/settings-01.html +424 -0
  21. package/dist/templates/blocks/settings-01/settings-01.ts +392 -0
  22. package/dist/templates/blocks/signup-01/index.ts +1 -0
  23. package/dist/templates/blocks/signup-01/signup-01.html +332 -0
  24. package/dist/templates/blocks/signup-01/signup-01.ts +186 -0
  25. package/dist/templates/blocks/table-01/index.ts +1 -0
  26. package/dist/templates/blocks/table-01/table-01.html +214 -0
  27. package/dist/templates/blocks/table-01/table-01.ts +266 -0
  28. package/dist/templates/components/checkbox/checkbox.ts +14 -5
  29. package/dist/templates/components/command/command-texts.ts +65 -0
  30. package/dist/templates/components/command/command.ts +553 -0
  31. package/dist/templates/components/command/index.ts +13 -0
  32. package/dist/templates/components/data-table/data-table-texts.ts +110 -0
  33. package/dist/templates/components/data-table/data-table.ts +1153 -0
  34. package/dist/templates/components/data-table/index.ts +18 -0
  35. package/dist/templates/components/sortable-list/index.ts +9 -0
  36. package/dist/templates/components/sortable-list/sortable-list-texts.ts +71 -0
  37. package/dist/templates/components/sortable-list/sortable-list.ts +510 -0
  38. package/dist/templates/components/tabs/tabs.ts +88 -23
  39. package/dist/templates/components/tree/index.ts +9 -0
  40. package/dist/templates/components/tree/tree-texts.ts +45 -0
  41. package/dist/templates/components/tree/tree.ts +730 -0
  42. package/dist/templates/theme/provider.ts +4 -0
  43. package/dist/templates/theme/types.ts +84 -0
  44. package/dist/ui/index.js +323 -13
  45. package/dist/ui/schema.json +50 -2
  46. package/dist/utils/pages.js +138 -0
  47. package/dist/utils/project.js +59 -0
  48. package/dist/utils/tailwind.js +84 -0
  49. package/dist/utils/write-files.js +63 -8
  50. package/package.json +1 -1
@@ -0,0 +1,730 @@
1
+ import { Directionality } from '@angular/cdk/bidi';
2
+ import {
3
+ ChangeDetectionStrategy,
4
+ Component,
5
+ DestroyRef,
6
+ Directive,
7
+ ElementRef,
8
+ Injector,
9
+ TemplateRef,
10
+ afterNextRender,
11
+ booleanAttribute,
12
+ type EmbeddedViewRef,
13
+ ViewContainerRef,
14
+ computed,
15
+ contentChild,
16
+ effect,
17
+ inject,
18
+ input,
19
+ isDevMode,
20
+ model,
21
+ output,
22
+ signal,
23
+ untracked,
24
+ viewChildren,
25
+ } from '@angular/core';
26
+ import type { FormValueControl } from '@angular/forms/signals';
27
+ import { MIMI_CHECKBOX_BOX_STYLES } from '@/components/ui/checkbox';
28
+ import { cn } from '@/components/ui/utils/cn';
29
+ import { MIMI_TREE_TEXT } from './tree-texts';
30
+
31
+ /*
32
+ * Tree (docs/design/Mimi F4 Command Tree Sortable.dc.html, «2 · Tree»; prefijo de tokens `tree`,
33
+ * spec 13, G3.0).
34
+ *
35
+ * Patrón «Tree View» de la APG con foco itinerante (una sola parada de Tab): filas planas con
36
+ * `aria-level`, `aria-setsize` y `aria-posinset`. Base propia (plan, G3.1-a): el `ngTree` de
37
+ * @angular/aria no tiene casillas con estado parcial ni carga diferida, que pide el diseño;
38
+ * `Directionality` del cdk invierte ← → en RTL.
39
+ *
40
+ * Simple: un nodo con hijos se abre o cierra y una hoja se elige (`aria-selected`). Múltiple
41
+ * (`multiple`): casillas con estado parcial en el `aria-checked` del nodo; la casilla se dibuja
42
+ * con los estilos de Checkbox, sin un control enfocable dentro del nodo (G3.1-d).
43
+ */
44
+
45
+ /** Un nodo del árbol. `id` es único en todo el árbol y es lo que lleva `value`. */
46
+ export interface MimiTreeNode<T = unknown> {
47
+ id: string;
48
+ label: string;
49
+ /** Hijos ya conocidos. */
50
+ children?: MimiTreeNode<T>[];
51
+ /** Tiene hijos que se piden al abrirlo con `loadChildren` (carga diferida). */
52
+ hasChildren?: boolean;
53
+ /** Dato secundario a la derecha (tamaño, cantidad…), en letra monoespaciada. */
54
+ meta?: string;
55
+ /** Se puede enfocar y abrir, pero no elegir ni marcar. */
56
+ disabled?: boolean;
57
+ /** Lo que quieras llevar con el nodo (lo recibe la plantilla). */
58
+ data?: T;
59
+ }
60
+
61
+ /** Contexto de `<ng-template mimiTreeNode let-node>`. */
62
+ export interface MimiTreeNodeContext<T = unknown> {
63
+ $implicit: MimiTreeNode<T>;
64
+ /** Nivel, desde 1. */
65
+ level: number;
66
+ expanded: boolean;
67
+ /** Elegido (simple) o marcado (múltiple). */
68
+ selected: boolean;
69
+ }
70
+
71
+ /**
72
+ * Contenido propio de cada nodo (ícono, texto, datos); reemplaza a la etiqueta y `meta`:
73
+ *
74
+ * ```html
75
+ * <ng-template mimiTreeNode let-node let-expanded="expanded">…</ng-template>
76
+ * ```
77
+ */
78
+ @Directive({ selector: 'ng-template[mimiTreeNode]' })
79
+ // `any` por defecto: el tipo de `nodes` no llega al `ng-template` (Angular no lo infiere), y con
80
+ // `unknown` la plantilla del usuario no podría leer `node.data.algo` con strictTemplates.
81
+ export class MimiTreeNodeTemplate<T = any> {
82
+ readonly template = inject<TemplateRef<MimiTreeNodeContext<T>>>(TemplateRef);
83
+
84
+ static ngTemplateContextGuard<T>(
85
+ _dir: MimiTreeNodeTemplate<T>,
86
+ ctx: unknown,
87
+ ): ctx is MimiTreeNodeContext<T> {
88
+ return true;
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Pinta la plantilla del nodo con su contexto. Se usa en vez de `NgTemplateOutlet`: con él, el
94
+ * optimizador de chunks llevaba `NgTemplateOutlet` al JS inicial del sitio (+1,6 kB, G3.1; spec
95
+ * 10, «Trampas de tamaño»). Recibe cada dato por separado: un objeto de contexto nuevo en cada
96
+ * detección de cambios haría que el efecto se repitiera sin fin (NG0103, encontrado en la G3.2).
97
+ *
98
+ * @internal No forma parte de la API de Mimi.
99
+ */
100
+ @Directive({ selector: '[mimiTreeOutlet]' })
101
+ export class MimiTreeOutlet<T = unknown> {
102
+ readonly template = input.required<TemplateRef<MimiTreeNodeContext<T>>>({
103
+ alias: 'mimiTreeOutlet',
104
+ });
105
+ readonly node = input.required<MimiTreeNode<T>>({ alias: 'mimiTreeOutletNode' });
106
+ readonly level = input.required<number>({ alias: 'mimiTreeOutletLevel' });
107
+ readonly expanded = input.required<boolean>({ alias: 'mimiTreeOutletExpanded' });
108
+ readonly selected = input.required<boolean>({ alias: 'mimiTreeOutletSelected' });
109
+
110
+ private readonly viewContainer = inject(ViewContainerRef);
111
+ private view: EmbeddedViewRef<MimiTreeNodeContext<T>> | null = null;
112
+ private rendered: TemplateRef<MimiTreeNodeContext<T>> | null = null;
113
+
114
+ private readonly render = effect(() => {
115
+ const template = this.template();
116
+ const context: MimiTreeNodeContext<T> = {
117
+ $implicit: this.node(),
118
+ level: this.level(),
119
+ expanded: this.expanded(),
120
+ selected: this.selected(),
121
+ };
122
+ untracked(() => {
123
+ if (this.view && this.rendered === template) {
124
+ Object.assign(this.view.context, context);
125
+ this.view.markForCheck();
126
+ return;
127
+ }
128
+ this.viewContainer.clear();
129
+ this.rendered = template;
130
+ this.view = this.viewContainer.createEmbeddedView(template, { ...context });
131
+ });
132
+ });
133
+ }
134
+
135
+ type CheckState = 'checked' | 'unchecked' | 'indeterminate';
136
+ type LoadStatus = 'loading' | 'error';
137
+
138
+ /** Una fila visible: un nodo o el estado de la carga de los hijos de un nodo. */
139
+ interface Row<T> {
140
+ kind: 'node' | LoadStatus;
141
+ node: MimiTreeNode<T>;
142
+ level: number;
143
+ parent: MimiTreeNode<T> | null;
144
+ setsize: number;
145
+ posinset: number;
146
+ expandable: boolean;
147
+ expanded: boolean;
148
+ }
149
+
150
+ /** Quita acentos y pasa a minúsculas, para la búsqueda por la primera letra. */
151
+ const fold = (text: string) => text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
152
+
153
+ /**
154
+ * Árbol: `<mimi-tree [nodes]="nodos" [(value)]="elegido" aria-label="Archivos" />`.
155
+ *
156
+ * - `value`: el id elegido (`string | null`) o, con `multiple`, los ids marcados (`string[]`).
157
+ * - `expanded`: los ids abiertos (`[(expanded)]`).
158
+ * - `loadChildren`: pide los hijos de un nodo con `hasChildren` la primera vez que se abre.
159
+ *
160
+ * Implementa `FormValueControl`: funciona con `[formField]`, `formControlName` y `[(ngModel)]`.
161
+ */
162
+ @Component({
163
+ selector: 'mimi-tree',
164
+ imports: [MimiTreeOutlet],
165
+ template: `
166
+ <div
167
+ role="tree"
168
+ [attr.aria-label]="ariaLabel() || null"
169
+ [attr.aria-labelledby]="ariaLabelledby() || null"
170
+ [attr.aria-disabled]="disabled() || null"
171
+ [attr.aria-busy]="busy() || null"
172
+ class="flex flex-col gap-px outline-none"
173
+ (keydown)="onKeydown($event)"
174
+ (focusout)="onFocusout($event)"
175
+ >
176
+ @for (row of rows(); track row.kind + ':' + row.node.id) {
177
+ @if (row.kind === 'node') {
178
+ <div
179
+ #item
180
+ role="treeitem"
181
+ [attr.data-id]="row.node.id"
182
+ [attr.aria-level]="row.level"
183
+ [attr.aria-setsize]="row.setsize"
184
+ [attr.aria-posinset]="row.posinset"
185
+ [attr.aria-expanded]="row.expandable ? row.expanded : null"
186
+ [attr.aria-selected]="ariaSelected(row)"
187
+ [attr.aria-checked]="multiple() ? ariaChecked(row.node) : null"
188
+ [attr.aria-disabled]="row.node.disabled || null"
189
+ [attr.aria-busy]="status().get(row.node.id) === 'loading' || null"
190
+ [attr.data-state]="rowState(row)"
191
+ [attr.data-disabled]="row.node.disabled || null"
192
+ [attr.data-selected]="!multiple() && value() === row.node.id ? '' : null"
193
+ [tabindex]="row.node.id === tabStop() ? 0 : -1"
194
+ [class]="rowClasses"
195
+ [style.padding-inline-start]="indentOf(row.level)"
196
+ (click)="onRowClick(row, $event)"
197
+ (focus)="activeId.set(row.node.id)"
198
+ >
199
+ <span
200
+ aria-hidden="true"
201
+ data-slot="chevron"
202
+ class="inline-flex size-4 shrink-0 items-center justify-center text-muted-foreground transition-transform duration-200 ease-[cubic-bezier(.2,.8,.2,1)] in-data-[selected]:text-current rtl:-scale-x-100"
203
+ [class.invisible]="!row.expandable"
204
+ [class.rotate-90]="row.expanded && status().get(row.node.id) !== 'loading'"
205
+ (click)="onChevronClick(row, $event)"
206
+ >
207
+ @if (status().get(row.node.id) === 'loading') {
208
+ <!-- Lucide loader-circle -->
209
+ <svg
210
+ xmlns="http://www.w3.org/2000/svg"
211
+ viewBox="0 0 24 24"
212
+ fill="none"
213
+ stroke="currentColor"
214
+ stroke-width="2"
215
+ stroke-linecap="round"
216
+ stroke-linejoin="round"
217
+ class="size-3.5 animate-spin motion-reduce:animate-none"
218
+ >
219
+ <path d="M21 12a9 9 0 1 1-6.219-8.56" />
220
+ </svg>
221
+ } @else {
222
+ <!-- Lucide chevron-right -->
223
+ <svg
224
+ xmlns="http://www.w3.org/2000/svg"
225
+ viewBox="0 0 24 24"
226
+ fill="none"
227
+ stroke="currentColor"
228
+ stroke-width="2"
229
+ stroke-linecap="round"
230
+ stroke-linejoin="round"
231
+ class="size-3.5"
232
+ >
233
+ <path d="m9 18 6-6-6-6" />
234
+ </svg>
235
+ }
236
+ </span>
237
+ @if (multiple()) {
238
+ <span
239
+ aria-hidden="true"
240
+ data-slot="checkbox"
241
+ [class]="boxClasses"
242
+ [attr.data-state]="checkStates().get(row.node.id)"
243
+ >
244
+ @switch (checkStates().get(row.node.id)) {
245
+ @case ('checked') {
246
+ <!-- Lucide check -->
247
+ <svg
248
+ xmlns="http://www.w3.org/2000/svg"
249
+ viewBox="0 0 24 24"
250
+ fill="none"
251
+ stroke="currentColor"
252
+ stroke-width="3"
253
+ stroke-linecap="round"
254
+ stroke-linejoin="round"
255
+ class="size-3"
256
+ >
257
+ <path d="M20 6 9 17l-5-5" />
258
+ </svg>
259
+ }
260
+ @case ('indeterminate') {
261
+ <!-- Lucide minus -->
262
+ <svg
263
+ xmlns="http://www.w3.org/2000/svg"
264
+ viewBox="0 0 24 24"
265
+ fill="none"
266
+ stroke="currentColor"
267
+ stroke-width="3"
268
+ stroke-linecap="round"
269
+ stroke-linejoin="round"
270
+ class="size-3"
271
+ >
272
+ <path d="M5 12h14" />
273
+ </svg>
274
+ }
275
+ }
276
+ </span>
277
+ }
278
+ @if (nodeTemplate(); as custom) {
279
+ <ng-container
280
+ [mimiTreeOutlet]="custom.template"
281
+ [mimiTreeOutletNode]="row.node"
282
+ [mimiTreeOutletLevel]="row.level"
283
+ [mimiTreeOutletExpanded]="row.expanded"
284
+ [mimiTreeOutletSelected]="isSelected(row.node)"
285
+ />
286
+ } @else {
287
+ <span class="min-w-0 flex-1 truncate">{{ row.node.label }}</span>
288
+ @if (row.node.meta) {
289
+ <span
290
+ class="font-mono text-[11px] text-muted-foreground in-data-[selected]:text-current"
291
+ >{{ row.node.meta }}</span
292
+ >
293
+ }
294
+ }
295
+ </div>
296
+ } @else {
297
+ <div
298
+ aria-hidden="true"
299
+ [attr.data-slot]="row.kind === 'loading' ? 'loading' : 'error'"
300
+ class="flex h-7 items-center gap-2 pe-2 text-[12.5px] text-muted-foreground"
301
+ [class.text-destructive-soft-foreground]="row.kind === 'error'"
302
+ [style.padding-inline-start]="statusIndent(row.level)"
303
+ >
304
+ @if (row.kind === 'loading') {
305
+ <span class="h-[9px] w-[120px] animate-mimi-pulse rounded-[4px] bg-muted"></span>
306
+ {{ text.loading }}
307
+ } @else {
308
+ {{ text.loadError }}
309
+ }
310
+ </div>
311
+ }
312
+ }
313
+ </div>
314
+ <span class="sr-only" role="status">{{ announcement() }}</span>
315
+ `,
316
+ host: {
317
+ '[class]': 'classes()',
318
+ '[attr.data-multiple]': 'multiple() || null',
319
+ '[attr.data-disabled]': 'disabled() || null',
320
+ // aria-* van al elemento con role="tree".
321
+ '[attr.aria-label]': 'null',
322
+ '[attr.aria-labelledby]': 'null',
323
+ },
324
+ changeDetection: ChangeDetectionStrategy.OnPush,
325
+ })
326
+ export class MimiTree<T = unknown> implements FormValueControl<string | null | string[]> {
327
+ /** Los nodos de primer nivel. */
328
+ readonly nodes = input<MimiTreeNode<T>[]>([]);
329
+ /** El id elegido (simple) o los ids marcados (`multiple`). */
330
+ readonly value = model<string | null | string[]>(null);
331
+ /** Selección múltiple con casillas y estado parcial. */
332
+ readonly multiple = input(false, { transform: booleanAttribute });
333
+ /** Los ids de los nodos abiertos. */
334
+ readonly expanded = model<string[]>([]);
335
+ /** Pide los hijos de un nodo con `hasChildren` (sin `children`) la primera vez que se abre. */
336
+ readonly loadChildren = input<(node: MimiTreeNode<T>) => Promise<MimiTreeNode<T>[]>>();
337
+ readonly disabled = input(false, { transform: booleanAttribute });
338
+ readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
339
+ readonly ariaLabelledby = input<string>(undefined, { alias: 'aria-labelledby' });
340
+ readonly userClass = input('', { alias: 'class' });
341
+
342
+ /** Se emite cuando el foco sale del árbol: marca el control como tocado. */
343
+ readonly touch = output<void>();
344
+
345
+ protected readonly text = inject(MIMI_TREE_TEXT);
346
+ private readonly dir = inject(Directionality, { optional: true });
347
+ private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
348
+ private readonly injector = inject(Injector);
349
+ /** Una carga diferida que termina después de destruirse no escribe señales (G3.P). */
350
+ private destroyed = false;
351
+ private readonly markDestroyed = inject(DestroyRef).onDestroy(() => (this.destroyed = true));
352
+ protected readonly nodeTemplate = contentChild(MimiTreeNodeTemplate);
353
+ private readonly items = viewChildren<ElementRef<HTMLElement>>('item');
354
+
355
+ /** Hijos cargados con `loadChildren`, por id del padre. */
356
+ private readonly loaded = signal(new Map<string, MimiTreeNode<T>[]>());
357
+ /** Nodos cargando o con error. */
358
+ protected readonly status = signal(new Map<string, LoadStatus>());
359
+ /** El nodo con el foco itinerante. */
360
+ protected readonly activeId = signal<string | null>(null);
361
+ protected readonly announcement = signal('');
362
+
363
+ protected readonly busy = computed(() => [...this.status().values()].includes('loading'));
364
+
365
+ protected readonly classes = computed(() =>
366
+ cn(
367
+ 'flex flex-col rounded-card border border-border bg-[color:var(--mimi-tree-bg,var(--mimi-card))] p-[var(--mimi-tree-padding,6px)] text-[length:var(--mimi-tree-font-size,13.5px)] text-card-foreground shadow-neutral',
368
+ this.userClass(),
369
+ ),
370
+ );
371
+
372
+ protected readonly rowClasses = cn(
373
+ 'flex h-[var(--mimi-tree-row-height,32px)] cursor-pointer items-center gap-1.5 rounded-[var(--mimi-tree-row-radius,var(--mimi-radius-sm))] pe-2 text-foreground outline-none select-none',
374
+ 'transition-[background-color,box-shadow] duration-150',
375
+ 'hover:bg-[color:var(--mimi-tree-hover,var(--mimi-accent))]',
376
+ 'focus-visible:shadow-[inset_0_0_0_2px_var(--mimi-ring)]',
377
+ 'data-[selected]:bg-[color:var(--mimi-tree-selected,var(--mimi-primary))] data-[selected]:font-semibold data-[selected]:text-[color:var(--mimi-tree-selected-fg,var(--mimi-primary-foreground))]',
378
+ 'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 data-[disabled]:hover:bg-transparent',
379
+ 'in-data-[multiple]:gap-2',
380
+ );
381
+
382
+ protected readonly boxClasses = MIMI_CHECKBOX_BOX_STYLES;
383
+
384
+ /** Los hijos de un nodo: los suyos o los cargados. `null`: todavía no se conocen. */
385
+ private childrenOf(node: MimiTreeNode<T>): MimiTreeNode<T>[] | null {
386
+ return node.children ?? this.loaded().get(node.id) ?? null;
387
+ }
388
+
389
+ private isExpandable(node: MimiTreeNode<T>): boolean {
390
+ const children = this.childrenOf(node);
391
+ return children ? children.length > 0 : !!node.hasChildren;
392
+ }
393
+
394
+ /** Padre de cada nodo conocido (cargados incluidos). */
395
+ private readonly parents = computed(() => {
396
+ const parents = new Map<string, MimiTreeNode<T> | null>();
397
+ const walk = (nodes: MimiTreeNode<T>[], parent: MimiTreeNode<T> | null) => {
398
+ for (const node of nodes) {
399
+ if (isDevMode() && parents.has(node.id)) {
400
+ console.warn(
401
+ `[Mimi] Tree: el id "${node.id}" está repetido; cada nodo necesita uno único.`,
402
+ );
403
+ }
404
+ parents.set(node.id, parent);
405
+ const children = node.children ?? this.loaded().get(node.id);
406
+ if (children) walk(children, node);
407
+ }
408
+ };
409
+ walk(this.nodes(), null);
410
+ return parents;
411
+ });
412
+
413
+ private readonly expandedSet = computed(() => new Set(this.expanded()));
414
+
415
+ /** Las filas visibles, en orden. */
416
+ protected readonly rows = computed(() => {
417
+ const rows: Row<T>[] = [];
418
+ const expanded = this.expandedSet();
419
+ const status = this.status();
420
+ const walk = (nodes: MimiTreeNode<T>[], level: number, parent: MimiTreeNode<T> | null) => {
421
+ nodes.forEach((node, index) => {
422
+ const expandable = this.isExpandable(node);
423
+ const open = expandable && expanded.has(node.id);
424
+ rows.push({
425
+ kind: 'node',
426
+ node,
427
+ level,
428
+ parent,
429
+ setsize: nodes.length,
430
+ posinset: index + 1,
431
+ expandable,
432
+ expanded: open,
433
+ });
434
+ if (!open) return;
435
+ const children = this.childrenOf(node);
436
+ const state = status.get(node.id);
437
+ if (state) rows.push({ ...rows[rows.length - 1], kind: state, level: level + 1 });
438
+ else if (children) walk(children, level + 1, node);
439
+ });
440
+ };
441
+ walk(this.nodes(), 1, null);
442
+ return rows;
443
+ });
444
+
445
+ private readonly nodeRows = computed(() => this.rows().filter((row) => row.kind === 'node'));
446
+
447
+ /** Ids marcados (múltiple). No se llama `checked`: lo reserva `FormValueControl`. */
448
+ private readonly checkedIds = computed(() => {
449
+ const value = this.value();
450
+ return new Set(Array.isArray(value) ? value : []);
451
+ });
452
+
453
+ /** Estado de la casilla de cada nodo conocido: los padres, según sus hijos (G3.1-d). */
454
+ protected readonly checkStates = computed(() => {
455
+ const states = new Map<string, CheckState>();
456
+ const checked = this.checkedIds();
457
+ const visit = (node: MimiTreeNode<T>): CheckState => {
458
+ const children = this.childrenOf(node);
459
+ let state: CheckState;
460
+ if (!children || children.length === 0) {
461
+ state = checked.has(node.id) ? 'checked' : 'unchecked';
462
+ } else {
463
+ const childStates = children.map(visit);
464
+ state = childStates.every((s) => s === 'checked')
465
+ ? 'checked'
466
+ : childStates.every((s) => s === 'unchecked')
467
+ ? 'unchecked'
468
+ : 'indeterminate';
469
+ }
470
+ states.set(node.id, state);
471
+ return state;
472
+ };
473
+ this.nodes().forEach(visit);
474
+ return states;
475
+ });
476
+
477
+ /**
478
+ * El nodo con `tabindex="0"`: el activo o, si quedó oculto (se cerró su padre), su antepasado
479
+ * visible más cercano; si no hay activo, el elegido o el primero.
480
+ */
481
+ protected readonly tabStop = computed(() => {
482
+ const rows = this.nodeRows();
483
+ const visible = new Set(rows.map((row) => row.node.id));
484
+ let id = this.activeId();
485
+ const parents = this.parents();
486
+ while (id && !visible.has(id)) id = parents.get(id)?.id ?? null;
487
+ if (id) return id;
488
+ const value = this.value();
489
+ if (typeof value === 'string' && visible.has(value)) return value;
490
+ return rows[0]?.node.id ?? null;
491
+ });
492
+
493
+ protected ariaSelected(row: Row<T>): 'true' | 'false' | null {
494
+ if (this.multiple() || row.expandable) return null;
495
+ return this.value() === row.node.id ? 'true' : 'false';
496
+ }
497
+
498
+ protected ariaChecked(node: MimiTreeNode<T>): 'true' | 'false' | 'mixed' {
499
+ const state = this.checkStates().get(node.id);
500
+ return state === 'checked' ? 'true' : state === 'indeterminate' ? 'mixed' : 'false';
501
+ }
502
+
503
+ protected isSelected(node: MimiTreeNode<T>): boolean {
504
+ return this.multiple()
505
+ ? this.checkStates().get(node.id) === 'checked'
506
+ : this.value() === node.id;
507
+ }
508
+
509
+ protected rowState(row: Row<T>): string {
510
+ return row.expandable ? (row.expanded ? 'open' : 'closed') : 'leaf';
511
+ }
512
+
513
+ protected indentOf(level: number): string {
514
+ return `calc(6px + ${level - 1} * var(--mimi-tree-indent, 18px))`;
515
+ }
516
+
517
+ /** Fila de carga o error (en el nivel de los hijos): a la altura de su texto, como el diseño. */
518
+ protected statusIndent(level: number): string {
519
+ return `calc(8px + ${level - 1} * var(--mimi-tree-indent, 18px) + 22px)`;
520
+ }
521
+
522
+ // ── Acciones ─────────────────────────────────────────────────────────────────────────
523
+
524
+ private setExpanded(node: MimiTreeNode<T>, open: boolean): void {
525
+ const current = this.expandedSet();
526
+ if (current.has(node.id) === open) {
527
+ if (open && this.status().get(node.id) === 'error') void this.load(node);
528
+ return;
529
+ }
530
+ this.expanded.set(
531
+ open ? [...this.expanded(), node.id] : this.expanded().filter((id) => id !== node.id),
532
+ );
533
+ if (open) void this.load(node);
534
+ }
535
+
536
+ private toggleExpanded(node: MimiTreeNode<T>): void {
537
+ this.setExpanded(node, !this.expandedSet().has(node.id));
538
+ }
539
+
540
+ /** Pide los hijos si hace falta (primera vez, o reintento tras un error). */
541
+ private async load(node: MimiTreeNode<T>): Promise<void> {
542
+ if (node.children || this.loaded().has(node.id) || !node.hasChildren) return;
543
+ if (this.status().get(node.id) === 'loading') return;
544
+ const loader = this.loadChildren();
545
+ if (!loader) {
546
+ if (isDevMode()) {
547
+ console.warn(`[Mimi] Tree: el nodo "${node.id}" tiene hasChildren y no hay loadChildren.`);
548
+ }
549
+ return;
550
+ }
551
+ this.setStatus(node.id, 'loading');
552
+ this.announcement.set(this.text.loading);
553
+ try {
554
+ const children = await loader(node);
555
+ if (this.destroyed) return;
556
+ this.loaded.update((map) => new Map(map).set(node.id, children));
557
+ this.setStatus(node.id, null);
558
+ this.announcement.set('');
559
+ // Un padre marcado marca también lo que acaba de llegar.
560
+ if (this.multiple() && this.checkedIds().has(node.id)) this.check(node, true);
561
+ } catch {
562
+ if (this.destroyed) return;
563
+ this.setStatus(node.id, 'error');
564
+ this.announcement.set(this.text.loadError);
565
+ }
566
+ }
567
+
568
+ private setStatus(id: string, state: LoadStatus | null): void {
569
+ this.status.update((map) => {
570
+ const next = new Map(map);
571
+ if (state) next.set(id, state);
572
+ else next.delete(id);
573
+ return next;
574
+ });
575
+ }
576
+
577
+ /** Marca o desmarca un nodo y lo cargado debajo, y recalcula sus antepasados. */
578
+ private check(node: MimiTreeNode<T>, on: boolean): void {
579
+ const ids = new Set(this.checkedIds());
580
+ const apply = (current: MimiTreeNode<T>) => {
581
+ if (current !== node && current.disabled) return;
582
+ if (on) ids.add(current.id);
583
+ else ids.delete(current.id);
584
+ this.childrenOf(current)?.forEach(apply);
585
+ };
586
+ apply(node);
587
+ const parents = this.parents();
588
+ for (let parent = parents.get(node.id); parent; parent = parents.get(parent.id)) {
589
+ const children = this.childrenOf(parent) ?? [];
590
+ if (children.length > 0 && children.every((child) => this.isChecked(child, ids))) {
591
+ ids.add(parent.id);
592
+ } else {
593
+ ids.delete(parent.id);
594
+ }
595
+ }
596
+ // En el orden del árbol, para que el valor sea estable.
597
+ const order: string[] = [];
598
+ const walk = (nodes: MimiTreeNode<T>[]) =>
599
+ nodes.forEach((n) => {
600
+ if (ids.has(n.id)) order.push(n.id);
601
+ const children = this.childrenOf(n);
602
+ if (children) walk(children);
603
+ });
604
+ walk(this.nodes());
605
+ // Ids que ya no están en el árbol (de otra carga) se conservan al final.
606
+ for (const id of ids) if (!order.includes(id)) order.push(id);
607
+ this.value.set(order);
608
+ }
609
+
610
+ private isChecked(node: MimiTreeNode<T>, ids: Set<string>): boolean {
611
+ const children = this.childrenOf(node);
612
+ if (!children || children.length === 0) return ids.has(node.id);
613
+ return children.every((child) => this.isChecked(child, ids));
614
+ }
615
+
616
+ /** Enter, Espacio o clic en la fila (G3.1-c y G3.1-d). */
617
+ private activate(row: Row<T>, key: 'enter' | 'space' | 'click'): void {
618
+ if (this.disabled() || row.node.disabled) return;
619
+ if (this.multiple()) {
620
+ if (key === 'enter' && row.expandable) this.toggleExpanded(row.node);
621
+ else this.check(row.node, this.checkStates().get(row.node.id) !== 'checked');
622
+ return;
623
+ }
624
+ if (row.expandable) this.toggleExpanded(row.node);
625
+ else this.value.set(row.node.id);
626
+ }
627
+
628
+ // ── Eventos ──────────────────────────────────────────────────────────────────────────
629
+
630
+ protected onRowClick(row: Row<T>, event: MouseEvent): void {
631
+ this.activeId.set(row.node.id);
632
+ (event.currentTarget as HTMLElement).focus();
633
+ this.activate(row, 'click');
634
+ }
635
+
636
+ /** El chevrón abre o cierra sin elegir ni marcar. */
637
+ protected onChevronClick(row: Row<T>, event: MouseEvent): void {
638
+ if (!row.expandable) return;
639
+ event.stopPropagation();
640
+ this.activeId.set(row.node.id);
641
+ this.focusActive();
642
+ if (!this.disabled()) this.toggleExpanded(row.node);
643
+ }
644
+
645
+ protected onFocusout(event: FocusEvent): void {
646
+ const next = event.relatedTarget as Node | null;
647
+ if (!next || !this.host.nativeElement.contains(next)) this.touch.emit();
648
+ }
649
+
650
+ protected onKeydown(event: KeyboardEvent): void {
651
+ // Un campo o botón dentro de la plantilla del nodo se queda con sus teclas (G3.P).
652
+ const origin = event.target as Element | null;
653
+ if (
654
+ origin?.closest?.(
655
+ 'input, textarea, select, button, [contenteditable]:not([contenteditable="false"])',
656
+ )
657
+ ) {
658
+ return;
659
+ }
660
+ const rows = this.nodeRows();
661
+ const index = rows.findIndex((row) => row.node.id === this.tabStop());
662
+ const row = rows[index];
663
+ if (!row) return;
664
+ const rtl = this.dir?.value === 'rtl';
665
+ const forward = rtl ? 'ArrowLeft' : 'ArrowRight';
666
+ const back = rtl ? 'ArrowRight' : 'ArrowLeft';
667
+ let target: Row<T> | undefined;
668
+
669
+ switch (event.key) {
670
+ case 'ArrowDown':
671
+ target = rows[index + 1];
672
+ break;
673
+ case 'ArrowUp':
674
+ target = rows[index - 1];
675
+ break;
676
+ case 'Home':
677
+ target = rows[0];
678
+ break;
679
+ case 'End':
680
+ target = rows[rows.length - 1];
681
+ break;
682
+ case forward:
683
+ if (row.expandable && !row.expanded) this.setExpanded(row.node, true);
684
+ else if (row.expanded && rows[index + 1]?.parent === row.node) target = rows[index + 1];
685
+ break;
686
+ case back:
687
+ if (row.expanded) this.setExpanded(row.node, false);
688
+ else if (row.parent) target = rows.find((r) => r.node === row.parent);
689
+ break;
690
+ case 'Enter':
691
+ this.activate(row, 'enter');
692
+ break;
693
+ case ' ':
694
+ this.activate(row, 'space');
695
+ break;
696
+ case '*':
697
+ for (const sibling of rows.filter((r) => r.parent === row.parent && r.expandable)) {
698
+ this.setExpanded(sibling.node, true);
699
+ }
700
+ break;
701
+ default: {
702
+ if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return;
703
+ const letter = fold(event.key);
704
+ const ordered = [...rows.slice(index + 1), ...rows.slice(0, index + 1)];
705
+ target = ordered.find((r) => fold(r.node.label).startsWith(letter));
706
+ if (!target) return;
707
+ }
708
+ }
709
+ event.preventDefault();
710
+ if (target) {
711
+ this.activeId.set(target.node.id);
712
+ this.focusActive();
713
+ }
714
+ }
715
+
716
+ /** Lleva el foco al nodo activo después de renderizar (puede aparecer o moverse). */
717
+ private focusActive(): void {
718
+ afterNextRender(
719
+ () => {
720
+ const id = this.tabStop();
721
+ const index = this.nodeRows().findIndex((row) => row.node.id === id);
722
+ this.items()[index]?.nativeElement.focus();
723
+ },
724
+ { injector: this.injector },
725
+ );
726
+ }
727
+ }
728
+
729
+ /** Todo lo que hace falta para usar el Tree: `imports: [MimiTreeImports]`. */
730
+ export const MimiTreeImports = [MimiTree, MimiTreeNodeTemplate] as const;