- name
- frontend-angular
- description
- Use ao implementar frontend em Angular, quando project.config.md indicar esta stack. Cobre estrutura de projeto Angular 17+, standalone components, signals, biblioteca de componentes padrão (Angular Material ou PrimeNG), acessibilidade WCAG 2.2 AA, e padrão de UI assíncrona.
# Frontend: Angular (v17+) + Standalone Components
> Leia também `.claude/skills/accessibility/SKILL.md` antes de implementar
> qualquer tela nova. Se o projeto requer o Design System do Governo Federal
> (DS Gov), leia adicionalmente `.claude/skills/frontend-angular-dsgov/SKILL.md`.
## Biblioteca de componentes padrão (escolher no project.config.md)
A pesquisa atual indica:
- **Angular Material** — melhor para: acessibilidade nativa (ARIA embutido no
CDK), SSR, consistência visual, apps SaaS/produto. Escolha padrão quando
acessibilidade é prioridade.
- **PrimeNG** — melhor para: dashboards com tabelas pesadas, charts, 80+
componentes prontos, grids com virtual scrolling. Preferir quando o app
é data-heavy (painéis administrativos).
- A decisão vai registrada em `project.config.md` — não inventar durante a
implementação.
## ⚠️ Verificar versão antes de implementar
```bash
npm show @angular/core version # versão atual do Angular
cat package.json | grep angular # versão instalada no projeto
```
Consultar https://angular.dev para APIs atuais (signals, resource(),
linkedSignal()). Angular 17+ usa standalone por padrão e tem mudanças
frequentes no sistema de reatividade — verificar se resource() e
linkedSignal() estão disponíveis na versão instalada.
## Estrutura de pastas
```
src/
app/
core/ # singleton services, guards, interceptors
auth/
http/ # interceptors (loading, error, auth token)
layout/ # AppShell, Header, Sidebar, Footer
shared/
ui/ # componentes visuais sem lógica de domínio
status-badge/ # StatusBadge
skeleton-table/ # SkeletonTable
empty-state/ # EmptyState
error-state/ # ErrorState
confirm-delete/ # ConfirmDelete
forms/ # componentes de formulário reutilizáveis
autocomplete-create/ # AutocompleteCreate (com criação inline)
rich-text-editor/ # RichTextEditor
pipes/ # pipes reutilizáveis
features/ # um diretório por funcionalidade
processo/
components/
processo-list/ # componentes específicos da feature
processo-form/
services/
processo.service.ts # chama API, expõe signals ou Observables
models/
processo.model.ts # interfaces/types — nunca classes anêmicas
processo.routes.ts # lazy loading
usuario/
components/
services/
models/
usuario.routes.ts
app.routes.ts
app.config.ts
```
**Regra de decisão shared/ vs features/:**
- Pode ser usado em mais de uma feature → `shared/ui/`, `shared/forms/` ou `shared/pipes/`
- Específico de uma entidade → `features/<entidade>/`
- Componente `shared/` nunca importa de `features/`
## Standalone Components (padrão Angular 17+)
```typescript
// features/processo/components/processo-list/processo-list.component.ts
@Component({
selector: 'app-processo-list',
standalone: true,
imports: [CommonModule, MatTableModule, MatPaginatorModule, RouterModule],
templateUrl: './processo-list.component.html',
})
export class ProcessoListComponent {
private processoService = inject(ProcessoService);
// Signals — estado reativo sem Zone.js
processos = this.processoService.processos;
isLoading = this.processoService.isLoading;
hasError = this.processoService.hasError;
meta = this.processoService.meta;
}
```
## Service com estado (Signals)
```typescript
// features/processo/services/processo.service.ts
@Injectable({ providedIn: 'root' })
export class ProcessoService {
private http = inject(HttpClient);
// Signals de estado
processos = signal<Processo[]>([]);
isLoading = signal(false);
hasError = signal(false);
isSubmitting = signal(false);
meta = signal<PaginationMeta | null>(null);
// Sort/filtro persistidos em localStorage
private readonly STORAGE_KEY = 'grid-state:processo';
gridState = signal<GridState>(this.loadGridState() ?? DEFAULT_GRID_STATE);
listar(): void {
this.isLoading.set(true);
this.hasError.set(false);
const { page, pageSize, sort, order, filtros } = this.gridState();
this.http.get<ListagemResponse<Processo>>('/api/v1/processos', {
params: { page, page_size: pageSize, sort, order, ...filtros }
}).subscribe({
next: (res) => {
this.processos.set(res.data);
this.meta.set(res.meta);
this.saveGridState(this.gridState());
},
error: () => this.hasError.set(true),
complete: () => this.isLoading.set(false),
});
}
toggleSort(coluna: string): void {
const atual = this.gridState();
const novo = atual.sort === coluna
? { ...atual, order: atual.order === 'asc' ? 'desc' : 'asc' }
: { ...atual, sort: coluna, order: 'asc', page: 1 };
this.gridState.set(novo as GridState);
this.saveGridState(novo as GridState);
this.listar();
}
private loadGridState(): GridState | null {
const s = localStorage.getItem(this.STORAGE_KEY);
return s ? JSON.parse(s) : null;
}
private saveGridState(state: GridState): void {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(state));
}
}
```
## UI assíncrona (obrigatório, ver CLAUDE.md)
```html
<!-- botão desabilitado durante operação -->
<button mat-raised-button [disabled]="isLoading() || isSubmitting()"
(click)="pesquisar()">
@if (isLoading()) { <mat-spinner diameter="16" /> Aguarde... }
@else { Pesquisar }
</button>
<!-- grid com 4 estados distintos -->
@if (isLoading()) {
<app-grid-skeleton [rows]="meta()?.pageSize ?? 10" />
} @else if (hasError()) {
<app-error-state (retry)="listar()" />
} @else if (!processos().length) {
<app-empty-state message="Nenhum resultado encontrado" />
} @else {
<mat-table [dataSource]="processos()">...</mat-table>
}
```
## Cabeçalho de coluna com toggle de ordenação
```html
<th mat-header-cell *matHeaderCellDef
role="button" tabindex="0"
[attr.aria-sort]="gridState().sort === 'descricao'
? gridState().order === 'asc' ? 'ascending' : 'descending'
: 'none'"
(click)="processoService.toggleSort('descricao')"
(keydown.enter)="processoService.toggleSort('descricao')">
Descrição
@if (gridState().sort === 'descricao') {
<mat-icon>{{ gridState().order === 'asc' ? 'arrow_upward' : 'arrow_downward' }}</mat-icon>
}
</th>
```
## AppShell (estrutura única, criada na primeira funcionalidade visual)
```
src/app/
core/
layout/
app-shell/
app-shell.component.ts # combina header + sidebar + conteúdo + footer
app-header/
app-header.component.ts # logo + título + toggle + info do usuário
app-sidebar/
app-sidebar.component.ts # menu hierárquico (accordion 2 níveis)
nav-config.ts # configuração do menu — nunca hardcoded
app-footer/
app-footer.component.ts # rodapé fixo
```
### nav-config.ts — fonte única de verdade para o menu
```typescript
export interface NavItem { label: string; path: string; icon?: string; }
export interface NavGroup { label: string; icon: string; items: NavItem[]; }
export const NAV_CONFIG: NavGroup[] = [
{
label: 'Cadastros', icon: 'people',
items: [
{ label: 'Clientes', path: '/app/clientes' },
{ label: 'Fornecedores', path: '/app/fornecedores' },
]
},
{
label: 'Tabelas Acessórias', icon: 'table_view',
items: [
{ label: 'Categorias', path: '/app/categorias' },
{ label: 'Status', path: '/app/status' },
]
},
];
```
### app-shell.component.ts
```typescript
@Component({
selector: 'app-shell',
standalone: true,
template: `
<div class="shell-wrapper">
<app-header
[sidebarOpen]="sidebarOpen()"
(toggleSidebar)="sidebarOpen.set(!sidebarOpen())"
/>
<div class="shell-body">
<app-sidebar
[open]="sidebarOpen()"
[navConfig]="navConfig"
/>
<main class="shell-content">
<router-outlet />
</main>
</div>
<app-footer />
</div>
`,
})
export class AppShellComponent {
readonly sidebarOpen = signal(true);
readonly navConfig = NAV_CONFIG;
}
```
### Menu hierárquico com accordion (Material ou DSGOV)
**Angular Material:**
```html
<!-- app-sidebar.component.html -->
@for (group of navConfig; track group.label) {
<mat-expansion-panel [class.collapsed]="!open">
<mat-expansion-panel-header>
<mat-icon>{{ group.icon }}</mat-icon>
@if (open) { <span>{{ group.label }}</span> }
</mat-expansion-panel-header>
@for (item of group.items; track item.path) {
<a mat-list-item [routerLink]="item.path" routerLinkActive="active">
{{ item.label }}
</a>
}
</mat-expansion-panel>
}
```
**DSGOV:** usar `<br-menu>` com `<br-list>` aninhados conforme
documentação em https://www.gov.br/ds/components/menu.
Quando sidebar recolhida (`open = false`): mostrar apenas ícones com
`[matTooltip]="group.label"` no header do painel.
### Header
```html
<header class="app-header">
<button mat-icon-button (click)="toggleSidebar.emit()"
[attr.aria-label]="sidebarOpen ? 'Recolher menu' : 'Expandir menu'">
<mat-icon>menu</mat-icon>
</button>
<img src="/assets/logo.svg" alt="Logo" class="logo" />
<span class="system-title">{{ systemName }}</span>
<div class="spacer"></div>
<!-- info do usuário: nome + avatar + dropdown de logout -->
</header>
```
### Rodapé
```html
<!-- app-footer.component.html -->
<footer class="app-footer">
<span>{{ systemName }} v{{ version }} — {{ year }}</span>
<!-- DSGOV: links obrigatórios do Padrão Digital de Governo -->
<!-- Privado: links de ajuda, política de privacidade -->
</footer>
```
```scss
// app-footer.component.scss
.app-footer {
height: 40px;
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: var(--muted-foreground);
flex-shrink: 0; // garante que o footer não some quando o conteúdo é grande
}
```
A página de login não usa o `AppShell` — é componente standalone com
rota própria na raiz.
## Autocomplete/Combobox com criação inline
> Padrão universal definido no `CLAUDE.md`. Use quando `ux.md` indicar campo
> de autocomplete. O comportamento é idêntico ao de qualquer outra tecnologia —
> esta seção descreve apenas a implementação específica para Angular.
**Angular Material:** use `<mat-autocomplete>` + `<input matInput>`.
**DSGOV:** use `<br-input>` com lista de sugestões customizada conforme
documentação do Design System.
```typescript
// shared/components/autocomplete-create/autocomplete-create.component.ts
@Component({
selector: 'app-autocomplete-create',
standalone: true,
template: `
<mat-form-field class="w-full">
<mat-label>{{ label }}</mat-label>
<input matInput
[matAutocomplete]="auto"
[formControl]="searchCtrl"
[attr.aria-label]="label" />
<button *ngIf="value()" mat-icon-button matSuffix
(click)="clear()" aria-label="Limpar seleção">
<mat-icon>close</mat-icon>
View on GitHub