Angular: Tabela dinâmica estilo Excel
Acredito que muitos desenvolvedores frontend passem por isso na carreira: construir tabelas para apresentar melhor os dados para áreas de negócio. Grande parte das vezes é um trabalho chato — ficar copiando e colando estilos de tabelas ou simplesmente criando elas do zero. E, obviamente, eu também cansei. Afinal, não existe nada mais produtivo do que um preguiçoso evitando tarefas repetitivas.
Grande parte das pessoas de negócio — ou até da população em geral — está acostumada com o visual e o padrão de layout do Excel. Baseado nisso, construí essa tabela dinâmica utilizando Angular na versão 18, Angular Material, Signals e TailwindCSS.
Chega de enrolação e bora pro que interessa: o código.
O primeiro passo é, criar seu componente, carinhosamente o chamo de simple table(tem nada simples).
ng g c simpleTable --standalone --skip-tests
Componente criado, vamos começar a sua transformação.
Aqui eu defino uma interface chamada IndexableObject, que serve como base para os dados que a tabela vai receber.
interface IndexableObject {
expanded?: boolean;
[key: string]: string | number | boolean | undefined; // Tenha calma que será explicado
}
E calma que já já explico para o que vai servir o [key: string]: string | number | boolean | undefined;
Em seguida defino que a nossa classe será um componente genérico, que aceita qualquer tipo de dado T.
interface IndexableObject {
expanded?: boolean;
[key: string]: string | number | boolean | undefined; // Tenha calma que será explicado
}
export class SimpleTableComponent<T extends IndexableObject> { }
Com a estrutura base já definida, agora vou criar uma nova interface responsável por descrever como cada coluna da tabela deve ser configurada:
interface TableColumnsModel {
field: string; // -> Importante: deve corresponder exatamente ao nome do campo presente no objeto de dados
label: string;
visible: boolean;
required: boolean; // Indica que a coluna é obrigatória na exibição
width: number;
dataFormattingType?: 'money' | 'percent' | 'default'; // Opcional: para formatar o dado de um jeito específico
}
Um ponto importante aqui é o campo field ele precisa ter exatamente o mesmo nome da propriedade presente nos objetos que serão renderizados na tabela. Isso porque o componente vai acessar os dados dinamicamente usando a chave field , então qualquer divergência aqui vai causar erro ou campo em branco na tabela.
Vamos ver um exemplo simples:
Suponha que estamos trabalhando com uma lista de usuários, e nosso modelo de dados seja:
interface UserModel {
name: string;
email: string;
status: string;
}
Ao definir as colunas para a tabela que vai exibir esses usuários, precisamos mapear os campos:
public tableColumns: TableColumnsModel[] = [
{
field: 'name', // deve bater com o campo da interface UserModel
label: 'Nome',
visible: true,
required: true,
width: 0
},
{
field: 'email',
label: 'E-mail',
visible: true,
required: false,
width: 0
},
{
field: 'status',
label: 'Usuário ativo',
visible: true,
required: false,
width: 0
}
];
Resumindo por que isso é importante: o campo field é o elo entre o dado e a coluna, então ele precisa estar 100% alinhado com as chaves do modelo que o componente receberá.
Bom, seguindo para os próximos passos — e prometo ser breve — agora vamos configurar o que a nossa classe irá receber e expor, utilizando Signals.
Também vamos começar a preparar o suporte para o drag and drop das colunas (usando o Angular Material) e redimensionamento das mesmas.
/**
* @author Walisson Ferreira
*/
@Component({
selector: 'simple-table',
standalone: true,
imports: [
CommonModule,
DragDropModule,
FormsModule,
OverlayModule,
PortalModule
],
templateUrl: './simple-table.component.html',
styleUrl: './simple-table.component.scss',
})
export class SimpleTableComponent<T extends IndexableObject> {
// As linhas de dados da nossa tabela.
// O 'model.required' significa que você precisa passar os dados das linhas para o componente.
public tableRows: ModelSignal<T[]> = model.required<T[]>();
// A configuração das colunas da tabela (o cabeçalho, visibilidade, etc.).
// Também é 'required', ou seja, você tem que dizer como as colunas devem ser.
public tableColumns: ModelSignal<TableColumnsModel[]> = model.required<TableColumnsModel[]>();
// Controla quantas linhas queremos exibir por vez. Começa mostrando só 1, mas você pode mudar.
public numberOfRowsToDisplay: ModelSignal<number> = model<number>(1);
// Sempre que 'tableRows' ou 'numberOfRowsToDisplay' mudarem, 'displayRows' se atualiza automaticamente.
// Aqui, ele simplesmente "corta" (slice) o array de linhas para mostrar apenas a quantidade que definimos.
public displayRows = computed(() => {
const rows = this.tableRows();
return rows.slice(0, this.numberOfRowsToDisplay());
});
/**
* Representa a coluna que está atualmente sendo redimensionada.
* Se nenhuma coluna estiver sendo redimensionada, o valor é nulo.
*/
public columnBeingResized: TableColumnsModel | null = null;
/**
* Armazena a posição horizontal (clientX) do mouse quando o redimensionamento começa.
* Esse valor é usado para calcular a diferença de pixels durante o redimensionamento.
*/
public startX: number = 0;
/**
* Armazena a largura inicial (em pixels) da coluna que está sendo redimensionada.
* Esse valor é utilizado juntamente com a diferença de posição do mouse para ajustar a nova largura da coluna.
*/
public startWidth: number = 0;
/**
* Reordena as colunas visíveis com base no drag and drop.
*
* - `event.previousIndex` = posição original da coluna
* - `event.currentIndex` = nova posição após o drop
*
* A reorganização usa `moveItemInArray` do Angular CDK.
*
* Colunas ocultas não são afetadas — elas mantêm a mesma ordem original.
*/
public reorderColumns(event: CdkDragDrop<any[]>): void {
const visibleColumns: TableColumnsModel[] = this.visibleColumns;
moveItemInArray(visibleColumns, event.previousIndex, event.currentIndex);
let visibleIndex = 0;
this.tableColumns.update(columns =>
columns.map(col => {
if (col.visible) {
return visibleColumns[visibleIndex++];
}
return col;
})
);
}
/**
* Inicia o redimensionamento de uma coluna.
* Este método é chamado quando o usuário começa a arrastar a borda da coluna.
*
* - Impede a propagação e o comportamento padrão do evento de mouse para evitar conflitos com o drag and drop do Angular CDK.
* - Armazena a posição inicial do mouse e a largura original da coluna.
* - Registra os listeners para acompanhar o movimento do mouse e finalizar o redimensionamento ao soltar o botão.
*
* @param event Evento de mouse que iniciou o redimensionamento.
* @param col A coluna que está sendo redimensionada.
*/
public startResizing(event: MouseEvent, col: TableColumnsModel): void {
event.stopPropagation();
event.preventDefault();
this.columnBeingResized = col;
this.startX = event.clientX;
this.startWidth = col.width || 150; // Largura padrão de 150px se não definida
document.addEventListener('mousemove', this.resizeColumn);
document.addEventListener('mouseup', this.stopResizing);
}
/**
* Manipula o movimento do mouse durante o redimensionamento da coluna.
* - Calcula a nova largura com base na diferença horizontal do mouse (delta).
* - E aplica um limite mínimo de 50px para a coluna.
*/
public resizeColumn = (event: MouseEvent): void => {
if (!this.columnBeingResized) {
return;
}
const delta = event.clientX - this.startX;
// Largura mínima de 50px para a coluna
const newWidth = Math.max(50, this.startWidth + delta);
this.columnBeingResized.width = newWidth;
};
/**
* Finaliza o redimensionamento da coluna.
* - Remove os event listeners registrados no início do processo.
*/
public stopResizing = (_event: MouseEvent): void => {
this.columnBeingResized = null;
document.removeEventListener('mousemove', this.resizeColumn);
document.removeEventListener('mouseup', this.stopResizing);
};
/**
* Converte o valor recebido para number.
* - Garante que valores inválidos sejam tratados com fallback para 0.
* - Números são retornados diretamente.
* - Strings são convertidas com parseFloat (se forem numéricas).
* - Booleans são convertidos para 1 (true) ou 0 (false).
* - Qualquer outro tipo ou valor inválido retorna 0.
*
* @param value Valor de entrada (string | number | boolean | undefined)
* @returns Valor convertido para number
*/
public getNumberValue(value: string | number | boolean | undefined): number {
if (typeof value === 'number') {
return value;
}
if (typeof value === 'string') {
const parsed = parseFloat(value);
return isNaN(parsed) ? 0 : parsed;
}
if (typeof value === 'boolean') {
return value ? 1 : 0;
}
return 0;
}
/**
* Prepara o valor para ser passado ao pipe de formatação (ex: money ou percent).
* - Booleans e undefined são tratados como 0.
* - Strings e numbers são retornados diretamente.
*
* @param value Valor de entrada a ser formatado (string | number)
* @returns Valor formatável (string | number)
*/
public getValueForFormatting(value: string | number | boolean | undefined): string | number {
if (typeof value === 'boolean' || value === undefined) {
return 0;
}
return value;
}
/**
* Alterna o estado expandido de uma linha da tabela.
* Pode ser utilizado para mostrar/ocultar detalhes adicionais.
*/
public toggleRowDetails(row: T): void {
row.expanded = !row.expanded;
}
/**
* Getter que retorna apenas as colunas visíveis.
* Utilizado para renderização dinâmica da tabela.
*/
public get visibleColumns(): TableColumnsModel[] {
return this.tableColumns().filter(col => col.visible);
}
/**
* Retorna a quantidade de colunas ocultas.
*/
public get hiddenColumnsCount(): number {
return this.tableColumns().filter(col => !col.visible).length;
}
}
Finalizamos a base lógica no arquivo .ts, onde ficam as principais regras e reatividade do componente. Agora vamos para o próximo passo: construir o template e transformar essa estrutura em algo visual e funcional.
<div class="space-y-1">
<!-- Tabela -->
<div class="rounded-lg border border-[#CECECE] overflow-auto antialiased transition-all duration-300 ease-out">
<table tabindex="0" role="table" aria-describedby="table-caption" class="w-full text-left">
<!-- Legenda da tabela -->
<div id="table-caption" class="sr-only">
Lista
</div>
<!-- Cabeçalho -->
<thead class="bg-[#EAEAEA] py-3 px-3 max-w-3xl w-full">
<tr cdkDropList cdkDropListOrientation="horizontal" (cdkDropListDropped)="reorderColumns($event)">
<!-- Colunas fixas, que não são arrastáveis -->
<th scope="col" class="bg-[#EAEAEA] py-3px px-3 max-w-3xl w-full">
<ng-container *ngFor="let col of visibleColumns">
<th cdkDrag scope="col" class="group relative bg-[#EAEAEA] py-3px px-3 max-w-3xl w-full"
[style.width]="col.width ? col.width + 'px' : 'auto'">
<button type="button" class="w-full flex items-center text-sm space-x-2"
[attr.aria-label]="'Ordenar por ' + col.label" tabindex="0" role="columnheader">
<!-- Ícone de arrasto (acessível) -->
<svg class="size-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300"
aria-hidden="true" viewBox="0 0 12 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="3" cy="3" r="1.5" fill="currentColor" />
<circle cx="9" cy="3" r="1.5" fill="currentColor" />
<circle cx="3" cy="9" r="1.5" fill="currentColor" />
<circle cx="9" cy="9" r="1.5" fill="currentColor" />
<circle cx="3" cy="15" r="1.5" fill="currentColor" />
<circle cx="9" cy="15" r="1.5" fill="currentColor" />
</svg>
<!-- Texto visível -->
<span class="text-sm font-medium text-[#3F3F3F] ">{{ col.label }}</span>
</button>
<div class="absolute right-0 top-0 h-full w-4 cursor-col-resize"
(mousedown)="startResizing($event, col)"></div>
<span class="w-0.5 h-full hover:bg-[#0F8FFF] "></span>
</th>
</ng-container>
</th>
</tr>
</thead>
<!-- Corpo da tabela -->
<tbody>
<ng-container *ngFor="let row of displayRows(); let isLast = last">
<tr class="transition-all duration-300 ease-out"
[ngClass]="{'border-b border-[#CECECE]': !isLast, 'border-b-0': isLast}">
<!-- Célula para o ícone de "detalhes" -->
<td class="px-4 py-2 w-[32px] max-w-w-[32px]">
<button (click)="toggleRowDetails(row)" class="group focus:outline-none flex items-center justify-center"
[ngClass]="{'rotate-90 transition-all duration-300': row.expanded}">
<svg class="size-3 group-hover:text-[#0F8FFF] transition-colors duration-300"
fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</button>
</td>
<td *ngFor="let col of tableColumns()" [hidden]="!col.visible" class="px-4 py-2">
<ng-container *ngIf="col.dataFormattingType === 'money'">
<span class="text-sm font-medium text-[#3F3F3F] ">{{ getValueForFormatting(row[col.field]) | currency:'BRL':'symbol':'1.2-2':'pt' }}</span>
</ng-container>
<ng-template #checkPercent>
<ng-container *ngIf="col.dataFormattingType === 'percent'">
<span class="text-sm font-medium text-[#3F3F3F] ">{{ getNumberValue(row[col.field]) | percent:'1.2-2':'pt' }}</span>
</ng-container>
</ng-template>
<ng-template #defaultCell>
<span class="text-sm font-medium text-[#3F3F3F] ">{{ row[col.field] }}</span>
</ng-template>
</td>
</tr>
<!-- Linha de detalhes -->
<tr *ngIf="row.expanded"
[ngClass]="{'border-b border-[#CECECE]': !isLast, 'border-b-0': isLast}">
<!--
[attr.colspan]="3 + columns.length" define que a célula de detalhes deve ocupar
todas as colunas da tabela. Ela calcula a quantidade de colunas dinâmicas
+ 2 colunas fixas (detalhes e troca)
+ o número de colunas dinâmicas (definido pelo tamanho do array columns).
-->
<td [attr.colspan]="3 + tableColumns.length" class="px-7 py-2">
<div class="flex flex-wrap gap-x-6 gap-y-2">
<ng-container *ngIf="hiddenColumnsCount > 0; else noDetails">
<ng-container *ngFor="let col of tableColumns()">
<div *ngIf="!col.visible" class="flex flex-col">
<span class="text-sm font-medium text-[#3F3F3F] ">{{ col.label }}:</span>
<!--
row[col.field] faz a "ponte" entre os dados da linha e a coluna.
col.field é uma string (ex: 'email'), então row['email'] acessa o valor.
Isso permite que o componente seja totalmente dinâmico e reutilizável.
-->
<ng-container *ngIf="col.dataFormattingType === 'money'">
<span class="text-sm font-medium text-[#3F3F3F] ">{{ getValueForFormatting(row[col.field]) | currency:'BRL':'symbol':'1.2-2':'pt' }}</span>
</ng-container>
<ng-template #checkPercent>
<ng-container *ngIf="col.dataFormattingType === 'percent'">
<span class="text-sm font-medium text-[#3F3F3F] ">{{ getNumberValue(row[col.field]) | percent:'1.2-2':'pt' }}</span>
</ng-container>
</ng-template>
<ng-template #defaultCell>
<span class="text-sm font-medium text-[#3F3F3F] ">{{ row[col.field] }}</span>
</ng-template>
</div>
</ng-container>
</ng-container>
<ng-template #noDetails>
<span class="text-sm font-medium text-[#3F3F3F] ">
Sem detalhes para apresentar.
</span>
</ng-template>
</div>
</td>
</tr>
</ng-container>
</tbody>
</table>
</div>
</div>
cdkDropList: Diretiva do Angular CDK que transforma esta linha em uma “área de soltar” para o Drag and Drop.
cdkDropListOrientation="horizontal": Especifica que o arraste e solte é na horizontal.
(cdkDropListDropped)="reorderColumns($event)": Quando o usuário solta uma coluna que estava arrastando, este evento é disparado, chamando a nossa função reorderColumns e passando os detalhes do evento ($event).
cdkDrag: Diretiva do Angular CDK que torna este cabeçalho arrastável.
O coração da lógica de renderização dinâmica da tabela é o row[col.field] , que acessa o valor de uma propriedade específica do objeto row usando o nome da coluna (col.field) como chave. Isso torna o componente flexível, pois ele não precisa saber de antemão quais serão as colunas ou os dados, adaptando-se a qualquer estrutura fornecida.
Conclusão
Neste artigo, exploramos a criação de uma tabela dinâmica em Angular, inspirada no estilo Excel, utilizando Angular Material, Signals e TailwindCSS. Vimos como construir um componente genérico, definir interfaces para dados e colunas, e implementar funcionalidades essenciais como reordenação e redimensionamento de colunas, além de um sistema de exibição de detalhes por linha.
Espero que este guia detalhado ajude você a implementar tabelas robustas e flexíveis em seus projetos Angular. Sinta-se à vontade para adaptar e expandir este código conforme suas necessidades. Se tiver dúvidas ou sugestões, deixe um comentário!
Até a próxima!