@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.
- package/README.md +42 -1
- package/dist/init/css.js +1 -1
- package/dist/init/index.js +33 -0
- package/dist/init/paths.js +20 -0
- package/dist/registry.js +4 -1
- package/dist/registry.json +198 -1
- package/dist/templates/blocks/login-01/index.ts +1 -0
- package/dist/templates/blocks/login-01/login-01.html +174 -0
- package/dist/templates/blocks/login-01/login-01.ts +114 -0
- package/dist/templates/blocks/login-02/index.ts +1 -0
- package/dist/templates/blocks/login-02/login-02.html +186 -0
- package/dist/templates/blocks/login-02/login-02.ts +101 -0
- package/dist/templates/blocks/otp-01/index.ts +1 -0
- package/dist/templates/blocks/otp-01/otp-01.html +166 -0
- package/dist/templates/blocks/otp-01/otp-01.ts +221 -0
- package/dist/templates/blocks/pricing-01/index.ts +1 -0
- package/dist/templates/blocks/pricing-01/pricing-01.html +173 -0
- package/dist/templates/blocks/pricing-01/pricing-01.ts +178 -0
- package/dist/templates/blocks/settings-01/index.ts +1 -0
- package/dist/templates/blocks/settings-01/settings-01.html +424 -0
- package/dist/templates/blocks/settings-01/settings-01.ts +392 -0
- package/dist/templates/blocks/signup-01/index.ts +1 -0
- package/dist/templates/blocks/signup-01/signup-01.html +332 -0
- package/dist/templates/blocks/signup-01/signup-01.ts +186 -0
- package/dist/templates/blocks/table-01/index.ts +1 -0
- package/dist/templates/blocks/table-01/table-01.html +214 -0
- package/dist/templates/blocks/table-01/table-01.ts +266 -0
- package/dist/templates/components/checkbox/checkbox.ts +14 -5
- package/dist/templates/components/command/command-texts.ts +65 -0
- package/dist/templates/components/command/command.ts +553 -0
- package/dist/templates/components/command/index.ts +13 -0
- package/dist/templates/components/data-table/data-table-texts.ts +110 -0
- package/dist/templates/components/data-table/data-table.ts +1153 -0
- package/dist/templates/components/data-table/index.ts +18 -0
- package/dist/templates/components/sortable-list/index.ts +9 -0
- package/dist/templates/components/sortable-list/sortable-list-texts.ts +71 -0
- package/dist/templates/components/sortable-list/sortable-list.ts +510 -0
- package/dist/templates/components/tabs/tabs.ts +88 -23
- package/dist/templates/components/tree/index.ts +9 -0
- package/dist/templates/components/tree/tree-texts.ts +45 -0
- package/dist/templates/components/tree/tree.ts +730 -0
- package/dist/templates/theme/provider.ts +4 -0
- package/dist/templates/theme/types.ts +84 -0
- package/dist/ui/index.js +323 -13
- package/dist/ui/schema.json +50 -2
- package/dist/utils/pages.js +138 -0
- package/dist/utils/project.js +59 -0
- package/dist/utils/tailwind.js +84 -0
- package/dist/utils/write-files.js +63 -8
- package/package.json +1 -1
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Solo la API pública: MimiTableOutlet es interno (se queda en './data-table').
|
|
2
|
+
export {
|
|
3
|
+
type MimiTableCellContext,
|
|
4
|
+
type MimiTableColumn,
|
|
5
|
+
type MimiTableDensity,
|
|
6
|
+
type MimiTableFilterOption,
|
|
7
|
+
type MimiTableFilters,
|
|
8
|
+
type MimiTableRowContext,
|
|
9
|
+
type MimiTableSort,
|
|
10
|
+
MimiDataTable,
|
|
11
|
+
MimiDataTableImports,
|
|
12
|
+
MimiTableActions,
|
|
13
|
+
MimiTableBulk,
|
|
14
|
+
MimiTableCell,
|
|
15
|
+
MimiTableDetail,
|
|
16
|
+
MimiTableEmpty,
|
|
17
|
+
} from './data-table';
|
|
18
|
+
export * from './data-table-texts';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Solo la API pública: MimiSortableOutlet es interno (se queda en './sortable-list').
|
|
2
|
+
export {
|
|
3
|
+
MimiSortableItemTemplate,
|
|
4
|
+
type MimiSortableItemContext,
|
|
5
|
+
MimiSortableList,
|
|
6
|
+
MimiSortableListImports,
|
|
7
|
+
type MimiSortableReorder,
|
|
8
|
+
} from './sortable-list';
|
|
9
|
+
export * from './sortable-list-texts';
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { InjectionToken, type Provider } from '@angular/core';
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Textos de Sortable List, en archivo propio y sin @angular/cdk: así se pueden proveer en
|
|
5
|
+
* `app.config.ts` sin llevar la lista (ni el drag-drop del cdk) al chunk inicial.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Textos que la lista muestra o anuncia. `label` es el de `itemLabel`. */
|
|
9
|
+
export interface MimiSortableText {
|
|
10
|
+
/** `aria-roledescription` de cada elemento. */
|
|
11
|
+
roleDescription: string;
|
|
12
|
+
/** Nombre del asa. */
|
|
13
|
+
handle: (label: string) => string;
|
|
14
|
+
/** Cómo se usa el teclado (en el `aria-describedby` del asa). */
|
|
15
|
+
instructions: string;
|
|
16
|
+
/** Al agarrar con el teclado. */
|
|
17
|
+
grabbed: (label: string, position: number, total: number) => string;
|
|
18
|
+
/** Al mover con el teclado. */
|
|
19
|
+
moved: (label: string, position: number, total: number) => string;
|
|
20
|
+
/** Al soltar (teclado o puntero). */
|
|
21
|
+
dropped: (label: string, position: number, total: number) => string;
|
|
22
|
+
/** Al cancelar con Escape. */
|
|
23
|
+
canceled: (label: string, position: number, total: number) => string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Textos predeterminados (inglés). */
|
|
27
|
+
export const MIMI_SORTABLE_TEXT_EN: MimiSortableText = {
|
|
28
|
+
roleDescription: 'sortable item',
|
|
29
|
+
handle: (label) => `Reorder ${label}`,
|
|
30
|
+
instructions:
|
|
31
|
+
'Press Space or Enter to grab, the arrow keys to move, Space or Enter to drop and Escape to cancel.',
|
|
32
|
+
grabbed: (label, position, total) =>
|
|
33
|
+
`${label} grabbed. Position ${position} of ${total}. Use the arrow keys to move it.`,
|
|
34
|
+
moved: (label, position, total) => `${label}: position ${position} of ${total}.`,
|
|
35
|
+
dropped: (label, position, total) => `${label} dropped at position ${position} of ${total}.`,
|
|
36
|
+
canceled: (label, position, total) =>
|
|
37
|
+
`Reordering canceled. ${label} is back at position ${position} of ${total}.`,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** Textos en español: `provideMimiSortableText(MIMI_SORTABLE_TEXT_ES)`. */
|
|
41
|
+
export const MIMI_SORTABLE_TEXT_ES: MimiSortableText = {
|
|
42
|
+
roleDescription: 'elemento ordenable',
|
|
43
|
+
handle: (label) => `Reordenar ${label}`,
|
|
44
|
+
instructions:
|
|
45
|
+
'Pulsa Espacio o Enter para agarrar, las flechas para mover, Espacio o Enter para soltar y Escape para cancelar.',
|
|
46
|
+
grabbed: (label, position, total) =>
|
|
47
|
+
`${label} agarrado. Lugar ${position} de ${total}. Usa las flechas para moverlo.`,
|
|
48
|
+
moved: (label, position, total) => `${label}: lugar ${position} de ${total}.`,
|
|
49
|
+
dropped: (label, position, total) => `${label} soltado en el lugar ${position} de ${total}.`,
|
|
50
|
+
canceled: (label, position, total) =>
|
|
51
|
+
`Se canceló. ${label} vuelve al lugar ${position} de ${total}.`,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Textos activos. Sin `provideMimiSortableText`, los predeterminados en inglés. */
|
|
55
|
+
export const MIMI_SORTABLE_TEXT = new InjectionToken<MimiSortableText>('MIMI_SORTABLE_TEXT', {
|
|
56
|
+
providedIn: 'root',
|
|
57
|
+
factory: () => MIMI_SORTABLE_TEXT_EN,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Cambia los textos de Sortable List. Lo que no definas sigue en inglés:
|
|
62
|
+
*
|
|
63
|
+
* ```ts
|
|
64
|
+
* provideMimiSortableText(MIMI_SORTABLE_TEXT_ES)
|
|
65
|
+
* ```
|
|
66
|
+
*
|
|
67
|
+
* Va en `app.config.ts` o en los `providers` de un componente o ruta.
|
|
68
|
+
*/
|
|
69
|
+
export function provideMimiSortableText(text: Partial<MimiSortableText>): Provider {
|
|
70
|
+
return { provide: MIMI_SORTABLE_TEXT, useValue: { ...MIMI_SORTABLE_TEXT_EN, ...text } };
|
|
71
|
+
}
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CdkDrag,
|
|
3
|
+
type CdkDragDrop,
|
|
4
|
+
CdkDragHandle,
|
|
5
|
+
CdkDragPlaceholder,
|
|
6
|
+
CdkDragPreview,
|
|
7
|
+
type CdkDragStart,
|
|
8
|
+
CdkDropList,
|
|
9
|
+
} from '@angular/cdk/drag-drop';
|
|
10
|
+
import {
|
|
11
|
+
ChangeDetectionStrategy,
|
|
12
|
+
Component,
|
|
13
|
+
Directive,
|
|
14
|
+
ElementRef,
|
|
15
|
+
type EmbeddedViewRef,
|
|
16
|
+
Injector,
|
|
17
|
+
TemplateRef,
|
|
18
|
+
ViewContainerRef,
|
|
19
|
+
afterNextRender,
|
|
20
|
+
booleanAttribute,
|
|
21
|
+
computed,
|
|
22
|
+
contentChild,
|
|
23
|
+
effect,
|
|
24
|
+
inject,
|
|
25
|
+
input,
|
|
26
|
+
isDevMode,
|
|
27
|
+
model,
|
|
28
|
+
output,
|
|
29
|
+
signal,
|
|
30
|
+
untracked,
|
|
31
|
+
viewChildren,
|
|
32
|
+
} from '@angular/core';
|
|
33
|
+
import { cn } from '@/components/ui/utils/cn';
|
|
34
|
+
import { MIMI_SORTABLE_TEXT } from './sortable-list-texts';
|
|
35
|
+
|
|
36
|
+
/*
|
|
37
|
+
* Sortable List (docs/design/Mimi F4 Command Tree Sortable.dc.html, «3 · Sortable List»;
|
|
38
|
+
* prefijo de tokens `sortable`, spec 13, G3.0).
|
|
39
|
+
*
|
|
40
|
+
* El puntero y el dedo usan @angular/cdk/drag-drop (spec 4): la lista es un `cdkDropList` con el
|
|
41
|
+
* eje Y fijo y cada elemento un `cdkDrag` que se toma solo desde su asa. El teclado es propio
|
|
42
|
+
* (el cdk no tiene, plan G3.2-d): Espacio o Enter en el asa agarran, ↑ ↓ Inicio Fin mueven,
|
|
43
|
+
* Espacio o Enter sueltan y Escape cancela; cada paso se anuncia en la región del diseño.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/** Contexto de `<ng-template mimiSortableItem let-item let-index="index">`. */
|
|
47
|
+
export interface MimiSortableItemContext<T = unknown> {
|
|
48
|
+
$implicit: T;
|
|
49
|
+
index: number;
|
|
50
|
+
/** Agarrado con el teclado o arrastrándose. */
|
|
51
|
+
grabbed: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Lo que emite `reordered` al soltar un elemento en otro lugar. */
|
|
55
|
+
export interface MimiSortableReorder<T = unknown> {
|
|
56
|
+
item: T;
|
|
57
|
+
previousIndex: number;
|
|
58
|
+
currentIndex: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Contenido de cada elemento (todo lo que va al lado del asa):
|
|
63
|
+
*
|
|
64
|
+
* ```html
|
|
65
|
+
* <ng-template mimiSortableItem let-task let-index="index">…</ng-template>
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
@Directive({ selector: 'ng-template[mimiSortableItem]' })
|
|
69
|
+
// `any` por defecto: el tipo de `items` no llega al `ng-template` (Angular no lo infiere), y con
|
|
70
|
+
// `unknown` la plantilla del usuario no podría leer `task.title` con strictTemplates.
|
|
71
|
+
export class MimiSortableItemTemplate<T = any> {
|
|
72
|
+
readonly template = inject<TemplateRef<MimiSortableItemContext<T>>>(TemplateRef);
|
|
73
|
+
|
|
74
|
+
static ngTemplateContextGuard<T>(
|
|
75
|
+
_dir: MimiSortableItemTemplate<T>,
|
|
76
|
+
ctx: unknown,
|
|
77
|
+
): ctx is MimiSortableItemContext<T> {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Pinta la plantilla de un elemento con su contexto, sin `NgTemplateOutlet` (lo lleva al JS
|
|
84
|
+
* inicial del sitio; G3.1, spec 10). Recibe cada dato por separado: un objeto nuevo en cada
|
|
85
|
+
* detección de cambios haría que el efecto se repitiera sin fin (NG0103).
|
|
86
|
+
*
|
|
87
|
+
* @internal No forma parte de la API de Mimi.
|
|
88
|
+
*/
|
|
89
|
+
@Directive({ selector: '[mimiSortableOutlet]' })
|
|
90
|
+
export class MimiSortableOutlet<T = unknown> {
|
|
91
|
+
readonly template = input.required<TemplateRef<MimiSortableItemContext<T>>>({
|
|
92
|
+
alias: 'mimiSortableOutlet',
|
|
93
|
+
});
|
|
94
|
+
readonly item = input.required<T>({ alias: 'mimiSortableOutletItem' });
|
|
95
|
+
readonly index = input.required<number>({ alias: 'mimiSortableOutletIndex' });
|
|
96
|
+
readonly grabbed = input.required<boolean>({ alias: 'mimiSortableOutletGrabbed' });
|
|
97
|
+
|
|
98
|
+
private readonly viewContainer = inject(ViewContainerRef);
|
|
99
|
+
private view: EmbeddedViewRef<MimiSortableItemContext<T>> | null = null;
|
|
100
|
+
private rendered: TemplateRef<MimiSortableItemContext<T>> | null = null;
|
|
101
|
+
|
|
102
|
+
private readonly render = effect(() => {
|
|
103
|
+
const template = this.template();
|
|
104
|
+
const context: MimiSortableItemContext<T> = {
|
|
105
|
+
$implicit: this.item(),
|
|
106
|
+
index: this.index(),
|
|
107
|
+
grabbed: this.grabbed(),
|
|
108
|
+
};
|
|
109
|
+
untracked(() => {
|
|
110
|
+
if (this.view && this.rendered === template) {
|
|
111
|
+
Object.assign(this.view.context, context);
|
|
112
|
+
this.view.markForCheck();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
this.viewContainer.clear();
|
|
116
|
+
this.rendered = template;
|
|
117
|
+
this.view = this.viewContainer.createEmbeddedView(template, { ...context });
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Lucide grip-vertical. */
|
|
123
|
+
const GRIP = [
|
|
124
|
+
'M9 12h.01',
|
|
125
|
+
'M9 5h.01',
|
|
126
|
+
'M9 19h.01',
|
|
127
|
+
'M15 12h.01',
|
|
128
|
+
'M15 5h.01',
|
|
129
|
+
'M15 19h.01',
|
|
130
|
+
] as const;
|
|
131
|
+
|
|
132
|
+
let nextId = 0;
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Lista ordenable: `<mimi-sortable-list aria-label="Prioridades" [(items)]="tareas"
|
|
136
|
+
* [itemLabel]="titulo">` con `<ng-template mimiSortableItem let-tarea>…</ng-template>`.
|
|
137
|
+
*
|
|
138
|
+
* `items` cambia (un arreglo nuevo) al soltar un elemento en otro lugar, y `reordered` dice cuál
|
|
139
|
+
* y de dónde a dónde. Se reordena desde el asa: con el puntero, el dedo o el teclado.
|
|
140
|
+
*/
|
|
141
|
+
@Component({
|
|
142
|
+
selector: 'mimi-sortable-list',
|
|
143
|
+
imports: [
|
|
144
|
+
CdkDropList,
|
|
145
|
+
CdkDrag,
|
|
146
|
+
CdkDragHandle,
|
|
147
|
+
CdkDragPlaceholder,
|
|
148
|
+
CdkDragPreview,
|
|
149
|
+
MimiSortableOutlet,
|
|
150
|
+
],
|
|
151
|
+
template: `
|
|
152
|
+
<ul
|
|
153
|
+
cdkDropList
|
|
154
|
+
cdkDropListLockAxis="y"
|
|
155
|
+
[cdkDropListDisabled]="disabled()"
|
|
156
|
+
[attr.aria-label]="ariaLabel() || null"
|
|
157
|
+
[attr.aria-labelledby]="ariaLabelledby() || null"
|
|
158
|
+
[class]="listClasses"
|
|
159
|
+
(cdkDropListDropped)="onDrop($event)"
|
|
160
|
+
>
|
|
161
|
+
@for (item of items(); track trackItem(item, $index); let index = $index) {
|
|
162
|
+
<li
|
|
163
|
+
cdkDrag
|
|
164
|
+
cdkDragPreviewContainer="parent"
|
|
165
|
+
[cdkDragData]="item"
|
|
166
|
+
[attr.aria-roledescription]="text.roleDescription"
|
|
167
|
+
[attr.data-grabbed]="grabbedIndex() === index || null"
|
|
168
|
+
[attr.data-index]="index"
|
|
169
|
+
[class]="itemClasses"
|
|
170
|
+
(cdkDragStarted)="onDragStart($event)"
|
|
171
|
+
>
|
|
172
|
+
<button
|
|
173
|
+
#handle
|
|
174
|
+
cdkDragHandle
|
|
175
|
+
type="button"
|
|
176
|
+
data-slot="handle"
|
|
177
|
+
[disabled]="disabled()"
|
|
178
|
+
[attr.aria-label]="text.handle(labelOf(item))"
|
|
179
|
+
[attr.aria-describedby]="instructionsId"
|
|
180
|
+
[attr.aria-pressed]="grabbedIndex() === index"
|
|
181
|
+
[class]="handleClasses"
|
|
182
|
+
(keydown)="onHandleKeydown($event, index)"
|
|
183
|
+
(blur)="onHandleBlur(index)"
|
|
184
|
+
>
|
|
185
|
+
<svg
|
|
186
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
187
|
+
viewBox="0 0 24 24"
|
|
188
|
+
fill="none"
|
|
189
|
+
stroke="currentColor"
|
|
190
|
+
stroke-width="2"
|
|
191
|
+
stroke-linecap="round"
|
|
192
|
+
stroke-linejoin="round"
|
|
193
|
+
class="size-4"
|
|
194
|
+
aria-hidden="true"
|
|
195
|
+
>
|
|
196
|
+
@for (d of grip; track d) {
|
|
197
|
+
<path [attr.d]="d" />
|
|
198
|
+
}
|
|
199
|
+
</svg>
|
|
200
|
+
</button>
|
|
201
|
+
@if (itemTemplate(); as custom) {
|
|
202
|
+
<ng-container
|
|
203
|
+
[mimiSortableOutlet]="custom.template"
|
|
204
|
+
[mimiSortableOutletItem]="item"
|
|
205
|
+
[mimiSortableOutletIndex]="index"
|
|
206
|
+
[mimiSortableOutletGrabbed]="grabbedIndex() === index"
|
|
207
|
+
/>
|
|
208
|
+
} @else {
|
|
209
|
+
<span class="min-w-0 flex-1 truncate text-sm font-semibold">{{ labelOf(item) }}</span>
|
|
210
|
+
}
|
|
211
|
+
<ng-template cdkDragPlaceholder>
|
|
212
|
+
<li
|
|
213
|
+
aria-hidden="true"
|
|
214
|
+
data-slot="placeholder"
|
|
215
|
+
[class]="placeholderClasses"
|
|
216
|
+
[style.height.px]="placeholderHeight()"
|
|
217
|
+
></li>
|
|
218
|
+
</ng-template>
|
|
219
|
+
<!-- El cdk mueve la copia con su transform; el giro va en el hijo. -->
|
|
220
|
+
<ng-template cdkDragPreview [matchSize]="true">
|
|
221
|
+
<div data-slot="preview" class="list-none">
|
|
222
|
+
<div [class]="previewClasses">
|
|
223
|
+
<span [class]="handleClasses" data-dragging="">
|
|
224
|
+
<svg
|
|
225
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
226
|
+
viewBox="0 0 24 24"
|
|
227
|
+
fill="none"
|
|
228
|
+
stroke="currentColor"
|
|
229
|
+
stroke-width="2"
|
|
230
|
+
stroke-linecap="round"
|
|
231
|
+
stroke-linejoin="round"
|
|
232
|
+
class="size-4"
|
|
233
|
+
aria-hidden="true"
|
|
234
|
+
>
|
|
235
|
+
@for (d of grip; track d) {
|
|
236
|
+
<path [attr.d]="d" />
|
|
237
|
+
}
|
|
238
|
+
</svg>
|
|
239
|
+
</span>
|
|
240
|
+
@if (itemTemplate(); as custom) {
|
|
241
|
+
<ng-container
|
|
242
|
+
[mimiSortableOutlet]="custom.template"
|
|
243
|
+
[mimiSortableOutletItem]="item"
|
|
244
|
+
[mimiSortableOutletIndex]="index"
|
|
245
|
+
[mimiSortableOutletGrabbed]="true"
|
|
246
|
+
/>
|
|
247
|
+
} @else {
|
|
248
|
+
<span class="min-w-0 flex-1 truncate text-sm font-semibold">{{
|
|
249
|
+
labelOf(item)
|
|
250
|
+
}}</span>
|
|
251
|
+
}
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
</ng-template>
|
|
255
|
+
</li>
|
|
256
|
+
}
|
|
257
|
+
</ul>
|
|
258
|
+
<span [id]="instructionsId" class="sr-only">{{ text.instructions }}</span>
|
|
259
|
+
<span
|
|
260
|
+
role="status"
|
|
261
|
+
aria-live="assertive"
|
|
262
|
+
data-slot="announcement"
|
|
263
|
+
class="min-h-[18px] text-[12.5px] text-muted-foreground"
|
|
264
|
+
>{{ announcement() }}</span
|
|
265
|
+
>
|
|
266
|
+
`,
|
|
267
|
+
host: {
|
|
268
|
+
'[class]': 'classes()',
|
|
269
|
+
'[attr.data-disabled]': 'disabled() || null',
|
|
270
|
+
'[attr.data-sorting]': 'grabbedIndex() !== null || null',
|
|
271
|
+
// aria-* van a la lista.
|
|
272
|
+
'[attr.aria-label]': 'null',
|
|
273
|
+
'[attr.aria-labelledby]': 'null',
|
|
274
|
+
},
|
|
275
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
276
|
+
})
|
|
277
|
+
export class MimiSortableList<T = unknown> {
|
|
278
|
+
/** Los elementos, en orden. Se reemplaza por un arreglo nuevo al reordenar. */
|
|
279
|
+
readonly items = model<T[]>([]);
|
|
280
|
+
/** Nombre de un elemento para el asa y los anuncios. Por defecto, `String(item)`. */
|
|
281
|
+
readonly itemLabel = input<(item: T) => string>((item) => String(item));
|
|
282
|
+
/**
|
|
283
|
+
* Clave estable de cada elemento (`track`). Por defecto, el propio elemento; si hay elementos
|
|
284
|
+
* repetidos (primitivos iguales), el índice, y en desarrollo avisa de que falta `trackBy` (G3.P).
|
|
285
|
+
*/
|
|
286
|
+
readonly trackBy = input<((item: T) => unknown) | undefined>(undefined);
|
|
287
|
+
readonly disabled = input(false, { transform: booleanAttribute });
|
|
288
|
+
readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
|
|
289
|
+
readonly ariaLabelledby = input<string>(undefined, { alias: 'aria-labelledby' });
|
|
290
|
+
readonly userClass = input('', { alias: 'class' });
|
|
291
|
+
|
|
292
|
+
/** Un elemento se soltó en otro lugar. */
|
|
293
|
+
readonly reordered = output<MimiSortableReorder<T>>();
|
|
294
|
+
|
|
295
|
+
protected readonly text = inject(MIMI_SORTABLE_TEXT);
|
|
296
|
+
private readonly injector = inject(Injector);
|
|
297
|
+
protected readonly itemTemplate = contentChild(MimiSortableItemTemplate);
|
|
298
|
+
private readonly handles = viewChildren<ElementRef<HTMLButtonElement>>('handle');
|
|
299
|
+
|
|
300
|
+
protected readonly grip = GRIP;
|
|
301
|
+
protected readonly instructionsId = `mimi-sortable-${nextId++}-help`;
|
|
302
|
+
/** Índice del elemento agarrado con el teclado. */
|
|
303
|
+
protected readonly grabbedIndex = signal<number | null>(null);
|
|
304
|
+
/** Dónde estaba al agarrarlo (para Escape) y el orden de entonces. */
|
|
305
|
+
private grabStart: { index: number; items: T[] } | null = null;
|
|
306
|
+
protected readonly announcement = signal('');
|
|
307
|
+
/** Alto del elemento que se arrastra con el puntero: el del hueco. */
|
|
308
|
+
protected readonly placeholderHeight = signal<number | null>(null);
|
|
309
|
+
|
|
310
|
+
protected readonly classes = computed(() => cn('flex flex-col gap-2', this.userClass()));
|
|
311
|
+
|
|
312
|
+
protected readonly listClasses = cn(
|
|
313
|
+
'relative m-0 flex list-none flex-col gap-[var(--mimi-sortable-gap,8px)] p-0',
|
|
314
|
+
// Mientras se arrastra, los demás se apartan con la curva del diseño (0,18 s).
|
|
315
|
+
'[&.cdk-drop-list-dragging>li:not(.cdk-drag-placeholder)]:transition-transform',
|
|
316
|
+
'[&.cdk-drop-list-dragging>li:not(.cdk-drag-placeholder)]:duration-[180ms]',
|
|
317
|
+
'[&.cdk-drop-list-dragging>li:not(.cdk-drag-placeholder)]:ease-[var(--mimi-sortable-easing,cubic-bezier(.2,.8,.2,1))]',
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
private readonly itemShape =
|
|
321
|
+
'flex min-h-[var(--mimi-sortable-item-height,56px)] items-center gap-2.5 rounded-[var(--mimi-sortable-radius,var(--mimi-radius))] border border-border bg-[color:var(--mimi-sortable-bg,var(--mimi-card))] ps-1.5 pe-3 text-card-foreground select-none';
|
|
322
|
+
|
|
323
|
+
/** El elemento, y agarrado con el teclado: levantado (sombra y anillo del hueco). */
|
|
324
|
+
protected readonly itemClasses = cn(
|
|
325
|
+
this.itemShape,
|
|
326
|
+
'transition-shadow duration-150',
|
|
327
|
+
'[&.cdk-drag-animating]:transition-transform [&.cdk-drag-animating]:duration-[180ms] [&.cdk-drag-animating]:ease-[var(--mimi-sortable-easing,cubic-bezier(.2,.8,.2,1))]',
|
|
328
|
+
'data-[grabbed]:relative data-[grabbed]:z-10 data-[grabbed]:shadow-[shadow:var(--mimi-shadow-popover),0_0_0_2px_var(--mimi-sortable-drop,var(--mimi-info))]',
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
/** La copia que sigue al puntero: girada y algo más grande, como el diseño. */
|
|
332
|
+
protected readonly previewClasses = cn(
|
|
333
|
+
this.itemShape,
|
|
334
|
+
'size-full [transform:var(--mimi-sortable-lift,rotate(-1.5deg)_scale(1.03))] shadow-[shadow:var(--mimi-shadow-popover),0_0_0_2px_var(--mimi-sortable-drop,var(--mimi-info))]',
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
protected readonly placeholderClasses =
|
|
338
|
+
'box-border min-h-[var(--mimi-sortable-item-height,56px)] list-none rounded-[var(--mimi-sortable-radius,var(--mimi-radius))] border-2 border-dashed border-[color:var(--mimi-sortable-drop,var(--mimi-info))] bg-[color:var(--mimi-sortable-drop-bg,var(--mimi-info-soft))]';
|
|
339
|
+
|
|
340
|
+
protected readonly handleClasses = cn(
|
|
341
|
+
'inline-flex h-9 w-7 shrink-0 cursor-grab touch-none items-center justify-center rounded-sm border-0 bg-transparent p-0 text-muted-foreground',
|
|
342
|
+
'mimi-transition hover:bg-[color:var(--mimi-sortable-handle-hover,var(--mimi-accent))] hover:text-foreground',
|
|
343
|
+
'aria-pressed:bg-[color:var(--mimi-sortable-handle-hover,var(--mimi-accent))] aria-pressed:text-foreground data-[dragging]:bg-[color:var(--mimi-sortable-handle-hover,var(--mimi-accent))] data-[dragging]:text-foreground',
|
|
344
|
+
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
|
|
345
|
+
'disabled:cursor-not-allowed disabled:opacity-50',
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
protected labelOf(item: T): string {
|
|
349
|
+
return this.itemLabel()(item);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Sin `trackBy`, el elemento mismo solo vale si no se repite (si no, NG0955 y vistas mezcladas). */
|
|
353
|
+
private readonly repeated = computed(() => {
|
|
354
|
+
if (this.trackBy()) return false;
|
|
355
|
+
const items = this.items();
|
|
356
|
+
return new Set(items).size !== items.length;
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
private warnedRepeated = false;
|
|
360
|
+
private readonly warnRepeated = isDevMode()
|
|
361
|
+
? effect(() => {
|
|
362
|
+
if (!this.repeated() || this.warnedRepeated) return;
|
|
363
|
+
this.warnedRepeated = true;
|
|
364
|
+
console.warn(
|
|
365
|
+
'[Mimi] Sortable List: hay elementos repetidos y falta `trackBy`; se identifican por su ' +
|
|
366
|
+
'lugar. Usa `[trackBy]="x => x.id"` para que las vistas sigan a cada elemento.',
|
|
367
|
+
);
|
|
368
|
+
})
|
|
369
|
+
: null;
|
|
370
|
+
|
|
371
|
+
protected trackItem(item: T, index: number): unknown {
|
|
372
|
+
const trackBy = this.trackBy();
|
|
373
|
+
if (trackBy) return trackBy(item);
|
|
374
|
+
return this.repeated() ? index : item;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── Puntero y dedo (cdk) ─────────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
protected onDragStart(event: CdkDragStart<T>): void {
|
|
380
|
+
this.cancelKeyboardGrab(false);
|
|
381
|
+
this.placeholderHeight.set(event.source.element.nativeElement.offsetHeight || null);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
protected onDrop(event: CdkDragDrop<T[], T[], T>): void {
|
|
385
|
+
this.placeholderHeight.set(null);
|
|
386
|
+
const { previousIndex, currentIndex } = event;
|
|
387
|
+
if (previousIndex === currentIndex) return;
|
|
388
|
+
const items = this.items();
|
|
389
|
+
this.items.set(move(items, previousIndex, currentIndex));
|
|
390
|
+
this.reordered.emit({ item: items[previousIndex], previousIndex, currentIndex });
|
|
391
|
+
this.announce('dropped', currentIndex);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// ── Teclado (G3.2-d) ─────────────────────────────────────────────────────────────────
|
|
395
|
+
|
|
396
|
+
protected onHandleKeydown(event: KeyboardEvent, index: number): void {
|
|
397
|
+
if (this.disabled()) return;
|
|
398
|
+
const grabbed = this.grabbedIndex();
|
|
399
|
+
if (grabbed === null) {
|
|
400
|
+
if (event.key === ' ' || event.key === 'Enter') {
|
|
401
|
+
event.preventDefault();
|
|
402
|
+
this.grabStart = { index, items: this.items() };
|
|
403
|
+
this.grabbedIndex.set(index);
|
|
404
|
+
this.announce('grabbed', index);
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
const last = this.items().length - 1;
|
|
409
|
+
let target: number | null = null;
|
|
410
|
+
switch (event.key) {
|
|
411
|
+
case 'ArrowUp':
|
|
412
|
+
target = Math.max(0, grabbed - 1);
|
|
413
|
+
break;
|
|
414
|
+
case 'ArrowDown':
|
|
415
|
+
target = Math.min(last, grabbed + 1);
|
|
416
|
+
break;
|
|
417
|
+
case 'Home':
|
|
418
|
+
target = 0;
|
|
419
|
+
break;
|
|
420
|
+
case 'End':
|
|
421
|
+
target = last;
|
|
422
|
+
break;
|
|
423
|
+
case ' ':
|
|
424
|
+
case 'Enter':
|
|
425
|
+
event.preventDefault();
|
|
426
|
+
this.drop();
|
|
427
|
+
return;
|
|
428
|
+
case 'Escape':
|
|
429
|
+
// Cancela el agarre y nada más: no cierra el diálogo que contenga la lista (G3.P).
|
|
430
|
+
event.preventDefault();
|
|
431
|
+
event.stopPropagation();
|
|
432
|
+
this.cancelKeyboardGrab(true);
|
|
433
|
+
return;
|
|
434
|
+
default:
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
event.preventDefault();
|
|
438
|
+
if (target === grabbed) return;
|
|
439
|
+
this.items.set(move(this.items(), grabbed, target));
|
|
440
|
+
this.grabbedIndex.set(target);
|
|
441
|
+
this.announce('moved', target);
|
|
442
|
+
this.focusHandle(target);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Si el foco sale del asa con el elemento agarrado, se suelta donde está. */
|
|
446
|
+
protected onHandleBlur(index: number): void {
|
|
447
|
+
// Al mover, el asa se vuelve a enfocar en su nuevo lugar: solo cuenta un blur de verdad.
|
|
448
|
+
afterNextRender(
|
|
449
|
+
() => {
|
|
450
|
+
const grabbed = this.grabbedIndex();
|
|
451
|
+
if (grabbed === null || grabbed !== index) return;
|
|
452
|
+
const handle = this.handles()[grabbed]?.nativeElement;
|
|
453
|
+
if (handle && handle.ownerDocument.activeElement !== handle) this.drop();
|
|
454
|
+
},
|
|
455
|
+
{ injector: this.injector },
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private drop(): void {
|
|
460
|
+
const grabbed = this.grabbedIndex();
|
|
461
|
+
const start = this.grabStart;
|
|
462
|
+
this.grabbedIndex.set(null);
|
|
463
|
+
this.grabStart = null;
|
|
464
|
+
if (grabbed === null || !start) return;
|
|
465
|
+
this.announce('dropped', grabbed);
|
|
466
|
+
if (grabbed !== start.index) {
|
|
467
|
+
this.reordered.emit({
|
|
468
|
+
item: start.items[start.index],
|
|
469
|
+
previousIndex: start.index,
|
|
470
|
+
currentIndex: grabbed,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** Escape (o un arrastre con el puntero): vuelve al orden de cuando se agarró. */
|
|
476
|
+
private cancelKeyboardGrab(announce: boolean): void {
|
|
477
|
+
const start = this.grabStart;
|
|
478
|
+
if (!start) return;
|
|
479
|
+
this.grabbedIndex.set(null);
|
|
480
|
+
this.grabStart = null;
|
|
481
|
+
this.items.set(start.items);
|
|
482
|
+
if (announce) {
|
|
483
|
+
this.announce('canceled', start.index);
|
|
484
|
+
this.focusHandle(start.index);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private announce(kind: 'grabbed' | 'moved' | 'dropped' | 'canceled', index: number): void {
|
|
489
|
+
const items = this.items();
|
|
490
|
+
const label = this.labelOf(items[index]);
|
|
491
|
+
this.announcement.set(this.text[kind](label, index + 1, items.length));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private focusHandle(index: number): void {
|
|
495
|
+
afterNextRender(() => this.handles()[index]?.nativeElement.focus(), {
|
|
496
|
+
injector: this.injector,
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Un arreglo nuevo con el elemento de `from` en `to`. */
|
|
502
|
+
function move<T>(items: T[], from: number, to: number): T[] {
|
|
503
|
+
const next = [...items];
|
|
504
|
+
const [moved] = next.splice(from, 1);
|
|
505
|
+
next.splice(to, 0, moved);
|
|
506
|
+
return next;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** Todo lo que hace falta para usar la lista: `imports: [MimiSortableListImports]`. */
|
|
510
|
+
export const MimiSortableListImports = [MimiSortableList, MimiSortableItemTemplate] as const;
|