Repository navigation
04 country app
Esta página es una parte de la guía de estudio; allí están el
mapa general y la ruta recomendada. La Parte 3, con la app de GIFs, está en
03-gifs-app. Proyecto: 04-country-app/.
Es una aplicación Angular 22 creada con ng new, con Tailwind CSS para las
clases de utilidad y daisyUI para los componentes ya diseñados (botones,
tarjetas, barras de navegación). Esta página va creciendo: cada módulo nuevo
se agrega al final.
Con Tailwind CSS se estiliza escribiendo clases pequeñas en el HTML
(text-3xl font-bold). Es muy flexible, pero un botón bonito termina con
diez clases repetidas en cada sitio donde aparece.
daisyUI resuelve eso: es un complemento (plugin) de Tailwind que agrega nombres de clase para componentes completos. En vez de diez clases, escribes una o dos:
<!-- Sólo Tailwind -->
<button class="px-4 py-2 rounded-lg bg-blue-600 text-white font-semibold hover:bg-blue-700">
Buscar
</button>
<!-- Tailwind con daisyUI -->
<button class="btn btn-primary">Buscar</button>Las dos cosas conviven: puedes usar btn de daisyUI y ajustar con clases de
Tailwind encima (btn btn-primary mt-4).
| Paquete | Para qué sirve |
|---|---|
tailwindcss |
El motor de clases de utilidad |
@tailwindcss/postcss |
La pieza que conecta Tailwind con el compilador de Angular |
postcss |
La herramienta que procesa el CSS y que usa la anterior |
daisyui |
Las clases de componentes (btn, card, navbar...) |
npm install tailwindcss @tailwindcss/postcss postcss
npm install -D daisyui@latestdaisyUI 5 necesita Tailwind 4: las versiones de este proyecto son compatibles. La guía oficial paso a paso está en daisyUI: install for Angular.
1. .postcssrc.json le dice a Angular que procese el CSS con Tailwind:
{
"plugins": {
"@tailwindcss/postcss": {}
}
}2. src/tailwind.css importa Tailwind y activa daisyUI:
@import "tailwindcss";
@plugin "daisyui";3. angular.json carga ese archivo antes de los estilos globales:
"styles": [
"src/tailwind.css",
"src/styles.scss"
]Tailwind va en su propio archivo .css y no dentro de styles.scss, por la
misma razón que se explica en el
Módulo G1:
Sass interpreta el @import por su cuenta y lo avisa como obsoleto.
-
Arranca el servidor con
npm start(o compila connpm run build). En la salida de la compilación aparece una línea con el nombre y la versión de daisyUI, señal de que el plugin se cargó. -
Escribe un componente de daisyUI en una plantilla:
<button class="btn btn-primary">Hola</button>
-
Mira la página: debe verse un botón con estilo. Si sale un botón sin estilo, la clase no llegó al CSS (ver Errores comunes).
Tailwind y daisyUI sólo incluyen en el CSS final las clases que realmente
aparecen en tus archivos. Por eso un proyecto recién creado, sin ningún
btn en las plantillas, genera un CSS muy pequeño.
daisyUI trae temas de color listos, y se cambian con el atributo data-theme
de un elemento (normalmente <html>). Cómo elegirlos, cuáles incluir y cómo
cambiarlos mientras la app corre se explica en el
Módulo C2.
VS Code puede subrayar @plugin "daisyui" con un aviso de que es una regla
CSS desconocida. Viene del validador de CSS del editor, que no conoce las
directivas propias de Tailwind 4 (@plugin, @theme, @source). No afecta
al build. La extensión Tailwind CSS IntelliSense lo quita y además
autocompleta las clases de daisyUI.
-
Meter
@import "tailwindcss"dentro destyles.scss: Sass lo interpreta y avisa. Va en un.cssaparte. -
Poner
tailwind.cssdespués destyles.scssenangular.json: lo que se carga al final gana en un empate, y tus estilos globales propios quedarían por debajo de los de Tailwind. -
Olvidar el
.postcssrc.json: Angular no sabe que debe procesar el CSS con Tailwind y las clases no tienen efecto. - Usar una versión de daisyUI que no corresponde a la de Tailwind: daisyUI 5 funciona con Tailwind 4; con Tailwind 3 se configura de otra forma.
-
Cambiar
.postcssrc.jsonoangular.jsoncon el servidor encendido: hay que reiniciarnpm startpara que los lea. -
Construir nombres de clase a trozos en el código
(
'btn-' + color): Tailwind busca el texto completo y no encuentra la clase. Se escriben enteras.
Tailwind CSS da las clases pequeñas y daisyUI agrega, encima, las clases de
componentes. Se instalan cuatro paquetes y se configuran tres archivos:
.postcssrc.json (procesar con Tailwind), tailwind.css (@import y
@plugin) y angular.json (cargarlo antes que los estilos propios). Sólo se
incluyen en el CSS las clases que de verdad se usan.
Respuesta:
Tailwind ofrece clases de utilidad pequeñas (px-4, bg-blue-600).
daisyUI es un plugin que agrega, encima, clases de componentes completos
(btn, card, navbar) y temas de color. No reemplaza a Tailwind: se pueden
combinar, y las clases de utilidad sirven para ajustar los componentes.
Respuesta:
Porque el validador de CSS del editor no conoce las directivas de Tailwind 4, y las marca como reglas desconocidas. Es un aviso del editor, no del compilador: el build funciona y el plugin se carga. La extensión Tailwind CSS IntelliSense lo resuelve.
Respuesta:
Porque Tailwind busca en los archivos del proyecto los nombres de clase que se
usan y sólo genera el CSS de esas. Si ninguna plantilla tiene btn, esas
reglas no se incluyen. Por eso tampoco sirve armar nombres de clase por partes
en el código ('btn-' + color): el texto completo debe aparecer escrito.
Respuesta:
Registra el plugin @tailwindcss/postcss en el proceso de CSS de Angular. Sin
ese archivo, Angular compila el CSS tal cual y las directivas de Tailwind
(@import "tailwindcss", @plugin) no se convierten en estilos reales.
Un tema es un conjunto de colores (fondo, texto, botones, bordes) con nombre.
Con daisyUI cambiar de light a dark, a sunset o a nord no obliga a
tocar ninguna clase: los componentes (btn, card...) usan los colores del
tema activo, y al cambiar el tema cambian todos a la vez.
Hay tres decisiones separadas, y conviene no mezclarlas:
| Pregunta | Dónde se decide |
|---|---|
| ¿Qué temas existen en mi CSS? |
tailwind.css, en las opciones del plugin |
| ¿Cuál se usa ahora? | El atributo data-theme
|
| ¿Cómo lo cambia el usuario? | Un poco de código (o un input especial) |
Por defecto el plugin incluye sólo dos: light (el predeterminado) y dark
(el que se usa cuando el sistema del usuario está en modo oscuro). Para
cambiarlo, se pasan opciones al plugin:
@import "tailwindcss";
@plugin "daisyui" {
themes: light --default, dark --prefersdark, sunset;
}Cada elemento de la lista es un nombre de tema, con banderas opcionales:
| Bandera | Qué significa |
|---|---|
--default |
Es el tema que se usa cuando nada indica otro |
--prefersdark |
Es el que se usa si el sistema está en modo oscuro y la página no fija un tema |
| (ninguna) | El tema está disponible, pero sólo se activa si se pide con data-theme
|
Otras formas válidas:
/* un tema como predeterminado y otro para el modo oscuro */
@plugin "daisyui" {
themes: nord --default, abyss --prefersdark;
}
/* todos los temas */
@plugin "daisyui" {
themes: all;
}Sólo se generan los temas de la lista. Si el CSS no lleva un tema, pedirlo
con data-theme no hace nada. Esto se comprobó compilando el proyecto con
themes: nord --default, abyss --prefersdark: en el CSS resultante aparecían
nord y abyss y no aparecían light ni dark.
<!-- src/index.html -->
<html lang="en" data-theme="sunset">Con ese atributo, toda la página usa sunset. Reglas útiles:
- El atributo gana sobre
--defaulty--prefersdark: si está, el modo oscuro del sistema deja de importar. - Funciona en cualquier elemento, no sólo en
<html>. Una sección condata-theme="dark"se ve oscura dentro de una página clara. - Sin atributo, se aplican las banderas:
--default, o--prefersdarksi el sistema está en modo oscuro.
light, dark, cupcake, bumblebee, emerald, corporate, synthwave,
retro, cyberpunk, valentine, halloween, garden, forest, aqua,
lofi, pastel, fantasy, wireframe, black, luxury, dracula,
cmyk, autumn, business, acid, lemonade, night, coffee, winter,
dim, nord, sunset, caramellatte, abyss y silk. Se ven todos juntos
en la página de temas de daisyUI.
Cambiar de tema es cambiar el valor de data-theme. En Angular conviene
un servicio pequeño que guarde la elección en una señal y la escriba en
<html>:
import { DOCUMENT } from '@angular/common'
import { effect, inject, Injectable, signal } from '@angular/core'
const THEME_KEY = 'theme'
@Injectable({ providedIn: 'root' })
export class ThemeService {
private document = inject(DOCUMENT)
theme = signal<string>(localStorage.getItem(THEME_KEY) ?? 'sunset')
private applyTheme = effect(() => {
const theme = this.theme()
this.document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem(THEME_KEY, theme)
})
}Cómo funciona:
-
themees una señal con el tema actual. Arranca con el que haya guardado enlocalStorage, o consunsetsi no hay ninguno. - El
effectse ejecuta cada vez quethemecambia: escribe el atributo en<html>y guarda la elección para la próxima visita. -
inject(DOCUMENT)es la forma de Angular de llegar al documento, en lugar de usardocumentdirectamente.
Y en cualquier componente:
themeService = inject(ThemeService)<button class="btn" (click)="themeService.theme.set('nord')">Nord</button>
<button class="btn" (click)="themeService.theme.set('sunset')">Sunset</button>daisyUI incluye una clase, theme-controller, que hace el cambio solo con
HTML. Un input con esa clase y el nombre del tema en value activa ese tema
cuando está marcado:
<input type="radio" name="theme" class="btn theme-controller" aria-label="Nord" value="nord">
<input type="radio" name="theme" class="btn theme-controller" aria-label="Sunset" value="sunset">Sirve para un prototipo, pero no recuerda la elección al recargar la página. Con el servicio sí se puede guardar.
-
Pedir un tema que no está en la lista
themes: el atributo no hace nada, porque ese CSS no se generó. -
Olvidar
--default: el plugin no sabe cuál usar cuando no haydata-themey la página puede quedar con colores inesperados. -
Un nombre mal escrito en
data-themeo en la lista (synthwave, nosynth-wave): no falla, simplemente no aplica. -
Fijar
data-themey esperar que el modo oscuro del sistema lo cambie: el atributo tiene prioridad, así que el sistema deja de influir. -
Reiniciar la app y perder la elección: si sólo se guarda en memoria,
vuelve al predeterminado. Hay que persistirla (
localStorage). -
Cambiar
tailwind.csscon el servidor encendido: hay que reiniciarnpm startpara que lo lea.
Elegir un tema son tres cosas: decidir cuáles se generan (opciones del
plugin en tailwind.css, con --default y --prefersdark), cuál está
activo (atributo data-theme, en <html> o en cualquier elemento) y
cómo se cambia (un servicio con una señal y un effect que escribe el
atributo, o theme-controller sin código). Un tema que no está en la lista
no se puede activar.
Respuesta:
--default marca el tema que se usa cuando la página no indica otro.
--prefersdark marca el que se usa cuando el sistema operativo del usuario
está en modo oscuro y la página no fija un tema con data-theme. Juntos
permiten que la app respete la preferencia del sistema sin código.
Respuesta:
Primero, que nord esté en la lista themes del plugin en tailwind.css:
daisyUI sólo genera el CSS de los temas listados, y sin él el atributo no
tiene efecto. Después, que el nombre esté bien escrito y que el servidor se
haya reiniciado tras tocar tailwind.css.
Respuesta:
Porque el effect reacciona al valor de la señal, sin importar quién lo
cambió: un botón, el valor recuperado de localStorage al arrancar o cualquier
otro componente. Así hay un solo punto donde se toca el DOM y guardar la
elección no se puede olvidar. Es un efecto secundario, que es justo para lo
que sirve effect (a diferencia de computed, que sólo calcula valores).
Respuesta:
--prefersdark depende del sistema del usuario y sólo se aplica si la página
no fija un tema. data-theme es una orden explícita de la página y tiene
prioridad: si está presente, el modo oscuro del sistema se ignora. Por eso
una app con selector de tema suele fijar el atributo, y una que sólo quiere
respetar al sistema deja que actúen las banderas.
Este módulo está escrito como instructivo: al final hay una lista de pasos para organizar las rutas de otro proyecto de la misma forma.
Al principio, todas las rutas viven en un solo archivo, app.routes.ts. Con
dos o tres pantallas no pasa nada, pero a medida que la app crece ese archivo
se llena de rutas de países, de usuarios, de pedidos... y cualquiera que lo
abra tiene que leer todo para encontrar lo suyo.
La solución es que cada funcionalidad (feature) tenga su propio archivo de
rutas, y que el archivo principal sólo diga "todo lo que empiece por
country se resuelve allá".
Antes (ejemplo) Después
app.routes.ts app.routes.ts ← sólo lo general
├─ '' ├─ '' (inicio)
├─ country ├─ country ──────┐ "esto lo resuelve
├─ country/by-capital └─ ** │ otro archivo"
├─ country/by-region ▼
├─ country/by-name country/country.routes.ts
└─ ** ├─ ''
├─ by-capital
└─ by-region
src/app/
├── app.routes.ts rutas generales (inicio, comodín)
├── app.html <router-outlet />
├── shared/pages/home-page/ pantalla de inicio
└── country/
├── country.routes.ts rutas de la funcionalidad "country"
└── pages/by-capital/ pantalla "por capital"
// app.routes.ts
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./shared/pages/home-page/home-page.component'),
},
{
path: 'country',
loadChildren: () => import('./country/country.routes'),
},
{
path: '**',
redirectTo: '',
},
]| Ruta | Qué hace |
|---|---|
path: '' + loadComponent
|
La pantalla de inicio. Se carga sólo cuando se visita |
path: 'country' + loadChildren
|
Delega: todo lo que empiece por /country lo resuelve otro archivo de rutas, que también se carga cuando hace falta
|
path: '**' + redirectTo: ''
|
Comodín: cualquier dirección que no coincida con nada manda al inicio |
El orden importa: Angular prueba las rutas de arriba hacia abajo y se queda
con la primera que coincide. Por eso el comodín ** va siempre al final.
// country/country.routes.ts
import { Routes } from '@angular/router'
import { ByCapitalComponent } from './pages/by-capital/by-capital.component'
export const countryRoutes: Routes = [
{
path: '',
component: ByCapitalComponent,
},
]
export default countryRoutes- Aquí
path: ''significa "justo/country", porque este archivo ya está colgado decountry: las rutas de un archivo hijo se suman a la ruta del padre. - En el proyecto, el arreglo todavía no lleva el tipo (
countryRoutes = [...]); con: Routesel editor autocompleta y avisa de errores, por eso se muestra tipado. - El archivo termina con
export default: es lo que permite escribirimport('./country/country.routes')en el padre sin indicar el nombre de nada.
En el Módulo C4 esta ruta
''deja de mostrar una sola pantalla: pasa a ser un layout con rutas hijas y su propio comodín. Desde entonces,/country/otraya no cae en el inicio sino enby-capital.
/ → app.routes: path '' → HomePage
/country → app.routes: 'country'
└─ country.routes: '' → ByCapital
/country/otra → 'country' coincide, pero no hay hija 'otra'
└─ cae en '**' de app.routes → redirige a '/'
/lo-que-sea → '**' → redirige a '/'
Esto se comprobó abriendo la app en el navegador: / muestra el inicio,
/country muestra la pantalla de capital, y tanto /lo-que-sea como
/country/otra terminan en el inicio.
<!-- app.html -->
<router-outlet /><router-outlet /> es el hueco donde Angular pone el componente de la ruta
activa. Al cambiar de dirección, cambia lo que hay en ese hueco; el resto de la
plantilla, si lo hubiera, no se toca.
| Archivo | Cómo se carga | Exportación |
|---|---|---|
HomePageComponent |
loadComponent: () => import(...) (perezosa) |
export default class |
country.routes.ts |
loadChildren: () => import(...) (perezosa) |
export default del arreglo |
ByCapitalComponent |
import { ByCapitalComponent } from ... (normal) |
export class con nombre |
La regla del proyecto: export default sólo para lo que se carga con
import() desde una ruta. Lo que se usa con un import normal (como
ByCapitalComponent, que ya viaja dentro del archivo de rutas) lleva
export class con nombre.
Al compilar, Angular genera un archivo separado por cada import() de una ruta:
Lazy chunk files | Names
chunk-….js | country-routes
chunk-….js | home-page-component
El código de country no se descarga hasta que el usuario entra en
/country. En una app grande, eso hace que la primera carga sea mucho más
liviana.
- Crea una carpeta por funcionalidad (
country/,users/...), con sus páginas dentro (pages/). - En cada una, un archivo
<feature>.routes.tscon un arreglo tipado comoRoutes, yexport defaultde ese arreglo. - En
app.routes.ts, una entrada por funcionalidad:{ path: 'country', loadChildren: () => import('./country/country.routes') }. - Las pantallas sueltas que no pertenecen a una funcionalidad (inicio) van en
shared/pages/conloadComponent. - El comodín
**conredirectTova al final deapp.routes.ts. - En la plantilla raíz, un
<router-outlet />. - Para añadir una pantalla nueva a la funcionalidad, se agrega una entrada en
su
*.routes.ts(por ejemplo{ path: 'by-region', component: ... }) y no se tocaapp.routes.ts.
-
Poner el comodín
**antes que otras rutas: como Angular se queda con la primera que coincide, todo lo que esté debajo deja de alcanzarse. -
Escribir
path: '/country'con barra inicial: Angular rechaza la configuración al arrancar. Lospathvan sin barra al principio. -
Repetir el prefijo en la ruta hija: si el padre ya es
country, la hija espath: 'by-capital', nopath: 'country/by-capital'. -
Olvidar el
export defaulten el archivo de rutas:import('./...')no encuentra qué usar y la ruta falla al cargarse. -
No tipar el arreglo como
Routes: funciona, pero se pierde el autocompletado y el aviso de errores en las propiedades (pthen vez depathpasaría desapercibido). -
Olvidar
<router-outlet />: la ruta cambia en la barra de direcciones, pero no se dibuja nada. -
Usar
export defaulten componentes que no se cargan conimport(): al importarlos con llaves en otra plantilla, Angular dice que el elemento no se conoce.
Cada funcionalidad tiene su propio archivo de rutas, y app.routes.ts sólo
la enlaza con loadChildren, de modo que el archivo principal se mantiene
corto y el código de cada funcionalidad se descarga cuando se usa. Las rutas
hijas se suman a la del padre ('' dentro de country es /country), el
comodín va al final, y export default se usa sólo para lo que se carga con
import() desde una ruta.
Respuesta:
loadComponent carga un componente de forma perezosa, para una sola
pantalla. loadChildren carga un conjunto de rutas (con sus propias
pantallas y subrutas) cuando se visita el prefijo indicado. En ambos casos el
código no se descarga hasta que hace falta; la diferencia es el tamaño de lo
que se delega.
Respuesta:
Que la ruta coincide justo con el prefijo del padre: como el archivo está
colgado de country, path: '' equivale a /country. Las rutas de un archivo
hijo se suman a la del padre, así que no hay que repetir country en ellas.
Respuesta:
Porque el enrutador recorre las rutas en orden y se queda con la primera
que coincide. ** coincide con cualquier dirección: si estuviera antes, taparía
a todas las que están debajo y nunca se alcanzarían.
Respuesta:
El prefijo country coincide, pero no hay ninguna hija para otra. El
enrutador sigue buscando en el resto de las rutas de app.routes.ts y termina
en el comodín **, que redirige al inicio. Se comprobó en el navegador: la
dirección acaba mostrando la pantalla de inicio.
Respuesta:
Para poder escribir loadChildren: () => import('./country/country.routes')
sin nombrar la exportación: Angular toma la exportación por defecto del módulo
cargado. Sin ella habría que indicar el nombre con un .then(m => m.countryRoutes).
Respuesta:
Dos cosas: orden (cada funcionalidad tiene sus rutas en un solo sitio y
app.routes.ts no crece) y rendimiento (con loadChildren, Angular genera
un archivo aparte por funcionalidad y sólo lo descarga cuando el usuario entra,
por lo que la carga inicial es menor).
Este módulo está escrito como instructivo: al final hay una lista de pasos para repetirlo en otra funcionalidad.
En el Módulo C3
cada ruta de country mostraba una sola pantalla. Pero todas las pantallas de
esa funcionalidad (por capital, por país, por región) van a compartir lo mismo:
un menú superior. Si cada página lo incluyera por su cuenta, habría que
repetirlo en todas y cambiarlo en todas.
La solución es un layout: un componente que dibuja lo común y deja un hueco para la pantalla que cambia.
┌──────────────────────────────────────────┐
│ CountryLayoutComponent │
│ ┌────────────────────────────────────┐ │
│ │ <country-top-menu /> ← siempre │ │
│ └────────────────────────────────────┘ │
│ ┌────────────────────────────────────┐ │
│ │ <router-outlet /> │ │
│ │ by-capital | by-country | ... │ │ ← cambia según la ruta
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘
// country-layout.component.ts
@Component({
selector: 'app-country-layout',
imports: [RouterOutlet, TopMenuComponent],
templateUrl: './country-layout.component.html',
})
export class CountryLayoutComponent {}<!-- country-layout.component.html -->
<country-top-menu />
<section class="px-4">
<router-outlet />
</section>Dos detalles importantes:
- El layout tiene su propio
<router-outlet />: es donde se dibujarán las pantallas hijas. El que está enapp.htmldibuja el layout; el del layout dibuja la pantalla. - Hay que importar
RouterOutleten el layout, y también el componente del menú, porque las plantillas sólo conocen lo que está enimports.
// country.routes.ts
export const countryRoutes: Routes = [
{
path: '',
component: CountryLayoutComponent,
children: [
{
path: 'by-capital',
component: ByCapitalComponent,
},
{
path: '**',
redirectTo: 'by-capital',
},
],
},
]
export default countryRoutesLa ruta del layout tiene component y children a la vez: es lo que la
convierte en un layout. Su path: '' no añade nada a la dirección, de modo que
las hijas cuelgan directamente de /country.
| Dirección | Qué dibuja |
|---|---|
/country/by-capital |
El layout con ByCapitalComponent en su hueco |
/country |
Cae en el comodín hijo y redirige a /country/by-capital
|
/country/lo-que-sea |
También redirige a /country/by-capital
|
/lo-que-sea |
Está fuera de country: cae en el comodín general y vuelve al inicio |
Esto se comprobó en el navegador: en /country la barra de direcciones termina
en /country/by-capital, y en la página aparecen el layout, el menú y la
pantalla de capital.
Ahora hay dos **, y cada uno actúa en su nivel:
app.routes.ts ** → redirige al inicio ('')
country.routes.ts ** → redirige a 'by-capital' (sólo dentro de /country)
El de la funcionalidad tiene prioridad dentro de /country, porque Angular
entra primero en esa rama. Por eso, desde que existe el layout, /country/otra
ya no cae en el inicio, como pasaba en el Módulo C3: cae en by-capital.
Las pantallas hijas se importan con un import normal y no de forma perezosa.
La razón: el layout sólo se carga cuando se entra en country, y las pantallas
de esa funcionalidad se necesitan justo en ese momento. Separarlas en archivos
distintos no ahorra nada. La carga perezosa ya ocurre un nivel arriba, con
loadChildren.
Por la misma regla del proyecto, CountryLayoutComponent y
ByCapitalComponent llevan export class con nombre: se importan de forma
normal en el archivo de rutas.
- Crea el componente layout en
<feature>/layouts/<nombre>-layout/. - En su plantilla, pon lo común (menú, cabecera, barra lateral) y un
<router-outlet />para el contenido. - Importa
RouterOutlety los componentes que use en elimportsdel layout. - En
<feature>.routes.ts, una ruta conpath: '',component: <Layout>ychildren: [...]con las pantallas. - Agrega un comodín
**como última hija, conredirectTohacia la pantalla por defecto. - Para una pantalla nueva, se añade una entrada en
children; el layout no se toca.
-
Olvidar el
<router-outlet />dentro del layout: el menú se ve, pero las pantallas nunca aparecen. - Poner el comodín antes que las demás hijas: captura todo y las hijas siguientes no se alcanzan.
-
Escribir
redirectTo: '/by-capital'con barra: es una ruta absoluta desde la raíz de la aplicación, no desdecountry. Sin barra es relativa a donde está la regla. - Repetir el menú en cada página: es justo lo que el layout evita.
-
Poner el layout como ruta sin
children: funciona como una pantalla más, pero no tiene dónde dibujar las demás.
Un layout es una ruta con component y children: el componente dibuja lo
común y su <router-outlet /> dibuja la pantalla hija que corresponda. Así el
menú se escribe una sola vez. El comodín de la funcionalidad actúa dentro de
/country y el general queda para el resto.
Respuesta:
Cuando varias pantallas comparten una estructura (menú, cabecera, barra
lateral) y sólo cambia el contenido. El layout se escribe una vez y las rutas
hijas se dibujan en su <router-outlet />. Evita repetir esa estructura en cada
página y cambiarla en varios sitios.
Respuesta:
Dos. El de app.html dibuja la ruta de primer nivel (inicio o el layout de
country). El del layout dibuja la pantalla hija (by-capital...). Cada
<router-outlet /> dibuja las rutas del nivel en el que está.
Respuesta:
La ruta del layout (path: '') coincide, pero ninguna hija coincide con una
dirección vacía; entonces actúa el comodín hijo, que redirige a by-capital.
La dirección termina siendo /country/by-capital y se ve el layout con esa
pantalla.
Respuesta:
El alcance. El de country.routes.ts sólo se evalúa dentro de /country y
manda a la pantalla por defecto de esa funcionalidad; el de app.routes.ts
atiende todo lo demás y manda al inicio. Angular entra primero en la rama que
coincide con el prefijo, así que dentro de /country manda el de la
funcionalidad.
A medida que crecen las pantallas, aparecen piezas que se repiten: un pie de página, un menú. La pregunta es dónde viven y quién las puede usar. Si todo está en una carpeta común, no se sabe qué depende de qué.
| Carpeta | Para qué | Ejemplo | Selector |
|---|---|---|---|
shared/components/ |
Piezas que usa toda la aplicación | footer |
app-footer |
<feature>/components/ |
Piezas que sólo usa esa funcionalidad | country/components/top-menu |
country-top-menu |
src/app/
├── app.html usa <app-footer />
├── shared/
│ ├── components/footer/ FooterComponent (toda la app)
│ └── pages/home-page/
└── country/
├── components/top-menu/ TopMenuComponent (sólo country)
├── layouts/country-layout/ usa <country-top-menu />
└── pages/by-capital/
La razón de separar: si mañana se borra o se mueve la funcionalidad country,
todo lo que es suyo se va con ella y no deja piezas sueltas. Lo que está en
shared se supone usado por varias partes.
El prefijo del selector (app- o country-) indica de dónde es el
componente cuando se lo ve en una plantilla. <country-top-menu /> dice que es
del menú de country; <app-footer /> es de toda la aplicación. Es una
convención del proyecto, no una obligación del framework.
Un componente standalone no se declara en ningún módulo: se importa donde se va a usar.
// app.ts
@Component({
selector: 'app-root',
imports: [RouterOutlet, FooterComponent],
templateUrl: './app.html',
})
export class App {}<!-- app.html -->
<main class="m-auto flex flex-grow flex-col min-h-screen">
<router-outlet />
<div class="flex-1 flex-grow">
<app-footer />
</div>
</main>Dos pasos y los dos hacen falta: importarlo en el imports del componente
que lo usa y escribir su etiqueta en la plantilla. Si falta el primero,
Angular avisa de que el elemento no se conoce.
Aquí el pie de página queda en app.html, fuera de las rutas: aparece en
todas las pantallas (inicio y country) sin que cada una lo incluya.
// footer.component.ts
export class FooterComponent {
currentYear = new Date().getFullYear()
}<p>Copyright © {{ currentYear }} Reskyon - All rights reserved</p>El año sale de una propiedad de la clase y se muestra con doble llave. Es
un error típico al copiar ejemplos pensados para React escribir
{new Date().getFullYear()} con llave simple: en Angular, la llave simple se
interpreta como el inicio de una expresión especial y la plantilla no compila.
| Componente | Responsabilidad |
|---|---|
App |
La raíz: outlet principal y pie de página |
CountryLayoutComponent |
La estructura de la funcionalidad (menú + hueco) |
TopMenuComponent |
El menú de navegación |
ByCapitalComponent |
La pantalla de búsqueda por capital |
Cada uno hace una cosa, y por eso se puede cambiar o reemplazar sin tocar los demás.
- Decide el alcance: si lo usará toda la app va en
shared/components/<nombre>/; si es de una funcionalidad, en<feature>/components/<nombre>/. - Crea el componente con un selector con el prefijo que corresponda
(
app-…,<feature>-…). - Exporta la clase con nombre (
export class): se importará en otro componente, no por una ruta. - Impórtalo en el
importsdel componente que lo va a usar. - Escribe la etiqueta en su plantilla.
- Escribir la etiqueta sin importar el componente: Angular dice que el elemento no se conoce.
- Importar el componente y no usarlo: el compilador avisa de que el import sobra.
-
Meter en
sharedalgo que sólo usa una funcionalidad:sharedse vuelve un cajón de todo y se pierde la ventaja de separar. -
Usar
export defaulten estos componentes: eldefaultes sólo para lo que se carga por una ruta conimport(). -
Llave simple en la plantilla (
{ expresión }): hay que usar doble llave ({{ expresión }}).
Lo que usa toda la aplicación va en shared/; lo que usa una funcionalidad,
en su propia carpeta components/. El prefijo del selector deja ver el origen.
Un componente standalone se importa en el imports de quien lo usa y se escribe
su etiqueta en la plantilla; el pie de página, al estar en app.html, aparece
en todas las pantallas.
Respuesta:
Por quién lo usa. Si lo necesitan varias partes de la aplicación (un pie de
página, un botón), va en shared. Si sólo tiene sentido dentro de una
funcionalidad (el menú de países), va con ella. Así, borrar la funcionalidad no
deja componentes huérfanos y shared no se convierte en un cajón de todo.
Respuesta:
Dos cosas: importar su clase en el imports del componente que lo usa, y
escribir su selector como etiqueta en la plantilla. Con sólo la etiqueta, Angular
no sabe qué componente es; con sólo el import, no se dibuja.
Respuesta:
Porque debe verse en todas las pantallas. Al ponerlo en la plantilla raíz, fuera
del <router-outlet />, se muestra siempre sin repetirlo ni olvidarlo en
ninguna página.
Los iconos del menú son SVG escritos directamente en la plantilla. Se ven bien,
pero tienen tamaño fijo (width="24" height="24") repetido en cada icono. Si
mañana se quiere uno de 32 píxeles, hay que cambiar muchos sitios. Lo natural
es guardar el tamaño en una variable de la clase y enlazarlo.
En un SVG, los atributos se enlazan con [attr.nombre], no con [nombre]:
// top-menu.component.ts
export class TopMenuComponent {
private readonly iconGrid = 24 // cuadrícula del dibujo
viewBox = `0 0 ${this.iconGrid} ${this.iconGrid}` // fijo: depende del dibujo
iconSize = 22 // tamaño en pantalla
}<svg
xmlns="http://www.w3.org/2000/svg"
[attr.width]="iconSize"
[attr.height]="iconSize"
[attr.viewBox]="viewBox"
>
<path d="M0 0h24v24H0z" fill="none" />
<path fill="none" stroke="currentColor" d="..." />
</svg>| Qué se enlaza | Forma |
|---|---|
Un atributo del SVG (width, height, viewBox, fill, d...) |
[attr.width]="valor" |
| Una clase |
[class.activo]="condicion" o [class]="expresion"
|
| Un estilo | [style.fill]="color" |
| Texto dentro del SVG | {{ valor }} |
| Un evento | (click)="metodo()" |
Con corchetes simples, Angular enlaza una propiedad del elemento en el DOM.
En un <svg>, width no es una propiedad simple sino un objeto especial del
navegador, y Angular no la reconoce: la plantilla no compila. [attr.width],
en cambio, escribe directamente el atributo HTML, que es lo que se necesita.
Esto se comprobó compilando las dos formas en el proyecto.
Son dos cosas distintas, y mezclarlas es el error más común con iconos:
-
viewBoxes el sistema de coordenadas del dibujo: la cuadrícula en la que se trazó el icono (0 0 24 24: un lienzo de 24 por 24 unidades). -
widthyheightson el tamaño en pantalla: escalan ese lienzo.
viewBox → depende del DIBUJO (24: no cambia)
width → depende de DÓNDE SE MUESTRA (iconSize: sí cambia)
iconSize = 22
viewBox = `0 0 ${this.iconSize} ${this.iconSize}` // 0 0 22 22 ✗Parece lógico (el icono mide 22, el lienzo mide 22), pero el dibujo sigue
trazado para 24 unidades. Con viewBox="0 0 22 22", las dos últimas unidades
quedan fuera del lienzo y se recortan; y si el tamaño sube a 48, el
viewBox pasa a 0 0 48 48 y el icono no crece: queda del mismo tamaño
dentro de una caja mayor.
export class TopMenuComponent {
// La cuadrícula en la que se dibujaron los iconos. No cambia con el tamaño.
private readonly iconGrid = 24
viewBox = `0 0 ${this.iconGrid} ${this.iconGrid}`
// Tamaño en pantalla. Cambiar este valor redimensiona todos los iconos.
iconSize = 22
}<svg [attr.width]="iconSize" [attr.height]="iconSize" [attr.viewBox]="viewBox">La plantilla no cambia: sigue enlazando las tres propiedades. Lo que cambia es
de dónde sale cada una. Ahora, iconSize puede valer 16, 22 o 48 y el
dibujo siempre se escala completo, sin recortes.
Midiendo el dibujo real en el navegador, con el mismo icono:
| Caja | viewBox |
Dibujo resultante |
|---|---|---|
| 24 px | 0 0 24 24 |
23 px (referencia) |
| 48 px |
0 0 24 24 (fijo) |
46 px: se amplía al doble |
| 48 px |
0 0 48 48 (derivado) |
23 px: no crece |
| 22 px |
0 0 24 24 (solución) |
21 px: escala 22/24 |
private readonly iconGrid = 24
viewBox = `0 0 ${this.iconGrid} ${this.iconGrid}` // usa iconGridLos campos de una clase se inicializan en orden. viewBox usa iconGrid,
así que debe ir después. Si se escribieran al revés, iconGrid valdría
undefined en ese momento y TypeScript lo marca.
Si iconSize pasa a ser una entrada (input()) o una señal, el viewBox no
tiene por qué cambiar: sólo width y height leen la señal
([attr.width]="iconSize()"). Un computed para el viewBox sólo tiene
sentido si lo que varía es la cuadrícula original del icono (16, 20 o 24),
no el tamaño con que se muestra:
iconGrid = signal(24) // cuadrícula en la que se dibujó
iconSize = signal(32) // tamaño en pantalla
viewBox = computed(() => `0 0 ${this.iconGrid()} ${this.iconGrid()}`)La regla para recordarlo: el viewBox depende del dibujo; width y
height, de dónde se muestra.
<path fill="none" stroke="currentColor" d="..." />currentColor significa "el color del texto de este elemento". El icono toma
el color de su contenedor: dentro del menú de daisyUI cambia con el tema y con
el estado (activo, hover) sin escribir ningún color.
Los de este proyecto vienen de Iconify, un catálogo de conjuntos de iconos. Desde ahí se copia el SVG y se pega en la plantilla, o se importa como componente. Pegado en la plantilla es lo más sencillo, y permite este tipo de enlace.
- Copia el SVG con su
viewBoxoriginal y déjalo así: es la cuadrícula del dibujo (iconGrid). - En la clase, una propiedad con el tamaño en pantalla (
iconSize = 22), distinta de la cuadrícula. - En el
<svg>,[attr.width]="iconSize"y[attr.height]="iconSize". - En los trazos,
stroke="currentColor"(ofill="currentColor") para heredar el color. - Para algo que cambia según datos:
[attr.fill],[attr.d],[class.…]o[style.…]. - Si se repite mucho, un componente
iconconinput()para el nombre y el tamaño.
-
[width]en vez de[attr.width]: la plantilla no compila. -
Derivar el
viewBoxdel tamaño (0 0 ${iconSize} ${iconSize}): con un tamaño menor que la cuadrícula recorta el icono, y con uno mayor no lo escala. ElviewBoxdepende del dibujo; el tamaño eswidthyheight. -
Poner un color fijo en el trazo: el icono no sigue al tema. Usa
currentColor. -
Declarar
viewBoxantes queiconSize:iconSizeaún no tiene valor. -
Olvidar
xmlns: al pegar un SVG suelto, conviene conservar suxmlns="http://www.w3.org/2000/svg".
En un SVG se enlaza con [attr.nombre], porque Angular enlaza propiedades del
DOM y las medidas del SVG son atributos. El tamaño se cambia con width y
height, nunca con el viewBox, que define el lienzo. currentColor hace que
el icono herede el color del texto y siga al tema.
Respuesta:
[width] enlaza una propiedad del elemento en el DOM, y en un <svg> esa
propiedad no es un valor simple sino un objeto especial del navegador, que
Angular no reconoce. [attr.width] enlaza el atributo, que es un texto, y
eso sí se puede asignar. Es la regla general para cualquier atributo que no sea
una propiedad.
Respuesta:
viewBox define el sistema de coordenadas interno del dibujo (el lienzo);
width y height definen el tamaño con que ese lienzo se muestra. Cambiando
width y height el dibujo se escala sin deformarse; cambiar el viewBox
cambia qué parte del lienzo se ve y puede recortar o desplazar el icono.
Respuesta:
Es un valor de CSS que equivale al color de texto del elemento. Usado en el
stroke o fill de un SVG, el icono toma el mismo color que el texto que lo
rodea, de modo que cambia con el tema, con el estado del botón o con una clase
de color, sin definir colores en el SVG.