@mimi-ng/cli 0.3.0 → 0.4.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 +20 -1
- package/dist/init/css.js +1 -1
- package/dist/init/index.js +25 -0
- package/dist/init/paths.js +20 -0
- package/dist/registry.json +112 -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 +105 -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 +92 -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 +212 -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 +382 -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 +176 -0
- package/dist/templates/components/tabs/tabs.ts +88 -23
- package/dist/ui/index.js +128 -10
- package/dist/ui/schema.json +26 -2
- package/dist/utils/project.js +35 -0
- package/dist/utils/tailwind.js +80 -0
- package/dist/utils/write-files.js +37 -7
- package/package.json +1 -1
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ChangeDetectionStrategy,
|
|
3
|
+
Component,
|
|
4
|
+
DOCUMENT,
|
|
5
|
+
DestroyRef,
|
|
6
|
+
ElementRef,
|
|
7
|
+
LOCALE_ID,
|
|
8
|
+
afterEveryRender,
|
|
9
|
+
booleanAttribute,
|
|
10
|
+
computed,
|
|
11
|
+
effect,
|
|
12
|
+
inject,
|
|
13
|
+
input,
|
|
14
|
+
model,
|
|
15
|
+
output,
|
|
16
|
+
signal,
|
|
17
|
+
untracked,
|
|
18
|
+
} from '@angular/core';
|
|
19
|
+
import {
|
|
20
|
+
FormField,
|
|
21
|
+
type ValidationError,
|
|
22
|
+
form,
|
|
23
|
+
maxLength,
|
|
24
|
+
pattern,
|
|
25
|
+
required,
|
|
26
|
+
} from '@angular/forms/signals';
|
|
27
|
+
import { MimiAvatar, MimiAvatarFallback, MimiAvatarImage } from '@/components/ui/avatar';
|
|
28
|
+
import { MimiBadge } from '@/components/ui/badge';
|
|
29
|
+
import { MimiButton } from '@/components/ui/button';
|
|
30
|
+
import { MimiCard } from '@/components/ui/card';
|
|
31
|
+
import { MimiFormField } from '@/components/ui/form-field';
|
|
32
|
+
import { MimiInput } from '@/components/ui/input';
|
|
33
|
+
import { MimiProgress } from '@/components/ui/progress';
|
|
34
|
+
import { MimiSeparator } from '@/components/ui/separator';
|
|
35
|
+
import { MimiSwitch } from '@/components/ui/switch';
|
|
36
|
+
import { MimiTabsImports } from '@/components/ui/tabs';
|
|
37
|
+
import { MimiTextarea } from '@/components/ui/textarea';
|
|
38
|
+
import { cn } from '@/components/ui/utils/cn';
|
|
39
|
+
|
|
40
|
+
export type Settings01Tab = 'profile' | 'notifications' | 'security' | 'billing';
|
|
41
|
+
|
|
42
|
+
export interface Settings01Profile {
|
|
43
|
+
name: string;
|
|
44
|
+
/** Sin el prefijo de la dirección («example.com/», en la plantilla: pon el tuyo). */
|
|
45
|
+
username: string;
|
|
46
|
+
bio: string;
|
|
47
|
+
/** Las iniciales del avatar si no hay foto. */
|
|
48
|
+
initials: string;
|
|
49
|
+
/** La foto; sin ella, las iniciales. */
|
|
50
|
+
avatarSrc?: string | null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Los interruptores de Perfil, Notificaciones y Seguridad. */
|
|
54
|
+
export interface Settings01Preferences {
|
|
55
|
+
compact: boolean;
|
|
56
|
+
sounds: boolean;
|
|
57
|
+
weeklySummary: boolean;
|
|
58
|
+
mentions: boolean;
|
|
59
|
+
failedPayments: boolean;
|
|
60
|
+
newClients: boolean;
|
|
61
|
+
comments: boolean;
|
|
62
|
+
twoFactor: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface Settings01Session {
|
|
66
|
+
id: string;
|
|
67
|
+
device: string;
|
|
68
|
+
/** Lugar y hora, ya escritos («Your city · now»). */
|
|
69
|
+
detail: string;
|
|
70
|
+
kind: 'desktop' | 'mobile';
|
|
71
|
+
/** La sesión de este navegador (no se puede cerrar desde aquí). */
|
|
72
|
+
current?: boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface Settings01Plan {
|
|
76
|
+
name: string;
|
|
77
|
+
/** Precio por persona y mes, en la moneda de `currency`. */
|
|
78
|
+
pricePerSeat: number;
|
|
79
|
+
currency: string;
|
|
80
|
+
seatsUsed: number;
|
|
81
|
+
seats: number;
|
|
82
|
+
/** La tarjeta, ya enmascarada («Visa •••• 4242»). */
|
|
83
|
+
card: string;
|
|
84
|
+
nextInvoice: Date;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Lo que sale al guardar: el perfil (sin iniciales ni foto) y las preferencias. */
|
|
88
|
+
export type Settings01Value = Pick<Settings01Profile, 'name' | 'username' | 'bio'> &
|
|
89
|
+
Settings01Preferences;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Valores de ejemplo, para que la pantalla funcione sin datos. Genéricos a propósito (M5): ninguna
|
|
93
|
+
* persona ni marca de terceros; tu app pasa los suyos.
|
|
94
|
+
*/
|
|
95
|
+
export const SETTINGS_01_PROFILE: Settings01Profile = {
|
|
96
|
+
name: 'Your name',
|
|
97
|
+
username: 'your-username',
|
|
98
|
+
bio: 'A short bio your team will see.',
|
|
99
|
+
initials: 'YN',
|
|
100
|
+
};
|
|
101
|
+
export const SETTINGS_01_PREFERENCES: Settings01Preferences = {
|
|
102
|
+
compact: true,
|
|
103
|
+
sounds: false,
|
|
104
|
+
weeklySummary: true,
|
|
105
|
+
mentions: true,
|
|
106
|
+
failedPayments: true,
|
|
107
|
+
newClients: false,
|
|
108
|
+
comments: true,
|
|
109
|
+
twoFactor: false,
|
|
110
|
+
};
|
|
111
|
+
export const SETTINGS_01_SESSIONS: Settings01Session[] = [
|
|
112
|
+
{
|
|
113
|
+
id: 'this',
|
|
114
|
+
device: 'Chrome · macOS',
|
|
115
|
+
detail: 'Your city · now',
|
|
116
|
+
kind: 'desktop',
|
|
117
|
+
current: true,
|
|
118
|
+
},
|
|
119
|
+
{ id: 'iphone', device: 'Safari · iPhone', detail: 'Another city · 2 days ago', kind: 'mobile' },
|
|
120
|
+
];
|
|
121
|
+
export const SETTINGS_01_PLAN: Settings01Plan = {
|
|
122
|
+
name: 'Team',
|
|
123
|
+
pricePerSeat: 12,
|
|
124
|
+
currency: 'USD',
|
|
125
|
+
seatsUsed: 18,
|
|
126
|
+
seats: 25,
|
|
127
|
+
card: 'Visa •••• 4242',
|
|
128
|
+
nextInvoice: new Date(2026, 10, 1),
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Los interruptores de cada sección, con su título y descripción. */
|
|
132
|
+
interface SwitchRow {
|
|
133
|
+
key: keyof Settings01Preferences;
|
|
134
|
+
label: string;
|
|
135
|
+
description: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Ancho desde el que la navegación es lateral (`md` de Tailwind). */
|
|
139
|
+
const SIDE_NAV_QUERY = '(min-width: 768px)';
|
|
140
|
+
|
|
141
|
+
let nextId = 0;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* settings-01 (Block de Mimi): ajustes de cuenta con navegación por secciones (pestañas
|
|
145
|
+
* verticales en escritorio, horizontales en móvil), formularios, interruptores, sesiones, zona
|
|
146
|
+
* de peligro, facturación y una barra de «cambios sin guardar».
|
|
147
|
+
* Diseño: docs/design/Block settings-01.dc.html.
|
|
148
|
+
*
|
|
149
|
+
* Es una pantalla: ponla en tus rutas y dale tus datos (`profile`, `preferences`, `sessions`,
|
|
150
|
+
* `plan`). Sin datos, muestra los del ejemplo. «Guardar» emite `saved` con el formulario; las
|
|
151
|
+
* acciones (foto, sesiones, borrar la cuenta, cambiar de plan) son salidas: tu app decide.
|
|
152
|
+
*
|
|
153
|
+
* ```html
|
|
154
|
+
* <mimi-settings-01 [profile]="me()" [saving]="saving()" (saved)="save($event)" (deleteAccount)="confirmDelete()" />
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
@Component({
|
|
158
|
+
selector: 'mimi-settings-01',
|
|
159
|
+
imports: [
|
|
160
|
+
FormField,
|
|
161
|
+
MimiAvatar,
|
|
162
|
+
MimiAvatarFallback,
|
|
163
|
+
MimiAvatarImage,
|
|
164
|
+
MimiBadge,
|
|
165
|
+
MimiButton,
|
|
166
|
+
MimiCard,
|
|
167
|
+
MimiFormField,
|
|
168
|
+
MimiInput,
|
|
169
|
+
MimiProgress,
|
|
170
|
+
MimiSeparator,
|
|
171
|
+
MimiSwitch,
|
|
172
|
+
MimiTabsImports,
|
|
173
|
+
MimiTextarea,
|
|
174
|
+
],
|
|
175
|
+
templateUrl: './settings-01.html',
|
|
176
|
+
host: {
|
|
177
|
+
'[class]': 'classes()',
|
|
178
|
+
'[attr.data-state]': 'changed() ? "changed" : null',
|
|
179
|
+
},
|
|
180
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
181
|
+
})
|
|
182
|
+
export class Settings01 {
|
|
183
|
+
readonly profile = input<Settings01Profile>(SETTINGS_01_PROFILE);
|
|
184
|
+
readonly preferences = input<Settings01Preferences>(SETTINGS_01_PREFERENCES);
|
|
185
|
+
readonly sessions = input<readonly Settings01Session[]>(SETTINGS_01_SESSIONS);
|
|
186
|
+
readonly plan = input<Settings01Plan>(SETTINGS_01_PLAN);
|
|
187
|
+
/** Mientras tu app guarda: «Saving…» en el botón. */
|
|
188
|
+
readonly saving = input(false, { transform: booleanAttribute });
|
|
189
|
+
/** La sección elegida. Admite `[(tab)]`. */
|
|
190
|
+
readonly tab = model<Settings01Tab>('profile');
|
|
191
|
+
readonly userClass = input('', { alias: 'class' });
|
|
192
|
+
|
|
193
|
+
/** «Save»: el formulario completo y válido. */
|
|
194
|
+
readonly saved = output<Settings01Value>();
|
|
195
|
+
readonly avatarChange = output<void>();
|
|
196
|
+
readonly avatarRemove = output<void>();
|
|
197
|
+
/** «Sign out» en una sesión: su `id`. */
|
|
198
|
+
readonly sessionEnd = output<string>();
|
|
199
|
+
/** «Delete account». Confírmalo en tu app (por ejemplo, con `mimi-confirm`). */
|
|
200
|
+
readonly deleteAccount = output<void>();
|
|
201
|
+
readonly changePlan = output<void>();
|
|
202
|
+
|
|
203
|
+
protected readonly id = `mimi-settings-01-${nextId++}`;
|
|
204
|
+
private readonly locale = inject(LOCALE_ID);
|
|
205
|
+
|
|
206
|
+
/** El valor de partida: el de las entradas o el último guardado. */
|
|
207
|
+
private readonly initial = signal<Settings01Value>(this.valueOf());
|
|
208
|
+
protected readonly model = signal<Settings01Value>(this.valueOf());
|
|
209
|
+
protected readonly settings = form(this.model, (path) => {
|
|
210
|
+
required(path.name, { message: 'Enter your name.' });
|
|
211
|
+
required(path.username, { message: 'Choose a username.' });
|
|
212
|
+
pattern(path.username, /^[a-z0-9._-]+$/, {
|
|
213
|
+
message: 'Use lowercase letters, numbers, dots, dashes or underscores.',
|
|
214
|
+
});
|
|
215
|
+
maxLength(path.bio, 160);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* «Con cambios»: el valor distinto del de partida (no `dirty`: volver al valor original quita
|
|
220
|
+
* la barra).
|
|
221
|
+
*/
|
|
222
|
+
protected readonly changed = computed(() => {
|
|
223
|
+
const now = this.model();
|
|
224
|
+
const start = this.initial();
|
|
225
|
+
return (Object.keys(start) as (keyof Settings01Value)[]).some((k) => now[k] !== start[k]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
/** Vertical en escritorio, horizontal en móvil (cambia en vivo, B.10). */
|
|
229
|
+
protected readonly wide = signal(true);
|
|
230
|
+
|
|
231
|
+
protected readonly tabs: { value: Settings01Tab; label: string }[] = [
|
|
232
|
+
{ value: 'profile', label: 'Profile' },
|
|
233
|
+
{ value: 'notifications', label: 'Notifications' },
|
|
234
|
+
{ value: 'security', label: 'Security' },
|
|
235
|
+
{ value: 'billing', label: 'Billing' },
|
|
236
|
+
];
|
|
237
|
+
protected readonly profileSwitches: SwitchRow[] = [
|
|
238
|
+
{ key: 'compact', label: 'Compact mode', description: 'Denser tables and lists.' },
|
|
239
|
+
{ key: 'sounds', label: 'Sounds', description: 'Play a sound with each notification.' },
|
|
240
|
+
];
|
|
241
|
+
protected readonly notificationGroups: {
|
|
242
|
+
title: string;
|
|
243
|
+
icon: 'mail' | 'push';
|
|
244
|
+
rows: SwitchRow[];
|
|
245
|
+
}[] = [
|
|
246
|
+
{
|
|
247
|
+
title: 'Email',
|
|
248
|
+
icon: 'mail',
|
|
249
|
+
rows: [
|
|
250
|
+
{ key: 'weeklySummary', label: 'Weekly summary', description: 'Every Monday at 9:00.' },
|
|
251
|
+
{ key: 'mentions', label: 'Mentions', description: 'When someone mentions you.' },
|
|
252
|
+
{ key: 'failedPayments', label: 'Failed payments', description: 'We tell you right away.' },
|
|
253
|
+
],
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
title: 'Push',
|
|
257
|
+
icon: 'push',
|
|
258
|
+
rows: [
|
|
259
|
+
{ key: 'newClients', label: 'New clients', description: 'When an account signs up.' },
|
|
260
|
+
{ key: 'comments', label: 'Comments', description: 'On projects you follow.' },
|
|
261
|
+
],
|
|
262
|
+
},
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
protected readonly price = computed(() => {
|
|
266
|
+
const plan = this.plan();
|
|
267
|
+
const amount = new Intl.NumberFormat(this.locale, {
|
|
268
|
+
style: 'currency',
|
|
269
|
+
currency: plan.currency,
|
|
270
|
+
maximumFractionDigits: 0,
|
|
271
|
+
}).format(plan.pricePerSeat);
|
|
272
|
+
return `${plan.name} · ${amount} per seat`;
|
|
273
|
+
});
|
|
274
|
+
protected readonly nextInvoice = computed(() =>
|
|
275
|
+
new Intl.DateTimeFormat(this.locale, { month: 'long', day: 'numeric' }).format(
|
|
276
|
+
this.plan().nextInvoice,
|
|
277
|
+
),
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
protected readonly classes = computed(() =>
|
|
281
|
+
cn('flex min-h-svh w-full flex-col bg-background text-foreground', this.userClass()),
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
constructor() {
|
|
285
|
+
// Datos nuevos desde fuera: pasan a ser el valor de partida (sin cambios).
|
|
286
|
+
effect(() => {
|
|
287
|
+
const value = this.valueOf();
|
|
288
|
+
untracked(() => {
|
|
289
|
+
this.initial.set(value);
|
|
290
|
+
this.model.set(value);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
// La descripción de cada interruptor, en su botón (Switch no tiene entrada para
|
|
294
|
+
// aria-describedby; el diseño solo ponía aria-label). Después de cada render: el panel de una
|
|
295
|
+
// sección se crea al mostrarse (contenido diferido de Tabs), sin una señal que avise.
|
|
296
|
+
const host = inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
|
|
297
|
+
afterEveryRender({
|
|
298
|
+
write: () => {
|
|
299
|
+
this.focusPending();
|
|
300
|
+
for (const sw of Array.from(host.querySelectorAll('mimi-switch[data-describedby]'))) {
|
|
301
|
+
const button = sw.querySelector('[role="switch"]');
|
|
302
|
+
const id = sw.getAttribute('data-describedby');
|
|
303
|
+
if (button && id && button.getAttribute('aria-describedby') !== id) {
|
|
304
|
+
button.setAttribute('aria-describedby', id);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
const window = inject(DOCUMENT).defaultView;
|
|
310
|
+
const media = window?.matchMedia?.(SIDE_NAV_QUERY) ?? null;
|
|
311
|
+
if (media) {
|
|
312
|
+
this.wide.set(media.matches);
|
|
313
|
+
const onChange = (event: MediaQueryListEvent) => this.wide.set(event.matches);
|
|
314
|
+
media.addEventListener('change', onChange);
|
|
315
|
+
inject(DestroyRef).onDestroy(() => media.removeEventListener('change', onChange));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
protected readonly bioId = `${this.id}-bio-count`;
|
|
320
|
+
|
|
321
|
+
/*
|
|
322
|
+
* La navegación con `class` sobre Tabs (D6): ni `underline` ni `pill` son el diseño. Clases
|
|
323
|
+
* completas y literales (Tailwind las detecta), sin variantes encadenadas.
|
|
324
|
+
*/
|
|
325
|
+
/** Escritorio: filas con fondo `accent` en la elegida, sin indicador ni borde. */
|
|
326
|
+
protected readonly sideNavClasses =
|
|
327
|
+
'w-[200px] shrink-0 border-s-0 [&_[data-slot=indicator]]:hidden [&_[role=tablist]]:gap-0.5 [&_[role=tab]]:h-[38px] [&_[role=tab]]:gap-2.5 [&_[role=tab]]:rounded-[var(--mimi-radius-sm)] [&_[role=tab]]:font-normal [&_[role=tab]:hover]:bg-accent [&_[role=tab][aria-selected=true]]:bg-accent [&_[role=tab][aria-selected=true]]:font-semibold [&_svg]:size-[17px] [&_svg]:shrink-0';
|
|
328
|
+
/** Móvil: píldoras que se desplazan en horizontal; la elegida en `primary`. */
|
|
329
|
+
protected readonly pillNavClasses =
|
|
330
|
+
'px-4 py-3 [&_[data-slot=indicator]]:hidden [&_[role=tablist]]:flex [&_[role=tablist]]:gap-1.5 [&_[role=tablist]]:overflow-x-auto [&_[role=tablist]]:[scrollbar-width:none] [&_[role=tab]]:h-[34px] [&_[role=tab]]:shrink-0 [&_[role=tab]]:gap-1.5 [&_[role=tab]]:rounded-full [&_[role=tab]]:border [&_[role=tab]]:border-border [&_[role=tab]]:bg-card [&_[role=tab]]:px-3 [&_[role=tab]]:text-[13.5px] [&_[role=tab]]:text-foreground [&_[role=tab][aria-selected=true]]:border-primary [&_[role=tab][aria-selected=true]]:bg-primary [&_[role=tab][aria-selected=true]]:text-primary-foreground [&_svg]:size-[15px] [&_svg]:shrink-0';
|
|
331
|
+
protected readonly panelClasses =
|
|
332
|
+
'flex min-w-0 max-w-[680px] flex-1 flex-col gap-5 px-4 pt-4 pb-6 md:p-0';
|
|
333
|
+
|
|
334
|
+
/** Lo que elige Tabs (un `string`): solo se toma si es una de las cuatro secciones. */
|
|
335
|
+
protected selectTab(value: string | undefined): void {
|
|
336
|
+
const tab = this.tabs.find((item) => item.value === value);
|
|
337
|
+
if (tab) this.tab.set(tab.value);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** El campo de un interruptor. */
|
|
341
|
+
protected field(key: keyof Settings01Preferences) {
|
|
342
|
+
return this.settings[key];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
protected discard(): void {
|
|
346
|
+
this.settings().reset(this.initial());
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
protected save(): void {
|
|
350
|
+
if (this.saving()) return;
|
|
351
|
+
const state = this.settings();
|
|
352
|
+
state.markAsTouched();
|
|
353
|
+
if (state.invalid()) {
|
|
354
|
+
// Los campos con validación están en Perfil. Si se guarda desde otra sección, su panel no
|
|
355
|
+
// existe todavía (contenido diferido de Tabs): el foco queda pendiente hasta que el campo
|
|
356
|
+
// esté enlazado en el DOM (M3), y lo pone el render que lo crea.
|
|
357
|
+
this.tab.set('profile');
|
|
358
|
+
this.pendingFocus = state.errorSummary()[0]?.fieldTree ?? null;
|
|
359
|
+
this.focusPending();
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const value = { ...this.model() };
|
|
363
|
+
this.initial.set(value);
|
|
364
|
+
this.saved.emit(value);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** El campo con error que espera su panel para recibir el foco (M3). */
|
|
368
|
+
private pendingFocus: ValidationError.WithFieldTree['fieldTree'] | null = null;
|
|
369
|
+
|
|
370
|
+
/** Enfoca el campo pendiente si ya está en el DOM (enlazado a su `[formField]`). */
|
|
371
|
+
private focusPending(): void {
|
|
372
|
+
const field = this.pendingFocus?.();
|
|
373
|
+
if (!field || field.formFieldBindings().length === 0) return;
|
|
374
|
+
this.pendingFocus = null;
|
|
375
|
+
field.focusBoundControl();
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
private valueOf(): Settings01Value {
|
|
379
|
+
const { name, username, bio } = this.profile();
|
|
380
|
+
return { name, username, bio, ...this.preferences() };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './signup-01';
|