This commit is contained in:
Luiz Silva 2026-01-27 12:22:30 -03:00
parent 24c07da6f8
commit 052337b9da
4 changed files with 201 additions and 32 deletions

View file

@ -6,9 +6,9 @@
<script lang="ts"> <script lang="ts">
import { defineComponent, h, onBeforeUnmount, onMounted, PropType, ref, watch } from "vue"; import { defineComponent, h, onBeforeUnmount, onMounted, PropType, ref, watch } from "vue";
import type { ComponentPublicInstance } from "vue"; import type { ComponentPublicInstance } from "vue";
import { MoreVertical } from "lucide-vue-next"; import { ArrowDown, ArrowUp, MoreVertical } from "lucide-vue-next";
import { codigosResposta } from "p-respostas"; import { codigosResposta } from "p-respostas";
import type { EliTabelaConsulta } from "./types"; import type { EliTabelaConsulta } from "./types-eli-tabela";
export default defineComponent({ export default defineComponent({
name: "EliTabela", name: "EliTabela",
@ -30,6 +30,8 @@ export default defineComponent({
const acoesVisiveis = ref<boolean[][]>([]); const acoesVisiveis = ref<boolean[][]>([]);
const menuAberto = ref<number | null>(null); const menuAberto = ref<number | null>(null);
const menuElementos = new Map<number, HTMLElement>(); const menuElementos = new Map<number, HTMLElement>();
const colunaOrdenacao = ref<string | null>(null);
const direcaoOrdenacao = ref<"asc" | "desc">("asc");
let carregamentoSequencial = 0; let carregamentoSequencial = 0;
function registrarMenuElemento(indice: number, elemento: HTMLElement | null) { function registrarMenuElemento(indice: number, elemento: HTMLElement | null) {
@ -50,6 +52,22 @@ export default defineComponent({
}; };
} }
function alternarOrdenacao(chave?: string) {
if (!chave) {
return;
}
if (colunaOrdenacao.value === chave) {
direcaoOrdenacao.value =
direcaoOrdenacao.value === "asc" ? "desc" : "asc";
} else {
colunaOrdenacao.value = chave;
direcaoOrdenacao.value = "asc";
}
void carregar();
}
function handleClickFora(evento: MouseEvent) { function handleClickFora(evento: MouseEvent) {
if (menuAberto.value === null) { if (menuAberto.value === null) {
return; return;
@ -115,9 +133,16 @@ export default defineComponent({
menuAberto.value = null; menuAberto.value = null;
menuElementos.clear(); menuElementos.clear();
const parametrosOrdenacao = colunaOrdenacao.value
? {
coluna_ordem: colunaOrdenacao.value as never,
direcao_ordem: direcaoOrdenacao.value,
}
: undefined;
try { try {
const tabelaConfig = props.tabela; const tabelaConfig = props.tabela;
const res = await tabelaConfig.resposta(); const res = await tabelaConfig.consulta(parametrosOrdenacao);
if (idCarregamento !== carregamentoSequencial) { if (idCarregamento !== carregamentoSequencial) {
return; return;
@ -145,10 +170,15 @@ export default defineComponent({
const preResultado = valores.map(() => const preResultado = valores.map(() =>
acoes.map((acao) => { acoes.map((acao) => {
if (acao.exibir === undefined) {
return true;
}
if (typeof acao.exibir === "boolean") { if (typeof acao.exibir === "boolean") {
return acao.exibir; return acao.exibir;
} }
return acao.exibir ? false : true;
return false;
}) })
); );
@ -209,6 +239,8 @@ export default defineComponent({
() => { () => {
menuAberto.value = null; menuAberto.value = null;
menuElementos.clear(); menuElementos.clear();
colunaOrdenacao.value = null;
direcaoOrdenacao.value = "asc";
// Caso a definição de tabela/consulta mude // Caso a definição de tabela/consulta mude
void carregar(); void carregar();
} }
@ -238,9 +270,59 @@ export default defineComponent({
return renderVazio(tabela.mensagemVazio); return renderVazio(tabela.mensagemVazio);
} }
const cabecalho = colunas.map((coluna) => const cabecalho = colunas.map((coluna) => {
h("th", { class: "eli-tabela__th", scope: "col" }, coluna.rotulo) const chaveOrdenacao =
coluna.coluna_ordem !== undefined
? (coluna.coluna_ordem as unknown as string)
: undefined;
const ordenavel = Boolean(chaveOrdenacao);
const ativa = ordenavel && colunaOrdenacao.value === chaveOrdenacao;
const iconeOrdenacao = ordenavel
? ativa
? h(direcaoOrdenacao.value === "asc" ? ArrowUp : ArrowDown, {
class: "eli-tabela__th-icone",
size: 16,
strokeWidth: 2,
"aria-hidden": "true",
})
: h(ArrowUp, {
class: "eli-tabela__th-icone eli-tabela__th-icone--oculto",
size: 16,
strokeWidth: 2,
"aria-hidden": "true",
})
: null;
const conteudo = ordenavel
? h(
"button",
{
type: "button",
class: [
"eli-tabela__th-botao",
ativa ? "eli-tabela__th-botao--ativo" : undefined,
],
onClick: () => alternarOrdenacao(chaveOrdenacao),
},
[
h("span", { class: "eli-tabela__th-texto" }, coluna.rotulo),
iconeOrdenacao,
]
)
: h("span", { class: "eli-tabela__th-label" }, coluna.rotulo);
return h(
"th",
{
class: [
"eli-tabela__th",
ordenavel ? "eli-tabela__th--ordenavel" : undefined,
],
scope: "col",
},
conteudo
); );
});
if (temAcoes) { if (temAcoes) {
cabecalho.push( cabecalho.push(
@ -290,17 +372,20 @@ export default defineComponent({
if (temAcoes) { if (temAcoes) {
const visibilidade = acoesVisiveis.value[i] ?? []; const visibilidade = acoesVisiveis.value[i] ?? [];
const acoesDisponiveis = acoes const acoesDisponiveis = acoes
.map((acao, indice) => ({ .map((acao, indice) => {
const fallbackVisivel =
acao.exibir === undefined
? true
: typeof acao.exibir === "boolean"
? acao.exibir
: false;
return {
acao, acao,
indice, indice,
visivel: visivel: visibilidade[indice] ?? fallbackVisivel,
visibilidade[indice] ?? };
(typeof acao.exibir === "boolean" })
? acao.exibir
: acao.exibir
? false
: true),
}))
.filter((item) => item.visivel); .filter((item) => item.visivel);
const possuiAcoes = acoesDisponiveis.length > 0; const possuiAcoes = acoesDisponiveis.length > 0;
@ -466,6 +551,57 @@ export default defineComponent({
background: rgba(0, 0, 0, 0.03); background: rgba(0, 0, 0, 0.03);
} }
.eli-tabela__th--ordenavel {
padding: 0;
}
.eli-tabela__th--ordenavel .eli-tabela__th-botao {
padding: 10px 12px;
}
.eli-tabela__th-botao {
display: inline-flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
width: 100%;
background: transparent;
border: none;
font: inherit;
color: inherit;
cursor: pointer;
text-align: left;
transition: color 0.2s ease;
}
.eli-tabela__th-botao:hover,
.eli-tabela__th-botao:focus-visible {
color: rgba(15, 23, 42, 0.85);
}
.eli-tabela__th-botao:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.45);
outline-offset: 2px;
}
.eli-tabela__th-botao--ativo {
color: rgba(37, 99, 235, 0.95);
}
.eli-tabela__th-texto {
flex: 1;
min-width: 0;
white-space: nowrap;
}
.eli-tabela__th-icone {
flex-shrink: 0;
}
.eli-tabela__th-icone--oculto {
opacity: 0;
}
.eli-tabela__tr:last-child .eli-tabela__td { .eli-tabela__tr:last-child .eli-tabela__td {
border-bottom: none; border-bottom: none;
} }

View file

@ -1,4 +1,4 @@
export { default as EliTabela } from "./EliTabela.vue"; export { default as EliTabela } from "./EliTabela.vue";
export * from "./types"; export * from "./types-eli-tabela";
export * from "./celulas/EliCelulaTextoSimples"; export * from "./celulas/EliCelulaTextoSimples";

View file

@ -8,6 +8,7 @@ export type EliColuna<T> = {
rotulo: string; rotulo: string;
celula: (linha: T) => ComponenteCelula; celula: (linha: T) => ComponenteCelula;
acao?: () => void; acao?: () => void;
coluna_ordem?: keyof T;
}; };
export type EliConsultaPaginada<T> = { export type EliConsultaPaginada<T> = {
@ -27,11 +28,14 @@ export type EliTabelaAcao<T> = {
* Estrutura de dados para uma tabela alimentada por uma consulta. * Estrutura de dados para uma tabela alimentada por uma consulta.
* *
* - `colunas`: definição de colunas e como renderizar cada célula * - `colunas`: definição de colunas e como renderizar cada célula
* - `resposta`: função assíncrona que retorna uma resposta padronizada * - `consulta`: função que recupera os dados, com suporte a ordenação
*/ */
export type EliTabelaConsulta<T> = { export type EliTabelaConsulta<T> = {
colunas: EliColuna<T>[]; colunas: EliColuna<T>[];
resposta: () => Promise<tipoResposta<EliConsultaPaginada<T>>>; consulta: (parametrosConsulta?: {
coluna_ordem?: keyof T;
direcao_ordem?: "asc" | "desc";
}) => Promise<tipoResposta<EliConsultaPaginada<T>>>;
/** Mensagem exibida quando a consulta retorna ok porém sem dados. */ /** Mensagem exibida quando a consulta retorna ok porém sem dados. */
mensagemVazio?: string; mensagemVazio?: string;
acoes?: EliTabelaAcao<T>[]; acoes?: EliTabelaAcao<T>[];

View file

@ -42,22 +42,52 @@ export default defineComponent({
acao: (linha) => { acao: (linha) => {
console.log("Remover registro de", linha.nome); console.log("Remover registro de", linha.nome);
}, },
exibir: async () => { exibir: (linha) => linha.email !== "ana@eli.com",
await new Promise((resolve) => setTimeout(resolve, 250));
return true;
},
}, },
]; ];
const linhasPadrao: Linha[] = [
{ nome: "Ana", email: "ana@eli.com" },
{ nome: "Bruno", email: "bruno@eli.com" },
{ nome: "Carla", email: "carla@eli.com" },
];
const ordenarLinhas = (
linhas: Linha[],
parametros?: { coluna_ordem?: keyof Linha; direcao_ordem?: "asc" | "desc" }
) => {
if (!parametros?.coluna_ordem) {
return [...linhas];
}
const direcao = parametros.direcao_ordem ?? "asc";
const chave = parametros.coluna_ordem;
const multiplicador = direcao === "asc" ? 1 : -1;
return [...linhas].sort((a, b) => {
const valorA = a[chave];
const valorB = b[chave];
return (
multiplicador *
String(valorA ?? "").localeCompare(String(valorB ?? ""), "pt-BR", {
sensitivity: "base",
})
);
});
};
const tabelaOk: EliTabelaConsulta<Linha> = { const tabelaOk: EliTabelaConsulta<Linha> = {
colunas: [ colunas: [
{ {
rotulo: "Nome", rotulo: "Nome",
celula: (l) => l.nome, celula: (l) => l.nome,
coluna_ordem: "nome",
}, },
{ {
rotulo: "E-mail", rotulo: "E-mail",
celula: (l) => l.email, celula: (l) => l.email,
coluna_ordem: "email",
acao: () => { acao: () => {
// Exemplo de ação: poderia abrir detalhes // Exemplo de ação: poderia abrir detalhes
console.log("clicou na coluna e-mail"); console.log("clicou na coluna e-mail");
@ -65,18 +95,17 @@ export default defineComponent({
}, },
], ],
acoes: acoesTabela, acoes: acoesTabela,
resposta: async () => { consulta: async (parametrosConsulta) => {
const valores = ordenarLinhas(linhasPadrao, parametrosConsulta);
return { return {
cod: codigosResposta.sucesso, cod: codigosResposta.sucesso,
eCerto: true, eCerto: true,
eErro: false, eErro: false,
mensagem: undefined, mensagem: undefined,
valor: { valor: {
quantidade: 2, quantidade: valores.length,
valores: [ valores,
{ nome: "Ana", email: "ana@eli.com" },
{ nome: "Bruno", email: "bruno@eli.com" },
],
}, },
}; };
}, },
@ -84,7 +113,7 @@ export default defineComponent({
const tabelaVazia: EliTabelaConsulta<Linha> = { const tabelaVazia: EliTabelaConsulta<Linha> = {
colunas: tabelaOk.colunas, colunas: tabelaOk.colunas,
resposta: async () => { consulta: async () => {
return { return {
cod: codigosResposta.sucesso, cod: codigosResposta.sucesso,
eCerto: true, eCerto: true,
@ -103,7 +132,7 @@ export default defineComponent({
const tabelaErro: EliTabelaConsulta<Linha> = { const tabelaErro: EliTabelaConsulta<Linha> = {
colunas: tabelaOk.colunas, colunas: tabelaOk.colunas,
acoes: acoesTabela, acoes: acoesTabela,
resposta: async () => { consulta: async () => {
return { return {
cod: codigosResposta.erroConhecido, cod: codigosResposta.erroConhecido,
eCerto: false, eCerto: false,