@mimi-ng/cli 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.
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/THIRD_PARTY_NOTICES.md +84 -0
- package/dist/collection.json +22 -0
- package/dist/init/css.js +32 -0
- package/dist/init/index.js +297 -0
- package/dist/init/paths.js +43 -0
- package/dist/init/schema.json +24 -0
- package/dist/ng-add/index.js +8 -0
- package/dist/registry.js +30 -0
- package/dist/registry.json +194 -0
- package/dist/templates/components/avatar/avatar.ts +129 -0
- package/dist/templates/components/avatar/avatar.variants.ts +25 -0
- package/dist/templates/components/avatar/index.ts +2 -0
- package/dist/templates/components/badge/badge.ts +21 -0
- package/dist/templates/components/badge/badge.variants.ts +27 -0
- package/dist/templates/components/badge/index.ts +2 -0
- package/dist/templates/components/button/button.ts +99 -0
- package/dist/templates/components/button/button.variants.ts +60 -0
- package/dist/templates/components/button/index.ts +2 -0
- package/dist/templates/components/card/card.ts +133 -0
- package/dist/templates/components/card/index.ts +1 -0
- package/dist/templates/components/checkbox/checkbox.ts +178 -0
- package/dist/templates/components/checkbox/index.ts +1 -0
- package/dist/templates/components/form-field/error-messages.ts +68 -0
- package/dist/templates/components/form-field/form-error.ts +82 -0
- package/dist/templates/components/form-field/form-field.ts +114 -0
- package/dist/templates/components/form-field/index.ts +3 -0
- package/dist/templates/components/input/index.ts +2 -0
- package/dist/templates/components/input/input.ts +52 -0
- package/dist/templates/components/input/input.variants.ts +38 -0
- package/dist/templates/components/separator/index.ts +2 -0
- package/dist/templates/components/separator/separator.ts +38 -0
- package/dist/templates/components/separator/separator.variants.ts +20 -0
- package/dist/templates/components/skeleton/index.ts +1 -0
- package/dist/templates/components/skeleton/skeleton.ts +27 -0
- package/dist/templates/components/switch/index.ts +1 -0
- package/dist/templates/components/switch/switch.ts +138 -0
- package/dist/templates/components/textarea/index.ts +2 -0
- package/dist/templates/components/textarea/textarea.ts +46 -0
- package/dist/templates/components/textarea/textarea.variants.ts +27 -0
- package/dist/templates/theme/index.ts +2 -0
- package/dist/templates/theme/provider.ts +150 -0
- package/dist/templates/theme/theme-base.css +241 -0
- package/dist/templates/theme/types.ts +124 -0
- package/dist/templates/utils/cn.ts +36 -0
- package/dist/templates/utils/control-styles.ts +27 -0
- package/dist/templates/utils/field-state.ts +164 -0
- package/dist/ui/index.js +172 -0
- package/dist/ui/schema.json +75 -0
- package/dist/utils/project.js +28 -0
- package/dist/utils/write-files.js +167 -0
- package/package.json +55 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
booleanAttribute,
|
|
5
|
+
computed,
|
|
6
|
+
input,
|
|
7
|
+
} from '@angular/core';
|
|
8
|
+
import { cn } from '@/components/ui/utils/cn';
|
|
9
|
+
import { type SeparatorOrientation, separatorVariants } from './separator.variants';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Línea divisoria: `<mimi-separator />` o `<mimi-separator orientation="vertical" />`.
|
|
13
|
+
*
|
|
14
|
+
* Por defecto es decorativa (`role="none"`): los lectores de pantalla no la anuncian. Con
|
|
15
|
+
* `decorative="false"` pasa a `role="separator"` con `aria-orientation`, para separar grupos
|
|
16
|
+
* con significado.
|
|
17
|
+
*/
|
|
18
|
+
@Component({
|
|
19
|
+
selector: 'mimi-separator',
|
|
20
|
+
template: '',
|
|
21
|
+
host: {
|
|
22
|
+
'[class]': 'classes()',
|
|
23
|
+
'[attr.role]': 'decorative() ? "none" : "separator"',
|
|
24
|
+
'[attr.aria-orientation]': 'decorative() ? null : orientation()',
|
|
25
|
+
'[attr.data-orientation]': 'orientation()',
|
|
26
|
+
},
|
|
27
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
28
|
+
})
|
|
29
|
+
export class MimiSeparator {
|
|
30
|
+
readonly orientation = input<SeparatorOrientation>('horizontal');
|
|
31
|
+
/** Solo visual (por defecto). En `false`, los lectores de pantalla la anuncian. */
|
|
32
|
+
readonly decorative = input(true, { transform: booleanAttribute });
|
|
33
|
+
readonly userClass = input('', { alias: 'class' });
|
|
34
|
+
|
|
35
|
+
protected readonly classes = computed(() =>
|
|
36
|
+
cn(separatorVariants({ orientation: this.orientation() }), this.userClass()),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { cva, type VariantProps } from 'class-variance-authority';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Clases de mimi-separator (docs/design/Mimi Componentes.dc.html, sección 8). Literales para que
|
|
5
|
+
* Tailwind las detecte. Línea de 1px con el color --mimi-border. La vertical toma el alto de su
|
|
6
|
+
* fila flex (align-self: stretch), como en el diseño.
|
|
7
|
+
*/
|
|
8
|
+
export const separatorVariants = cva('shrink-0 bg-border', {
|
|
9
|
+
variants: {
|
|
10
|
+
orientation: {
|
|
11
|
+
horizontal: 'block h-px w-full',
|
|
12
|
+
vertical: 'w-px self-stretch',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
defaultVariants: { orientation: 'horizontal' },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export type SeparatorOrientation = NonNullable<
|
|
19
|
+
VariantProps<typeof separatorVariants>['orientation']
|
|
20
|
+
>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './skeleton';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core';
|
|
2
|
+
import { cn } from '@/components/ui/utils/cn';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Bloque de carga (docs/design/Mimi Componentes.dc.html, sección 9): fondo --mimi-muted que
|
|
6
|
+
* late con `animate-mimi-pulse`. El tamaño y la forma se ponen con `class`:
|
|
7
|
+
* `<mimi-skeleton class="h-3 w-2/3" />`, `<mimi-skeleton class="size-12 rounded-full" />`.
|
|
8
|
+
*
|
|
9
|
+
* Es `aria-hidden`: el contenedor que carga lleva `aria-busy="true"` y un texto para lectores
|
|
10
|
+
* de pantalla. Con movimiento reducido queda quieto.
|
|
11
|
+
*/
|
|
12
|
+
@Component({
|
|
13
|
+
selector: 'mimi-skeleton',
|
|
14
|
+
template: '',
|
|
15
|
+
host: {
|
|
16
|
+
'aria-hidden': 'true',
|
|
17
|
+
'[class]': 'classes()',
|
|
18
|
+
},
|
|
19
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
20
|
+
})
|
|
21
|
+
export class MimiSkeleton {
|
|
22
|
+
readonly userClass = input('', { alias: 'class' });
|
|
23
|
+
|
|
24
|
+
protected readonly classes = computed(() =>
|
|
25
|
+
cn('block rounded-sm bg-muted animate-mimi-pulse motion-reduce:animate-none', this.userClass()),
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './switch';
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
ElementRef,
|
|
5
|
+
booleanAttribute,
|
|
6
|
+
computed,
|
|
7
|
+
input,
|
|
8
|
+
model,
|
|
9
|
+
output,
|
|
10
|
+
viewChild,
|
|
11
|
+
} from '@angular/core';
|
|
12
|
+
import type { FormCheckboxControl } from '@angular/forms/signals';
|
|
13
|
+
import { cn } from '@/components/ui/utils/cn';
|
|
14
|
+
import { buttonFocusStyles, controlDisabledStyles } from '@/components/ui/utils/control-styles';
|
|
15
|
+
import {
|
|
16
|
+
MIMI_FIELD_CONTROL,
|
|
17
|
+
type MimiFieldControl,
|
|
18
|
+
injectFieldState,
|
|
19
|
+
} from '@/components/ui/utils/field-state';
|
|
20
|
+
|
|
21
|
+
let nextId = 0;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Interruptor encendido/apagado (docs/design/Mimi Componentes.dc.html, sección 7):
|
|
25
|
+
* `<mimi-switch [(checked)]="avion">Modo avión</mimi-switch>`.
|
|
26
|
+
*
|
|
27
|
+
* Por dentro es un `<button type="button" role="switch">` y no un `<input type="checkbox">`:
|
|
28
|
+
* el pulgar del diseño no se puede dibujar de forma fiable con pseudoelementos sobre un input
|
|
29
|
+
* en todos los navegadores. El texto va en un `<label for>` que también lo alterna.
|
|
30
|
+
*
|
|
31
|
+
* Implementa `FormCheckboxControl`: funciona con `[formField]`, `formControlName` y
|
|
32
|
+
* `[(ngModel)]` sin adaptador (spec, sección 8).
|
|
33
|
+
*/
|
|
34
|
+
@Component({
|
|
35
|
+
selector: 'mimi-switch',
|
|
36
|
+
// mimi-form-field lo encuentra por este token para mostrar el mensaje de error.
|
|
37
|
+
providers: [{ provide: MIMI_FIELD_CONTROL, useExisting: MimiSwitch }],
|
|
38
|
+
template: `
|
|
39
|
+
<button
|
|
40
|
+
#control
|
|
41
|
+
type="button"
|
|
42
|
+
role="switch"
|
|
43
|
+
[id]="controlId()"
|
|
44
|
+
[class]="trackClasses"
|
|
45
|
+
[disabled]="disabled()"
|
|
46
|
+
[attr.aria-checked]="checked()"
|
|
47
|
+
[attr.aria-invalid]="showInvalid() || null"
|
|
48
|
+
[attr.aria-required]="required() || null"
|
|
49
|
+
[attr.aria-label]="ariaLabel() || null"
|
|
50
|
+
[attr.aria-labelledby]="ariaLabelledby() || null"
|
|
51
|
+
[attr.data-state]="state()"
|
|
52
|
+
(click)="toggle()"
|
|
53
|
+
(blur)="touch.emit()"
|
|
54
|
+
>
|
|
55
|
+
<span [class]="thumbClasses"></span>
|
|
56
|
+
</button>
|
|
57
|
+
<label
|
|
58
|
+
[for]="controlId()"
|
|
59
|
+
class="cursor-pointer empty:hidden peer-disabled:cursor-not-allowed peer-disabled:opacity-50"
|
|
60
|
+
><ng-content
|
|
61
|
+
/></label>
|
|
62
|
+
`,
|
|
63
|
+
host: {
|
|
64
|
+
'[class]': 'classes()',
|
|
65
|
+
'[attr.data-state]': 'state()',
|
|
66
|
+
'[attr.data-disabled]': 'disabled() || null',
|
|
67
|
+
// id y aria-* se pasan al botón: fuera del anfitrión, para no duplicarlos.
|
|
68
|
+
'[attr.id]': 'null',
|
|
69
|
+
'[attr.aria-label]': 'null',
|
|
70
|
+
'[attr.aria-labelledby]': 'null',
|
|
71
|
+
},
|
|
72
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
73
|
+
})
|
|
74
|
+
export class MimiSwitch implements FormCheckboxControl, MimiFieldControl {
|
|
75
|
+
/** Encendido o apagado. Con `[(checked)]` o un formulario. */
|
|
76
|
+
readonly checked = model(false);
|
|
77
|
+
readonly disabled = input(false, { transform: booleanAttribute });
|
|
78
|
+
/** Los pasa el formulario. Se muestra el error si es inválido y se tocó o modificó. */
|
|
79
|
+
readonly invalid = input(false, { transform: booleanAttribute });
|
|
80
|
+
readonly touched = input(false, { transform: booleanAttribute });
|
|
81
|
+
readonly dirty = input(false, { transform: booleanAttribute });
|
|
82
|
+
readonly required = input(false, { transform: booleanAttribute });
|
|
83
|
+
/** id del botón, para una etiqueta externa (`<label for>`). */
|
|
84
|
+
readonly id = input<string>();
|
|
85
|
+
readonly ariaLabel = input<string>(undefined, { alias: 'aria-label' });
|
|
86
|
+
readonly ariaLabelledby = input<string>(undefined, { alias: 'aria-labelledby' });
|
|
87
|
+
readonly userClass = input('', { alias: 'class' });
|
|
88
|
+
|
|
89
|
+
/** Se emite al perder el foco: marca el control como tocado. */
|
|
90
|
+
readonly touch = output<void>();
|
|
91
|
+
|
|
92
|
+
private readonly control = viewChild.required<ElementRef<HTMLButtonElement>>('control');
|
|
93
|
+
|
|
94
|
+
/** Estado del formulario, con los errores, para el mensaje de mimi-form-field. */
|
|
95
|
+
readonly fieldState = injectFieldState();
|
|
96
|
+
|
|
97
|
+
/** El botón interno: mimi-form-field le agrega el aria-describedby del mensaje. */
|
|
98
|
+
get controlElement(): HTMLElement {
|
|
99
|
+
return this.control().nativeElement;
|
|
100
|
+
}
|
|
101
|
+
private readonly autoId = `mimi-switch-${nextId++}`;
|
|
102
|
+
|
|
103
|
+
protected readonly controlId = computed(() => this.id() || this.autoId);
|
|
104
|
+
protected readonly state = computed(() => (this.checked() ? 'checked' : 'unchecked'));
|
|
105
|
+
/** Error visible (inválido y tocado o modificado). Lo lee también mimi-form-field. */
|
|
106
|
+
readonly showInvalid = computed(() => this.invalid() && (this.touched() || this.dirty()));
|
|
107
|
+
|
|
108
|
+
protected readonly classes = computed(() =>
|
|
109
|
+
cn('inline-flex items-center gap-2.5 text-sm', this.userClass()),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Pista: 36×20, 2px de padding. Encendida: primary con su sombra.
|
|
113
|
+
protected readonly trackClasses = cn(
|
|
114
|
+
'peer group inline-flex h-5 w-9 shrink-0 items-center rounded-full bg-switch-off p-0.5',
|
|
115
|
+
'mimi-transition active:scale-(--mimi-press-scale-sm)',
|
|
116
|
+
'data-[state=checked]:bg-primary data-[state=checked]:shadow-primary disabled:shadow-none',
|
|
117
|
+
buttonFocusStyles,
|
|
118
|
+
controlDisabledStyles,
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
// Pulgar: 16px; se desplaza 16px al encender.
|
|
122
|
+
protected readonly thumbClasses = cn(
|
|
123
|
+
'pointer-events-none block size-4 rounded-full bg-background shadow-thumb mimi-transition',
|
|
124
|
+
'group-data-[state=checked]:translate-x-4 group-data-[state=checked]:bg-primary-foreground',
|
|
125
|
+
'group-disabled:shadow-none',
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
/** Alterna el estado (no hace nada si está deshabilitado). */
|
|
129
|
+
toggle(): void {
|
|
130
|
+
if (this.disabled()) return;
|
|
131
|
+
this.checked.update((value) => !value);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Enfoca el botón. Lo usa Signal Forms para llevar el foco al campo con error. */
|
|
135
|
+
focus(options?: FocusOptions): void {
|
|
136
|
+
this.control().nativeElement.focus(options);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Directive, ElementRef, computed, inject, input } from '@angular/core';
|
|
2
|
+
import { cn } from '@/components/ui/utils/cn';
|
|
3
|
+
import {
|
|
4
|
+
MIMI_FIELD_CONTROL,
|
|
5
|
+
type MimiFieldControl,
|
|
6
|
+
injectFieldState,
|
|
7
|
+
} from '@/components/ui/utils/field-state';
|
|
8
|
+
import { textareaVariants } from './textarea.variants';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Campo de varias líneas de Mimi sobre el `<textarea>` nativo: funciona con `[formField]`,
|
|
12
|
+
* `formControlName`, `[formControl]` y `[(ngModel)]` (spec, sección 8). Con `rows="1"` queda
|
|
13
|
+
* alineado al píxel con Input y Button.
|
|
14
|
+
*/
|
|
15
|
+
@Directive({
|
|
16
|
+
selector: 'textarea[mimiTextarea]',
|
|
17
|
+
// mimi-form-field lo encuentra por este token (utils/field-state.ts).
|
|
18
|
+
providers: [{ provide: MIMI_FIELD_CONTROL, useExisting: MimiTextarea }],
|
|
19
|
+
exportAs: 'mimiTextarea',
|
|
20
|
+
host: {
|
|
21
|
+
'[class]': 'classes()',
|
|
22
|
+
'[attr.aria-invalid]': 'showInvalid() ? "true" : null',
|
|
23
|
+
'[attr.data-invalid]': 'showInvalid() ? "" : null',
|
|
24
|
+
'[attr.data-disabled]': 'fieldState.disabled() ? "" : null',
|
|
25
|
+
},
|
|
26
|
+
})
|
|
27
|
+
export class MimiTextarea implements MimiFieldControl {
|
|
28
|
+
/**
|
|
29
|
+
* Muestra el error a mano. `undefined` (por defecto): decide el formulario. `true`: lo muestra.
|
|
30
|
+
* `false`: lo oculta aunque el formulario sea inválido.
|
|
31
|
+
*
|
|
32
|
+
* No se llama `invalid`: Signal Forms escribe su estado en cualquier entrada con ese nombre
|
|
33
|
+
* (también touched, dirty, disabled, errors, required, name…) de las directivas del elemento.
|
|
34
|
+
*/
|
|
35
|
+
readonly showError = input<boolean | undefined>(undefined);
|
|
36
|
+
readonly userClass = input('', { alias: 'class' });
|
|
37
|
+
|
|
38
|
+
/** Estado del campo según su formulario. Lo lee también MimiFormField. */
|
|
39
|
+
readonly fieldState = injectFieldState();
|
|
40
|
+
readonly controlElement = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
|
|
41
|
+
|
|
42
|
+
/** Error visible: el formulario o `showError` a mano. */
|
|
43
|
+
readonly showInvalid = computed(() => this.showError() ?? this.fieldState.showError());
|
|
44
|
+
|
|
45
|
+
protected readonly classes = computed(() => cn(textareaVariants(), this.userClass()));
|
|
46
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { cva } from 'class-variance-authority';
|
|
2
|
+
import {
|
|
3
|
+
controlDisabledStyles,
|
|
4
|
+
controlInvalidStyles,
|
|
5
|
+
fieldFocusStyles,
|
|
6
|
+
} from '@/components/ui/utils/control-styles';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Clases de mimiTextarea (docs/design/Mimi Componentes.dc.html, sección 2). Usa los mismos
|
|
10
|
+
* tokens que Input (--mimi-input-*). Con una fila mide lo mismo que Input y Button: alto mínimo
|
|
11
|
+
* = alto del control, interlineado de 20px y padding vertical (alto − 20px − 2 bordes) / 2.
|
|
12
|
+
*/
|
|
13
|
+
export const textareaVariants = cva([
|
|
14
|
+
'block w-full min-w-0 rounded-[var(--mimi-input-radius,var(--mimi-radius,0.75rem))]',
|
|
15
|
+
'border-[length:var(--mimi-input-border-width,1px)] border-input bg-input-background',
|
|
16
|
+
'min-h-[var(--mimi-input-height,var(--mimi-control-height,2.5rem))]',
|
|
17
|
+
'px-[var(--mimi-input-padding-x,0.75rem)]',
|
|
18
|
+
'py-[calc((var(--mimi-input-height,var(--mimi-control-height,2.5rem))_-_20px_-_2*var(--mimi-input-border-width,1px))/2)]',
|
|
19
|
+
'font-sans text-[length:var(--mimi-input-font-size,0.875rem)] leading-5 text-foreground',
|
|
20
|
+
'resize-y mimi-transition disabled:resize-none',
|
|
21
|
+
'placeholder:text-[color:var(--mimi-input-placeholder-color,var(--mimi-muted-foreground))]',
|
|
22
|
+
fieldFocusStyles,
|
|
23
|
+
'focus-visible:ring-[length:var(--mimi-input-focus-ring-width,3px)]',
|
|
24
|
+
controlDisabledStyles,
|
|
25
|
+
'disabled:opacity-[var(--mimi-input-disabled-opacity,0.5)]',
|
|
26
|
+
controlInvalidStyles,
|
|
27
|
+
]);
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DOCUMENT,
|
|
3
|
+
type EnvironmentProviders,
|
|
4
|
+
InjectionToken,
|
|
5
|
+
inject,
|
|
6
|
+
isDevMode,
|
|
7
|
+
makeEnvironmentProviders,
|
|
8
|
+
provideAppInitializer,
|
|
9
|
+
} from '@angular/core';
|
|
10
|
+
import type { MimiComponentTokens, MimiThemePreset } from './types';
|
|
11
|
+
|
|
12
|
+
/** id del <style> que genera el tema. */
|
|
13
|
+
export const MIMI_THEME_STYLE_ID = 'mimi-theme';
|
|
14
|
+
|
|
15
|
+
/** Preset registrado con provideMimiTheme(). */
|
|
16
|
+
export const MIMI_THEME = new InjectionToken<MimiThemePreset>('MIMI_THEME');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Selector de cada modo.
|
|
20
|
+
* - light usa :root:not(.dark): con :root a secas le ganaría al .dark de theme-base.css
|
|
21
|
+
* (misma especificidad, va después) y el modo oscuro tomaría los colores claros.
|
|
22
|
+
* - dark usa .dark: va después de theme-base.css, así que le gana.
|
|
23
|
+
*/
|
|
24
|
+
const SELECTORS = {
|
|
25
|
+
any: ':root',
|
|
26
|
+
light: ':root:not(.dark)',
|
|
27
|
+
dark: '.dark',
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
type Scope = keyof typeof SELECTORS;
|
|
31
|
+
type TokenValue = string | number | undefined;
|
|
32
|
+
type TokenGroup = Partial<Record<string, TokenValue>>;
|
|
33
|
+
|
|
34
|
+
const kebab = (key: string): string => key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
|
|
35
|
+
|
|
36
|
+
const RADII: Record<string, string> = {
|
|
37
|
+
sm: 'radius-sm',
|
|
38
|
+
card: 'radius-card',
|
|
39
|
+
badge: 'badge-radius',
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** Grupos del preset: en qué modo van y cómo se nombra cada variable (sin el prefijo --mimi-). */
|
|
43
|
+
const GROUPS: readonly {
|
|
44
|
+
key: keyof MimiThemePreset;
|
|
45
|
+
scope: Scope;
|
|
46
|
+
name: (key: string) => string;
|
|
47
|
+
}[] = [
|
|
48
|
+
{ key: 'colors', scope: 'light', name: kebab },
|
|
49
|
+
{ key: 'darkColors', scope: 'dark', name: kebab },
|
|
50
|
+
{ key: 'shadows', scope: 'light', name: (k) => `shadow-${kebab(k)}` },
|
|
51
|
+
{ key: 'darkShadows', scope: 'dark', name: (k) => `shadow-${kebab(k)}` },
|
|
52
|
+
{ key: 'radii', scope: 'any', name: (k) => RADII[k] ?? `radius-${kebab(k)}` },
|
|
53
|
+
{ key: 'fonts', scope: 'any', name: (k) => `font-${kebab(k)}` },
|
|
54
|
+
{ key: 'motion', scope: 'any', name: kebab },
|
|
55
|
+
{ key: 'controls', scope: 'any', name: (k) => `control-${kebab(k)}` },
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
/** Prefijo de las variables de cada componente: button.fontWeight → --mimi-btn-font-weight. */
|
|
59
|
+
const COMPONENT_PREFIX: Record<keyof MimiComponentTokens, string> = {
|
|
60
|
+
button: 'btn',
|
|
61
|
+
input: 'input',
|
|
62
|
+
card: 'card',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Caracteres que romperían el CSS o cerrarían la etiqueta <style>. */
|
|
66
|
+
const UNSAFE_VALUE = /[;{}<]/;
|
|
67
|
+
|
|
68
|
+
/** Convierte el preset en CSS. Devuelve '' si no hay nada que aplicar. Función pura. */
|
|
69
|
+
export function mimiThemeToCss(preset: MimiThemePreset): string {
|
|
70
|
+
const declarations: Record<Scope, string[]> = { any: [], light: [], dark: [] };
|
|
71
|
+
|
|
72
|
+
const add = (scope: Scope, name: string, value: TokenValue): void => {
|
|
73
|
+
if (value === undefined) return;
|
|
74
|
+
const text = String(value).trim();
|
|
75
|
+
if (text === '') return;
|
|
76
|
+
if (UNSAFE_VALUE.test(text)) {
|
|
77
|
+
if (isDevMode()) {
|
|
78
|
+
console.warn(`[mimi] Se ignora --mimi-${name}: el valor "${text}" no es seguro.`);
|
|
79
|
+
}
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
declarations[scope].push(` --mimi-${name}: ${text};`);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
add('any', 'radius', preset.radius);
|
|
86
|
+
|
|
87
|
+
for (const group of GROUPS) {
|
|
88
|
+
const tokens = preset[group.key] as TokenGroup | undefined;
|
|
89
|
+
for (const [key, value] of Object.entries(tokens ?? {})) {
|
|
90
|
+
add(group.scope, group.name(key), value);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const components = preset.components ?? {};
|
|
95
|
+
for (const component of Object.keys(components) as (keyof MimiComponentTokens)[]) {
|
|
96
|
+
const tokens = components[component] as TokenGroup | undefined;
|
|
97
|
+
for (const [key, value] of Object.entries(tokens ?? {})) {
|
|
98
|
+
add('any', `${COMPONENT_PREFIX[component]}-${kebab(key)}`, value);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const blocks = (Object.keys(SELECTORS) as Scope[])
|
|
103
|
+
.filter((scope) => declarations[scope].length > 0)
|
|
104
|
+
.map((scope) => `${SELECTORS[scope]} {\n${declarations[scope].join('\n')}\n}`);
|
|
105
|
+
|
|
106
|
+
// El :root del preset le ganaría al prefers-reduced-motion de theme-base.css.
|
|
107
|
+
const hasMotion = declarations.any.some((d) =>
|
|
108
|
+
/--mimi-(press-scale|press-scale-sm|lift|transition):/.test(d),
|
|
109
|
+
);
|
|
110
|
+
if (hasMotion) {
|
|
111
|
+
blocks.push(
|
|
112
|
+
'@media (prefers-reduced-motion: reduce) {\n :root {\n --mimi-press-scale: 1;\n --mimi-press-scale-sm: 1;\n --mimi-lift: 0;\n }\n}',
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return blocks.length > 0 ? `${blocks.join('\n')}\n` : '';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Aplica el preset en un <style id="mimi-theme"> al final del <head>, después del CSS global,
|
|
121
|
+
* para ganarle a theme-base.css. Reutiliza el <style> si ya existe y lo vuelve a mover al
|
|
122
|
+
* final. Un preset vacío lo elimina. Nunca usa estilos en línea: le ganarían a .dark.
|
|
123
|
+
*/
|
|
124
|
+
export function applyMimiTheme(
|
|
125
|
+
document: Document,
|
|
126
|
+
preset: MimiThemePreset | null | undefined,
|
|
127
|
+
): void {
|
|
128
|
+
const css = mimiThemeToCss(preset ?? {});
|
|
129
|
+
let style = document.getElementById(MIMI_THEME_STYLE_ID);
|
|
130
|
+
|
|
131
|
+
if (css === '') {
|
|
132
|
+
style?.remove();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!style) {
|
|
137
|
+
style = document.createElement('style');
|
|
138
|
+
style.id = MIMI_THEME_STYLE_ID;
|
|
139
|
+
}
|
|
140
|
+
style.textContent = css;
|
|
141
|
+
document.head.appendChild(style);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Registra un preset del tema. Opcional: sin él, se usa theme-base.css tal cual. */
|
|
145
|
+
export function provideMimiTheme(preset: MimiThemePreset): EnvironmentProviders {
|
|
146
|
+
return makeEnvironmentProviders([
|
|
147
|
+
{ provide: MIMI_THEME, useValue: preset },
|
|
148
|
+
provideAppInitializer(() => applyMimiTheme(inject(DOCUMENT), inject(MIMI_THEME))),
|
|
149
|
+
]);
|
|
150
|
+
}
|